From 5dc4f5a0b522805e99eb0e52a3e38b315ad9bb2d Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 5 Jan 2026 19:29:16 +0100 Subject: [PATCH 01/37] refactor: reorg env and rename props --- .../java/to/bitkit/services/TxBumpingTests.kt | 9 +- app/src/main/java/to/bitkit/env/Env.kt | 152 ++++++++------- .../main/java/to/bitkit/ext/PeerDetails.kt | 6 +- app/src/main/java/to/bitkit/fcm/FcmService.kt | 4 +- .../to/bitkit/repositories/BlocktankRepo.kt | 2 +- .../to/bitkit/repositories/LightningRepo.kt | 24 +-- .../to/bitkit/services/LightningService.kt | 178 ++++++++---------- .../services/LspNotificationsService.kt | 4 +- .../main/java/to/bitkit/ui/NodeInfoScreen.kt | 2 +- .../ui/screens/transfer/FundingScreen.kt | 4 +- .../external/ExternalConnectionScreen.kt | 4 +- .../external/ExternalNodeViewModel.kt | 4 +- .../transfer/external/LnurlChannelScreen.kt | 2 +- .../external/LnurlChannelViewModel.kt | 4 +- .../send/SendCoinSelectionViewModel.kt | 6 +- .../CustomFeeSettingsScreen.kt | 4 +- .../ui/sheets/BoostTransactionViewModel.kt | 4 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 6 +- .../to/bitkit/viewmodels/LdkDebugViewModel.kt | 4 +- .../java/to/bitkit/ext/PeerDetailsTest.kt | 8 +- .../bitkit/repositories/LightningRepoTest.kt | 10 +- .../java/to/bitkit/ui/WalletViewModelTest.kt | 4 +- .../test/java/to/bitkit/utils/CryptoTest.kt | 6 +- 23 files changed, 211 insertions(+), 240 deletions(-) diff --git a/app/src/androidTest/java/to/bitkit/services/TxBumpingTests.kt b/app/src/androidTest/java/to/bitkit/services/TxBumpingTests.kt index d698f9a1a..e46347f45 100644 --- a/app/src/androidTest/java/to/bitkit/services/TxBumpingTests.kt +++ b/app/src/androidTest/java/to/bitkit/services/TxBumpingTests.kt @@ -138,9 +138,10 @@ class TxBumpingTests { assertEquals(depositAmount, totalBalance, "Balance should equal deposit amount") // Send a transaction with a low fee rate + @Suppress("SpellCheckingInspection") val destinationAddress = "bcrt1qs04g2ka4pr9s3mv73nu32tvfy7r3cxd27wkyu8" val sendAmount = 10_000uL // Send 10,000 sats - val lowFeeRate = 1u // 1 sat/vbyte (very low) + val lowFeeRate = 1uL // 1 sat/vbyte (very low) println("Sending $sendAmount sats to $destinationAddress with low fee rate of $lowFeeRate sat/vbyte") val originalTxId = lightningService.send( @@ -160,7 +161,7 @@ class TxBumpingTests { println("Wait completed") // Bump the fee using RBF with a higher fee rate - val highFeeRate = 10u // 10 sat/vbyte (much higher) + val highFeeRate = 10uL // 10 sat/vbyte (much higher) println("Bumping fee for transaction $originalTxId to $highFeeRate sat/vbyte using RBF") val replacementTxId = lightningService.bumpFeeByRbf( @@ -261,7 +262,7 @@ class TxBumpingTests { // Now use CPFP to spend from the incoming transaction with high fees // This demonstrates using CPFP to quickly move received funds - val highFeeRate = 20u // 20 sat/vbyte (very high for fast confirmation) + val highFeeRate = 20uL // 20 sat/vbyte (very high for fast confirmation) println("Using CPFP to quickly spend from incoming transaction $stuckIncomingTxId with $highFeeRate sat/vbyte") // Generate a destination address for the CPFP transaction (where we'll send the funds) @@ -272,7 +273,7 @@ class TxBumpingTests { val childTxId = lightningService.accelerateByCpfp( txid = stuckIncomingTxId, satsPerVByte = highFeeRate, - destinationAddress = cpfpDestinationAddress, + toAddress = cpfpDestinationAddress, ) assertFalse(childTxId.isEmpty(), "CPFP child transaction ID should not be empty") diff --git a/app/src/main/java/to/bitkit/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt index 30e815741..2fbbb680a 100644 --- a/app/src/main/java/to/bitkit/env/Env.kt +++ b/app/src/main/java/to/bitkit/env/Env.kt @@ -6,18 +6,18 @@ import org.lightningdevkit.ldknode.Network import org.lightningdevkit.ldknode.PeerDetails import to.bitkit.BuildConfig import to.bitkit.ext.ensureDir -import to.bitkit.ext.parse +import to.bitkit.ext.of import to.bitkit.models.BlocktankNotificationType import to.bitkit.utils.Logger import java.io.File import kotlin.io.path.Path -@Suppress("ConstPropertyName", "KotlinConstantConditions") +@Suppress("ConstPropertyName", "KotlinConstantConditions", "SimplifyBooleanWithConstants") internal object Env { val isDebug = BuildConfig.DEBUG const val isE2eTest = BuildConfig.E2E const val isGeoblockingEnabled = BuildConfig.GEO - private val e2eBackend = BuildConfig.E2E_BACKEND.lowercase() + val e2eBackend = BuildConfig.E2E_BACKEND.lowercase() val network = Network.valueOf(BuildConfig.NETWORK) val locales = BuildConfig.LOCALES.split(",") val walletSyncIntervalSecs = 10_uL // TODO review @@ -28,25 +28,18 @@ internal object Env { val trustedLnPeers get() = when (network) { - Network.BITCOIN -> listOf(Peers.mainnetLnd1, Peers.mainnetLnd3, Peers.mainnetLnd4) - Network.REGTEST -> listOf(Peers.staging) - Network.TESTNET -> listOf(Peers.staging) - else -> emptyList() + Network.BITCOIN -> listOf(Peers.lnd1, Peers.lnd3, Peers.lnd4) + Network.REGTEST -> listOf(Peers.stag) + Network.TESTNET -> listOf(Peers.stag) + else -> listOf() } - const val fxRateRefreshInterval: Long = 2 * 60 * 1000 // 2 minutes in milliseconds - const val fxRateStaleThreshold: Long = 10 * 60 * 1000 // 10 minutes in milliseconds + const val fxRateRefreshInterval = 2 * 60 * 1000L // 2 minutes in millis + const val fxRateStaleThreshold = 10 * 60 * 1000L // 10 minutes in millis + const val lspOrdersRefreshInterval = 2 * 60 * 1000L // 2 minutes in millis - const val blocktankOrderRefreshInterval: Long = 2 * 60 * 1000 // 2 minutes in milliseconds - - val pushNotificationFeatures = listOf( - BlocktankNotificationType.incomingHtlc, - BlocktankNotificationType.mutualClose, - BlocktankNotificationType.orderPaymentConfirmed, - BlocktankNotificationType.cjitPaymentArrived, - BlocktankNotificationType.wakeToTimeout, - ) - const val DERIVATION_NAME = "bitkit-notifications" + val pushNotificationFeatures = BlocktankNotificationType.entries + const val derivationName = "bitkit-notifications" const val FILE_PROVIDER_AUTHORITY = "${BuildConfig.APPLICATION_ID}.fileprovider" const val SUPPORT_EMAIL = "support@synonym.to" @@ -54,56 +47,15 @@ internal object Env { const val PIN_LENGTH = 4 const val PIN_ATTEMPTS = 8 - // region File Paths - - private lateinit var appStoragePath: String - - fun initAppStoragePath(path: String) { - require(path.isNotBlank()) { "App storage path cannot be empty." } - appStoragePath = path - Logger.info("App storage path: $path") - } - - val logDir: File - get() { - require(::appStoragePath.isInitialized) - return File(appStoragePath).resolve("logs").ensureDir() - } - - fun ldkStoragePath(walletIndex: Int) = storagePathOf(walletIndex, network.name.lowercase(), "ldk") - - fun bitkitCoreStoragePath(walletIndex: Int): String { - return storagePathOf(walletIndex, network.name.lowercase(), "core") - } - - /** - * 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) - .toFile() - .ensureDir() - .path - Logger.debug("Using ${dir.uppercase()} storage path: $path") - return path - } - - // endregion - - // region Server URLs + // region urls val electrumServerUrl: String get() { - if (isE2eTest && e2eBackend == "local") return ElectrumServers.E2E + if (isE2eTest && e2eBackend == "local") return ElectrumServers.REGTEST.LOCAL return when (network) { - Network.REGTEST -> ElectrumServers.REGTEST + Network.REGTEST -> ElectrumServers.REGTEST.STAGING Network.TESTNET -> ElectrumServers.TESTNET - Network.BITCOIN -> ElectrumServers.BITCOIN + Network.BITCOIN -> ElectrumServers.MAINNET.FULCRUM else -> TODO("${network.name} network not implemented") } } @@ -201,11 +153,52 @@ internal object Env { } // endregion + + // region paths + + private lateinit var appStoragePath: String + + fun initAppStoragePath(path: String) { + require(path.isNotBlank()) { "App storage path cannot be empty." } + appStoragePath = path + Logger.info("App storage path: $path") + } + + val logDir: File + get() { + require(::appStoragePath.isInitialized) + return File(appStoragePath).resolve("logs").ensureDir() + } + + fun ldkStoragePath(walletIndex: Int) = storagePathOf(walletIndex, network.name.lowercase(), "ldk") + + fun bitkitCoreStoragePath(walletIndex: Int): String { + return storagePathOf(walletIndex, network.name.lowercase(), "core") + } + + /** + * 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) + .toFile() + .ensureDir() + .path + Logger.debug("Using ${dir.uppercase()} storage path: $path") + return path + } + + // endregion } @Suppress("ConstPropertyName") -object TransactionDefaults { - /** Total recommended tx base fee in sats */ +object Defaults { + /** Recommended transaction base fee in sats */ const val recommendedBaseFee = 256u /** @@ -213,22 +206,27 @@ object TransactionDefaults { * required to include them in a block would be greater than the value of the transaction itself. * */ const val dustLimit = 546u + + } object Peers { - val staging = - PeerDetails.parse("028a8910b0048630d4eb17af25668cdd7ea6f2d8ae20956e7a06e2ae46ebcb69fc@34.65.86.104:9400") - val mainnetLnd1 = - PeerDetails.parse("039b8b4dd1d88c2c5db374290cda397a8f5d79f312d6ea5d5bfdfc7c6ff363eae3@34.65.111.104:9735") - val mainnetLnd3 = - PeerDetails.parse("03816141f1dce7782ec32b66a300783b1d436b19777e7c686ed00115bd4b88ff4b@34.65.191.64:9735") - val mainnetLnd4 = - PeerDetails.parse("02a371038863605300d0b3fc9de0cf5ccb57728b7f8906535709a831b16e311187@34.65.186.40:9735") + val stag = PeerDetails.of("028a8910b0048630d4eb17af25668cdd7ea6f2d8ae20956e7a06e2ae46ebcb69fc@34.65.86.104:9400") + val lnd1 = PeerDetails.of("039b8b4dd1d88c2c5db374290cda397a8f5d79f312d6ea5d5bfdfc7c6ff363eae3@34.65.111.104:9735") + val lnd3 = PeerDetails.of("03816141f1dce7782ec32b66a300783b1d436b19777e7c686ed00115bd4b88ff4b@34.65.191.64:9735") + val lnd4 = PeerDetails.of("02a371038863605300d0b3fc9de0cf5ccb57728b7f8906535709a831b16e311187@34.65.186.40:9735") } private object ElectrumServers { - const val BITCOIN = "ssl://fulcrum.bitkit.blocktank.to:8900" + object MAINNET { + const val FULCRUM = "ssl://fulcrum.bitkit.blocktank.to:8900" + const val ESPLORA = "ssl://34.65.252.32:18484" + } + + object REGTEST { + const val STAGING = "tcp://34.65.252.32:18483" + const val LOCAL = "tcp://127.0.0.1:60001" + } + const val TESTNET = "ssl://electrum.blockstream.info:60002" - const val REGTEST = "tcp://34.65.252.32:18483" - const val E2E = "tcp://127.0.0.1:60001" } diff --git a/app/src/main/java/to/bitkit/ext/PeerDetails.kt b/app/src/main/java/to/bitkit/ext/PeerDetails.kt index cb419a395..6ed4e5543 100644 --- a/app/src/main/java/to/bitkit/ext/PeerDetails.kt +++ b/app/src/main/java/to/bitkit/ext/PeerDetails.kt @@ -9,7 +9,8 @@ val PeerDetails.port get() = address.substringAfter(":") val PeerDetails.uri get() = "$nodeId@$address" -fun PeerDetails.Companion.parse(uri: String): PeerDetails { +/*** Creates a [PeerDetails] object from a URI string.*/ +fun PeerDetails.Companion.of(uri: String): PeerDetails { val parts = uri.split("@") require(parts.size == 2) { "Invalid uri format, expected: '@:', got: '$uri'" } @@ -30,7 +31,8 @@ fun PeerDetails.Companion.parse(uri: String): PeerDetails { ) } -fun PeerDetails.Companion.from(nodeId: String, host: String, port: String) = PeerDetails( +/*** Creates a [PeerDetails] object from a node ID, host, and port.*/ +fun PeerDetails.Companion.of(nodeId: String, host: String, port: String) = PeerDetails( nodeId = nodeId, address = "$host:$port", isConnected = false, diff --git a/app/src/main/java/to/bitkit/fcm/FcmService.kt b/app/src/main/java/to/bitkit/fcm/FcmService.kt index a9ab62d45..b37a7e655 100644 --- a/app/src/main/java/to/bitkit/fcm/FcmService.kt +++ b/app/src/main/java/to/bitkit/fcm/FcmService.kt @@ -14,7 +14,7 @@ import kotlinx.serialization.SerializationException import kotlinx.serialization.json.JsonObject import to.bitkit.data.keychain.Keychain import to.bitkit.di.json -import to.bitkit.env.Env.DERIVATION_NAME +import to.bitkit.env.Env.derivationName import to.bitkit.ext.fromBase64 import to.bitkit.ext.fromHex import to.bitkit.models.BlocktankNotificationType @@ -105,7 +105,7 @@ class FcmService : FirebaseMessagingService() { return } val password = - runCatching { crypto.generateSharedSecret(privateKey, response.publicKey, DERIVATION_NAME) }.getOrElse { + runCatching { crypto.generateSharedSecret(privateKey, response.publicKey, derivationName) }.getOrElse { Logger.error("Failed to generate shared secret", it, context = TAG) return } diff --git a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt index 59fa2ebc0..aa007bbe3 100644 --- a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt @@ -88,7 +88,7 @@ class BlocktankRepo @Inject constructor( flow { while (currentCoroutineContext().isActive) { emit(Unit) - delay(Env.blocktankOrderRefreshInterval) + delay(Env.lspOrdersRefreshInterval) } }.flowOn(bgDispatcher) .onEach { refreshOrders() } diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 2b350d488..80fb68be6 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -671,9 +671,9 @@ class LightningRepo @Inject constructor( require(address.isNotEmpty()) { "Send address cannot be empty" } val transactionSpeed = speed ?: settingsStore.data.first().defaultTransactionSpeed - val satsPerVByte = getFeeRateForSpeed(transactionSpeed, feeRates).getOrThrow().toUInt() + val satsPerVByte = getFeeRateForSpeed(transactionSpeed, feeRates).getOrThrow() - // if utxos are manually specified, use them, otherwise run auto coin select if enabled + // use passed utxos if specified, otherwise run auto coin select if enabled val finalUtxosToSpend = utxosToSpend ?: determineUtxosToSpend( sats = sats, satsPerVByte = satsPerVByte, @@ -697,7 +697,7 @@ class LightningRepo @Inject constructor( txId = txId, address = address, isReceive = false, - feeRate = satsPerVByte.toULong(), + feeRate = satsPerVByte, isTransfer = isTransfer, channelId = channelId ?: "", ) @@ -709,7 +709,7 @@ class LightningRepo @Inject constructor( suspend fun determineUtxosToSpend( sats: ULong, - satsPerVByte: UInt, + satsPerVByte: ULong, ): List? = withContext(bgDispatcher) { return@withContext runCatching { val settings = settingsStore.data.first() @@ -767,7 +767,7 @@ class LightningRepo @Inject constructor( ): Result = withContext(bgDispatcher) { return@withContext try { val transactionSpeed = speed ?: settingsStore.data.first().defaultTransactionSpeed - val satsPerVByte = getFeeRateForSpeed(transactionSpeed, feeRates).getOrThrow().toUInt() + val satsPerVByte = getFeeRateForSpeed(transactionSpeed, feeRates).getOrThrow() val addressOrDefault = address ?: cacheStore.data.first().onchainAddress @@ -905,22 +905,18 @@ class LightningRepo @Inject constructor( suspend fun bumpFeeByRbf( originalTxId: Txid, - satsPerVByte: UInt, + satsPerVByte: ULong, ): Result = executeWhenNodeRunning("bumpFeeByRbf") { try { if (originalTxId.isBlank()) { return@executeWhenNodeRunning Result.failure( - IllegalArgumentException( - "originalTxId is null or empty: $originalTxId" - ) + IllegalArgumentException("originalTxId is null or empty: $originalTxId") ) } if (satsPerVByte <= 0u) { return@executeWhenNodeRunning Result.failure( - IllegalArgumentException( - "satsPerVByte invalid: $satsPerVByte" - ) + IllegalArgumentException("satsPerVByte invalid: $satsPerVByte") ) } @@ -944,7 +940,7 @@ class LightningRepo @Inject constructor( suspend fun accelerateByCpfp( originalTxId: Txid, - satsPerVByte: UInt, + satsPerVByte: ULong, destinationAddress: Address, ): Result = executeWhenNodeRunning("accelerateByCpfp") { try { @@ -969,7 +965,7 @@ class LightningRepo @Inject constructor( val newDestinationTxId = lightningService.accelerateByCpfp( txid = originalTxId, satsPerVByte = satsPerVByte, - destinationAddress = destinationAddress, + toAddress = destinationAddress, ) Logger.debug( "accelerateByCpfp success, newDestinationTxId: $newDestinationTxId originalTxId: $originalTxId, satsPerVByte: $satsPerVByte destinationAddress: $destinationAddress" diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index ecf919925..00f130aab 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -73,7 +73,7 @@ class LightningService @Inject constructor( private val _syncStatusChanged = MutableSharedFlow(extraBufferCapacity = 1) val syncStatusChanged: SharedFlow = _syncStatusChanged.asSharedFlow() - private var trustedPeers: List = Env.trustedLnPeers + private lateinit var trustedPeers: List suspend fun setup( walletIndex: Int, @@ -82,7 +82,7 @@ class LightningService @Inject constructor( trustedPeers: List? = null, channelMigration: ChannelDataMigration? = null, ) { - Logger.debug("Building node…") + Logger.debug("Building node…", context = TAG) val config = config(walletIndex, trustedPeers) node = build( @@ -93,7 +93,7 @@ class LightningService @Inject constructor( channelMigration, ) - Logger.info("LDK node setup") + Logger.info("LDK node setup", context = TAG) } private fun config( @@ -102,11 +102,10 @@ class LightningService @Inject constructor( ): Config { val dirPath = Env.ldkStoragePath(walletIndex) - trustedPeers?.takeIf { it.isNotEmpty() }?.let { - this.trustedPeers = it - } ?: run { - Logger.info("Using fallback trusted peers from Env (${Env.trustedLnPeers.size})", context = TAG) + this.trustedPeers = trustedPeers?.takeIf { it.isNotEmpty() } ?: Env.trustedLnPeers.also { + Logger.warn("Missing trusted peers from LSP, falling back to preconfigured env peers", context = TAG) } + val trustedPeerNodeIds = this.trustedPeers.map { it.nodeId } return defaultConfig().copy( @@ -152,7 +151,8 @@ class LightningService @Inject constructor( val lnurlAuthServerUrl = Env.lnurlAuthServerUrl val fixedHeaders = emptyMap() Logger.verbose( - "Building ldk-node with \n\t vssUrl: '$vssUrl'\n\t lnurlAuthServerUrl: '$lnurlAuthServerUrl'" + "Building node with \n\t vssUrl: '$vssUrl'\n\t lnurlAuthServerUrl: '$lnurlAuthServerUrl'", + context = TAG, ) if (lnurlAuthServerUrl.isNotEmpty()) { builder.buildWithVssStore(vssUrl, vssStoreId, lnurlAuthServerUrl, fixedHeaders) @@ -169,17 +169,17 @@ class LightningService @Inject constructor( private suspend fun Builder.configureGossipSource(customRgsServerUrl: String?) { val rgsServerUrl = customRgsServerUrl ?: settingsStore.data.first().rgsServerUrl if (rgsServerUrl != null) { - Logger.info("Using gossip source: RGS server '$rgsServerUrl'") + Logger.info("Using gossip source: RGS server '$rgsServerUrl'", context = TAG) setGossipSourceRgs(rgsServerUrl) } else { - Logger.info("Using gossip source: P2P") + Logger.info("Using gossip source: P2P", context = TAG) setGossipSourceP2p() } } private suspend fun Builder.configureChainSource(customServerUrl: String? = null) { val serverUrl = customServerUrl ?: settingsStore.data.first().electrumServer - Logger.info("Using onchain source Electrum Sever url: $serverUrl") + Logger.info("Using onchain source Electrum Sever url: $serverUrl", context = TAG) setChainSourceElectrum( serverUrl = serverUrl, config = ElectrumSyncConfig( @@ -195,7 +195,7 @@ class LightningService @Inject constructor( suspend fun start(timeout: Duration? = null, onEvent: NodeEventHandler? = null) { val node = this.node ?: throw ServiceError.NodeNotSetup - Logger.debug("Starting node…") + Logger.debug("Starting node…", context = TAG) ServiceQueue.LDK.background { try { @@ -210,26 +210,26 @@ class LightningService @Inject constructor( shouldListenForEvents = true launch { try { - Logger.debug("LDK event listener started") + Logger.debug("LDK event listener started", context = TAG) if (timeout != null) { withTimeout(timeout) { listenForEvents(eventHandler) } } else { listenForEvents(eventHandler) } } catch (e: Exception) { - Logger.error("LDK event listener error", e) + Logger.error("LDK event listener error", e, context = TAG) } } } - Logger.info("Node started") + Logger.info("Node started", context = TAG) } suspend fun stop() { shouldListenForEvents = false val node = this.node ?: throw ServiceError.NodeNotStarted - Logger.debug("Stopping node…") + Logger.debug("Stopping node…", context = TAG) ServiceQueue.LDK.background { try { node.stop() @@ -239,47 +239,28 @@ class LightningService @Inject constructor( this@LightningService.node = null } } - Logger.info("Node stopped") + Logger.info("Node stopped", context = TAG) } fun wipeStorage(walletIndex: Int) { if (node != null) throw ServiceError.NodeStillRunning - Logger.warn("Wiping lightning storage…") + Logger.warn("Wiping LDK storage…", context = TAG) Path(Env.ldkStoragePath(walletIndex)).toFile().deleteRecursively() - Logger.info("Lightning wallet wiped") + Logger.info("LDK storage wiped", context = TAG) } suspend fun sync() { val node = this.node ?: throw ServiceError.NodeNotSetup - Logger.verbose("Syncing LDK…") + Logger.verbose("Syncing LDK…", context = TAG) ServiceQueue.LDK.background { node.syncWallets() - // launch { setMaxDustHtlcExposureForCurrentChannels() } } _syncStatusChanged.tryEmit(Unit) - Logger.debug("LDK synced") - } - - // private fun setMaxDustHtlcExposureForCurrentChannels() { - // if (Env.network != Network.REGTEST) { - // Logger.debug("Not updating channel config for non-regtest network") - // return - // } - // val node = this.node ?: throw ServiceError.NodeNotStarted - // runCatching { - // for (channel in node.listChannels()) { - // val config = channel.config - // config.maxDustHtlcExposure = MaxDustHtlcExposure.FixedLimit(limitMsat = 999_999_UL * 1000u) - // node.updateChannelConfig(channel.userChannelId, channel.counterpartyNodeId, config) - // Logger.info("Updated channel config for: ${channel.userChannelId}") - // } - // }.onFailure { - // Logger.error("Failed to update channel config", it) - // } - // } + Logger.debug("LDK synced", context = TAG) + } suspend fun sign(message: String): String { val node = this.node ?: throw ServiceError.NodeNotSetup @@ -306,9 +287,9 @@ class LightningService @Inject constructor( for (peer in trustedPeers) { try { node.connect(peer.nodeId, peer.address, persist = true) - Logger.info("Connected to trusted peer: $peer") + Logger.info("Connected to trusted peer: $peer", context = TAG) } catch (e: NodeException) { - Logger.error("Peer connect error: $peer", LdkError(e)) + Logger.error("Peer connect error: $peer", LdkError(e), context = TAG) } } @@ -321,13 +302,13 @@ class LightningService @Inject constructor( val trustedConnected = trustedPeers.count { it.nodeId in connectedPeerIds } if (trustedConnected == 0 && trustedPeers.isNotEmpty()) { - Logger.warn("No trusted peers connected, falling back to Env peers", context = TAG) + Logger.warn("No trusted peers connected, falling back to preconfigured env peers", context = TAG) for (peer in Env.trustedLnPeers) { try { node.connect(peer.nodeId, peer.address, persist = true) - Logger.info("Connected to fallback peer: $peer") + Logger.info("Connected to fallback peer: $peer", context = TAG) } catch (e: NodeException) { - Logger.error("Fallback peer connect error: $peer", LdkError(e)) + Logger.error("Fallback peer connect error: $peer", LdkError(e), context = TAG) } } } else { @@ -340,16 +321,16 @@ class LightningService @Inject constructor( val uri = peer.uri return ServiceQueue.LDK.background { try { - Logger.debug("Connecting peer: $uri") + Logger.debug("Connecting peer: $uri", context = TAG) node.connect(peer.nodeId, peer.address, persist = true) - Logger.info("Peer connected: $uri") + Logger.info("Peer connected: $uri", context = TAG) Result.success(Unit) } catch (e: NodeException) { val error = LdkError(e) - Logger.error("Peer connect error: $uri", error) + Logger.error("Peer connect error: $uri", error, context = TAG) Result.failure(error) } } @@ -358,14 +339,14 @@ class LightningService @Inject constructor( suspend fun disconnectPeer(peer: PeerDetails) { val node = this.node ?: throw ServiceError.NodeNotSetup val uri = peer.uri - Logger.debug("Disconnecting peer: $uri") + Logger.debug("Disconnecting peer: $uri", context = TAG) try { ServiceQueue.LDK.background { node.disconnect(peer.nodeId) } - Logger.info("Peer disconnected: $uri") + Logger.info("Peer disconnected: $uri", context = TAG) } catch (e: NodeException) { - Logger.warn("Peer disconnect error: $uri", LdkError(e)) + Logger.warn("Peer disconnect error: $uri", LdkError(e), context = TAG) } } @@ -389,7 +370,7 @@ class LightningService @Inject constructor( return ServiceQueue.LDK.background { try { val pushToCounterpartyMsat = pushToCounterpartySats?.let { it * 1000u } - Logger.debug("Initiating channel open (sats: $channelAmountSats) with peer: ${peer.uri}") + Logger.debug("Initiating channel open (sats: '$channelAmountSats') with: '${peer.uri}'", context = TAG) val userChannelId = node.openChannel( peer.nodeId, @@ -407,12 +388,12 @@ class LightningService @Inject constructor( channelConfig, ) - Logger.info("Channel open initiated, result: $result") + Logger.info("Channel open initiated, result: $result", context = TAG) Result.success(result) } catch (e: NodeException) { val error = LdkError(e) - Logger.error("Error initiating channel open", error) + Logger.error("Error initiating channel open", error, context = TAG) Result.failure(error) } } @@ -449,12 +430,12 @@ class LightningService @Inject constructor( fun canReceive(): Boolean { val channels = this.channels if (channels == null) { - Logger.warn("canReceive = false: Channels not available") + Logger.warn("canReceive = false: Channels not available", context = TAG) return false } if (channels.none { it.isChannelReady }) { - Logger.warn("canReceive = false: Found no LN channel ready to enable receive: $channels") + Logger.warn("canReceive = false: Found no LN channel ready to enable receive: '$channels'", context = TAG) return false } @@ -489,14 +470,14 @@ class LightningService @Inject constructor( fun canSend(amountSats: ULong): Boolean { val channels = this.channels if (channels == null) { - Logger.warn("Channels not available") + Logger.warn("Channels not available", context = TAG) return false } val totalNextOutboundHtlcLimitSats = channels.totalNextOutboundHtlcLimitSats() if (totalNextOutboundHtlcLimitSats < amountSats) { - Logger.warn("Insufficient outbound capacity: $totalNextOutboundHtlcLimitSats < $amountSats") + Logger.warn("Insufficient outbound capacity: $totalNextOutboundHtlcLimitSats < $amountSats", context = TAG) return false } @@ -506,26 +487,29 @@ class LightningService @Inject constructor( suspend fun send( address: Address, sats: ULong, - satsPerVByte: UInt, + satsPerVByte: ULong, utxosToSpend: List? = null, isMaxAmount: Boolean = false, ): Txid { val node = this.node ?: throw ServiceError.NodeNotSetup - Logger.info("Sending $sats sats to $address, satsPerVByte=$satsPerVByte, isMaxAmount = $isMaxAmount") + Logger.info( + "Sending $sats sats to $address, satsPerVByte=$satsPerVByte, isMaxAmount = $isMaxAmount", + context = TAG, + ) return ServiceQueue.LDK.background { if (isMaxAmount) { node.onchainPayment().sendAllToAddress( address = address, retainReserve = true, - feeRate = FeeRate.fromSatPerVbUnchecked(satsPerVByte.toULong()), + feeRate = FeeRate.fromSatPerVbUnchecked(satsPerVByte), ) } else { node.onchainPayment().sendToAddress( address = address, amountSats = sats, - feeRate = convertVByteToKwu(satsPerVByte), + feeRate = FeeRate.fromSatPerVbUnchecked(satsPerVByte), utxosToSpend = utxosToSpend, ) } @@ -535,7 +519,7 @@ class LightningService @Inject constructor( suspend fun send(bolt11: String, sats: ULong? = null): PaymentId { val node = this.node ?: throw ServiceError.NodeNotSetup - Logger.debug("Paying bolt11: $bolt11") + Logger.debug("Paying bolt11: $bolt11", context = TAG) val bolt11Invoice = runCatching { Bolt11Invoice.fromStr(bolt11) } .getOrElse { e -> throw LdkError(e as NodeException) } @@ -606,7 +590,7 @@ class LightningService @Inject constructor( suspend fun selectUtxosWithAlgorithm( targetAmountSats: ULong, - satsPerVByte: UInt, + satsPerVByte: ULong, algorithm: CoinSelectionAlgorithm, utxos: List?, ): Result> { @@ -616,7 +600,7 @@ class LightningService @Inject constructor( return@background try { val result = node.onchainPayment().selectUtxosWithAlgorithm( targetAmountSats = targetAmountSats, - feeRate = convertVByteToKwu(satsPerVByte), + feeRate = FeeRate.fromSatPerVbUnchecked(satsPerVByte), algorithm = algorithm, utxos = utxos, ) @@ -631,16 +615,16 @@ class LightningService @Inject constructor( // endregion // region boost - suspend fun bumpFeeByRbf(txid: Txid, satsPerVByte: UInt): Txid { + suspend fun bumpFeeByRbf(txid: Txid, satsPerVByte: ULong): Txid { val node = this.node ?: throw ServiceError.NodeNotSetup - Logger.info("Bumping fee for tx $txid with satsPerVByte=$satsPerVByte") + Logger.info("RBF for txid='$txid' using satsPerVByte='$satsPerVByte'", context = TAG) return ServiceQueue.LDK.background { return@background try { node.onchainPayment().bumpFeeByRbf( txid = txid, - feeRate = convertVByteToKwu(satsPerVByte), + feeRate = FeeRate.fromSatPerVbUnchecked(satsPerVByte), ) } catch (e: NodeException) { throw LdkError(e) @@ -650,19 +634,19 @@ class LightningService @Inject constructor( suspend fun accelerateByCpfp( txid: Txid, - satsPerVByte: UInt, - destinationAddress: Address, + satsPerVByte: ULong, + toAddress: Address, ): Txid { val node = this.node ?: throw ServiceError.NodeNotSetup - Logger.info("Accelerating tx $txid by CPFP, satsPerVByte=$satsPerVByte, destinationAddress=$destinationAddress") + Logger.info("CPFP for txid='$txid' using satsPerVByte='$satsPerVByte', to address='$toAddress'", context = TAG) return ServiceQueue.LDK.background { return@background try { node.onchainPayment().accelerateByCpfp( txid = txid, - feeRate = convertVByteToKwu(satsPerVByte), - destinationAddress = destinationAddress, + feeRate = FeeRate.fromSatPerVbUnchecked(satsPerVByte), + destinationAddress = toAddress, ) } catch (e: NodeException) { throw LdkError(e) @@ -675,7 +659,7 @@ class LightningService @Inject constructor( suspend fun calculateCpfpFeeRate(parentTxid: Txid): FeeRate { val node = this.node ?: throw ServiceError.NodeNotSetup - Logger.info("Calculating CPFP fee for parentTxid $parentTxid") + Logger.debug("Calculating CPFP fee for parentTxid $parentTxid", context = TAG) return ServiceQueue.LDK.background { return@background try { @@ -692,13 +676,14 @@ class LightningService @Inject constructor( suspend fun calculateTotalFee( address: Address, amountSats: ULong, - satsPerVByte: UInt, + satsPerVByte: ULong, utxosToSpend: List? = null, ): ULong { val node = this.node ?: throw ServiceError.NodeNotSetup Logger.verbose( - "Calculating fee for $amountSats sats to $address, UTXOs=${utxosToSpend?.size}, satsPerVByte=$satsPerVByte" + "Calculating fee for $amountSats sats to $address, ${utxosToSpend?.size} UTXOs, satsPerVByte=$satsPerVByte", + context = TAG, ) return ServiceQueue.LDK.background { @@ -706,10 +691,13 @@ class LightningService @Inject constructor( val fee = node.onchainPayment().calculateTotalFee( address = address, amountSats = amountSats, - feeRate = convertVByteToKwu(satsPerVByte), + feeRate = FeeRate.fromSatPerVbUnchecked(satsPerVByte), utxosToSpend = utxosToSpend, ) - Logger.verbose("Calculated fee=$fee for $amountSats sats to $address, satsPerVByte=$satsPerVByte") + Logger.debug( + "Calculated fee='$fee' for $amountSats sats to $address, satsPerVByte=$satsPerVByte", + context = TAG, + ) fee } catch (e: NodeException) { throw LdkError(e) @@ -724,16 +712,16 @@ class LightningService @Inject constructor( suspend fun listenForEvents(onEvent: NodeEventHandler? = null) = withContext(bgDispatcher) { while (shouldListenForEvents) { val node = this@LightningService.node ?: let { - Logger.error(ServiceError.NodeNotStarted.message.orEmpty()) + Logger.error(ServiceError.NodeNotStarted.message.orEmpty(), context = TAG) return@withContext } val event = node.nextEventAsync() - Logger.debug("LDK-node event fired: ${jsonLogOf(event)}") + Logger.debug("LDK event fired: ${jsonLogOf(event)}", context = TAG) try { node.eventHandled() - Logger.verbose("LDK-node eventHandled: $event") + Logger.verbose("LDK eventHandled: '$event'", context = TAG) } catch (e: NodeException) { - Logger.verbose("LDK eventHandled error: $event", LdkError(e)) + Logger.verbose("LDK eventHandled error: '$event'", LdkError(e), context = TAG) } onEvent?.invoke(event) } @@ -771,6 +759,7 @@ class LightningService @Inject constructor( Logger.error("Node not available for network graph dump", context = TAG) return } + val nodeIdPreviewLength = 20 val sb = StringBuilder() sb.appendLine("\n\n=== ROUTE NOT FOUND - NETWORK GRAPH DUMP ===\n") @@ -830,7 +819,7 @@ class LightningService @Inject constructor( sb.appendLine(" Total peers: ${peers.size}") peers.forEachIndexed { index, peer -> - sb.appendLine(" Peer ${index + 1}: ${peer.nodeId.take(NODE_ID_PREVIEW_LENGTH)}... @ ${peer.address}") + sb.appendLine(" Peer ${index + 1}: ${peer.nodeId.take(nodeIdPreviewLength)}... @ ${peer.address}") sb.appendLine(" - Connected: ${peer.isConnected}, Persisted: ${peer.isPersisted}") } @@ -873,15 +862,15 @@ class LightningService @Inject constructor( val nodeId = peer.nodeId if (allNodes.any { it == nodeId }) { foundTrustedNodes++ - sb.appendLine(" OK: ${nodeId.take(NODE_ID_PREVIEW_LENGTH)}... found in graph") + sb.appendLine(" OK: ${nodeId.take(nodeIdPreviewLength)}... found in graph") } else { - sb.appendLine(" MISSING: ${nodeId.take(NODE_ID_PREVIEW_LENGTH)}... NOT in graph") + sb.appendLine(" MISSING: ${nodeId.take(nodeIdPreviewLength)}... NOT in graph") } } sb.appendLine(" Summary: $foundTrustedNodes/${trustedPeers.size} trusted peers found in graph") // Show first 10 nodes - val nodesToShow = minOf(NETWORK_GRAPH_PREVIEW_LIMIT, allNodes.size) + val nodesToShow = minOf(10, allNodes.size) sb.appendLine("\n First $nodesToShow nodes:") allNodes.take(nodesToShow).forEachIndexed { index, nodeId -> sb.appendLine(" ${index + 1}. $nodeId") @@ -940,8 +929,6 @@ class LightningService @Inject constructor( companion object { private const val TAG = "LightningService" - private const val NODE_ID_PREVIEW_LENGTH = 20 - private const val NETWORK_GRAPH_PREVIEW_LIMIT = 10 } } @@ -951,16 +938,3 @@ data class NetworkGraphInfo( val channelCount: Int, val latestRgsSyncTimestamp: ULong?, ) - -// region helpers -/** - * TODO remove, replace all usages with [FeeRate.fromSatPerVbUnchecked] - * */ -@Deprecated("replace all usages with [FeeRate.fromSatPerVbUnchecked]") -private fun convertVByteToKwu(satsPerVByte: UInt): FeeRate { - // 1 vbyte = 4 weight units, so 1 sats/vbyte = 250 sats/kwu - val satPerKwu = satsPerVByte.toULong() * 250u - // Ensure we're above the minimum relay fee - return FeeRate.fromSatPerKwu(maxOf(satPerKwu, 253u)) // FEERATE_FLOOR_SATS_PER_KW is 253 in LDK -} -// endregion diff --git a/app/src/main/java/to/bitkit/services/LspNotificationsService.kt b/app/src/main/java/to/bitkit/services/LspNotificationsService.kt index d29e8b95b..08215384a 100644 --- a/app/src/main/java/to/bitkit/services/LspNotificationsService.kt +++ b/app/src/main/java/to/bitkit/services/LspNotificationsService.kt @@ -7,7 +7,7 @@ import to.bitkit.data.keychain.Keychain import to.bitkit.data.keychain.Keychain.Key import to.bitkit.di.BgDispatcher import to.bitkit.env.Env -import to.bitkit.env.Env.DERIVATION_NAME +import to.bitkit.env.Env.derivationName import to.bitkit.ext.nowTimestamp import to.bitkit.ext.toHex import to.bitkit.utils.Crypto @@ -29,7 +29,7 @@ class LspNotificationsService @Inject constructor( Logger.debug("Registering device for notifications…") val timestamp = nowTimestamp() - val messageToSign = "$DERIVATION_NAME$deviceToken$timestamp" + val messageToSign = "$derivationName$deviceToken$timestamp" val signature = lightningService.sign(messageToSign) diff --git a/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt b/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt index 320165885..d79765303 100644 --- a/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt +++ b/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt @@ -489,7 +489,7 @@ private fun PreviewDevMode() { latestPathfindingScoresSyncTimestamp = null, ), nodeId = "0348a2b7c2d3f4e5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9", - peers = listOf(Peers.staging), + peers = listOf(Peers.stag), channels = listOf( createChannelDetails().copy( channelId = "abc123def456789012345678901234567890123456789012345678901234567890", diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/FundingScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/FundingScreen.kt index 82d50b63f..b2e806b47 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/FundingScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/FundingScreen.kt @@ -22,7 +22,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import to.bitkit.R -import to.bitkit.env.TransactionDefaults +import to.bitkit.env.Defaults import to.bitkit.ui.LocalBalances import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BodyMB @@ -45,7 +45,7 @@ fun FundingScreen( ) { val balances = LocalBalances.current val canTransfer = remember(balances.totalOnchainSats) { - balances.totalOnchainSats >= TransactionDefaults.recommendedBaseFee + balances.totalOnchainSats >= Defaults.recommendedBaseFee } var showNoFundsAlert by remember { mutableStateOf(false) } diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalConnectionScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalConnectionScreen.kt index 5d45f2430..fcc0a07f7 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalConnectionScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalConnectionScreen.kt @@ -35,7 +35,7 @@ import androidx.lifecycle.SavedStateHandle import kotlinx.coroutines.flow.filterNotNull import org.lightningdevkit.ldknode.PeerDetails import to.bitkit.R -import to.bitkit.ext.from +import to.bitkit.ext.of import to.bitkit.ext.getClipboardText import to.bitkit.ext.host import to.bitkit.ext.port @@ -220,7 +220,7 @@ private fun ExternalConnectionContent( ) PrimaryButton( text = stringResource(R.string.common__continue), - onClick = { onContinueClick(PeerDetails.from(nodeId = nodeId, host = host, port = port)) }, + onClick = { onContinueClick(PeerDetails.of(nodeId = nodeId, host = host, port = port)) }, enabled = isValid, isLoading = uiState.isLoading, modifier = Modifier diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalNodeViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalNodeViewModel.kt index 6c3e0924e..3e6c6479e 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalNodeViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalNodeViewModel.kt @@ -18,7 +18,7 @@ import org.lightningdevkit.ldknode.UserChannelId import to.bitkit.R import to.bitkit.data.SettingsStore import to.bitkit.ext.WatchResult -import to.bitkit.ext.parse +import to.bitkit.ext.of import to.bitkit.ext.watchUntil import to.bitkit.models.Toast import to.bitkit.models.TransactionSpeed @@ -84,7 +84,7 @@ class ExternalNodeViewModel @Inject constructor( fun parseNodeUri(uriString: String) { viewModelScope.launch { - val result = runCatching { PeerDetails.parse(uriString) } + val result = runCatching { PeerDetails.of(uriString) } if (result.isSuccess) { _uiState.update { it.copy(peer = result.getOrNull()) } diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/external/LnurlChannelScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/external/LnurlChannelScreen.kt index 78105bfa3..5f6a13d4b 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/external/LnurlChannelScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/external/LnurlChannelScreen.kt @@ -173,7 +173,7 @@ private fun InfoRow( private fun Preview() { AppThemeSurface { Content( - uiState = LnurlChannelUiState(peer = Peers.staging), + uiState = LnurlChannelUiState(peer = Peers.stag), ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/external/LnurlChannelViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/external/LnurlChannelViewModel.kt index 21891070d..52401cc90 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/external/LnurlChannelViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/external/LnurlChannelViewModel.kt @@ -11,7 +11,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.lightningdevkit.ldknode.PeerDetails import to.bitkit.R -import to.bitkit.ext.parse +import to.bitkit.ext.of import to.bitkit.models.Toast import to.bitkit.repositories.LightningRepo import to.bitkit.ui.Routes @@ -38,7 +38,7 @@ class LnurlChannelViewModel @Inject constructor( viewModelScope.launch { lightningRepo.fetchLnurlChannelInfo(params.uri) .onSuccess { channelInfo -> - val peer = runCatching { PeerDetails.parse(channelInfo.uri) }.getOrElse { + val peer = runCatching { PeerDetails.of(channelInfo.uri) }.getOrElse { errorToast(it) return@onSuccess } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionViewModel.kt index 33c03430c..0cc33ade3 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionViewModel.kt @@ -12,7 +12,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.lightningdevkit.ldknode.SpendableUtxo import to.bitkit.di.BgDispatcher -import to.bitkit.env.TransactionDefaults +import to.bitkit.env.Defaults import to.bitkit.ext.rawId import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.LightningRepo @@ -133,8 +133,8 @@ class SendCoinSelectionViewModel @Inject constructor( } private fun validateCoinSelection(totalSelectedSat: ULong, totalRequiredSat: ULong): Boolean { - return totalSelectedSat > TransactionDefaults.dustLimit && - totalRequiredSat > TransactionDefaults.dustLimit && + return totalSelectedSat > Defaults.dustLimit && + totalRequiredSat > Defaults.dustLimit && totalSelectedSat >= totalRequiredSat } diff --git a/app/src/main/java/to/bitkit/ui/settings/transactionSpeed/CustomFeeSettingsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/transactionSpeed/CustomFeeSettingsScreen.kt index 01ea002d1..5e2799ff7 100644 --- a/app/src/main/java/to/bitkit/ui/settings/transactionSpeed/CustomFeeSettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/transactionSpeed/CustomFeeSettingsScreen.kt @@ -19,7 +19,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController import to.bitkit.R -import to.bitkit.env.TransactionDefaults +import to.bitkit.env.Defaults import to.bitkit.models.BITCOIN_SYMBOL import to.bitkit.models.ConvertedAmount import to.bitkit.models.TransactionSpeed @@ -50,7 +50,7 @@ fun CustomFeeSettingsScreen( var input by remember { mutableStateOf((customFeeRate.value as? TransactionSpeed.Custom)?.satsPerVByte?.toString() ?: "") } - val totalFee = TransactionDefaults.recommendedBaseFee * (input.toUIntOrNull() ?: 0u) + val totalFee = Defaults.recommendedBaseFee * (input.toUIntOrNull() ?: 0u) LaunchedEffect(input) { val inputNum = input.toLongOrNull() ?: 0 diff --git a/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionViewModel.kt b/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionViewModel.kt index 22d219d20..38a6c62dd 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionViewModel.kt @@ -180,7 +180,7 @@ class BoostTransactionViewModel @Inject constructor( private suspend fun handleRbfBoost(activity: Activity.Onchain) { lightningRepo.bumpFeeByRbf( - satsPerVByte = _uiState.value.feeRate.toUInt(), + satsPerVByte = _uiState.value.feeRate, originalTxId = activity.v1.txId ).fold( onSuccess = { newTxId -> @@ -194,7 +194,7 @@ class BoostTransactionViewModel @Inject constructor( private suspend fun handleCpfpBoost(activity: Activity.Onchain) { lightningRepo.accelerateByCpfp( - satsPerVByte = _uiState.value.feeRate.toUInt(), + satsPerVByte = _uiState.value.feeRate, originalTxId = activity.v1.txId, destinationAddress = walletRepo.getOnchainAddress(), ).fold( diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index c249a4b9c..8d7911bf1 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -61,7 +61,7 @@ import to.bitkit.di.BgDispatcher import to.bitkit.domain.commands.NotifyPaymentReceived import to.bitkit.domain.commands.NotifyPaymentReceivedHandler import to.bitkit.env.Env -import to.bitkit.env.TransactionDefaults +import to.bitkit.env.Defaults import to.bitkit.ext.WatchResult import to.bitkit.ext.amountOnClose import to.bitkit.ext.getClipboardText @@ -820,7 +820,7 @@ class AppViewModel @Inject constructor( } } - SendMethod.ONCHAIN -> amount > TransactionDefaults.dustLimit.toULong() + SendMethod.ONCHAIN -> amount > Defaults.dustLimit.toULong() } } @@ -1584,7 +1584,7 @@ class AppViewModel @Inject constructor( .mapCatching { satsPerVByte -> lightningRepo.determineUtxosToSpend( sats = currentState.amount, - satsPerVByte = satsPerVByte.toUInt(), + satsPerVByte = satsPerVByte, ) } .onSuccess { utxos -> diff --git a/app/src/main/java/to/bitkit/viewmodels/LdkDebugViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/LdkDebugViewModel.kt index 2ea8afa21..d8aced251 100644 --- a/app/src/main/java/to/bitkit/viewmodels/LdkDebugViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/LdkDebugViewModel.kt @@ -16,7 +16,7 @@ import kotlinx.coroutines.launch import org.lightningdevkit.ldknode.PeerDetails import to.bitkit.data.backup.VssBackupClient import to.bitkit.di.BgDispatcher -import to.bitkit.ext.parse +import to.bitkit.ext.of import to.bitkit.models.Toast import to.bitkit.repositories.LightningRepo import to.bitkit.services.NetworkGraphInfo @@ -77,7 +77,7 @@ class LdkDebugViewModel @Inject constructor( viewModelScope.launch(bgDispatcher) { _uiState.update { it.copy(isLoading = true) } runCatching { - val peer = PeerDetails.parse(uri) + val peer = PeerDetails.of(uri) lightningRepo.connectPeer(peer) }.onSuccess { result -> result.onSuccess { diff --git a/app/src/test/java/to/bitkit/ext/PeerDetailsTest.kt b/app/src/test/java/to/bitkit/ext/PeerDetailsTest.kt index c2cc0aee7..960f951ae 100644 --- a/app/src/test/java/to/bitkit/ext/PeerDetailsTest.kt +++ b/app/src/test/java/to/bitkit/ext/PeerDetailsTest.kt @@ -53,7 +53,7 @@ class PeerDetailsTest : BaseUnitTest() { fun `parse correctly parses full connection string`() { val uri = "028a8910b0048630d4eb17af25668cdd7ea6f2d8ae20956e7a06e2ae46ebcb69fc@34.65.86.104:9400" - val peer = PeerDetails.parse(uri) + val peer = PeerDetails.of(uri) assertEquals("028a8910b0048630d4eb17af25668cdd7ea6f2d8ae20956e7a06e2ae46ebcb69fc", peer.nodeId) assertEquals("34.65.86.104:9400", peer.address) @@ -66,7 +66,7 @@ class PeerDetailsTest : BaseUnitTest() { val invalidUri = "node123example.com:9735" val exception = assertFailsWith { - PeerDetails.parse(invalidUri) + PeerDetails.of(invalidUri) } assertTrue(exception.message!!.contains("Invalid uri format")) @@ -77,7 +77,7 @@ class PeerDetailsTest : BaseUnitTest() { val invalidUri = "node123@example.com" val exception = assertFailsWith { - PeerDetails.parse(invalidUri) + PeerDetails.of(invalidUri) } assertTrue(exception.message!!.contains("Invalid uri format")) @@ -85,7 +85,7 @@ class PeerDetailsTest : BaseUnitTest() { @Test fun `from creates PeerDetails with correct values`() { - val peer = PeerDetails.from( + val peer = PeerDetails.of( nodeId = "node123", host = "example.com", port = "9735", diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index fba21ae9c..a113f3e17 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -32,7 +32,7 @@ import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain import to.bitkit.ext.createChannelDetails -import to.bitkit.ext.from +import to.bitkit.ext.of import to.bitkit.models.BalanceState import to.bitkit.models.CoinSelectionPreference import to.bitkit.models.NodeLifecycleState @@ -206,7 +206,7 @@ class LightningRepoTest : BaseUnitTest() { @Test fun `openChannel should fail when node is not running`() = test { - val testPeer = PeerDetails.from("nodeId", "host", "9735") + val testPeer = PeerDetails.of("nodeId", "host", "9735") val result = sut.openChannel(testPeer, 100000uL) assertTrue(result.isFailure) } @@ -214,7 +214,7 @@ class LightningRepoTest : BaseUnitTest() { @Test fun `openChannel should succeed when node is running`() = test { startNodeForTesting() - val peer = PeerDetails.from("nodeId", "host", "9735") + val peer = PeerDetails.of("nodeId", "host", "9735") val userChannelId = "testChannelId" val channelAmountSats = 100_000uL whenever(lightningService.openChannel(peer, channelAmountSats, null, null)) @@ -350,7 +350,7 @@ class LightningRepoTest : BaseUnitTest() { @Test fun `disconnectPeer should fail when node is not running`() = test { - val testPeer = PeerDetails.from("nodeId", "host", "9735") + val testPeer = PeerDetails.of("nodeId", "host", "9735") val result = sut.disconnectPeer(testPeer) assertTrue(result.isFailure) } @@ -358,7 +358,7 @@ class LightningRepoTest : BaseUnitTest() { @Test fun `disconnectPeer should succeed when node is running`() = test { startNodeForTesting() - val testPeer = PeerDetails.from("nodeId", "host", "9735") + val testPeer = PeerDetails.of("nodeId", "host", "9735") whenever(lightningService.disconnectPeer(any())).thenReturn(Unit) val result = sut.disconnectPeer(testPeer) diff --git a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt index 8f55f55a1..e3f96a8af 100644 --- a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt @@ -15,7 +15,7 @@ import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.data.SettingsStore -import to.bitkit.ext.from +import to.bitkit.ext.of import to.bitkit.models.BalanceState import to.bitkit.repositories.BackupRepo import to.bitkit.repositories.BlocktankRepo @@ -93,7 +93,7 @@ class WalletViewModelTest : BaseUnitTest() { @Test fun `disconnectPeer should call lightningRepo disconnectPeer`() = test { - val testPeer = PeerDetails.from("nodeId", "host", "9735") + val testPeer = PeerDetails.of("nodeId", "host", "9735") val testError = Exception("Test error") whenever(lightningRepo.disconnectPeer(testPeer)).thenReturn(Result.failure(testError)) diff --git a/app/src/test/java/to/bitkit/utils/CryptoTest.kt b/app/src/test/java/to/bitkit/utils/CryptoTest.kt index eb38f327a..09c2c011f 100644 --- a/app/src/test/java/to/bitkit/utils/CryptoTest.kt +++ b/app/src/test/java/to/bitkit/utils/CryptoTest.kt @@ -2,7 +2,7 @@ package to.bitkit.utils import org.junit.Before import org.junit.Test -import to.bitkit.env.Env.DERIVATION_NAME +import to.bitkit.env.Env.derivationName import to.bitkit.ext.fromBase64 import to.bitkit.ext.fromHex import to.bitkit.ext.toBase64 @@ -28,13 +28,13 @@ class CryptoTest { val sharedSecret = sut.generateSharedSecret(privateKey, publicKey.toHex()) assertEquals(33, sharedSecret.size) - val sharedSecretHash = sut.generateSharedSecret(privateKey, publicKey.toHex(), DERIVATION_NAME) + val sharedSecretHash = sut.generateSharedSecret(privateKey, publicKey.toHex(), derivationName) assertEquals(32, sharedSecretHash.size) } @Test fun `it should decrypt payload it encrypted`() { - val derivationName = DERIVATION_NAME + val derivationName = derivationName // Step 1: Client generates a key pair val clientKeys = sut.generateKeyPair() From e042680c7509c0c6d782276ec5eb6ff947741319 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 5 Jan 2026 21:39:19 +0100 Subject: [PATCH 02/37] chore: update README --- .github/img/detekt.png | Bin 0 -> 449071 bytes README.md | 17 +++++++++++++---- docs/transfer.md | 2 +- 3 files changed, 14 insertions(+), 5 deletions(-) create mode 100644 .github/img/detekt.png diff --git a/.github/img/detekt.png b/.github/img/detekt.png new file mode 100644 index 0000000000000000000000000000000000000000..792abe76e3e952cc329e9185918ed146655691a2 GIT binary patch literal 449071 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

sBgQnm4%7=l}vnZ5dO_O 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�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�$Z(mpqrD{4wi!@ExA}q%ONd?u4@DK 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ƥfv*2C3?vGa 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 z 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

+

See screenshot on how to setup the Detekt plugin after installation. + + ![Detekt plugin setup][img_detekt] + **Commands** ```sh @@ -217,3 +224,5 @@ Destructive operations like `rm -rf`, `git commit`, and `git push` still require This project is licensed under the MIT License. See the [LICENSE](./LICENSE) file for more details. + +[img_detekt]: .github/img/detekt.png diff --git a/docs/transfer.md b/docs/transfer.md index 1a7c0a402..06671be7b 100644 --- a/docs/transfer.md +++ b/docs/transfer.md @@ -455,5 +455,5 @@ Transfers are serialized to JSON and included in backup. Restore deserializes an [^1]: Currently we can't display the channel closure transactions as 'transfer' yet in the activity list, due to an api missing in ldk-node. See comment in [ldk-node/wallet/mod.rs#L728-L738][ldk-node-comment] -[external-node-channel]: https://github.com/ovitrif/bitkit-docker#external-node-channel +[external-node-channel]: https://github.com/synonymdev/bitkit-docker#external-node-channel [ldk-node-comment]: https://github.com/lightningdevkit/ldk-node/blob/22a5d7742cf4e9265173ae51106db4bd9668ec8a/src/wallet/mod.rs#L728-L738 From a66feef7dccd27b9ab731467c493a58a7d605ad9 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 5 Jan 2026 22:20:40 +0100 Subject: [PATCH 03/37] chore: cleanup --- app/src/main/java/to/bitkit/ui/scaffold/AppTopBar.kt | 11 +++++------ .../ui/screens/wallets/send/SendQuickPayScreen.kt | 2 +- app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt | 2 +- .../main/java/to/bitkit/viewmodels/AppViewModel.kt | 8 +++----- 4 files changed, 10 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/scaffold/AppTopBar.kt b/app/src/main/java/to/bitkit/ui/scaffold/AppTopBar.kt index e6c122822..97c9aaafa 100644 --- a/app/src/main/java/to/bitkit/ui/scaffold/AppTopBar.kt +++ b/app/src/main/java/to/bitkit/ui/scaffold/AppTopBar.kt @@ -29,6 +29,7 @@ import to.bitkit.ui.LocalDrawerState import to.bitkit.ui.components.Title import to.bitkit.ui.theme.AppThemeSurface +@OptIn(ExperimentalMaterial3Api::class) @Composable @OptIn(ExperimentalMaterial3Api::class) fun AppTopBar( @@ -40,17 +41,15 @@ fun AppTopBar( ) { CenterAlignedTopAppBar( navigationIcon = { - if (onBackClick != null) { - BackNavIcon(onBackClick) - } + onBackClick?.let { BackNavIcon(it) } }, title = { - if (titleText != null) { + titleText?.let { text -> Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp) ) { - icon?.let { painter -> + icon?.let { Icon( painter = painter, contentDescription = null, @@ -60,7 +59,7 @@ fun AppTopBar( .size(32.dp) ) } - Title(text = titleText, maxLines = 1) + Title(text = text, maxLines = 1) } } }, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendQuickPayScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendQuickPayScreen.kt index 186b85b88..bcaef17eb 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendQuickPayScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendQuickPayScreen.kt @@ -54,7 +54,7 @@ fun SendQuickPayScreen( DisposableEffect(Unit) { onDispose { - app.resetQuickPayData() + app.resetQuickPay() } } diff --git a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt index 5c4f49e59..c3fec1648 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt @@ -56,7 +56,7 @@ fun SendSheet( // always reset state on new user-initiated send if (startDestination == SendRoute.Recipient) { appViewModel.resetSendState() - appViewModel.resetQuickPayData() + appViewModel.resetQuickPay() } } Column( diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 8d7911bf1..ecb6a37f3 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -853,7 +853,7 @@ class AppViewModel @Inject constructor( private suspend fun handleScan(result: String) = withContext(bgDispatcher) { // always reset state on new scan resetSendState() - resetQuickPayData() + resetQuickPay() @Suppress("ForbiddenComment") // TODO: wrap `decode` from bindings in a `CoreService` method and call that one val scan = runCatching { decode(result) } @@ -1564,7 +1564,7 @@ class AppViewModel @Inject constructor( } } - fun resetQuickPayData() = _quickPayData.update { null } + fun resetQuickPay() = _quickPayData.update { null } /** Reselect utxos for current amount & speed then refresh fees using updated utxos */ private fun refreshOnchainSendIfNeeded() { @@ -1588,9 +1588,7 @@ class AppViewModel @Inject constructor( ) } .onSuccess { utxos -> - _sendUiState.update { - it.copy(selectedUtxos = utxos) - } + _sendUiState.update { it.copy(selectedUtxos = utxos) } } } refreshFeeEstimates() From fefa5a3c4b8fe20ea0ed08310331cf0acf730bf1 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 5 Jan 2026 23:27:05 +0100 Subject: [PATCH 04/37] chore: fix warnings --- app/src/main/java/to/bitkit/data/AppDb.kt | 5 ++++- app/src/main/java/to/bitkit/ext/WebView.kt | 7 +++---- app/src/main/java/to/bitkit/ui/scaffold/AppTopBar.kt | 11 +++++------ .../java/to/bitkit/ui/screens/wallets/HomeScreen.kt | 2 +- .../bitkit/ui/screens/wallets/SavingsWalletScreen.kt | 2 +- .../bitkit/ui/screens/wallets/SpendingWalletScreen.kt | 2 +- 6 files changed, 15 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/to/bitkit/data/AppDb.kt b/app/src/main/java/to/bitkit/data/AppDb.kt index 1763a3818..d67f22094 100644 --- a/app/src/main/java/to/bitkit/data/AppDb.kt +++ b/app/src/main/java/to/bitkit/data/AppDb.kt @@ -23,6 +23,7 @@ import to.bitkit.data.dao.TransferDao import to.bitkit.data.entities.ConfigEntity import to.bitkit.data.entities.TransferEntity import to.bitkit.data.typeConverters.StringListConverter +import to.bitkit.env.Env @Database( entities = [ @@ -53,7 +54,6 @@ abstract class AppDb : RoomDatabase() { private fun buildDatabase(context: Context): AppDb { return Room.databaseBuilder(context, AppDb::class.java, DB_NAME) .setJournalMode(JournalMode.TRUNCATE) - .fallbackToDestructiveMigration() // TODO remove in prod .addCallback(object : Callback() { override fun onCreate(db: SupportSQLiteDatabase) { super.onCreate(db) @@ -65,6 +65,9 @@ abstract class AppDb : RoomDatabase() { } } }) + .apply { + if (Env.isDebug) fallbackToDestructiveMigration(dropAllTables = true) + } .build() } } diff --git a/app/src/main/java/to/bitkit/ext/WebView.kt b/app/src/main/java/to/bitkit/ext/WebView.kt index 806c637dc..5dae2f839 100644 --- a/app/src/main/java/to/bitkit/ext/WebView.kt +++ b/app/src/main/java/to/bitkit/ext/WebView.kt @@ -4,17 +4,16 @@ import android.annotation.SuppressLint import android.webkit.WebSettings import android.webkit.WebView -/** - * Configures WebView settings for basic web content display - */ -@SuppressLint("SetJavaScriptEnabled") fun WebView.configureForBasicWebContent() { settings.apply { + @SuppressLint("SetJavaScriptEnabled") javaScriptEnabled = true domStorageEnabled = true allowContentAccess = true allowFileAccess = false + @Suppress("DEPRECATION") allowUniversalAccessFromFileURLs = false + @Suppress("DEPRECATION") allowFileAccessFromFileURLs = false // Disable mixed content for security mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW diff --git a/app/src/main/java/to/bitkit/ui/scaffold/AppTopBar.kt b/app/src/main/java/to/bitkit/ui/scaffold/AppTopBar.kt index 97c9aaafa..7b72984ad 100644 --- a/app/src/main/java/to/bitkit/ui/scaffold/AppTopBar.kt +++ b/app/src/main/java/to/bitkit/ui/scaffold/AppTopBar.kt @@ -1,5 +1,6 @@ package to.bitkit.ui.scaffold +import androidx.annotation.DrawableRes import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope @@ -16,7 +17,6 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource @@ -31,12 +31,11 @@ import to.bitkit.ui.theme.AppThemeSurface @OptIn(ExperimentalMaterial3Api::class) @Composable -@OptIn(ExperimentalMaterial3Api::class) fun AppTopBar( titleText: String?, onBackClick: (() -> Unit)?, modifier: Modifier = Modifier, - icon: Painter? = null, + @DrawableRes icon: Int? = null, actions: @Composable (RowScope.() -> Unit) = {}, ) { CenterAlignedTopAppBar( @@ -51,7 +50,7 @@ fun AppTopBar( ) { icon?.let { Icon( - painter = painter, + painter = painterResource(icon), contentDescription = null, tint = Color.Unspecified, modifier = Modifier @@ -64,7 +63,7 @@ fun AppTopBar( } }, actions = actions, - colors = TopAppBarDefaults.centerAlignedTopAppBarColors( + colors = TopAppBarDefaults.topAppBarColors( containerColor = Color.Transparent, scrolledContainerColor = Color.Transparent, ), @@ -146,7 +145,7 @@ private fun Preview2() { AppTopBar( titleText = "Title And Icon", onBackClick = {}, - icon = painterResource(R.drawable.ic_ln_circle), + icon = R.drawable.ic_ln_circle, ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt index 5e8a6c64d..a18fbfaef 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt @@ -679,7 +679,7 @@ private fun TopBar( ) } }, - colors = TopAppBarDefaults.largeTopAppBarColors(Color.Transparent), + colors = TopAppBarDefaults.topAppBarColors(Color.Transparent), modifier = Modifier.fillMaxWidth() ) } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/SavingsWalletScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/SavingsWalletScreen.kt index d071dfe6d..cb3399fa2 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/SavingsWalletScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/SavingsWalletScreen.kt @@ -82,7 +82,7 @@ fun SavingsWalletScreen( ScreenColumn(noBackground = true) { AppTopBar( titleText = stringResource(R.string.wallet__savings__title), - icon = painterResource(R.drawable.ic_btc_circle), + icon = R.drawable.ic_btc_circle, onBackClick = onBackClick, actions = { DrawerNavIcon() diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/SpendingWalletScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/SpendingWalletScreen.kt index 82460dca1..201b4ea0a 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/SpendingWalletScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/SpendingWalletScreen.kt @@ -84,7 +84,7 @@ fun SpendingWalletScreen( ScreenColumn(noBackground = true) { AppTopBar( titleText = stringResource(R.string.wallet__spending__title), - icon = painterResource(R.drawable.ic_ln_circle), + icon = R.drawable.ic_ln_circle, onBackClick = onBackClick, actions = { DrawerNavIcon() From 08dc1af9ddec429fe84da240ba6b2bd6223f9538 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 5 Jan 2026 23:28:14 +0100 Subject: [PATCH 05/37] chore: add compose-stability-analyzer dependency --- app/build.gradle.kts | 2 ++ build.gradle.kts | 1 + gradle/libs.versions.toml | 2 ++ 3 files changed, 5 insertions(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4450eab5b..19caafb39 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -9,6 +9,7 @@ import java.util.Properties plugins { alias(libs.plugins.android.application) alias(libs.plugins.compose.compiler) + alias(libs.plugins.compose.stability.analyzer) alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.serialization) alias(libs.plugins.ksp) @@ -219,6 +220,7 @@ dependencies { androidTestImplementation(platform(libs.compose.bom)) implementation(libs.compose.material3) implementation(libs.compose.material.icons.extended) + implementation(libs.compose.runtime.tracing) implementation(libs.compose.ui) implementation(libs.compose.ui.graphics) implementation(libs.compose.ui.tooling.preview) diff --git a/build.gradle.kts b/build.gradle.kts index 3e0ebc458..6b4fab0da 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,6 +2,7 @@ plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.compose.compiler) apply false + alias(libs.plugins.compose.stability.analyzer) apply false alias(libs.plugins.google.services) apply false alias(libs.plugins.hilt.android) apply false // https://github.com/google/dagger/releases/ alias(libs.plugins.kotlin.android) apply false diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a95f687e5..9f7374b59 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -28,6 +28,7 @@ camera-view = { module = "androidx.camera:camera-view", version.ref = "camera" } compose-bom = { group = "androidx.compose", name = "compose-bom", version = "2025.12.00" } compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" } compose-material3 = { module = "androidx.compose.material3:material3" } +compose-runtime-tracing = { module = "androidx.compose.runtime:runtime-tracing" } compose-ui = { group = "androidx.compose.ui", name = "ui" } compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" } @@ -92,6 +93,7 @@ haze-materials = { module = "dev.chrisbanes.haze:haze-materials", version.ref = [plugins] android-application = { id = "com.android.application", version.ref = "agp" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +compose-stability-analyzer = { id = "com.github.skydoves.compose.stability.analyzer", version = "0.6.6" } detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } google-services = { id = "com.google.gms.google-services", version = "4.4.4" } hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } From 9e8d5eae09f0292db54f912d764c3205d97ac4a0 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 5 Jan 2026 23:28:21 +0100 Subject: [PATCH 06/37] chore: update agp --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9f7374b59..0489a314e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -agp = "8.12.0" +agp = "8.13.2" camera = "1.5.2" detekt = "1.23.8" hilt = "2.57.2" From 6f9fa8f856e304abf7de798b07b0ec036caa4b38 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 6 Jan 2026 00:01:21 +0100 Subject: [PATCH 07/37] chore: update env --- app/src/main/java/to/bitkit/env/Env.kt | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/to/bitkit/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt index 2fbbb680a..9e985f8e6 100644 --- a/app/src/main/java/to/bitkit/env/Env.kt +++ b/app/src/main/java/to/bitkit/env/Env.kt @@ -51,11 +51,11 @@ internal object Env { val electrumServerUrl: String get() { - if (isE2eTest && e2eBackend == "local") return ElectrumServers.REGTEST.LOCAL + val isE2eLocal = isE2eTest && e2eBackend == "local" return when (network) { - Network.REGTEST -> ElectrumServers.REGTEST.STAGING - Network.TESTNET -> ElectrumServers.TESTNET Network.BITCOIN -> ElectrumServers.MAINNET.FULCRUM + Network.REGTEST -> if (isE2eLocal) ElectrumServers.REGTEST.LOCAL else ElectrumServers.REGTEST.STAG + Network.TESTNET -> ElectrumServers.TESTNET else -> TODO("${network.name} network not implemented") } } @@ -220,11 +220,10 @@ object Peers { private object ElectrumServers { object MAINNET { const val FULCRUM = "ssl://fulcrum.bitkit.blocktank.to:8900" - const val ESPLORA = "ssl://34.65.252.32:18484" } object REGTEST { - const val STAGING = "tcp://34.65.252.32:18483" + const val STAG = "tcp://34.65.252.32:18483" const val LOCAL = "tcp://127.0.0.1:60001" } From 464a2f48efe1025bbb21afa0ef3d0e751aafe1e2 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 6 Jan 2026 01:42:06 +0100 Subject: [PATCH 08/37] chore: update ai rules --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index e70f3c16a..a8a9f9ffe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -195,7 +195,7 @@ suspend fun getData(): Result = withContext(Dispatchers.IO) { - ALWAYS be mindful of thread safety when working with mutable lists & state - ALWAYS split screen composables into parent accepting viewmodel + inner private child accepting state and callbacks `Content()` - ALWAYS name lambda parameters in a composable function using present tense, NEVER use past tense -- ALWAYS list 3 suggested commit messages after implementation work for the entire set of uncommitted changes +- ALWAYS list 3 suggested commit messages after implementation work for ALL uncommitted changes - NEVER use `wheneverBlocking` in unit test expression body functions wrapped in a `= test {}` lambda - ALWAYS wrap unit tests `setUp` methods mocking suspending calls with `runBlocking`, e.g `setUp() = runBlocking { }` - ALWAYS add business logic to Repository layer via methods returning `Result` and use it in ViewModels From 64be2d3dab010d1a3154e66c2896ab87466aabe3 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 6 Jan 2026 01:45:25 +0100 Subject: [PATCH 09/37] fix: replace WalletRepo uiState with repo states --- .../wallets/send/SendAmountContentTest.kt | 34 +++---- app/src/main/java/to/bitkit/env/Env.kt | 2 - app/src/main/java/to/bitkit/ui/ContentView.kt | 12 +-- .../main/java/to/bitkit/ui/NodeInfoScreen.kt | 98 +++++++++---------- .../screens/transfer/SavingsAdvancedScreen.kt | 4 +- .../screens/transfer/SavingsConfirmScreen.kt | 4 +- .../external/ExternalConnectionScreen.kt | 2 +- .../bitkit/ui/screens/wallets/HomeScreen.kt | 15 ++- .../screens/wallets/SpendingWalletScreen.kt | 22 ++--- .../wallets/receive/ReceiveAmountScreen.kt | 4 +- .../wallets/receive/ReceiveQrScreen.kt | 50 ++++++---- .../screens/wallets/receive/ReceiveSheet.kt | 5 +- .../screens/wallets/send/SendAmountScreen.kt | 23 +++-- .../ui/settings/BlocktankRegtestScreen.kt | 4 +- .../settings/lightning/ChannelDetailScreen.kt | 5 +- .../java/to/bitkit/ui/sheets/SendSheet.kt | 8 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 2 +- .../to/bitkit/viewmodels/WalletViewModel.kt | 56 +---------- 18 files changed, 147 insertions(+), 203 deletions(-) diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendAmountContentTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendAmountContentTest.kt index cf0cb569f..2bde503cf 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendAmountContentTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendAmountContentTest.kt @@ -7,9 +7,6 @@ import androidx.compose.ui.test.performClick import org.junit.Rule import org.junit.Test import to.bitkit.models.NodeLifecycleState -import to.bitkit.models.PrimaryDisplay -import to.bitkit.repositories.CurrencyState -import to.bitkit.viewmodels.MainUiState import to.bitkit.viewmodels.SendMethod import to.bitkit.viewmodels.SendUiState import to.bitkit.viewmodels.previewAmountInputViewModel @@ -19,22 +16,20 @@ class SendAmountContentTest { @get:Rule val composeTestRule = createComposeRule() - private val testUiState = SendUiState( + private val uiState = SendUiState( payMethod = SendMethod.LIGHTNING, amount = 100u, isUnified = true ) - private val testWalletState = MainUiState( - nodeLifecycleState = NodeLifecycleState.Running - ) + private val nodeLifecycleState = NodeLifecycleState.Running @Test fun whenScreenLoaded_shouldShowAllComponents() { composeTestRule.setContent { SendAmountContent( - walletUiState = testWalletState, - uiState = testUiState, + nodeLifecycleState = nodeLifecycleState, + uiState = uiState, amountInputViewModel = previewAmountInputViewModel(), ) } @@ -51,10 +46,8 @@ class SendAmountContentTest { fun whenNodeNotRunning_shouldShowSyncView() { composeTestRule.setContent { SendAmountContent( - walletUiState = MainUiState( - nodeLifecycleState = NodeLifecycleState.Initializing - ), - uiState = testUiState, + nodeLifecycleState = nodeLifecycleState, + uiState = uiState, amountInputViewModel = previewAmountInputViewModel(), ) } @@ -68,15 +61,14 @@ class SendAmountContentTest { var eventTriggered = false composeTestRule.setContent { SendAmountContent( - walletUiState = testWalletState, - uiState = testUiState, + nodeLifecycleState = nodeLifecycleState, + uiState = uiState, amountInputViewModel = previewAmountInputViewModel(), onClickPayMethod = { eventTriggered = true } ) } - composeTestRule.onNodeWithTag("AssetButton-switch") - .performClick() + composeTestRule.onNodeWithTag("AssetButton-switch").performClick() assert(eventTriggered) } @@ -86,8 +78,8 @@ class SendAmountContentTest { var eventTriggered = false composeTestRule.setContent { SendAmountContent( - walletUiState = testWalletState, - uiState = testUiState, + nodeLifecycleState = nodeLifecycleState, + uiState = uiState, amountInputViewModel = previewAmountInputViewModel(), onContinue = { eventTriggered = true } ) @@ -103,8 +95,8 @@ class SendAmountContentTest { fun whenAmountInvalid_continueButtonShouldBeDisabled() { composeTestRule.setContent { SendAmountContent( - walletUiState = testWalletState, - uiState = testUiState.copy(amount = 0u), + nodeLifecycleState = nodeLifecycleState, + uiState = uiState.copy(amount = 0u), amountInputViewModel = previewAmountInputViewModel(), ) } diff --git a/app/src/main/java/to/bitkit/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt index 9e985f8e6..c725f6884 100644 --- a/app/src/main/java/to/bitkit/env/Env.kt +++ b/app/src/main/java/to/bitkit/env/Env.kt @@ -206,8 +206,6 @@ object Defaults { * required to include them in a block would be greater than the value of the transaction itself. * */ const val dustLimit = 546u - - } object Peers { diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 36da5c814..3d7e786f8 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -381,9 +381,9 @@ fun ContentView( } is Sheet.Receive -> { - val walletUiState by walletViewModel.uiState.collectAsState() + val walletState by walletViewModel.walletState.collectAsState() ReceiveSheet( - walletState = walletUiState, + walletState = walletState, navigateToExternalConnection = { navController.navigate(ExternalConnection()) appViewModel.hideSheet() @@ -760,7 +760,7 @@ private fun NavGraphBuilder.home( drawerState: DrawerState, ) { composable { - val uiState by walletViewModel.uiState.collectAsStateWithLifecycle() + val isRefreshing by walletViewModel.isRefreshing.collectAsStateWithLifecycle() val isRecoveryMode by walletViewModel.isRecoveryMode.collectAsStateWithLifecycle() val hazeState = rememberHazeState() @@ -776,7 +776,7 @@ private fun NavGraphBuilder.home( .hazeSource(hazeState) ) { HomeScreen( - mainUiState = uiState, + isRefreshing = isRefreshing, drawerState = drawerState, rootNavController = navController, walletNavController = navController, @@ -816,11 +816,11 @@ private fun NavGraphBuilder.home( exitTransition = { Transitions.slideOutHorizontally }, ) { val hasSeenSavingsIntro by settingsViewModel.hasSeenSavingsIntro.collectAsStateWithLifecycle() - val uiState by walletViewModel.uiState.collectAsStateWithLifecycle() + val lightningState by walletViewModel.lightningState.collectAsStateWithLifecycle() val lightningActivities by activityListViewModel.lightningActivities.collectAsStateWithLifecycle() SpendingWalletScreen( - uiState = uiState, + channels = lightningState.channels, lightningActivities = lightningActivities.orEmpty(), onAllActivityButtonClick = { navController.navigateToAllActivity() }, onActivityItemClick = { navController.navigateToActivityItem(it) }, diff --git a/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt b/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt index d79765303..eef545a3f 100644 --- a/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt +++ b/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt @@ -48,6 +48,7 @@ import to.bitkit.ext.uri import to.bitkit.models.NodeLifecycleState import to.bitkit.models.Toast import to.bitkit.models.formatToModernDisplay +import to.bitkit.repositories.LightningState import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.Caption import to.bitkit.ui.components.ChannelStatusUi @@ -66,7 +67,6 @@ import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.copyToClipboard import to.bitkit.ui.utils.withAccent -import to.bitkit.viewmodels.MainUiState import kotlin.time.Clock.System.now import kotlin.time.ExperimentalTime @@ -79,14 +79,14 @@ fun NodeInfoScreen( val settings = settingsViewModel ?: return val context = LocalContext.current - val uiState by wallet.uiState.collectAsStateWithLifecycle() + val isRefreshing by wallet.isRefreshing.collectAsStateWithLifecycle() val isDevModeEnabled by settings.isDevModeEnabled.collectAsStateWithLifecycle() val lightningState by wallet.lightningState.collectAsStateWithLifecycle() Content( - uiState = uiState, + lightningState = lightningState, + isRefreshing = isRefreshing, isDevModeEnabled = isDevModeEnabled, - balanceDetails = lightningState.balances, onBack = { navController.popBackStack() }, onRefresh = { wallet.onPullToRefresh() }, onDisconnectPeer = { wallet.disconnectPeer(it) }, @@ -103,9 +103,9 @@ fun NodeInfoScreen( @OptIn(ExperimentalMaterial3Api::class) @Composable private fun Content( - uiState: MainUiState, + lightningState: LightningState, + isRefreshing: Boolean = false, isDevModeEnabled: Boolean, - balanceDetails: BalanceDetails? = null, onBack: () -> Unit = {}, onRefresh: () -> Unit = {}, onDisconnectPeer: (PeerDetails) -> Unit = {}, @@ -118,7 +118,7 @@ private fun Content( actions = { DrawerNavIcon() }, ) PullToRefreshBox( - isRefreshing = uiState.isRefreshing, + isRefreshing = isRefreshing, onRefresh = onRefresh, ) { Column( @@ -127,17 +127,17 @@ private fun Content( .verticalScroll(rememberScrollState()) ) { NodeIdSection( - nodeId = uiState.nodeId, + nodeId = lightningState.nodeId, onCopy = onCopy, ) if (isDevModeEnabled) { NodeStateSection( - nodeLifecycleState = uiState.nodeLifecycleState, - nodeStatus = uiState.nodeStatus, + nodeLifecycleState = lightningState.nodeLifecycleState, + nodeStatus = lightningState.nodeStatus, ) - balanceDetails?.let { details -> + lightningState.balances?.let { details -> WalletBalancesSection(balanceDetails = details) if (details.lightningBalances.isNotEmpty()) { @@ -145,16 +145,16 @@ private fun Content( } } - if (uiState.channels.isNotEmpty()) { + if (lightningState.channels.isNotEmpty()) { ChannelsSection( - channels = uiState.channels, + channels = lightningState.channels, onCopy = onCopy, ) } - if (uiState.peers.isNotEmpty()) { + if (lightningState.peers.isNotEmpty()) { PeersSection( - peers = uiState.peers, + peers = lightningState.peers, onDisconnectPeer = onDisconnectPeer, onCopy = onCopy, ) @@ -457,7 +457,7 @@ private fun Preview() { AppThemeSurface { Content( isDevModeEnabled = false, - uiState = MainUiState( + lightningState = LightningState( nodeId = "0348a2b7c2d3f4e5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9", ), ) @@ -472,7 +472,7 @@ private fun PreviewDevMode() { val syncTime = now().epochSeconds.toULong() Content( isDevModeEnabled = true, - uiState = MainUiState( + lightningState = LightningState( nodeLifecycleState = NodeLifecycleState.Running, nodeStatus = NodeStatus( isRunning = true, @@ -519,40 +519,40 @@ private fun PreviewDevMode() { inboundHtlcMaximumMsat = 200000000UL, ), ), - ), - balanceDetails = BalanceDetails( - totalOnchainBalanceSats = 1000000UL, - spendableOnchainBalanceSats = 900000UL, - totalAnchorChannelsReserveSats = 50000UL, - totalLightningBalanceSats = 500000UL, - lightningBalances = listOf( - LightningBalance.ClaimableOnChannelClose( - channelId = "abc123def456789012345678901234567890123456789012345678901234567890", - counterpartyNodeId = "0248a2b7c2d3f4e5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9", - amountSatoshis = 250000UL, - transactionFeeSatoshis = 1000UL, - outboundPaymentHtlcRoundedMsat = 0UL, - outboundForwardedHtlcRoundedMsat = 0UL, - inboundClaimingHtlcRoundedMsat = 0UL, - inboundHtlcRoundedMsat = 0UL, - ), - LightningBalance.ClaimableAwaitingConfirmations( - channelId = "def456789012345678901234567890123456789012345678901234567890abc123", - counterpartyNodeId = "0348a2b7c2d3f4e5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9", - amountSatoshis = 150000UL, - confirmationHeight = 850005U, - source = BalanceSource.COUNTERPARTY_FORCE_CLOSED, - ), - LightningBalance.MaybeTimeoutClaimableHtlc( - channelId = "789012345678901234567890123456789012345678901234567890abc123def456", - counterpartyNodeId = "0448a2b7c2d3f4e5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9", - amountSatoshis = 100000UL, - claimableHeight = 850010U, - paymentHash = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", - outboundPayment = true, + balances = BalanceDetails( + totalOnchainBalanceSats = 1000000UL, + spendableOnchainBalanceSats = 900000UL, + totalAnchorChannelsReserveSats = 50000UL, + totalLightningBalanceSats = 500000UL, + lightningBalances = listOf( + LightningBalance.ClaimableOnChannelClose( + channelId = "abc123def456789012345678901234567890123456789012345678901234567890", + counterpartyNodeId = "0248a2b7c2d3f4e5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9", + amountSatoshis = 250000UL, + transactionFeeSatoshis = 1000UL, + outboundPaymentHtlcRoundedMsat = 0UL, + outboundForwardedHtlcRoundedMsat = 0UL, + inboundClaimingHtlcRoundedMsat = 0UL, + inboundHtlcRoundedMsat = 0UL, + ), + LightningBalance.ClaimableAwaitingConfirmations( + channelId = "def456789012345678901234567890123456789012345678901234567890abc123", + counterpartyNodeId = "0348a2b7c2d3f4e5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9", + amountSatoshis = 150000UL, + confirmationHeight = 850005U, + source = BalanceSource.COUNTERPARTY_FORCE_CLOSED, + ), + LightningBalance.MaybeTimeoutClaimableHtlc( + channelId = "789012345678901234567890123456789012345678901234567890abc123def456", + counterpartyNodeId = "0448a2b7c2d3f4e5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9", + amountSatoshis = 100000UL, + claimableHeight = 850010U, + paymentHash = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + outboundPayment = true, + ), ), + pendingBalancesFromChannelClosures = listOf(), ), - pendingBalancesFromChannelClosures = listOf(), ), ) } diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsAdvancedScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsAdvancedScreen.kt index ef77ad01d..b72503af6 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsAdvancedScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsAdvancedScreen.kt @@ -53,8 +53,8 @@ fun SavingsAdvancedScreen( val wallet = walletViewModel ?: return val transfer = transferViewModel ?: return - val walletState by wallet.uiState.collectAsStateWithLifecycle() - val openChannels = walletState.channels.filterOpen() + val lightningState by wallet.lightningState.collectAsStateWithLifecycle() + val openChannels = lightningState.channels.filterOpen() var selectedChannelIds by remember { mutableStateOf(setOf()) } diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsConfirmScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsConfirmScreen.kt index 4aeeac17a..f7351c939 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsConfirmScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsConfirmScreen.kt @@ -54,8 +54,8 @@ fun SavingsConfirmScreen( val transfer = transferViewModel ?: return val wallet = walletViewModel ?: return - val walletState by wallet.uiState.collectAsStateWithLifecycle() - val openChannels = walletState.channels.filterOpen() + val lightningState by wallet.lightningState.collectAsStateWithLifecycle() + val openChannels = lightningState.channels.filterOpen() val hasMultiple = openChannels.size > 1 diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalConnectionScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalConnectionScreen.kt index fcc0a07f7..2fe68d868 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalConnectionScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalConnectionScreen.kt @@ -35,9 +35,9 @@ import androidx.lifecycle.SavedStateHandle import kotlinx.coroutines.flow.filterNotNull import org.lightningdevkit.ldknode.PeerDetails import to.bitkit.R -import to.bitkit.ext.of import to.bitkit.ext.getClipboardText import to.bitkit.ext.host +import to.bitkit.ext.of import to.bitkit.ext.port import to.bitkit.ui.Routes import to.bitkit.ui.components.BodyM diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt index a18fbfaef..dd5f376c9 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt @@ -115,13 +115,12 @@ import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.withAccent import to.bitkit.viewmodels.ActivityListViewModel import to.bitkit.viewmodels.AppViewModel -import to.bitkit.viewmodels.MainUiState import to.bitkit.viewmodels.SettingsViewModel import to.bitkit.viewmodels.WalletViewModel @Composable fun HomeScreen( - mainUiState: MainUiState, + isRefreshing: Boolean, drawerState: DrawerState, rootNavController: NavController, walletNavController: NavHostController, @@ -157,7 +156,7 @@ fun HomeScreen( } Content( - mainUiState = mainUiState, + isRefreshing = isRefreshing, homeUiState = homeUiState, rootNavController = rootNavController, walletNavController = walletNavController, @@ -279,7 +278,7 @@ fun HomeScreen( @OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class) @Composable private fun Content( - mainUiState: MainUiState, + isRefreshing: Boolean, homeUiState: HomeUiState, rootNavController: NavController, walletNavController: NavController, @@ -313,11 +312,11 @@ private fun Content( val pullToRefreshState = rememberPullToRefreshState() PullToRefreshBox( state = pullToRefreshState, - isRefreshing = mainUiState.isRefreshing, + isRefreshing = isRefreshing, onRefresh = onRefresh, indicator = { Indicator( - isRefreshing = mainUiState.isRefreshing, + isRefreshing = isRefreshing, state = pullToRefreshState, modifier = Modifier .padding(top = heightStatusBar) @@ -709,7 +708,7 @@ private fun Preview() { AppThemeSurface { Box { Content( - mainUiState = MainUiState(), + isRefreshing = false, homeUiState = HomeUiState( showWidgets = true, ), @@ -733,7 +732,7 @@ private fun PreviewEmpty() { AppThemeSurface { Box { Content( - mainUiState = MainUiState(), + isRefreshing = false, homeUiState = HomeUiState( showEmptyState = true, ), diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/SpendingWalletScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/SpendingWalletScreen.kt index 201b4ea0a..5d659ce1b 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/SpendingWalletScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/SpendingWalletScreen.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.synonym.bitkitcore.Activity +import org.lightningdevkit.ldknode.ChannelDetails import to.bitkit.R import to.bitkit.ext.createChannelDetails import to.bitkit.models.BalanceState @@ -43,11 +44,10 @@ import to.bitkit.ui.screens.wallets.activity.utils.previewLightningActivityItems import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.withAccent -import to.bitkit.viewmodels.MainUiState @Composable fun SpendingWalletScreen( - uiState: MainUiState, + channels: List, lightningActivities: List, onAllActivityButtonClick: () -> Unit, onActivityItemClick: (String) -> Unit, @@ -61,9 +61,9 @@ fun SpendingWalletScreen( val hasActivity = lightningActivities.isNotEmpty() mutableStateOf(hasLnFunds && !hasActivity) } - val canTransfer by remember(balances.totalLightningSats, uiState.channels.size) { + val canTransfer by remember(balances.totalLightningSats, channels.size) { val hasLnBalance = balances.totalLightningSats > 0uL - val hasChannels = uiState.channels.isNotEmpty() + val hasChannels = channels.isNotEmpty() mutableStateOf(hasLnBalance && hasChannels) } @@ -153,9 +153,7 @@ private fun Preview() { AppThemeSurface { Box { SpendingWalletScreen( - uiState = MainUiState( - channels = listOf(createChannelDetails()) - ), + channels = listOf(createChannelDetails()), lightningActivities = previewLightningActivityItems(), onAllActivityButtonClick = {}, onActivityItemClick = {}, @@ -175,9 +173,7 @@ private fun PreviewTransfer() { AppThemeSurface { Box { SpendingWalletScreen( - uiState = MainUiState( - channels = listOf(createChannelDetails()) - ), + channels = listOf(createChannelDetails()), lightningActivities = previewLightningActivityItems(), onAllActivityButtonClick = {}, onActivityItemClick = {}, @@ -200,9 +196,7 @@ private fun PreviewNoActivity() { AppThemeSurface { Box { SpendingWalletScreen( - uiState = MainUiState( - channels = listOf(createChannelDetails()) - ), + channels = listOf(createChannelDetails()), lightningActivities = emptyList(), onAllActivityButtonClick = {}, onActivityItemClick = {}, @@ -222,7 +216,7 @@ private fun PreviewEmpty() { AppThemeSurface { Box { SpendingWalletScreen( - uiState = MainUiState(), + channels = emptyList(), lightningActivities = emptyList(), onAllActivityButtonClick = {}, onActivityItemClick = {}, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveAmountScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveAmountScreen.kt index f88eba327..15ecf20d8 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveAmountScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveAmountScreen.kt @@ -65,7 +65,7 @@ fun ReceiveAmountScreen( val app = appViewModel ?: return val wallet = walletViewModel ?: return val blocktank = blocktankViewModel ?: return - val walletState by wallet.uiState.collectAsStateWithLifecycle() + val lightningState by wallet.lightningState.collectAsStateWithLifecycle() val amountInputUiState by amountInputViewModel.uiState.collectAsStateWithLifecycle() var isCreatingInvoice by remember { mutableStateOf(false) } @@ -90,7 +90,7 @@ fun ReceiveAmountScreen( scope.launch { isCreatingInvoice = true runCatching { - require(walletState.nodeLifecycleState == NodeLifecycleState.Running) { + require(lightningState.nodeLifecycleState == NodeLifecycleState.Running) { "Should not be able to land on this screen if the node is not running." } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt index 09484c735..8b48768e4 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt @@ -56,6 +56,8 @@ import to.bitkit.R import to.bitkit.ext.setClipboardText import to.bitkit.ext.truncate import to.bitkit.models.NodeLifecycleState +import to.bitkit.repositories.LightningState +import to.bitkit.repositories.WalletState import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BodyS import to.bitkit.ui.components.BottomSheetPreview @@ -77,14 +79,14 @@ import to.bitkit.ui.theme.AppShapes import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.withAccent -import to.bitkit.viewmodels.MainUiState @Suppress("CyclomaticComplexMethod") @OptIn(FlowPreview::class) @Composable fun ReceiveQrScreen( cjitInvoice: String?, - walletState: MainUiState, + walletState: WalletState, + lightningState: LightningState, onClickEditInvoice: () -> Unit, onClickReceiveCjit: () -> Unit, modifier: Modifier = Modifier, @@ -93,7 +95,7 @@ fun ReceiveQrScreen( SetMaxBrightness() val haptic = LocalHapticFeedback.current - val hasUsableChannels = walletState.channels.any { it.isChannelReady } + val hasUsableChannels = lightningState.channels.any { it.isChannelReady } var showDetails by remember { mutableStateOf(false) } @@ -113,7 +115,7 @@ fun ReceiveQrScreen( walletState.bolt11, walletState.onchainAddress, cjitInvoice, - walletState.nodeLifecycleState + lightningState.nodeLifecycleState ) { visibleTabs.associateWith { tab -> getInvoiceForTab( @@ -121,7 +123,7 @@ fun ReceiveQrScreen( bip21 = walletState.bip21, bolt11 = walletState.bolt11, cjitInvoice = cjitInvoice, - isNodeRunning = walletState.nodeLifecycleState.isRunning(), + isNodeRunning = lightningState.nodeLifecycleState.isRunning(), onchainAddress = walletState.onchainAddress ) } @@ -174,9 +176,9 @@ fun ReceiveQrScreen( } } - val showingCjitOnboarding = remember(walletState, cjitInvoice, hasUsableChannels) { + val showingCjitOnboarding = remember(lightningState, cjitInvoice, hasUsableChannels) { !hasUsableChannels && - walletState.nodeLifecycleState.isRunning() && + lightningState.nodeLifecycleState.isRunning() && cjitInvoice.isNullOrEmpty() } @@ -273,7 +275,7 @@ fun ReceiveQrScreen( Spacer(Modifier.height(24.dp)) - AnimatedVisibility(visible = walletState.nodeLifecycleState.isRunning()) { + AnimatedVisibility(visible = lightningState.nodeLifecycleState.isRunning()) { val showCjitButton = showingCjitOnboarding && selectedTab == ReceiveTab.SPENDING PrimaryButton( text = stringResource( @@ -467,7 +469,7 @@ fun CjitOnBoardingView(modifier: Modifier = Modifier) { @Composable private fun ReceiveDetailsView( tab: ReceiveTab, - walletState: MainUiState, + walletState: WalletState, cjitInvoice: String?, onClickEditInvoice: () -> Unit, modifier: Modifier = Modifier, @@ -639,9 +641,11 @@ private fun PreviewSavingsMode() { BottomSheetPreview { ReceiveQrScreen( cjitInvoice = null, - walletState = MainUiState( - nodeLifecycleState = NodeLifecycleState.Running, + walletState = WalletState( onchainAddress = "bcrt1qfserxgtuesul4m9zva56wzk849yf9l8rk4qy0l", + ), + lightningState = LightningState( + nodeLifecycleState = NodeLifecycleState.Running, channels = emptyList() ), onClickEditInvoice = {}, @@ -703,9 +707,7 @@ private fun PreviewAutoMode() { BottomSheetPreview { ReceiveQrScreen( cjitInvoice = null, - walletState = MainUiState( - nodeLifecycleState = NodeLifecycleState.Running, - channels = listOf(mockChannel), + walletState = WalletState( onchainAddress = "bcrt1qfserxgtuesul4m9zva56wzk849yf9l8rk4qy0l", bolt11 = "lnbcrt500u1pn7umn7pp5x0s9lt9fwrff6rp70pz3guwnjgw97sjuv79vhx9n2ps8q6tcdehhxapqd9h8vmmfv" + "djjqen0wgsyqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxq", @@ -713,6 +715,10 @@ private fun PreviewAutoMode() { "lnbcrt500u1pn7umn7pp5x0s9lt9fwrff6rp70pz3guwnjgw97sjuv79vhx9n2ps8q6tcdehhxapqd9h8vmmfv" + "djjqen0wgsyqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxq", ), + lightningState = LightningState( + nodeLifecycleState = NodeLifecycleState.Running, + channels = listOf(mockChannel), + ), onClickEditInvoice = {}, modifier = Modifier.sheetHeight(), initialTab = ReceiveTab.AUTO, @@ -771,12 +777,14 @@ private fun PreviewSpendingMode() { BottomSheetPreview { ReceiveQrScreen( cjitInvoice = null, - walletState = MainUiState( - nodeLifecycleState = NodeLifecycleState.Running, - channels = listOf(mockChannel), + walletState = WalletState( bolt11 = "lnbcrt500u1pn7umn7pp5x0s9lt9fwrff6rp70pz3guwnjgw97sjuv79vhx9n2ps8q6tcdehhxapqd9h8vmmfv" + "djjqen0wgsyqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxq" ), + lightningState = LightningState( + nodeLifecycleState = NodeLifecycleState.Running, + channels = listOf(mockChannel), + ), onClickEditInvoice = {}, modifier = Modifier.sheetHeight(), initialTab = ReceiveTab.SPENDING, @@ -793,7 +801,8 @@ private fun PreviewNodeNotReady() { BottomSheetPreview { ReceiveQrScreen( cjitInvoice = null, - walletState = MainUiState( + walletState = WalletState(), + lightningState = LightningState( nodeLifecycleState = NodeLifecycleState.Starting, ), onClickReceiveCjit = {}, @@ -811,7 +820,8 @@ private fun PreviewSmall() { BottomSheetPreview { ReceiveQrScreen( cjitInvoice = null, - walletState = MainUiState( + walletState = WalletState(), + lightningState = LightningState( nodeLifecycleState = NodeLifecycleState.Running, ), onClickEditInvoice = {}, @@ -835,7 +845,7 @@ private fun PreviewDetailsMode() { ) { ReceiveDetailsView( tab = ReceiveTab.AUTO, - walletState = MainUiState( + walletState = WalletState( onchainAddress = "bcrt1qfserxgtuesul4m9zva56wzk849yf9l8rk4qy0l", bolt11 = "lnbcrt500u1pn7umn7pp5x0s9lt9fwrff6rp70pz3guwnjgw97sjuv79...", ), 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 3cee808dc..187054cff 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 @@ -17,19 +17,19 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.rememberNavController import kotlinx.serialization.Serializable import to.bitkit.repositories.LightningState +import to.bitkit.repositories.WalletState import to.bitkit.ui.screens.wallets.send.AddTagScreen import to.bitkit.ui.shared.modifiers.sheetHeight import to.bitkit.ui.utils.NotificationUtils import to.bitkit.ui.utils.composableWithDefaultTransitions import to.bitkit.ui.walletViewModel import to.bitkit.viewmodels.AmountInputViewModel -import to.bitkit.viewmodels.MainUiState import to.bitkit.viewmodels.SettingsViewModel @Composable fun ReceiveSheet( navigateToExternalConnection: () -> Unit, - walletState: MainUiState, + walletState: WalletState, editInvoiceAmountViewModel: AmountInputViewModel = hiltViewModel(), settingsViewModel: SettingsViewModel = hiltViewModel(), ) { @@ -67,6 +67,7 @@ fun ReceiveSheet( ReceiveQrScreen( cjitInvoice = cjitInvoice.value, walletState = walletState, + lightningState = lightningState, onClickReceiveCjit = { if (lightningState.isGeoBlocked) { navController.navigate(ReceiveRoute.GeoBlock) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendAmountScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendAmountScreen.kt index 215902134..292aeaca3 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendAmountScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendAmountScreen.kt @@ -60,7 +60,6 @@ import to.bitkit.ui.theme.Colors import to.bitkit.viewmodels.AmountInputUiState import to.bitkit.viewmodels.AmountInputViewModel import to.bitkit.viewmodels.LnurlParams -import to.bitkit.viewmodels.MainUiState import to.bitkit.viewmodels.SendEvent import to.bitkit.viewmodels.SendMethod import to.bitkit.viewmodels.SendUiState @@ -70,7 +69,7 @@ import to.bitkit.viewmodels.previewAmountInputViewModel @Composable fun SendAmountScreen( uiState: SendUiState, - walletUiState: MainUiState, + nodeLifecycleState: NodeLifecycleState, canGoBack: Boolean, onBack: () -> Unit, onEvent: (SendEvent) -> Unit, @@ -99,7 +98,7 @@ fun SendAmountScreen( } SendAmountContent( - walletUiState = walletUiState, + nodeLifecycleState = nodeLifecycleState, uiState = uiState, amountInputViewModel = amountInputViewModel, currencies = currencies, @@ -125,7 +124,7 @@ fun SendAmountScreen( @Suppress("ViewModelForwarding") @Composable fun SendAmountContent( - walletUiState: MainUiState, + nodeLifecycleState: NodeLifecycleState, uiState: SendUiState, amountInputViewModel: AmountInputViewModel, modifier: Modifier = Modifier, @@ -154,7 +153,7 @@ fun SendAmountContent( onBack = onBack, ) - when (walletUiState.nodeLifecycleState) { + when (nodeLifecycleState) { is NodeLifecycleState.Running -> { SendAmountNodeRunning( amountInputViewModel = amountInputViewModel, @@ -328,7 +327,7 @@ private fun PreviewLightningNoAmount() { AppThemeSurface { BottomSheetPreview { SendAmountContent( - walletUiState = MainUiState(nodeLifecycleState = NodeLifecycleState.Running), + nodeLifecycleState = NodeLifecycleState.Running, uiState = SendUiState( payMethod = SendMethod.LIGHTNING, ), @@ -351,7 +350,7 @@ private fun PreviewUnified() { ) } SendAmountContent( - walletUiState = MainUiState(nodeLifecycleState = NodeLifecycleState.Running), + nodeLifecycleState = NodeLifecycleState.Running, uiState = SendUiState( payMethod = SendMethod.LIGHTNING, isUnified = true, @@ -371,7 +370,7 @@ private fun PreviewOnchain() { AppThemeSurface { BottomSheetPreview { SendAmountContent( - walletUiState = MainUiState(nodeLifecycleState = NodeLifecycleState.Running), + nodeLifecycleState = NodeLifecycleState.Running, uiState = SendUiState( payMethod = SendMethod.ONCHAIN, ), @@ -389,7 +388,7 @@ private fun PreviewInitializing() { AppThemeSurface { BottomSheetPreview { SendAmountContent( - walletUiState = MainUiState(nodeLifecycleState = NodeLifecycleState.Initializing), + nodeLifecycleState = NodeLifecycleState.Initializing, uiState = SendUiState( payMethod = SendMethod.LIGHTNING, ), @@ -406,7 +405,7 @@ private fun PreviewWithdraw() { AppThemeSurface { BottomSheetPreview { SendAmountContent( - walletUiState = MainUiState(nodeLifecycleState = NodeLifecycleState.Running), + nodeLifecycleState = NodeLifecycleState.Running, uiState = SendUiState( payMethod = SendMethod.LIGHTNING, lnurl = LnurlParams.LnurlWithdraw( @@ -435,7 +434,7 @@ private fun PreviewLnurlPay() { AppThemeSurface { BottomSheetPreview { SendAmountContent( - walletUiState = MainUiState(nodeLifecycleState = NodeLifecycleState.Running), + nodeLifecycleState = NodeLifecycleState.Running, uiState = SendUiState( payMethod = SendMethod.LIGHTNING, lnurl = LnurlParams.LnurlPay( @@ -465,7 +464,7 @@ private fun PreviewSmallScreen() { AppThemeSurface { BottomSheetPreview { SendAmountContent( - walletUiState = MainUiState(nodeLifecycleState = NodeLifecycleState.Running), + nodeLifecycleState = NodeLifecycleState.Running, uiState = SendUiState( payMethod = SendMethod.LIGHTNING, ), diff --git a/app/src/main/java/to/bitkit/ui/settings/BlocktankRegtestScreen.kt b/app/src/main/java/to/bitkit/ui/settings/BlocktankRegtestScreen.kt index 47778d650..c09d03807 100644 --- a/app/src/main/java/to/bitkit/ui/settings/BlocktankRegtestScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/BlocktankRegtestScreen.kt @@ -50,7 +50,7 @@ fun BlocktankRegtestScreen( val coroutineScope = rememberCoroutineScope() val wallet = walletViewModel ?: return val app = appViewModel ?: return - val uiState by wallet.uiState.collectAsStateWithLifecycle() + val walletState by wallet.walletState.collectAsStateWithLifecycle() ScreenColumn { AppTopBar( @@ -65,7 +65,7 @@ fun BlocktankRegtestScreen( .verticalScroll(rememberScrollState()) .imePadding() ) { - var depositAddress by remember { mutableStateOf(uiState.onchainAddress) } + var depositAddress by remember { mutableStateOf(walletState.onchainAddress) } var depositAmount by remember { mutableStateOf("100000") } var mineBlockCount by remember { mutableStateOf("1") } var paymentInvoice by remember { mutableStateOf("") } diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt index 3081689de..e67caecf8 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt @@ -97,8 +97,7 @@ fun ChannelDetailScreen( val paidOrders by viewModel.blocktankRepo.blocktankState.collectAsStateWithLifecycle() val isClosedChannel = uiState.closedChannels.any { it.details.channelId == channel.details.channelId } - val txDetails by viewModel.txDetails.collectAsStateWithLifecycle() - val walletState by wallet.uiState.collectAsStateWithLifecycle() + val lightningState by wallet.lightningState.collectAsStateWithLifecycle() // Fetch transaction details for funding transaction if available LaunchedEffect(channel.details.fundingTxo?.txid) { @@ -140,7 +139,7 @@ fun ChannelDetailScreen( val intent = Intent(Intent.ACTION_VIEW, url.toUri()) context.startActivity(intent) }, - onSupport = { order -> contactSupport(order, channel, walletState.nodeId, context) }, + onSupport = { order -> contactSupport(order, channel, lightningState.nodeId, context) }, onCloseConnection = { navController.navigate(Routes.CloseConnection) }, ) } diff --git a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt index c3fec1648..2998520c5 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt @@ -111,10 +111,10 @@ fun SendSheet( } composableWithDefaultTransitions { val uiState by appViewModel.sendUiState.collectAsStateWithLifecycle() - val walletUiState by walletViewModel.uiState.collectAsStateWithLifecycle() + val lightningState by walletViewModel.lightningState.collectAsStateWithLifecycle() SendAmountScreen( uiState = uiState, - walletUiState = walletUiState, + nodeLifecycleState = lightningState.nodeLifecycleState, canGoBack = startDestination != SendRoute.Amount, onBack = { if (!navController.popBackStack()) { @@ -167,12 +167,12 @@ fun SendSheet( } composableWithDefaultTransitions { val uiState by appViewModel.sendUiState.collectAsStateWithLifecycle() - val walletUiState by walletViewModel.uiState.collectAsStateWithLifecycle() + val lightningState by walletViewModel.lightningState.collectAsStateWithLifecycle() SendConfirmScreen( savedStateHandle = it.savedStateHandle, uiState = uiState, - isNodeRunning = walletUiState.nodeLifecycleState.isRunning(), + isNodeRunning = lightningState.nodeLifecycleState.isRunning(), canGoBack = startDestination != SendRoute.Confirm, onBack = { if (!navController.popBackStack()) { diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index ecb6a37f3..acef6032b 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -60,8 +60,8 @@ import to.bitkit.data.resetPin import to.bitkit.di.BgDispatcher import to.bitkit.domain.commands.NotifyPaymentReceived import to.bitkit.domain.commands.NotifyPaymentReceivedHandler -import to.bitkit.env.Env import to.bitkit.env.Defaults +import to.bitkit.env.Env import to.bitkit.ext.WatchResult import to.bitkit.ext.amountOnClose import to.bitkit.ext.getClipboardText diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index 7895faf4b..102c0fc39 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -16,16 +16,10 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.coroutines.withTimeoutOrNull -import org.lightningdevkit.ldknode.ChannelDataMigration -import org.lightningdevkit.ldknode.ChannelDetails -import org.lightningdevkit.ldknode.NodeStatus import org.lightningdevkit.ldknode.PeerDetails import to.bitkit.data.SettingsStore import to.bitkit.di.BgDispatcher -import to.bitkit.models.NodeLifecycleState import to.bitkit.models.Toast import to.bitkit.repositories.BackupRepo import to.bitkit.repositories.BlocktankRepo @@ -79,10 +73,8 @@ class WalletViewModel @Inject constructor( private val _restoreState = MutableStateFlow(RestoreState.Initial) val restoreState: StateFlow = _restoreState.asStateFlow() - private val _uiState = MutableStateFlow(MainUiState()) - - @Deprecated("Prioritize get the wallet and lightning states from LightningRepo or WalletRepo") - val uiState = _uiState.asStateFlow() + private val _isRefreshing = MutableStateFlow(false) + val isRefreshing = _isRefreshing.asStateFlow() private var syncJob: Job? = null @@ -150,35 +142,11 @@ class WalletViewModel @Inject constructor( viewModelScope.launch { walletState.collect { state -> walletExists = state.walletExists - _uiState.update { - it.copy( - onchainAddress = state.onchainAddress, - bolt11 = state.bolt11, - bip21 = state.bip21, - bip21AmountSats = state.bip21AmountSats, - bip21Description = state.bip21Description, - selectedTags = state.selectedTags, - ) - } if (state.walletExists && _restoreState.value == RestoreState.InProgress.Wallet) { restoreFromBackup() } } } - - viewModelScope.launch { - lightningState.collect { state -> - _uiState.update { - it.copy( - nodeId = state.nodeId, - nodeStatus = state.nodeStatus, - nodeLifecycleState = state.nodeLifecycleState, - peers = state.peers, - channels = state.channels, - ) - } - } - } } private suspend fun restoreFromBackup() { @@ -319,11 +287,11 @@ class WalletViewModel @Inject constructor( lightningRepo.clearPendingSync() syncJob = viewModelScope.launch { - _uiState.update { it.copy(isRefreshing = true) } + _isRefreshing.value = true try { walletRepo.syncNodeAndWallet(source = SyncSource.MANUAL) } finally { - _uiState.update { it.copy(isRefreshing = false) } + _isRefreshing.value = false } } } @@ -432,22 +400,6 @@ class WalletViewModel @Inject constructor( } } -// TODO rename to walletUiState -data class MainUiState( - val nodeId: String = "", - val onchainAddress: String = "", - val bolt11: String = "", - val bip21: String = "", - val nodeStatus: NodeStatus? = null, - val nodeLifecycleState: NodeLifecycleState = NodeLifecycleState.Stopped, - val peers: List = emptyList(), - val channels: List = emptyList(), - val isRefreshing: Boolean = false, - val bip21AmountSats: ULong? = null, - val bip21Description: String = "", - val selectedTags: List = listOf(), -) - sealed interface RestoreState { data object Initial : RestoreState sealed interface InProgress : RestoreState { From 0d3f3a5f73ce5f32d8608d01c11b4a0b68042039 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 6 Jan 2026 05:23:33 +0100 Subject: [PATCH 10/37] feat: localize remaining hardcoded strings --- .../main/java/to/bitkit/fcm/WakeNodeWorker.kt | 4 +- .../main/java/to/bitkit/ui/MainActivity.kt | 6 +- .../ui/components/NotificationPreview.kt | 6 +- .../ui/screens/profile/CreateProfileScreen.kt | 2 +- .../screens/transfer/SpendingConfirmScreen.kt | 18 ++- .../transfer/components/ProgressSteps.kt | 2 + .../external/ExternalConfirmScreen.kt | 6 +- .../ui/screens/wallets/SavingsWalletScreen.kt | 2 +- .../screens/wallets/SpendingWalletScreen.kt | 2 +- .../wallets/activity/ActivityDetailScreen.kt | 6 +- .../activity/DateRangeSelectorSheet.kt | 4 +- .../activity/components/ActivityRow.kt | 13 +- .../wallets/receive/ReceiveLiquidityScreen.kt | 4 +- .../wallets/withdraw/WithdrawErrorScreen.kt | 3 +- .../ui/settings/BackupSettingsScreen.kt | 2 +- .../ui/settings/LanguageSettingsScreen.kt | 4 +- .../advanced/CoinSelectPreferenceScreen.kt | 8 +- .../BackgroundPaymentsIntroScreen.kt | 8 +- .../BackgroundPaymentsSettings.kt | 54 ++++---- .../settings/general/GeneralSettingsScreen.kt | 8 +- .../ui/sheets/BackgroundPaymentsIntroSheet.kt | 6 +- .../bitkit/ui/sheets/BoostTransactionSheet.kt | 4 +- .../to/bitkit/ui/sheets/LnurlAuthSheet.kt | 9 +- .../to/bitkit/ui/utils/NotificationUtils.kt | 18 ++- .../java/to/bitkit/viewmodels/AppViewModel.kt | 18 +-- .../to/bitkit/viewmodels/WalletViewModel.kt | 16 ++- app/src/main/res/values/strings.xml | 52 +++++++- docs/strings.md | 118 ++++++++++++++++++ 28 files changed, 275 insertions(+), 128 deletions(-) create mode 100644 docs/strings.md diff --git a/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt b/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt index 77aedd670..93957ea27 100644 --- a/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt +++ b/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt @@ -96,7 +96,7 @@ class WakeNodeWorker @AssistedInject constructor( Logger.error("Failed to open channel", e, context = TAG) bestAttemptContent = NotificationDetails( title = appContext.getString(R.string.notification_channel_open_failed_title), - body = e.message ?: appContext.getString(R.string.notification_unknown_error), + body = e.message ?: appContext.getString(R.string.common__error_desc), ) deliver() } @@ -106,7 +106,7 @@ class WakeNodeWorker @AssistedInject constructor( withTimeout(timeout) { deliverSignal.await() } // Stops node on timeout & avoids notification replay by OS return Result.success() } catch (e: Exception) { - val reason = e.message ?: appContext.getString(R.string.notification_unknown_error) + val reason = e.message ?: appContext.getString(R.string.common__error_desc) bestAttemptContent = NotificationDetails( title = appContext.getString(R.string.notification_lightning_error_title), diff --git a/app/src/main/java/to/bitkit/ui/MainActivity.kt b/app/src/main/java/to/bitkit/ui/MainActivity.kt index 4977eedd8..6faf85798 100644 --- a/app/src/main/java/to/bitkit/ui/MainActivity.kt +++ b/app/src/main/java/to/bitkit/ui/MainActivity.kt @@ -30,6 +30,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.serialization.Serializable +import to.bitkit.R import to.bitkit.androidServices.LightningNodeService import to.bitkit.androidServices.LightningNodeService.Companion.CHANNEL_ID_NODE import to.bitkit.models.NewTransactionSheetDetails @@ -77,10 +78,9 @@ class MainActivity : FragmentActivity() { initNotificationChannel() initNotificationChannel( - // TODO Transifex id = CHANNEL_ID_NODE, - name = "Lightning node notification", - desc = "Channel for LightningNodeService", + name = getString(R.string.notification_channel_node_name), + desc = getString(R.string.notification_channel_node_desc), importance = NotificationManager.IMPORTANCE_LOW ) appViewModel.handleDeeplinkIntent(intent) diff --git a/app/src/main/java/to/bitkit/ui/components/NotificationPreview.kt b/app/src/main/java/to/bitkit/ui/components/NotificationPreview.kt index f36ac8bce..ab850572f 100644 --- a/app/src/main/java/to/bitkit/ui/components/NotificationPreview.kt +++ b/app/src/main/java/to/bitkit/ui/components/NotificationPreview.kt @@ -15,6 +15,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import to.bitkit.R @@ -50,7 +51,10 @@ fun NotificationPreview( verticalArrangement = Arrangement.SpaceBetween ) { BodySSB(text = title, color = Colors.Black) - val textDescription = if (showDetails) description else "Open Bitkit to see details" // TODO Transifex + val textDescription = when (showDetails) { + true -> description + else -> stringResource(R.string.notification_received_body_hidden) + } AnimatedContent(targetState = textDescription) { text -> Footnote(text = text, color = Colors.Gray3) } diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/CreateProfileScreen.kt b/app/src/main/java/to/bitkit/ui/screens/profile/CreateProfileScreen.kt index 0c5071c03..8d8e11c83 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/CreateProfileScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/CreateProfileScreen.kt @@ -33,7 +33,7 @@ fun CreateProfileScreen( Spacer(Modifier.weight(1f)) Display( - text = "Comming soon", + text = stringResource(R.string.other__coming_soon), color = Colors.White ) Spacer(Modifier.weight(1f)) diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingConfirmScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingConfirmScreen.kt index dd2c4cc60..3f62f2312 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingConfirmScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingConfirmScreen.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -99,14 +100,12 @@ fun SpendingConfirmScreen( onLearnMoreClick = onLearnMoreClick, onAdvancedClick = onAdvancedClick, onConfirm = onConfirm, - onUseDefaultLspBalanceClick = { viewModel.onUseDefaultLspBalanceClick() }, - onTransferToSpendingConfirm = { order -> viewModel.onTransferToSpendingConfirm(order) }, + onUseDefaultLspBalanceClick = viewModel::onUseDefaultLspBalanceClick, + onTransferToSpendingConfirm = viewModel::onTransferToSpendingConfirm, order = order, hasNotificationPermission = notificationsGranted, - onSwitchClick = { - NotificationUtils.openNotificationSettings(context) - }, - isAdvanced = isAdvanced + onSwitchClick = { NotificationUtils.openNotificationSettings(context) }, + isAdvanced = isAdvanced, ) } @@ -140,7 +139,7 @@ private fun Content( modifier = Modifier .fillMaxWidth() .padding(horizontal = 60.dp) - .align(alignment = Alignment.BottomCenter) + .align(Alignment.BottomCenter) .padding(bottom = 76.dp) ) } @@ -158,10 +157,7 @@ private fun Content( val lspBalance = order.lspBalanceSat VerticalSpacer(32.dp) - Display( - text = stringResource(R.string.lightning__transfer__confirm) - .withAccent(accentColor = Colors.Purple) - ) + Display(stringResource(R.string.lightning__transfer__confirm).withAccent(accentColor = Colors.Purple)) VerticalSpacer(8.dp) Row( diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/components/ProgressSteps.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/components/ProgressSteps.kt index b3c53885d..987e755df 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/components/ProgressSteps.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/components/ProgressSteps.kt @@ -24,8 +24,10 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import to.bitkit.R import to.bitkit.ui.components.BodySSB import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalConfirmScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalConfirmScreen.kt index fbaae5a19..9fd0426b5 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalConfirmScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalConfirmScreen.kt @@ -66,7 +66,7 @@ fun ExternalConfirmScreen( Content( uiState = uiState, - onConfirm = { viewModel.onConfirm() }, + onConfirm = viewModel::onConfirm, onNetworkFeeClick = onNetworkFeeClick, onBackClick = onBackClick, ) @@ -96,9 +96,7 @@ private fun Content( val totalFee = uiState.amount.sats + networkFee Spacer(modifier = Modifier.height(16.dp)) - Display( - text = stringResource(R.string.lightning__transfer__confirm).withAccent(accentColor = Colors.Purple) - ) + Display(stringResource(R.string.lightning__transfer__confirm).withAccent(accentColor = Colors.Purple)) Spacer(modifier = Modifier.height(8.dp)) Row( diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/SavingsWalletScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/SavingsWalletScreen.kt index cb3399fa2..7b2b48671 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/SavingsWalletScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/SavingsWalletScreen.kt @@ -112,7 +112,7 @@ fun SavingsWalletScreen( if (canTransfer) { SecondaryButton( onClick = onTransferToSpendingClick, - text = "Transfer To Spending", // TODO add missing localized text + text = stringResource(R.string.wallet__transfer_to_spending), icon = { Icon( painter = painterResource(R.drawable.ic_transfer), diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/SpendingWalletScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/SpendingWalletScreen.kt index 5d659ce1b..5aeac50d9 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/SpendingWalletScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/SpendingWalletScreen.kt @@ -114,7 +114,7 @@ fun SpendingWalletScreen( if (canTransfer) { SecondaryButton( onClick = onTransferToSavingsClick, - text = "Transfer To Savings", // TODO add missing localized text + text = stringResource(R.string.wallet__transfer_to_savings), icon = { Icon( painter = painterResource(R.drawable.ic_transfer), diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt index 0e2a543f7..63886cd13 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt @@ -436,7 +436,7 @@ private fun ActivityDetailContent( text = when { isTransferToSpending -> stringResource(R.string.wallet__activity_transfer_to_spending) isTransferFromSpending -> stringResource(R.string.wallet__activity_transfer_to_savings) - isSelfSend -> "Sent to myself" // TODO add missing localized text + isSelfSend -> stringResource(R.string.wallet__activity_sent_self) else -> stringResource(R.string.wallet__activity_payment) }, color = Colors.White64, @@ -740,7 +740,6 @@ private fun StatusSection( if (item.v1.isTransfer) { val duration = FeeRate.getFeeDescription(item.v1.feeRate, feeRates) - .removeEstimationSymbol() statusText = stringResource(R.string.wallet__activity_transfer_pending) .replace("{duration}", duration) statusTestTag = "StatusTransfer" @@ -953,6 +952,3 @@ private fun isBoostCompleted( return activity.boostTxIds.any { boostTxDoesExist[it] == true } } } - -// TODO remove this method after transifex update -private fun String.removeEstimationSymbol() = this.replace("±", "") diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/DateRangeSelectorSheet.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/DateRangeSelectorSheet.kt index 6c47947fa..07580448a 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/DateRangeSelectorSheet.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/DateRangeSelectorSheet.kt @@ -255,7 +255,7 @@ private fun Content( ) { Icon( imageVector = Icons.AutoMirrored.Filled.KeyboardArrowLeft, - contentDescription = "Previous month", + contentDescription = stringResource(R.string.wallet__activity_previous_month), tint = Colors.Brand ) } @@ -268,7 +268,7 @@ private fun Content( ) { Icon( imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = "Next month", + contentDescription = stringResource(R.string.wallet__activity_next_month), tint = Colors.Brand ) } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt index b7c9d7638..81962392a 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt @@ -130,7 +130,7 @@ fun ActivityRow( } else { val duration = FeeRate.getFeeDescription(item.v1.feeRate, feeRates) stringResource(R.string.wallet__activity_transfer_spending_pending) - .replace("{duration}", duration.removeEstimationSymbol()) + .replace("{duration}", duration) } isTransfer && !isSent -> if (item.v1.confirmed) { @@ -138,17 +138,15 @@ fun ActivityRow( } else { val duration = FeeRate.getFeeDescription(item.v1.feeRate, feeRates) stringResource(R.string.wallet__activity_transfer_savings_pending) - .replace("{duration}", duration.removeEstimationSymbol()) + .replace("{duration}", duration) } confirmed == true -> formattedTime(timestamp) else -> { val feeDescription = FeeRate.getFeeDescription(item.v1.feeRate, feeRates) - stringResource(R.string.wallet__activity_confirms_in).replace( - "{feeRateDescription}", - feeDescription - ) + stringResource(R.string.wallet__activity_confirms_in) + .replace("{feeRateDescription}", feeDescription) } } } @@ -323,9 +321,6 @@ private fun formattedTime(timestamp: ULong): String { } } -// TODO remove this method after transifex update -private fun String.removeEstimationSymbol() = this.replace("±", "") - private class ActivityItemsPreviewProvider : PreviewParameterProvider { override val values: Sequence get() = previewActivityItems.asSequence() } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveLiquidityScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveLiquidityScreen.kt index 5f3b9c801..4cfc6b879 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveLiquidityScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveLiquidityScreen.kt @@ -131,14 +131,14 @@ private fun Content( FillHeight() BodyM( - text = "Enable background setup to safely exit Bitkit while your balance is being configured.", + text = stringResource(R.string.wallet__receive_liquidity__bg_setup_desc), color = Colors.White64 ) VerticalSpacer(15.dp) SettingsSwitchRow( - title = "Set up in background", + title = stringResource(R.string.wallet__receive_liquidity__bg_setup_switch), isChecked = hasNotificationPermission, colors = AppSwitchDefaults.colorsPurple, onClick = onSwitchClick, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/withdraw/WithdrawErrorScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/withdraw/WithdrawErrorScreen.kt index bae5d34bf..f2f7b6f65 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/withdraw/WithdrawErrorScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/withdraw/WithdrawErrorScreen.kt @@ -56,8 +56,7 @@ fun WithdrawErrorScreen( VerticalSpacer(46.dp) BodyM( - // TODO add missing localized text - text = "Your withdrawal was unsuccessful. Please scan the QR code again or contact support.", + text = stringResource(R.string.wallet__withdraw_error), color = Colors.White64, ) 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 558d127f2..903b61124 100644 --- a/app/src/main/java/to/bitkit/ui/settings/BackupSettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/BackupSettingsScreen.kt @@ -124,7 +124,7 @@ private fun BackupSettingsScreenContent( if (Env.isE2eTest && allSynced) { Icon( painter = painterResource(R.drawable.ic_check_circle), - contentDescription = "All Synced", + contentDescription = null, tint = Colors.Green, modifier = Modifier .padding(end = 4.dp) diff --git a/app/src/main/java/to/bitkit/ui/settings/LanguageSettingsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/LanguageSettingsScreen.kt index 3b21f3a2d..9c0276207 100644 --- a/app/src/main/java/to/bitkit/ui/settings/LanguageSettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/LanguageSettingsScreen.kt @@ -8,10 +8,12 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import to.bitkit.R import to.bitkit.models.Language import to.bitkit.ui.components.Text13Up import to.bitkit.ui.components.settings.SettingsButtonRow @@ -53,7 +55,7 @@ private fun Content( modifier = modifier.screen() ) { AppTopBar( - titleText = "Language", // TODO Transifex + titleText = stringResource(R.string.settings__language_title), onBackClick = onBackClick, actions = { DrawerNavIcon() } ) diff --git a/app/src/main/java/to/bitkit/ui/settings/advanced/CoinSelectPreferenceScreen.kt b/app/src/main/java/to/bitkit/ui/settings/advanced/CoinSelectPreferenceScreen.kt index 9555246b5..dbe193a4c 100644 --- a/app/src/main/java/to/bitkit/ui/settings/advanced/CoinSelectPreferenceScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/advanced/CoinSelectPreferenceScreen.kt @@ -143,8 +143,8 @@ private fun Content( // ) SettingsButtonRow( - title = "Branch and Bound", // TODO add missing localized text - description = "Finds exact amount matches to minimize change", // TODO add missing localized text + title = stringResource(R.string.settings__cs__bnb_title), + description = stringResource(R.string.settings__cs__bnb_desc), value = SettingsButtonValue.BooleanValue( uiState.coinSelectionPreference == CoinSelectionPreference.BranchAndBound ), @@ -153,8 +153,8 @@ private fun Content( ) SettingsButtonRow( - title = "Single Random Draw", // TODO add missing localized text - description = "Random selection for privacy", // TODO add missing localized text + title = stringResource(R.string.settings__cs__srd_title), + description = stringResource(R.string.settings__cs__srd_desc), value = SettingsButtonValue.BooleanValue( uiState.coinSelectionPreference == CoinSelectionPreference.SingleRandomDraw ), diff --git a/app/src/main/java/to/bitkit/ui/settings/backgroundPayments/BackgroundPaymentsIntroScreen.kt b/app/src/main/java/to/bitkit/ui/settings/backgroundPayments/BackgroundPaymentsIntroScreen.kt index 08204ea77..73c8a8457 100644 --- a/app/src/main/java/to/bitkit/ui/settings/backgroundPayments/BackgroundPaymentsIntroScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/backgroundPayments/BackgroundPaymentsIntroScreen.kt @@ -36,7 +36,7 @@ fun BackgroundPaymentsIntroScreen( modifier = modifier.screen() ) { AppTopBar( - titleText = "Background Payments", // Todo Transifex + titleText = stringResource(R.string.settings__bg__title), onBackClick = onBack, actions = { DrawerNavIcon() }, ) @@ -66,11 +66,11 @@ fun BackgroundPaymentsIntroContent( ) Display( - text = "GET PAID\nPASSIVELY".withAccent(accentColor = Colors.Blue), - color = Colors.White + text = stringResource(R.string.settings__bg__intro_title).withAccent(accentColor = Colors.Blue), + color = Colors.White, ) VerticalSpacer(8.dp) - BodyM(text = "Turn on notifications to get paid, even when your Bitkit app is closed.", color = Colors.White64) + BodyM(text = stringResource(R.string.settings__bg__intro_desc), color = Colors.White64) VerticalSpacer(32.dp) PrimaryButton( text = stringResource(R.string.common__continue), diff --git a/app/src/main/java/to/bitkit/ui/settings/backgroundPayments/BackgroundPaymentsSettings.kt b/app/src/main/java/to/bitkit/ui/settings/backgroundPayments/BackgroundPaymentsSettings.kt index 562bbfe62..c1021acf8 100644 --- a/app/src/main/java/to/bitkit/ui/settings/backgroundPayments/BackgroundPaymentsSettings.kt +++ b/app/src/main/java/to/bitkit/ui/settings/backgroundPayments/BackgroundPaymentsSettings.kt @@ -10,6 +10,7 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -35,44 +36,40 @@ import to.bitkit.viewmodels.SettingsViewModel @Composable fun BackgroundPaymentsSettings( - onBack: () -> Unit, settingsViewModel: SettingsViewModel = hiltViewModel(), + onBack: () -> Unit, ) { val context = LocalContext.current val notificationsGranted by settingsViewModel.notificationsGranted.collectAsStateWithLifecycle() val showNotificationDetails by settingsViewModel.showNotificationDetails.collectAsStateWithLifecycle() RequestNotificationPermissions( - onPermissionChange = { granted -> - settingsViewModel.setNotificationPreference(granted) - }, - showPermissionDialog = false + onPermissionChange = settingsViewModel::setNotificationPreference, + showPermissionDialog = false, ) Content( - onBack = onBack, - onSystemSettingsClick = { - NotificationUtils.openNotificationSettings(context) - }, hasPermission = notificationsGranted, showDetails = showNotificationDetails, + onBack = onBack, + onSystemSettingsClick = { NotificationUtils.openNotificationSettings(context) }, toggleNotificationDetails = settingsViewModel::toggleNotificationDetails, ) } @Composable private fun Content( + hasPermission: Boolean, + showDetails: Boolean, onBack: () -> Unit, onSystemSettingsClick: () -> Unit, toggleNotificationDetails: () -> Unit, - hasPermission: Boolean, - showDetails: Boolean, ) { Column( modifier = Modifier.screen() ) { AppTopBar( - titleText = "Background Payments", + titleText = stringResource(R.string.settings__bg__title), onBackClick = onBack, actions = { DrawerNavIcon() }, ) @@ -85,15 +82,14 @@ private fun Content( VerticalSpacer(16.dp) SettingsSwitchRow( - title = "Get paid when Bitkit is closed", + title = stringResource(R.string.settings__bg__switch_title), isChecked = hasPermission, - onClick = onSystemSettingsClick + onClick = onSystemSettingsClick, ) if (hasPermission) { - @Suppress("MaxLineLength") // TODO transifex BodyM( - text = "Background payments are enabled. You can receive funds even when the app is closed (if your device is connected to the internet).", + text = stringResource(R.string.settings__bg__enabled), color = Colors.White64, modifier = Modifier.padding(vertical = 16.dp), ) @@ -104,14 +100,14 @@ private fun Content( modifier = Modifier.padding(vertical = 16.dp) ) { BodyMB( - text = "Background payments are disabled, because you have denied notifications.", + text = stringResource(R.string.settings__bg__disabled), color = Colors.Red, ) } NotificationPreview( enabled = hasPermission, - title = "Payment Received", + title = stringResource(R.string.notification_received_title), description = "₿ 21 000", showDetails = showDetails, modifier = Modifier.fillMaxWidth() @@ -120,12 +116,12 @@ private fun Content( VerticalSpacer(32.dp) Text13Up( - text = "Privacy", + text = stringResource(R.string.settings__bg__privacy_header), color = Colors.White64 ) SettingsButtonRow( - "Include amount in notifications", + stringResource(R.string.settings__bg__include_amount), value = SettingsButtonValue.BooleanValue(showDetails), onClick = toggleNotificationDetails, ) @@ -133,18 +129,16 @@ private fun Content( VerticalSpacer(32.dp) Text13Up( - text = "Notifications", + text = stringResource(R.string.settings__bg__notifications_header), color = Colors.White64 ) VerticalSpacer(16.dp) SecondaryButton( - "Customize in Android Bitkit Settings", - icon = { - Image(painter = painterResource(R.drawable.ic_bell), contentDescription = null) - }, - onClick = onSystemSettingsClick + stringResource(R.string.settings__bg__customize), + icon = { Image(painter = painterResource(R.drawable.ic_bell), contentDescription = null) }, + onClick = onSystemSettingsClick, ) } } @@ -155,11 +149,11 @@ private fun Content( private fun Preview1() { AppThemeSurface { Content( + hasPermission = true, + showDetails = true, onBack = {}, onSystemSettingsClick = {}, toggleNotificationDetails = {}, - hasPermission = true, - showDetails = true, ) } } @@ -169,11 +163,11 @@ private fun Preview1() { private fun Preview2() { AppThemeSurface { Content( + hasPermission = false, + showDetails = false, onBack = {}, onSystemSettingsClick = {}, toggleNotificationDetails = {}, - hasPermission = false, - showDetails = false, ) } } diff --git a/app/src/main/java/to/bitkit/ui/settings/general/GeneralSettingsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/general/GeneralSettingsScreen.kt index cc63bfc56..1e188b51c 100644 --- a/app/src/main/java/to/bitkit/ui/settings/general/GeneralSettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/general/GeneralSettingsScreen.kt @@ -109,7 +109,7 @@ private fun GeneralSettingsContent( .verticalScroll(rememberScrollState()) ) { SettingsButtonRow( - title = "Language", + title = stringResource(R.string.settings__language_title), value = SettingsButtonValue.StringValue(selectedLanguage), onClick = onLanguageSettingsClick, modifier = Modifier.testTag("LanguageSettings") @@ -155,9 +155,11 @@ private fun GeneralSettingsContent( modifier = Modifier.testTag("QuickpaySettings") ) SettingsButtonRow( - title = "Background Payments", // TODO Transifex + title = stringResource(R.string.settings__bg__title), onClick = onBgPaymentsClick, - value = SettingsButtonValue.StringValue(if (notificationsGranted) "On" else "Off"), + value = SettingsButtonValue.StringValue( + stringResource(if (notificationsGranted) R.string.settings__bg__on else R.string.settings__bg__off) + ), modifier = Modifier.testTag("BackgroundPaymentSettings") ) } diff --git a/app/src/main/java/to/bitkit/ui/sheets/BackgroundPaymentsIntroSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/BackgroundPaymentsIntroSheet.kt index feae729ea..7daaf880d 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/BackgroundPaymentsIntroSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/BackgroundPaymentsIntroSheet.kt @@ -6,7 +6,9 @@ import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview +import to.bitkit.R import to.bitkit.ui.components.BottomSheetPreview import to.bitkit.ui.scaffold.SheetTopBar import to.bitkit.ui.settings.backgroundPayments.BackgroundPaymentsIntroContent @@ -27,9 +29,7 @@ fun BackgroundPaymentsIntroSheet( .navigationBarsPadding() .testTag("background_payments_intro_sheet") ) { - SheetTopBar( - titleText = "Background Payments", // Todo Transifex - ) + SheetTopBar(titleText = stringResource(R.string.settings__bg__title)) BackgroundPaymentsIntroContent(onContinue = onContinue) } } diff --git a/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionSheet.kt index f9d38456a..2ba8313cb 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionSheet.kt @@ -302,7 +302,7 @@ private fun CustomModeContent( backgroundColor = Colors.Red16, enabled = uiState.decreaseEnabled, onClick = { onChangeAmount(false) }, - contentDescription = "Reduce fee", + contentDescription = stringResource(R.string.wallet__boost_decrease_fee), modifier = Modifier.testTag(BoostTransactionTestTags.DECREASE_FEE_BUTTON) ) @@ -349,7 +349,7 @@ private fun CustomModeContent( backgroundColor = Colors.Green16, enabled = uiState.increaseEnabled, onClick = { onChangeAmount(true) }, - contentDescription = "Increase fee", + contentDescription = stringResource(R.string.wallet__boost_increase_fee), modifier = Modifier.testTag(BoostTransactionTestTags.INCREASE_FEE_BUTTON) ) } diff --git a/app/src/main/java/to/bitkit/ui/sheets/LnurlAuthSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/LnurlAuthSheet.kt index 36c951e96..711b13ebd 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/LnurlAuthSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/LnurlAuthSheet.kt @@ -62,13 +62,11 @@ private fun Content( .navigationBarsPadding() .padding(horizontal = 16.dp) ) { - // TODO add missing localized text - SheetTopBar(titleText = "Log In") + SheetTopBar(titleText = stringResource(R.string.other__lnurl_auth_login_title)) VerticalSpacer(16.dp) BodyM( - // TODO add missing localized text - text = "Log in to {domain}?".replace("{domain}", domain), + text = stringResource(R.string.other__lnurl_auth_login_prompt).replace("{domain}", domain), color = Colors.White64, ) @@ -93,9 +91,8 @@ private fun Content( .weight(1f) .testTag("LnurlAuthCancel") ) - // TODO add missing localized text PrimaryButton( - text = "Log In", + text = stringResource(R.string.other__lnurl_auth_login_button), onClick = onContinue, fullWidth = false, modifier = Modifier diff --git a/app/src/main/java/to/bitkit/ui/utils/NotificationUtils.kt b/app/src/main/java/to/bitkit/ui/utils/NotificationUtils.kt index 61445ae23..e74efc74e 100644 --- a/app/src/main/java/to/bitkit/ui/utils/NotificationUtils.kt +++ b/app/src/main/java/to/bitkit/ui/utils/NotificationUtils.kt @@ -1,6 +1,6 @@ package to.bitkit.ui.utils -import android.Manifest +import android.Manifest.permission.POST_NOTIFICATIONS import android.content.Context import android.content.Intent import android.content.pm.PackageManager @@ -17,17 +17,16 @@ object NotificationUtils { * On older versions, opens the general app settings. */ fun openNotificationSettings(context: Context) { - val intent = - Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { - putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) - } + val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { + putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) + } intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) runCatching { context.startActivity(intent) - }.onFailure { e -> - Logger.error("Failed to open notification settings", e = e, context = "NotificationUtils") + }.onFailure { + Logger.error("Failed to open notification settings", e = it, context = "NotificationUtils") } } @@ -38,10 +37,7 @@ object NotificationUtils { */ fun areNotificationsEnabled(context: Context): Boolean { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - ContextCompat.checkSelfPermission( - context, - Manifest.permission.POST_NOTIFICATIONS - ) == PackageManager.PERMISSION_GRANTED + ContextCompat.checkSelfPermission(context, POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED } else { NotificationManagerCompat.from(context).areNotificationsEnabled() } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index acef6032b..b8277fa03 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -971,8 +971,8 @@ class AppViewModel @Inject constructor( if (!lightningRepo.canSend(invoice.amountSatoshis)) { toast( type = Toast.ToastType.ERROR, - title = "Insufficient Funds", - description = "You do not have enough funds to send this payment." + title = context.getString(R.string.wallet__error_insufficient_funds_title), + description = context.getString(R.string.wallet__error_insufficient_funds_msg) ) return } @@ -1317,7 +1317,7 @@ class AppViewModel @Inject constructor( it.copy(decodedInvoice = invoice) } }.onFailure { - toast(Exception("Error fetching lnurl invoice")) + toast(Exception(context.getString(R.string.wallet__error_lnurl_invoice_fetch))) hideSheet() return } @@ -1330,7 +1330,7 @@ class AppViewModel @Inject constructor( val validatedAddress = runCatching { validateBitcoinAddress(address) } .getOrElse { e -> Logger.error("Invalid bitcoin send address: '$address'", e, context = TAG) - toast(Exception("Invalid bitcoin send address")) + toast(Exception(context.getString(R.string.wallet__error_invalid_bitcoin_address))) hideSheet() return } @@ -1354,8 +1354,8 @@ class AppViewModel @Inject constructor( Logger.error(msg = "Error sending onchain payment", e = e, context = TAG) toast( type = Toast.ToastType.ERROR, - title = "Error Sending", - description = e.message ?: "Unknown error" + title = context.getString(R.string.wallet__error_sending_title), + description = e.message ?: context.getString(R.string.common__error_desc) ) hideSheet() } @@ -1808,7 +1808,11 @@ class AppViewModel @Inject constructor( } fun toast(error: Throwable) { - toast(type = Toast.ToastType.ERROR, title = "Error", description = error.message ?: "Unknown error") + toast( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.common__error), + description = error.message ?: context.getString(R.string.common__error_desc) + ) } fun toast(toast: Toast) { diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index 102c0fc39..7e79459ec 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -1,11 +1,13 @@ package to.bitkit.viewmodels +import android.content.Context import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Job import kotlinx.coroutines.async @@ -18,6 +20,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import org.lightningdevkit.ldknode.PeerDetails +import to.bitkit.R import to.bitkit.data.SettingsStore import to.bitkit.di.BgDispatcher import to.bitkit.models.Toast @@ -39,6 +42,7 @@ import kotlin.time.Duration.Companion.seconds @HiltViewModel class WalletViewModel @Inject constructor( + @ApplicationContext private val context: Context, @BgDispatcher private val bgDispatcher: CoroutineDispatcher, private val walletRepo: WalletRepo, private val lightningRepo: LightningRepo, @@ -302,15 +306,15 @@ class WalletViewModel @Inject constructor( .onSuccess { ToastEventBus.send( type = Toast.ToastType.INFO, - title = "Success", - description = "Peer disconnected." + title = context.getString(R.string.common__success), + description = "Peer disconnected.," ) } .onFailure { error -> ToastEventBus.send( type = Toast.ToastType.ERROR, - title = "Error", - description = error.message ?: "Unknown error" + title = context.getString(R.string.common__error), + description = error.message ?: context.getString(R.string.common__error_desc) ) } } @@ -323,8 +327,8 @@ class WalletViewModel @Inject constructor( walletRepo.updateBip21Invoice(amountSats).onFailure { error -> ToastEventBus.send( type = Toast.ToastType.ERROR, - title = "Error updating invoice", - description = error.message ?: "Unknown error" + title = context.getString(R.string.wallet__error_invoice_update), + description = error.message ?: context.getString(R.string.common__error_desc) ) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 41729a2dc..6305c7dc5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -73,6 +73,9 @@ Max Default Preview + Error + Unknown error + Success Instant ±2-10 seconds 2-10s @@ -362,6 +365,7 @@ <bold>It appears Bitkit does not have permission to access your camera.</bold>\n\nTo utilize this feature in the future you will need to enable camera permissions for this app from your phone\'s settings. Clipboard Data Detected Do you want to be redirected to the relevant screen? + Coming soon Insufficient Savings Insufficient Spending Balance More ₿ needed to pay this Bitcoin invoice. @@ -390,6 +394,9 @@ Channel Requested Successfully requested channel from: {peer} Successfully requested channel. + Log In + Log in to {domain}? + Log In Sign In Failed (LNURL) An error occurred when you attempted to sign in. {raw} Signed In @@ -608,6 +615,23 @@ If enabled, scanned invoices below ${amount} will be paid automatically without requiring your confirmation or PIN*. Quickpay threshold * Bitkit QuickPay exclusively supports payments from your Spending Balance. + Background Payments + On + Off + Background payments are enabled. You can receive funds even when the app is closed (if your device is connected to the internet). + Background payments are disabled, because you have denied notifications. + GET PAID\n<accent>PASSIVELY</accent> + Turn on notifications to get paid, even when your Bitkit app is closed. + Customize in Android Bitkit Settings + Include amount in notifications + Notifications + Privacy + Get paid when Bitkit is closed + Branch and Bound + Finds exact amount matches to minimize change + Single Random Draw + Random selection for privacy + Language Security And Privacy Swipe balance to hide Hide balance on open @@ -853,8 +877,6 @@ Unable To Delete Profile Unable To Pay Contact The contact you’re trying to send to hasn’t enabled payments. - Deprecated - Slashauth is deprecated. Please use Bitkit Beta. Wallet Activity Contacts @@ -989,6 +1011,8 @@ Your Spending Balance uses the Lightning Network to make your payments cheaper, faster, and more private.\n\nThis works like internet access, but you pay for liquidity & routing instead of bandwidth.\n\nBitkit needs to increase the receiving capacity of your spending balance to process this payment. Spending Balance Liquidity Additional Spending Balance Liquidity + Enable background setup to safely exit Bitkit while your balance is being configured. + Set up in background Transaction Failed Failed to send funds to your spending account. You will receive @@ -1004,19 +1028,20 @@ No activity yet Receive some funds to get started Sent + Sent to myself Received Pending Failed Boost Fee Boosted incoming transaction Transfer - From Spending (±{duration}) + From Spending ({duration}) From Spending - From Savings (±{duration}) + From Savings ({duration}) From Savings To Spending To Savings - Transfer (±{duration}) + Transfer ({duration}) Confirms in {feeRateDescription} Boosting. Confirms in {feeRateDescription} Fee potentially too low @@ -1064,6 +1089,8 @@ Sent Received Other + Next month + Previous month Savings Spending Savings @@ -1071,6 +1098,8 @@ Spending <accent>Send\nbitcoin</accent>\nto your\nspending balance Incoming Transfer: + Transfer To Savings + Transfer To Spending Transaction Invalid Boost Boost Transaction @@ -1083,13 +1112,23 @@ Your transaction may settle faster if you include an additional network fee. Here is a recommendation: Use Suggested Fee Swipe To Boost + Reduce fee + Increase fee Received Bitcoin Received Instant Bitcoin + Peer disconnected. Transaction Creation Failed An error occurred. Please try again {raw} Transaction Broadcast Failed An error occurred when broadcasting your transaction. {raw} Please check your connection and try again.\n{message} + Insufficient Funds + You do not have enough funds to send this payment. + Invalid bitcoin send address + Error updating invoice + Error fetching lnurl invoice + Error Sending + Your withdrawal was unsuccessful. Please scan the QR code again or contact support. Select Range Clear Apply @@ -1162,6 +1201,8 @@ Channel opened Pending Ready to send + Lightning node notification + Channel for LightningNodeService Lightning error Payment failed Please try again @@ -1170,7 +1211,6 @@ Payment Received Bitkit is running in background so you can receive Lightning payments Stop App - Unknown error Via new channel Lightning Wallet Sync Time Ready diff --git a/docs/strings.md b/docs/strings.md new file mode 100644 index 000000000..f411b80f7 --- /dev/null +++ b/docs/strings.md @@ -0,0 +1,118 @@ +# Untranslated Strings Tracker + +This document tracks hardcoded strings in the codebase. Strings in dev-only screens do not need translation. + +## Dev-Only Strings (No Translation Needed) + +These screens are only accessible in development builds and contain hardcoded strings that don't need localization. + +### DevSettingsScreen.kt +| Line | String | +|------|--------| +| 52 | Fee Settings | +| 53 | Channel Orders | +| 54 | LDK Debug | +| 56 | LOGS | +| 57 | Logs | +| 59 | Export Logs | +| 66 | REGTEST | +| 68 | Blocktank Regtest | +| 71 | APP CACHE | +| 74 | Reset Settings State | +| 77 | Settings state reset | +| 81 | Reset All Activities | +| 84 | Activities removed | +| 88 | Reset Backup State | +| 91 | Backup state reset | +| 95 | Reset Widgets State | +| 98 | Widgets state reset | +| 102 | Refresh Currency Rates | +| 105 | Currency rates refreshed | +| 109 | Reset App Database | +| 112 | Database state reset | +| 116 | Reset Blocktank State | +| 119 | Blocktank state reset | +| 123 | Reset Cache Store | +| 126 | Cache store reset | +| 130 | Wipe App | +| 133 | Wallet wiped | +| 137 | DEBUG | +| 140 | Generate Test Activities | +| 144 | Generated $count test activities | +| 148 | Fake New BG Receive | +| 151 | Restart app to see the payment received sheet | +| 155 | Open Channel To Trusted Peer | +| 161 | NOTIFICATIONS | +| 164 | Register For LSP Notifications | +| 170 | Test LSP Notification | + +### LdkDebugScreen.kt +| Line | String | +|------|--------| +| 96 | LDK Debug | +| 105 | ADD PEER | +| 109 | pubkey@host:port | +| 120 | Add Peer | +| 127 | Paste & Add | +| 135 | NETWORK GRAPH | +| 137 | Log Graph Info | +| 149 | Export to File | +| 160 | VSS | +| 162 | List Keys | +| 164 | found | +| 191 | Delete key | +| 205 | Delete All | +| 211 | NODE | +| 213 | Restart | +| 225 | Delete All VSS Keys? | +| 226 | This will permanently delete all... | +| 227 | Delete All | + +### BlocktankRegtestScreen.kt +| Line | String | +|------|--------| +| 57 | Blocktank Regtest | +| 81 | These actions are executed on the staging Blocktank server node. | +| 84 | DEPOSIT | +| 97 | Amount (sats) | +| 104 | Depositing... / Make Deposit | +| 116 | Success | +| 117 | Deposit successful. TxID: ... | +| 123 | Failed to deposit | +| 136 | MINING | +| 146 | Block Count | +| 180 | Mining... / Mine Blocks | +| 162 | Success | +| 163 | Successfully mined $count blocks | +| 169 | Failed to mine | +| 185 | LIGHTNING PAYMENT | +| 189 | Invoice | +| 197 | Amount (optional, sats) | +| 204 | Pay Invoice | +| 215 | Success | +| 216 | Payment successful. ID: ... | +| 222 | Failed to pay invoice from LND | +| 232 | CHANNEL CLOSE | +| 236 | Funding TxID | +| 244 | Vout | +| 253 | Force Close After (seconds) | +| 260 | Close Channel | +| 279 | Success | +| 280 | Channel closed. Closing TxID: ... | + +### LogsScreen.kt +- All strings are technical log display (no localization needed) + +### ChannelOrdersScreen.kt +- All strings are technical channel order data (no localization needed) + +## Preview Functions + +Hardcoded strings in `@Preview` functions throughout the codebase do not need translation as they are only visible in Android Studio previews, not to end users. + +## Borderline Cases + +| File | Line | String | Notes | +|------|------|--------|-------| +| NotificationPreview.kt | 63 | 3m ago | Placeholder in notification mockup | +| BackgroundPaymentsSettings.kt | 111 | ₿ 21 000 | Example amount in preview | From 546710070b2c3f50e6f5411113d7a045bea24125 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 6 Jan 2026 05:33:34 +0100 Subject: [PATCH 11/37] refactor: turn NotificationUtils fns to ext --- .../main/java/to/bitkit/ui/Notifications.kt | 23 +++++++++- .../screens/transfer/SpendingConfirmScreen.kt | 4 +- .../wallets/receive/ReceiveConfirmScreen.kt | 6 +-- .../screens/wallets/receive/ReceiveSheet.kt | 10 ++--- .../BackgroundPaymentsSettings.kt | 4 +- .../to/bitkit/ui/utils/NotificationUtils.kt | 45 ------------------- .../utils/RequestNotificationPermissions.kt | 7 +-- 7 files changed, 35 insertions(+), 64 deletions(-) delete mode 100644 app/src/main/java/to/bitkit/ui/utils/NotificationUtils.kt diff --git a/app/src/main/java/to/bitkit/ui/Notifications.kt b/app/src/main/java/to/bitkit/ui/Notifications.kt index 58b2ccbda..a41717f82 100644 --- a/app/src/main/java/to/bitkit/ui/Notifications.kt +++ b/app/src/main/java/to/bitkit/ui/Notifications.kt @@ -1,6 +1,7 @@ package to.bitkit.ui import android.Manifest +import android.Manifest.* import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent @@ -9,10 +10,14 @@ import android.app.PendingIntent.FLAG_ONE_SHOT import android.content.Context import android.content.Intent import android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP +import android.content.pm.PackageManager import android.media.RingtoneManager import android.os.Build import android.os.Bundle +import android.provider.Settings import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat import to.bitkit.R import to.bitkit.ext.notificationManager import to.bitkit.ext.notificationManagerCompat @@ -66,7 +71,7 @@ internal fun Context.pushNotification( // Only check permission if running on Android 13+ (SDK 33+) val needsPermissionGrant = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && - requiresPermission(Manifest.permission.POST_NOTIFICATIONS) + requiresPermission(permission.POST_NOTIFICATIONS) if (!needsPermissionGrant) { val builder = notificationBuilder(extras) @@ -87,4 +92,20 @@ internal fun Context.pushNotification( } } +fun Context.openNotificationSettings() { + val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { + putExtra(Settings.EXTRA_APP_PACKAGE, packageName) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + runCatching { startActivity(intent) } + .onFailure { Logger.error("Failed to open notification settings", e = it, context = TAG) } +} + +fun Context.areNotificationsEnabled(): Boolean = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + ContextCompat.checkSelfPermission(this, permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED + } else { + NotificationManagerCompat.from(this).areNotificationsEnabled() + } + private const val TAG = "Notifications" diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingConfirmScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingConfirmScreen.kt index 3f62f2312..86198aeca 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingConfirmScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingConfirmScreen.kt @@ -57,10 +57,10 @@ import to.bitkit.ui.components.settings.SettingsSwitchRow import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.DrawerNavIcon import to.bitkit.ui.scaffold.ScreenColumn +import to.bitkit.ui.openNotificationSettings import to.bitkit.ui.theme.AppSwitchDefaults import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors -import to.bitkit.ui.utils.NotificationUtils import to.bitkit.ui.utils.RequestNotificationPermissions import to.bitkit.ui.utils.withAccent import to.bitkit.viewmodels.SettingsViewModel @@ -104,7 +104,7 @@ fun SpendingConfirmScreen( onTransferToSpendingConfirm = viewModel::onTransferToSpendingConfirm, order = order, hasNotificationPermission = notificationsGranted, - onSwitchClick = { NotificationUtils.openNotificationSettings(context) }, + onSwitchClick = { context.openNotificationSettings() }, isAdvanced = isAdvanced, ) } 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 611224d24..442133b71 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 @@ -39,10 +39,10 @@ import to.bitkit.ui.currencyViewModel import to.bitkit.ui.scaffold.SheetTopBar import to.bitkit.ui.shared.modifiers.sheetHeight import to.bitkit.ui.shared.util.gradientBackground +import to.bitkit.ui.openNotificationSettings import to.bitkit.ui.theme.AppSwitchDefaults import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors -import to.bitkit.ui.utils.NotificationUtils import to.bitkit.ui.utils.withAccent import to.bitkit.viewmodels.SettingsViewModel @@ -107,9 +107,7 @@ fun ReceiveConfirmScreen( receiveAmountFormatted = receiveAmountFormatted, onLearnMoreClick = onLearnMore, isAdditional = isAdditional, - onSystemSettingsClick = { - NotificationUtils.openNotificationSettings(context) - }, + onSystemSettingsClick = { context.openNotificationSettings() }, hasNotificationPermission = notificationsGranted, onContinueClick = { onContinue(entry.invoice) }, onBackClick = onBack, 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 187054cff..d948c29e8 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 @@ -19,8 +19,8 @@ import kotlinx.serialization.Serializable import to.bitkit.repositories.LightningState import to.bitkit.repositories.WalletState import to.bitkit.ui.screens.wallets.send.AddTagScreen +import to.bitkit.ui.openNotificationSettings import to.bitkit.ui.shared.modifiers.sheetHeight -import to.bitkit.ui.utils.NotificationUtils import to.bitkit.ui.utils.composableWithDefaultTransitions import to.bitkit.ui.walletViewModel import to.bitkit.viewmodels.AmountInputViewModel @@ -131,9 +131,7 @@ fun ReceiveSheet( onContinue = { navController.popBackStack() }, onBack = { navController.popBackStack() }, hasNotificationPermission = notificationsGranted, - onSwitchClick = { - NotificationUtils.openNotificationSettings(context) - }, + onSwitchClick = { context.openNotificationSettings() }, ) } } @@ -148,9 +146,7 @@ fun ReceiveSheet( isAdditional = true, onBack = { navController.popBackStack() }, hasNotificationPermission = notificationsGranted, - onSwitchClick = { - NotificationUtils.openNotificationSettings(context) - }, + onSwitchClick = { context.openNotificationSettings() }, ) } } diff --git a/app/src/main/java/to/bitkit/ui/settings/backgroundPayments/BackgroundPaymentsSettings.kt b/app/src/main/java/to/bitkit/ui/settings/backgroundPayments/BackgroundPaymentsSettings.kt index c1021acf8..bb26bad83 100644 --- a/app/src/main/java/to/bitkit/ui/settings/backgroundPayments/BackgroundPaymentsSettings.kt +++ b/app/src/main/java/to/bitkit/ui/settings/backgroundPayments/BackgroundPaymentsSettings.kt @@ -28,9 +28,9 @@ import to.bitkit.ui.components.settings.SettingsSwitchRow import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.DrawerNavIcon import to.bitkit.ui.shared.util.screen +import to.bitkit.ui.openNotificationSettings import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors -import to.bitkit.ui.utils.NotificationUtils import to.bitkit.ui.utils.RequestNotificationPermissions import to.bitkit.viewmodels.SettingsViewModel @@ -52,7 +52,7 @@ fun BackgroundPaymentsSettings( hasPermission = notificationsGranted, showDetails = showNotificationDetails, onBack = onBack, - onSystemSettingsClick = { NotificationUtils.openNotificationSettings(context) }, + onSystemSettingsClick = context::openNotificationSettings, toggleNotificationDetails = settingsViewModel::toggleNotificationDetails, ) } diff --git a/app/src/main/java/to/bitkit/ui/utils/NotificationUtils.kt b/app/src/main/java/to/bitkit/ui/utils/NotificationUtils.kt deleted file mode 100644 index e74efc74e..000000000 --- a/app/src/main/java/to/bitkit/ui/utils/NotificationUtils.kt +++ /dev/null @@ -1,45 +0,0 @@ -package to.bitkit.ui.utils - -import android.Manifest.permission.POST_NOTIFICATIONS -import android.content.Context -import android.content.Intent -import android.content.pm.PackageManager -import android.os.Build -import android.provider.Settings -import androidx.core.app.NotificationManagerCompat -import androidx.core.content.ContextCompat -import to.bitkit.utils.Logger - -object NotificationUtils { - /** - * Opens the Android system notification settings for the app. - * On Android 8.0+ (API 26+), opens the app's notification settings. - * On older versions, opens the general app settings. - */ - fun openNotificationSettings(context: Context) { - val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { - putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) - } - - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - - runCatching { - context.startActivity(intent) - }.onFailure { - Logger.error("Failed to open notification settings", e = it, context = "NotificationUtils") - } - } - - /** - * Checks if notification permissions are granted. - * For Android 13+ (API 33+), checks the POST_NOTIFICATIONS permission. - * For older versions, checks if notifications are enabled via NotificationManagerCompat. - */ - fun areNotificationsEnabled(context: Context): Boolean { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - ContextCompat.checkSelfPermission(context, POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED - } else { - NotificationManagerCompat.from(context).areNotificationsEnabled() - } - } -} diff --git a/app/src/main/java/to/bitkit/ui/utils/RequestNotificationPermissions.kt b/app/src/main/java/to/bitkit/ui/utils/RequestNotificationPermissions.kt index 490ff9d03..978554396 100644 --- a/app/src/main/java/to/bitkit/ui/utils/RequestNotificationPermissions.kt +++ b/app/src/main/java/to/bitkit/ui/utils/RequestNotificationPermissions.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner +import to.bitkit.ui.areNotificationsEnabled @Composable fun RequestNotificationPermissions( @@ -30,7 +31,7 @@ fun RequestNotificationPermissions( val requiresPermission = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU var isGranted by remember { - mutableStateOf(NotificationUtils.areNotificationsEnabled(context)) + mutableStateOf(context.areNotificationsEnabled()) } // Permission request launcher @@ -43,7 +44,7 @@ fun RequestNotificationPermissions( // Request permission on first composition if needed LaunchedEffect(Unit) { - val currentPermissionState = NotificationUtils.areNotificationsEnabled(context) + val currentPermissionState = context.areNotificationsEnabled() isGranted = currentPermissionState currentOnPermissionChange(currentPermissionState) @@ -56,7 +57,7 @@ fun RequestNotificationPermissions( DisposableEffect(lifecycleOwner) { val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) { - val currentPermissionState = NotificationUtils.areNotificationsEnabled(context) + val currentPermissionState = context.areNotificationsEnabled() if (currentPermissionState != isGranted) { isGranted = currentPermissionState currentOnPermissionChange(currentPermissionState) From 2b94b6f5f9b19cfbfb7b8d041cc21ee77b4078eb Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 6 Jan 2026 06:05:40 +0100 Subject: [PATCH 12/37] feat: localize NodeLifecycleState strings --- .../to/bitkit/models/NodeLifecycleState.kt | 21 ++++++++++--------- .../main/java/to/bitkit/ui/NodeInfoScreen.kt | 3 ++- .../main/java/to/bitkit/ui/Notifications.kt | 3 +-- .../ui/settings/appStatus/AppStatusScreen.kt | 3 +-- .../settings/appStatus/AppStatusViewModel.kt | 2 +- app/src/main/res/values/strings.xml | 6 ++++++ .../appStatus/AppStatusViewModelTest.kt | 5 +++++ 7 files changed, 27 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/to/bitkit/models/NodeLifecycleState.kt b/app/src/main/java/to/bitkit/models/NodeLifecycleState.kt index ab8cf7694..1c5e92a16 100644 --- a/app/src/main/java/to/bitkit/models/NodeLifecycleState.kt +++ b/app/src/main/java/to/bitkit/models/NodeLifecycleState.kt @@ -1,5 +1,8 @@ package to.bitkit.models +import android.content.Context +import to.bitkit.R + sealed class NodeLifecycleState { data object Stopped : NodeLifecycleState() data object Starting : NodeLifecycleState() @@ -14,16 +17,14 @@ sealed class NodeLifecycleState { fun isRunning() = this is Running fun canRun() = this.isRunningOrStarting() || this is Initializing - // TODO add missing localized texts - val uiText: String - get() = when (this) { - is Stopped -> "Stopped" - is Starting -> "Starting" - is Running -> "Running" - is Stopping -> "Stopping" - is ErrorStarting -> "Error starting: ${cause.message}" - is Initializing -> "Setting up wallet..." - } + fun uiText(context: Context): String = when (this) { + is Stopped -> context.getString(R.string.other__node_stopped) + is Starting -> context.getString(R.string.other__node_starting) + is Running -> context.getString(R.string.other__node_running) + is Stopping -> context.getString(R.string.other__node_stopping) + is ErrorStarting -> context.getString(R.string.other__node_error_starting, cause.message ?: "") + is Initializing -> context.getString(R.string.other__node_initializing) + } fun asHealth() = when (this) { Running -> HealthState.READY diff --git a/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt b/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt index eef545a3f..ff439a5b7 100644 --- a/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt +++ b/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt @@ -191,11 +191,12 @@ private fun NodeStateSection( nodeLifecycleState: NodeLifecycleState, nodeStatus: NodeStatus?, ) { + val context = LocalContext.current Column(modifier = Modifier.fillMaxWidth()) { SectionHeader("Node State") SettingsTextButtonRow( title = stringResource(R.string.lightning__status), - value = nodeLifecycleState.uiText, + value = nodeLifecycleState.uiText(context), ) nodeStatus?.let { status -> diff --git a/app/src/main/java/to/bitkit/ui/Notifications.kt b/app/src/main/java/to/bitkit/ui/Notifications.kt index a41717f82..029472950 100644 --- a/app/src/main/java/to/bitkit/ui/Notifications.kt +++ b/app/src/main/java/to/bitkit/ui/Notifications.kt @@ -1,7 +1,6 @@ package to.bitkit.ui -import android.Manifest -import android.Manifest.* +import android.Manifest.permission import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent diff --git a/app/src/main/java/to/bitkit/ui/settings/appStatus/AppStatusScreen.kt b/app/src/main/java/to/bitkit/ui/settings/appStatus/AppStatusScreen.kt index 9fe6a8799..3f1e8c174 100644 --- a/app/src/main/java/to/bitkit/ui/settings/appStatus/AppStatusScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/appStatus/AppStatusScreen.kt @@ -31,7 +31,6 @@ import to.bitkit.R import to.bitkit.ext.startActivityAppSettings import to.bitkit.ext.toLocalizedTimestamp import to.bitkit.models.HealthState -import to.bitkit.models.NodeLifecycleState import to.bitkit.repositories.AppHealthState import to.bitkit.ui.Routes import to.bitkit.ui.components.BodyMSB @@ -262,7 +261,7 @@ private fun Preview() { backups = HealthState.READY, ), backupSubtitle = now().minus(3.minutes).toEpochMilliseconds().toLocalizedTimestamp(), - nodeSubtitle = NodeLifecycleState.Running.uiText, + nodeSubtitle = "Running", ), ) } diff --git a/app/src/main/java/to/bitkit/ui/settings/appStatus/AppStatusViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/appStatus/AppStatusViewModel.kt index 44199deca..5c2f1d03c 100644 --- a/app/src/main/java/to/bitkit/ui/settings/appStatus/AppStatusViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/appStatus/AppStatusViewModel.kt @@ -48,7 +48,7 @@ class AppStatusViewModel @Inject constructor( backupSubtitle = computeBackupSubtitle(healthState.backups, backupStatuses), nodeSubtitle = when (healthState.node) { HealthState.ERROR -> context.getString(R.string.settings__status__lightning_node__error) - else -> lightningState.nodeLifecycleState.uiText + else -> lightningState.nodeLifecycleState.uiText(context) }, ) }.collect { newState -> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6305c7dc5..a6cfe54f2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -409,6 +409,12 @@ Incorrect LNURL withdraw params, min/max not set correctly. Withdraw Requested Your withdraw was successfully requested. Waiting for payment. + Error starting: %1$s + Setting up wallet… + Running + Starting + Stopped + Stopping Open Phone Settings Transfer Failed Unable to add LSP node as a peer at this time. diff --git a/app/src/test/java/to/bitkit/ui/settings/appStatus/AppStatusViewModelTest.kt b/app/src/test/java/to/bitkit/ui/settings/appStatus/AppStatusViewModelTest.kt index 8cb5bf270..dc5ff93a1 100644 --- a/app/src/test/java/to/bitkit/ui/settings/appStatus/AppStatusViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/settings/appStatus/AppStatusViewModelTest.kt @@ -36,6 +36,11 @@ class AppStatusViewModelTest : BaseUnitTest() { fun setUp() { whenever(context.getString(R.string.settings__status__backup__error)).thenReturn(failedBackupSubtitle) whenever(context.getString(R.string.settings__status__backup__ready)).thenReturn(readyBackupSubtitle) + whenever(context.getString(R.string.other__node_stopped)).thenReturn("Stopped") + whenever(context.getString(R.string.other__node_starting)).thenReturn("Starting") + whenever(context.getString(R.string.other__node_running)).thenReturn("Running") + whenever(context.getString(R.string.other__node_stopping)).thenReturn("Stopping") + whenever(context.getString(R.string.other__node_initializing)).thenReturn("Setting up wallet…") whenever(healthRepo.healthState).thenReturn(MutableStateFlow(AppHealthState())) whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState())) whenever(cacheStore.backupStatuses).thenReturn(flowOf(emptyMap())) From 962a0305ba79d008537a9586df679119d6e0a21f Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 6 Jan 2026 19:26:49 +0100 Subject: [PATCH 13/37] chore: update gitignore --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 6da892352..fb2999375 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,5 @@ google-services.json .env *.keystore !debug.keystore -keystore.properties +keystore.* +!keystore.properties.template From 40654c11144f62b6154b82cc1cf4e36c21c62b52 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 6 Jan 2026 19:56:04 +0100 Subject: [PATCH 14/37] chore: update readme --- README.md | 49 +++++++++++++++++++++++-------------------------- 1 file changed, 23 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 88ce6a21d..9d2c1bf3e 100644 --- a/README.md +++ b/README.md @@ -17,9 +17,9 @@ This repository contains a **new native Android app** which is **not ready for p #### 1. Firebase Configuration -Download `google-services.json` from the Firebase Console for each build flavor: -- **Dev/Testnet**: Place in `app/` (default location) -- **Mainnet**: Place in `app/src/mainnet/google-services.json` +Download `google-services.json` from the Firebase Console for each of the following build flavor groups,: +- dev/tnet/mainnetDebug: Place in `app/google-services.json` +- mainnetRelease: Place in `app/src/mainnetRelease/google-services.json` > **Note**: Each flavor requires its own Firebase project configuration. The mainnet flavor will fail to build without its dedicated `google-services.json` file. @@ -113,16 +113,31 @@ The build config supports building 3 different apps for the 3 bitcoin networks ( - `mainnet` flavour = mainnet - `tnet` flavour = testnet -### Build for Mainnet +### Build for Internal Testing -To build the mainnet flavor: +**Prerequisites** +Setup the signing config: +- Add the keystore file to root dir (i.e. `internal.keystore`) +- Setup `keystore.properties` file in root dir (`cp keystore.properties.template keystore.properties`) + +**Routine** +Increment `versionCode` and `versionName` in `app/build.gradle.kts`, then run: ```sh -./gradlew assembleMainnetDebug # debug build -./gradlew assembleMainnetRelease # release build (requires signing config) +./gradlew assembleDevRelease +# ./gradlew assembleRelease # for all flavors ``` -> **Important**: Ensure `app/src/mainnet/google-services.json` exists before building. See [Firebase Configuration](#1-firebase-configuration). +APK is generated in `app/build/outputs/apk/_flavor_/release`. (`_flavor_` can be any of 'dev', 'mainnet', 'tnet'). +Example for dev: `app/build/outputs/apk/dev/release` + +### Build for Release + +To build the mainnet flavor for release run: + +```sh +./gradlew assembleMainnetRelease +``` ### Build for E2E Testing @@ -153,24 +168,6 @@ By default, geoblocking checks via API are enabled. To disable at build time, us GEO=false E2E=true ./gradlew assembleDevRelease ``` -### Build for Release - -**Prerequisites** -Setup the signing config: -- Add the keystore file to root dir (i.e. `release.keystore`) -- Setup `keystore.properties` file in root dir (`cp keystore.properties.template keystore.properties`) - -**Routine** - -Increment `versionCode` and `versionName` in `app/build.gradle.kts`, then run: -```sh -./gradlew assembleDevRelease -# ./gradlew assembleRelease # for all flavors -``` - -APK is generated in `app/build/outputs/apk/_flavor_/release`. (`_flavor_` can be any of 'dev', 'mainnet', 'tnet'). -Example for dev: `app/build/outputs/apk/dev/release` - ## Contributing ### AI Code Review with Claude From 8c59952444ed9a77813f3777d7c0eaf208ec4725 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 7 Jan 2026 00:59:24 +0100 Subject: [PATCH 15/37] feat: dynamic boost fee time estimates --- app/src/main/java/to/bitkit/models/FeeRate.kt | 15 +- .../wallets/activity/ActivityDetailScreen.kt | 5 +- .../activity/components/ActivityRow.kt | 10 +- .../ui/sheets/BoostTransactionViewModel.kt | 13 +- .../java/to/bitkit/ui/WalletViewModelTest.kt | 6 + .../sheets/BoostTransactionViewModelTest.kt | 203 ++++++++++++------ 6 files changed, 169 insertions(+), 83 deletions(-) diff --git a/app/src/main/java/to/bitkit/models/FeeRate.kt b/app/src/main/java/to/bitkit/models/FeeRate.kt index 4c0b6328d..d4a8785de 100644 --- a/app/src/main/java/to/bitkit/models/FeeRate.kt +++ b/app/src/main/java/to/bitkit/models/FeeRate.kt @@ -1,10 +1,9 @@ package to.bitkit.models +import android.content.Context import androidx.annotation.DrawableRes import androidx.annotation.StringRes -import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.stringResource import com.synonym.bitkitcore.FeeRates import to.bitkit.R import to.bitkit.ui.theme.Colors @@ -81,16 +80,12 @@ enum class FeeRate( } } - @Composable - fun getFeeDescription( + fun Context.getFeeShortDescription( feeRate: ULong, - feeEstimates: FeeRates?, + feeRates: FeeRates?, ): String { - val feeRateEnum = feeEstimates?.let { - fromSatsPerVByte(feeRate, it) - } ?: NORMAL - - return stringResource(feeRateEnum.shortDescription) + val feeRateEnum = feeRates?.let { fromSatsPerVByte(feeRate, it) } ?: NORMAL + return getString(feeRateEnum.shortDescription) } } } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt index 63886cd13..b3568a750 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt @@ -57,7 +57,7 @@ import to.bitkit.ext.timestamp import to.bitkit.ext.toActivityItemDate import to.bitkit.ext.toActivityItemTime import to.bitkit.ext.totalValue -import to.bitkit.models.FeeRate +import to.bitkit.models.FeeRate.Companion.getFeeShortDescription import to.bitkit.models.Toast import to.bitkit.ui.Routes import to.bitkit.ui.appViewModel @@ -739,7 +739,8 @@ private fun StatusSection( var statusTestTag: String? = null if (item.v1.isTransfer) { - val duration = FeeRate.getFeeDescription(item.v1.feeRate, feeRates) + val context = LocalContext.current + val duration = context.getFeeShortDescription(item.v1.feeRate, feeRates) statusText = stringResource(R.string.wallet__activity_transfer_pending) .replace("{duration}", duration) statusTestTag = "StatusTransfer" diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt index 81962392a..f6681ec7e 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt @@ -17,6 +17,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource @@ -37,7 +38,7 @@ import to.bitkit.ext.rawId import to.bitkit.ext.timestamp import to.bitkit.ext.totalValue import to.bitkit.ext.txType -import to.bitkit.models.FeeRate +import to.bitkit.models.FeeRate.Companion.getFeeShortDescription import to.bitkit.models.PrimaryDisplay import to.bitkit.models.formatToModernDisplay import to.bitkit.ui.LocalCurrencies @@ -117,6 +118,7 @@ fun ActivityRow( isTransfer = isTransfer, isCpfpChild = isCpfpChild ) + val context = LocalContext.current val subtitleText = when (item) { is Activity.Lightning -> item.v1.message.ifEmpty { formattedTime(timestamp) } is Activity.Onchain -> { @@ -128,7 +130,7 @@ fun ActivityRow( isTransfer && isSent -> if (item.v1.confirmed) { stringResource(R.string.wallet__activity_transfer_spending_done) } else { - val duration = FeeRate.getFeeDescription(item.v1.feeRate, feeRates) + val duration = context.getFeeShortDescription(item.v1.feeRate, feeRates) stringResource(R.string.wallet__activity_transfer_spending_pending) .replace("{duration}", duration) } @@ -136,7 +138,7 @@ fun ActivityRow( isTransfer && !isSent -> if (item.v1.confirmed) { stringResource(R.string.wallet__activity_transfer_savings_done) } else { - val duration = FeeRate.getFeeDescription(item.v1.feeRate, feeRates) + val duration = context.getFeeShortDescription(item.v1.feeRate, feeRates) stringResource(R.string.wallet__activity_transfer_savings_pending) .replace("{duration}", duration) } @@ -144,7 +146,7 @@ fun ActivityRow( confirmed == true -> formattedTime(timestamp) else -> { - val feeDescription = FeeRate.getFeeDescription(item.v1.feeRate, feeRates) + val feeDescription = context.getFeeShortDescription(item.v1.feeRate, feeRates) stringResource(R.string.wallet__activity_confirms_in) .replace("{feeRateDescription}", feeDescription) } diff --git a/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionViewModel.kt b/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionViewModel.kt index 38a6c62dd..e2b5d8004 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionViewModel.kt @@ -1,11 +1,13 @@ package to.bitkit.ui.sheets +import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.OnchainActivity import com.synonym.bitkitcore.PaymentType import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow @@ -16,8 +18,10 @@ import org.lightningdevkit.ldknode.Txid import to.bitkit.ext.BoostType import to.bitkit.ext.boostType import to.bitkit.ext.nowTimestamp +import to.bitkit.models.FeeRate.Companion.getFeeShortDescription import to.bitkit.models.TransactionSpeed import to.bitkit.repositories.ActivityRepo +import to.bitkit.repositories.BlocktankRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.WalletRepo import to.bitkit.utils.Logger @@ -26,9 +30,11 @@ import javax.inject.Inject @HiltViewModel class BoostTransactionViewModel @Inject constructor( + @ApplicationContext private val context: Context, private val lightningRepo: LightningRepo, private val walletRepo: WalletRepo, private val activityRepo: ActivityRepo, + private val blocktankRepo: BlocktankRepo, ) : ViewModel() { private val _uiState = MutableStateFlow(BoostTransactionUiState()) @@ -37,7 +43,7 @@ class BoostTransactionViewModel @Inject constructor( private val _boostTransactionEffect = MutableSharedFlow(extraBufferCapacity = 1) val boostTransactionEffect = _boostTransactionEffect.asSharedFlow() - private companion object { + companion object { const val TAG = "BoostTransactionViewModel" const val MAX_FEE_PERCENTAGE = 0.5 const val MAX_FEE_RATE = 100UL @@ -133,6 +139,8 @@ class BoostTransactionViewModel @Inject constructor( val currentFee = activity?.v1?.fee ?: 0u val isIncreaseEnabled = totalFee < maxTotalFee && feeRate < MAX_FEE_RATE val isDecreaseEnabled = totalFee > currentFee && feeRate > minFeeRate + val feeRates = blocktankRepo.blocktankState.value.info?.onchain?.feeRates + val estimateTime = context.getFeeShortDescription(feeRate, feeRates) _uiState.update { it.copy( @@ -141,6 +149,7 @@ class BoostTransactionViewModel @Inject constructor( increaseEnabled = isIncreaseEnabled, decreaseEnabled = isDecreaseEnabled, loading = false, + estimateTime = estimateTime, ) } } @@ -370,6 +379,6 @@ data class BoostTransactionUiState( val increaseEnabled: Boolean = true, val boosting: Boolean = false, val loading: Boolean = false, - val estimateTime: String = "±10-20 minutes", // TODO: Implement dynamic time estimation + val estimateTime: String = "", val isRbf: Boolean = false, ) diff --git a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt index e3f96a8af..df2b089cd 100644 --- a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt @@ -1,5 +1,6 @@ package to.bitkit.ui +import android.content.Context import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.runBlocking @@ -33,6 +34,7 @@ import to.bitkit.viewmodels.WalletViewModel class WalletViewModelTest : BaseUnitTest() { private lateinit var sut: WalletViewModel + private val context = mock() private val walletRepo = mock() private val lightningRepo = mock() private val settingsStore = mock() @@ -47,11 +49,13 @@ class WalletViewModelTest : BaseUnitTest() { @Before fun setUp() = runBlocking { + whenever(context.getString(any())).thenReturn("") whenever(walletRepo.walletState).thenReturn(walletState) whenever(lightningRepo.lightningState).thenReturn(lightningState) whenever(migrationService.isMigrationChecked()).thenReturn(true) sut = WalletViewModel( + context = context, bgDispatcher = testDispatcher, walletRepo = walletRepo, lightningRepo = lightningRepo, @@ -235,6 +239,7 @@ class WalletViewModelTest : BaseUnitTest() { .thenReturn(Result.success(Unit)) val testSut = WalletViewModel( + context = context, bgDispatcher = testDispatcher, walletRepo = testWalletRepo, lightningRepo = testLightningRepo, @@ -274,6 +279,7 @@ class WalletViewModelTest : BaseUnitTest() { .thenReturn(Result.success(Unit)) val testSut = WalletViewModel( + context = context, bgDispatcher = testDispatcher, walletRepo = testWalletRepo, lightningRepo = testLightningRepo, diff --git a/app/src/test/java/to/bitkit/ui/sheets/BoostTransactionViewModelTest.kt b/app/src/test/java/to/bitkit/ui/sheets/BoostTransactionViewModelTest.kt index c726bed62..8b9e64ff1 100644 --- a/app/src/test/java/to/bitkit/ui/sheets/BoostTransactionViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/sheets/BoostTransactionViewModelTest.kt @@ -1,10 +1,16 @@ package to.bitkit.ui.sheets +import android.content.Context import app.cash.turbine.test import com.synonym.bitkitcore.Activity +import com.synonym.bitkitcore.FeeRates +import com.synonym.bitkitcore.IBtInfo +import com.synonym.bitkitcore.IBtInfoOnchain import com.synonym.bitkitcore.OnchainActivity import com.synonym.bitkitcore.PaymentType import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Test @@ -15,55 +21,79 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever -import org.mockito.kotlin.wheneverBlocking +import to.bitkit.R import to.bitkit.ext.create import to.bitkit.models.TransactionSpeed import to.bitkit.repositories.ActivityRepo +import to.bitkit.repositories.BlocktankRepo +import to.bitkit.repositories.BlocktankState import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.WalletRepo import to.bitkit.test.BaseUnitTest +import to.bitkit.ui.sheets.BoostTransactionViewModel.Companion.MAX_FEE_RATE import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue class BoostTransactionViewModelTest : BaseUnitTest() { - private lateinit var sut: BoostTransactionViewModel - private val lightningRepo: LightningRepo = mock() - private val walletRepo: WalletRepo = mock() - private val activityRepo: ActivityRepo = mock() + private val context = mock() + private val lightningRepo = mock() + private val walletRepo = mock() + private val activityRepo = mock() + private val blocktankRepo = mock() + + private val onchain = mock() + private val mockBtInfo = mock() + private val feeRates = FeeRates(fast = 20u, mid = 10u, slow = 5u) + private val blocktankState = MutableStateFlow(BlocktankState(info = mockBtInfo)) // Test data private val mockTxId = "test_txid_123" - private val mockNewTxId = "new_txid_456" - private val mockAddress = "bc1rt1test123" - private val testFeeRate = 10UL - private val testTotalFee = 1000UL + private val newTxId = "new_txid_456" + private val address = "bc1rt1test123" + private val feeRate = 10UL + private val totalFee = 1000UL private val testValue = 50000UL - private val mockOnchainActivity = OnchainActivity.create( + private val onchainActivity = OnchainActivity.create( id = "test_id", txType = PaymentType.SENT, txId = mockTxId, value = testValue, fee = 500UL, - address = mockAddress, + address = address, timestamp = 1234567890UL, feeRate = 10UL, ) - private val mockActivitySent = Activity.Onchain(v1 = mockOnchainActivity) + private val activitySent = Activity.Onchain(onchainActivity) + + private val fastFeeTime = "±10m" + private val normalFeeTime = "±20m" + private val flowFeeTime = "±1h" + private val minFeeTime = "+2h" @Before - fun setUp() { + fun setUp() = runBlocking { + whenever(context.getString(R.string.fee__fast__shortDescription)).thenReturn(fastFeeTime) + whenever(context.getString(R.string.fee__normal__shortDescription)).thenReturn(normalFeeTime) + whenever(context.getString(R.string.fee__slow__shortDescription)).thenReturn(flowFeeTime) + whenever(context.getString(R.string.fee__minimum__shortDescription)).thenReturn(minFeeTime) + whenever(onchain.feeRates).thenReturn(feeRates) + whenever(mockBtInfo.onchain).thenReturn(onchain) + whenever(blocktankRepo.blocktankState).thenReturn(blocktankState) + whenever(lightningRepo.listSpendableOutputs()).thenReturn(Result.success(emptyList())) + whenever(lightningRepo.syncAsync()).thenReturn(Job()) + sut = BoostTransactionViewModel( + context = context, lightningRepo = lightningRepo, walletRepo = walletRepo, - activityRepo = activityRepo + activityRepo = activityRepo, + blocktankRepo = blocktankRepo, ) - wheneverBlocking { lightningRepo.listSpendableOutputs() }.thenReturn(Result.success(emptyList())) - whenever(lightningRepo.syncAsync()).thenReturn(Job()) } @Test @@ -82,14 +112,13 @@ class BoostTransactionViewModelTest : BaseUnitTest() { @Test fun `setupActivity should set loading state initially`() = runTest { - whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())) - .thenReturn(Result.success(testFeeRate)) + whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(feeRate)) whenever(lightningRepo.calculateTotalFee(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())) - .thenReturn(Result.success(testTotalFee)) + .thenReturn(Result.success(totalFee)) sut.uiState.test { awaitItem() // initial state - sut.setupActivity(mockActivitySent) + sut.setupActivity(activitySent) val loadingState = awaitItem() assertTrue(loadingState.loading) @@ -99,12 +128,11 @@ class BoostTransactionViewModelTest : BaseUnitTest() { @Test fun `setupActivity should call correct repository methods for sent transaction`() = runTest { - whenever(lightningRepo.getFeeRateForSpeed(eq(TransactionSpeed.Fast), anyOrNull())) - .thenReturn(Result.success(testFeeRate)) + whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(feeRate)) whenever(lightningRepo.calculateTotalFee(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())) - .thenReturn(Result.success(testTotalFee)) + .thenReturn(Result.success(totalFee)) - sut.setupActivity(mockActivitySent) + sut.setupActivity(activitySent) verify(lightningRepo).getFeeRateForSpeed(eq(TransactionSpeed.Fast), anyOrNull()) verify(lightningRepo).calculateTotalFee(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) @@ -112,12 +140,10 @@ class BoostTransactionViewModelTest : BaseUnitTest() { @Test fun `setupActivity should call CPFP method for received transaction`() = runTest { - val receivedActivity = Activity.Onchain( - v1 = mockOnchainActivity.copy(txType = PaymentType.RECEIVED) - ) + val receivedActivity = Activity.Onchain(onchainActivity.copy(txType = PaymentType.RECEIVED)) whenever(lightningRepo.calculateCpfpFeeRate(eq(mockTxId))) - .thenReturn(Result.success(testFeeRate)) + .thenReturn(Result.success(feeRate)) sut.setupActivity(receivedActivity) @@ -146,12 +172,11 @@ class BoostTransactionViewModelTest : BaseUnitTest() { @Test fun `onChangeAmount should emit OnMaxFee when at maximum rate`() = runTest { - whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())) - .thenReturn(Result.success(100UL)) // MAX_FEE_RATE + whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(MAX_FEE_RATE)) whenever(lightningRepo.calculateTotalFee(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())) - .thenReturn(Result.success(testTotalFee)) + .thenReturn(Result.success(totalFee)) - sut.setupActivity(mockActivitySent) + sut.setupActivity(activitySent) sut.boostTransactionEffect.test { sut.onChangeAmount(increase = true) @@ -161,12 +186,11 @@ class BoostTransactionViewModelTest : BaseUnitTest() { @Test fun `onChangeAmount should emit OnMinFee when at minimum rate`() = runTest { - whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())) - .thenReturn(Result.success(1UL)) // MIN_FEE_RATE + whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(1UL)) // MIN_FEE_RATE whenever(lightningRepo.calculateTotalFee(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())) - .thenReturn(Result.success(testTotalFee)) + .thenReturn(Result.success(totalFee)) - sut.setupActivity(mockActivitySent) + sut.setupActivity(activitySent) sut.boostTransactionEffect.test { sut.onChangeAmount(increase = false) @@ -176,47 +200,30 @@ class BoostTransactionViewModelTest : BaseUnitTest() { @Test fun `setupActivity failure should emit OnBoostFailed`() = runTest { - whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())) - .thenReturn(Result.failure(Exception("Fee estimation failed"))) + whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.failure(Exception("error"))) sut.boostTransactionEffect.test { - sut.setupActivity(mockActivitySent) + sut.setupActivity(activitySent) assertEquals(BoostTransactionEffects.OnBoostFailed, awaitItem()) } } @Test fun `successful CPFP boost should call correct repository methods`() = runTest { - val receivedActivity = Activity.Onchain( - v1 = mockOnchainActivity.copy(txType = PaymentType.RECEIVED) - ) + val receivedActivity = Activity.Onchain(onchainActivity.copy(txType = PaymentType.RECEIVED)) - whenever(lightningRepo.calculateCpfpFeeRate(any())) - .thenReturn(Result.success(testFeeRate)) + whenever(lightningRepo.calculateCpfpFeeRate(any())).thenReturn(Result.success(feeRate)) whenever(lightningRepo.calculateTotalFee(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())) - .thenReturn(Result.success(testTotalFee)) - whenever(walletRepo.getOnchainAddress()) - .thenReturn(mockAddress) - whenever(lightningRepo.accelerateByCpfp(any(), any(), any())) - .thenReturn(Result.success(mockNewTxId)) - - val newActivity = mockOnchainActivity.copy( - txType = PaymentType.SENT, - txId = mockNewTxId, - isBoosted = true - ) + .thenReturn(Result.success(totalFee)) + whenever(walletRepo.getOnchainAddress()).thenReturn(address) + whenever(lightningRepo.accelerateByCpfp(any(), any(), any())).thenReturn(Result.success(newTxId)) - whenever( - activityRepo.findActivityByPaymentId( - paymentHashOrTxId = any(), - type = any(), - txType = any(), - retry = any(), - ) - ).thenReturn(Result.success(Activity.Onchain(v1 = newActivity))) + val newActivity = onchainActivity.copy(txType = PaymentType.SENT, txId = newTxId, isBoosted = true) - whenever(activityRepo.updateActivity(any(), any(), any())) - .thenReturn(Result.success(Unit)) + whenever(activityRepo.findActivityByPaymentId(any(), any(), any(), any())) + .thenReturn(Result.success(Activity.Onchain(newActivity))) + + whenever(activityRepo.updateActivity(any(), any(), any())).thenReturn(Result.success(Unit)) sut.setupActivity(receivedActivity) @@ -230,4 +237,70 @@ class BoostTransactionViewModelTest : BaseUnitTest() { verify(activityRepo).updateActivity(any(), any(), any()) verify(activityRepo, never()).deleteActivity(any()) } + + // region estimateTime dynamic tier tests + + @Test + fun `estimateTime shows fast description when fee rate at or above fast threshold`() = runTest { + whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(25UL)) + whenever(lightningRepo.calculateTotalFee(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())) + .thenReturn(Result.success(totalFee)) + + sut.uiState.test { + awaitItem() + sut.setupActivity(activitySent) + awaitItem() + val state = awaitItem() + assertEquals(fastFeeTime, state.estimateTime) + } + } + + @Test + fun `estimateTime shows normal description when fee rate between mid and fast`() = runTest { + whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(15UL)) + whenever(lightningRepo.calculateTotalFee(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())) + .thenReturn(Result.success(totalFee)) + + sut.uiState.test { + awaitItem() + sut.setupActivity(activitySent) + awaitItem() + val state = awaitItem() + assertEquals(normalFeeTime, state.estimateTime) + } + } + + @Test + fun `estimateTime shows slow description when fee rate between slow and mid`() = runTest { + val lowFeeActivity = Activity.Onchain(onchainActivity.copy(feeRate = 1UL)) + whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(7UL)) + whenever(lightningRepo.calculateTotalFee(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())) + .thenReturn(Result.success(totalFee)) + + sut.uiState.test { + awaitItem() // initial state + sut.setupActivity(lowFeeActivity) + awaitItem() // loading state + val state = awaitItem() + assertEquals(flowFeeTime, state.estimateTime) + } + } + + @Test + fun `estimateTime shows minimum description when fee rate below slow threshold`() = runTest { + val lowFeeActivity = Activity.Onchain(onchainActivity.copy(feeRate = 1UL)) + whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(3UL)) + whenever(lightningRepo.calculateTotalFee(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())) + .thenReturn(Result.success(totalFee)) + + sut.uiState.test { + awaitItem() // initial state + sut.setupActivity(lowFeeActivity) + awaitItem() // loading state + val state = awaitItem() + assertEquals(minFeeTime, state.estimateTime) + } + } + + // endregion } From c23f3f63efe3d2ed0d4ba545a472feb475847c56 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 7 Jan 2026 02:09:01 +0100 Subject: [PATCH 16/37] feat: weather widget API calls caching --- .../to/bitkit/data/widgets/WeatherService.kt | 62 ++++++++++++------- app/src/main/java/to/bitkit/ext/DateTime.kt | 3 + 2 files changed, 44 insertions(+), 21 deletions(-) diff --git a/app/src/main/java/to/bitkit/data/widgets/WeatherService.kt b/app/src/main/java/to/bitkit/data/widgets/WeatherService.kt index 4c5f8b3e1..d3e516b51 100644 --- a/app/src/main/java/to/bitkit/data/widgets/WeatherService.kt +++ b/app/src/main/java/to/bitkit/data/widgets/WeatherService.kt @@ -10,6 +10,7 @@ import to.bitkit.data.dto.FeeCondition import to.bitkit.data.dto.FeeEstimates import to.bitkit.data.dto.WeatherDTO import to.bitkit.env.Env +import to.bitkit.ext.nowMs import to.bitkit.models.WidgetType import to.bitkit.repositories.CurrencyRepo import to.bitkit.utils.AppError @@ -18,26 +19,42 @@ import java.math.BigDecimal import javax.inject.Inject import javax.inject.Singleton import kotlin.math.floor +import kotlin.time.Clock +import kotlin.time.Duration import kotlin.time.Duration.Companion.minutes +import kotlin.time.ExperimentalTime +@OptIn(ExperimentalTime::class) @Singleton class WeatherService @Inject constructor( private val client: HttpClient, private val currencyRepo: CurrencyRepo, + private val clock: Clock, ) : WidgetService { override val widgetType = WidgetType.WEATHER override val refreshInterval = 8.minutes - private companion object { + private var cachedFeeEstimates: FeeEstimates? = null + private var feeEstimatesTimestamp: Long = 0L + private var cachedHistoricalData: List? = null + private var historicalDataTimestamp: Long = 0L + + companion object { private const val TAG = "WeatherService" + + @Suppress("SpellCheckingInspection") private const val AVERAGE_SEGWIT_VBYTES_SIZE = 140 private const val USD_GOOD_THRESHOLD = 1.0 // $1 USD threshold for good condition private const val PERCENTILE_LOW = 0.33 private const val PERCENTILE_HIGH = 0.66 private const val USD_CURRENCY = "USD" + private val TTL_FEE_ESTIMATES = 2.minutes + private val TTL_HISTORICAL_DATA = 30.minutes } + private fun isCacheValid(timestamp: Long, ttl: Duration) = clock.nowMs() - timestamp < ttl.inWholeMilliseconds + override suspend fun fetchData(): Result = runCatching { // Fetch fee estimates and historical data in parallel val feeEstimates = getFeeEstimates() @@ -56,34 +73,42 @@ class WeatherService @Inject constructor( nextBlockFee = feeEstimates.fast ) }.onFailure { - Logger.warn(e = it, msg = "Failed to fetch weather data", context = TAG) + Logger.warn("Failed to fetch weather data", e = it, context = TAG) } - private suspend fun getFeeEstimates(): FeeEstimates { // TODO CACHE + private suspend fun getFeeEstimates(): FeeEstimates { + cachedFeeEstimates?.takeIf { isCacheValid(feeEstimatesTimestamp, TTL_FEE_ESTIMATES) }?.let { return it } val response: HttpResponse = client.get("${Env.mempoolBaseUrl}/v1/fees/recommended") return when (response.status.isSuccess()) { - true -> response.body() + true -> response.body().also { + cachedFeeEstimates = it + feeEstimatesTimestamp = clock.nowMs() + } + else -> throw WeatherError.InvalidResponse("Failed to fetch fee estimates: ${response.status.description}") } } - private suspend fun getHistoricalFeeData(): List { // TODO CACHE + private suspend fun getHistoricalFeeData(): List { + cachedHistoricalData?.takeIf { isCacheValid(historicalDataTimestamp, TTL_HISTORICAL_DATA) }?.let { return it } val response: HttpResponse = client.get("${Env.mempoolBaseUrl}/v1/mining/blocks/fee-rates/3m") return when (response.status.isSuccess()) { - true -> response.body>() + true -> response.body>().also { + cachedHistoricalData = it + historicalDataTimestamp = clock.nowMs() + } + else -> throw WeatherError.InvalidResponse( "Failed to fetch historical fee data: ${response.status.description}" ) } } - private suspend fun calculateCondition( + private fun calculateCondition( currentFeeRate: Double, - history: List + history: List, ): FeeCondition { - if (history.isEmpty()) { - return FeeCondition.AVERAGE - } + if (history.isEmpty()) return FeeCondition.AVERAGE // Extract median fees from historical data and sort val historicalFees = history.map { it.avgFee50 }.sorted() @@ -94,11 +119,10 @@ class WeatherService @Inject constructor( // Check USD threshold first val avgFeeSats = currentFeeRate * AVERAGE_SEGWIT_VBYTES_SIZE - val avgFeeUsd = currencyRepo.convertSatsToFiat(avgFeeSats.toLong(), currency = USD_CURRENCY).getOrNull() ?: return FeeCondition.AVERAGE + val avgFeeUsd = currencyRepo.convertSatsToFiat(avgFeeSats.toLong(), currency = USD_CURRENCY).getOrNull() + ?: return FeeCondition.AVERAGE - if (avgFeeUsd.value <= BigDecimal(USD_GOOD_THRESHOLD)) { - return FeeCondition.GOOD - } + if (avgFeeUsd.value <= BigDecimal(USD_GOOD_THRESHOLD)) return FeeCondition.GOOD // Determine condition based on percentiles return when { @@ -108,16 +132,12 @@ class WeatherService @Inject constructor( } } - private suspend fun formatFeeForDisplay(satoshis: Int): String { + private fun formatFeeForDisplay(satoshis: Int): String { val usdValue = currencyRepo.convertSatsToFiat(satoshis.toLong(), currency = USD_CURRENCY).getOrNull() return usdValue?.formatted.orEmpty() } } -/** - * Weather-specific error types - */ sealed class WeatherError(message: String) : AppError(message) { - data class InvalidResponse(override val message: String) : WeatherError(message) - data class ConversionError(override val message: String) : WeatherError(message) + class InvalidResponse(override val message: String) : WeatherError(message) } diff --git a/app/src/main/java/to/bitkit/ext/DateTime.kt b/app/src/main/java/to/bitkit/ext/DateTime.kt index bea254c2b..5ed0b1750 100644 --- a/app/src/main/java/to/bitkit/ext/DateTime.kt +++ b/app/src/main/java/to/bitkit/ext/DateTime.kt @@ -36,6 +36,9 @@ import kotlin.time.Instant as KInstant @OptIn(ExperimentalTime::class) fun nowMillis(clock: Clock = Clock.System): Long = clock.now().toEpochMilliseconds() +@OptIn(ExperimentalTime::class) +fun Clock.nowMs(): Long = now().toEpochMilliseconds() + fun nowTimestamp(): Instant = Instant.now().truncatedTo(ChronoUnit.SECONDS) fun Instant.formatted(pattern: String = DatePattern.DATE_TIME): String { From d3a5abe6a44f3df521bebf2479e756a1515b5897 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 7 Jan 2026 02:33:44 +0100 Subject: [PATCH 17/37] chore: cleanup todos --- app/src/main/java/to/bitkit/env/Env.kt | 4 ++-- app/src/main/java/to/bitkit/ext/DateTime.kt | 1 - app/src/main/java/to/bitkit/repositories/WalletRepo.kt | 4 ++-- app/src/main/java/to/bitkit/repositories/WidgetsRepo.kt | 1 + 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/to/bitkit/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt index c725f6884..31f1574cd 100644 --- a/app/src/main/java/to/bitkit/env/Env.kt +++ b/app/src/main/java/to/bitkit/env/Env.kt @@ -20,7 +20,7 @@ internal object Env { val e2eBackend = BuildConfig.E2E_BACKEND.lowercase() val network = Network.valueOf(BuildConfig.NETWORK) val locales = BuildConfig.LOCALES.split(",") - val walletSyncIntervalSecs = 10_uL // TODO review + val walletSyncIntervalSecs = 10_uL val platform = "Android ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})" const val version = "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})" @@ -56,7 +56,7 @@ internal object Env { Network.BITCOIN -> ElectrumServers.MAINNET.FULCRUM Network.REGTEST -> if (isE2eLocal) ElectrumServers.REGTEST.LOCAL else ElectrumServers.REGTEST.STAG Network.TESTNET -> ElectrumServers.TESTNET - else -> TODO("${network.name} network not implemented") + else -> throw Error("${network.name} network not implemented") } } diff --git a/app/src/main/java/to/bitkit/ext/DateTime.kt b/app/src/main/java/to/bitkit/ext/DateTime.kt index 5ed0b1750..757024341 100644 --- a/app/src/main/java/to/bitkit/ext/DateTime.kt +++ b/app/src/main/java/to/bitkit/ext/DateTime.kt @@ -111,7 +111,6 @@ fun Long.toRelativeTimeString( fun getDaysInMonth(month: LocalDate): List { val firstDayOfMonth = LocalDate(month.year, month.month, Constants.FIRST_DAY_OF_MONTH) - // FIXME fix month.number val daysInMonth = month.month.toJavaMonth().length(isLeapYear(month.year)) // Get the day of week for the first day (1 = Monday, 7 = Sunday) diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index b361e67e9..d46f18eb9 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -405,7 +405,7 @@ class WalletRepo @Inject constructor( bitcoinAddress = bitcoinAddress, amountSats = amountSats, message = message, - lightningInvoice = lightningInvoice + lightningInvoice = lightningInvoice, ) } @@ -443,7 +443,7 @@ class WalletRepo @Inject constructor( val hash = paymentHash() if (hash != null) return@withContext hash val address = getOnchainAddress() - return@withContext if (address.isEmpty()) null else address + return@withContext address.ifEmpty { null } } // Pre-activity metadata tag management diff --git a/app/src/main/java/to/bitkit/repositories/WidgetsRepo.kt b/app/src/main/java/to/bitkit/repositories/WidgetsRepo.kt index 9d9829127..9bdd491bd 100644 --- a/app/src/main/java/to/bitkit/repositories/WidgetsRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WidgetsRepo.kt @@ -60,6 +60,7 @@ class WidgetsRepo @Inject constructor( private val _refreshStates = MutableStateFlow( WidgetType.entries.associateWith { false } ) + val refreshStates: StateFlow> = _refreshStates.asStateFlow() init { From 79bb268c38d4ac236a44a8ff47a4d16cf0460ad2 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 7 Jan 2026 02:38:44 +0100 Subject: [PATCH 18/37] chore: remove deprecated LdkMigrationTest.kt --- .../to/bitkit/services/LdkMigrationTest.kt | 62 ------------------- 1 file changed, 62 deletions(-) delete mode 100644 app/src/androidTest/java/to/bitkit/services/LdkMigrationTest.kt diff --git a/app/src/androidTest/java/to/bitkit/services/LdkMigrationTest.kt b/app/src/androidTest/java/to/bitkit/services/LdkMigrationTest.kt deleted file mode 100644 index 51dfbab12..000000000 --- a/app/src/androidTest/java/to/bitkit/services/LdkMigrationTest.kt +++ /dev/null @@ -1,62 +0,0 @@ -package to.bitkit.services - -import android.content.Context -import androidx.test.core.app.ApplicationProvider -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import dagger.hilt.android.testing.HiltAndroidRule -import dagger.hilt.android.testing.HiltAndroidTest -import kotlinx.coroutines.runBlocking -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import to.bitkit.data.keychain.Keychain -import to.bitkit.env.Env -import to.bitkit.ext.readAsset -import javax.inject.Inject -import kotlin.test.assertTrue - -@HiltAndroidTest -class LdkMigrationTest { - @get:Rule - var hiltRule = HiltAndroidRule(this) - - @Inject - lateinit var keychain: Keychain - - @Inject - lateinit var lightningService: LightningService - - private val mnemonic = "pool curve feature leader elite dilemma exile toast smile couch crane public" - - private val testContext by lazy { InstrumentationRegistry.getInstrumentation().context } - private val appContext = ApplicationProvider.getApplicationContext() - - @Before - fun init() { - hiltRule.inject() - Env.initAppStoragePath(appContext.filesDir.absolutePath) - runBlocking { keychain.saveString(Keychain.Key.BIP39_MNEMONIC.name, mnemonic) } - } - - @Test - fun nodeShouldStartFromBackupAfterMigration() = runBlocking { -// TODO Fix or remove check on channel size -// val seed = testContext.readAsset("ldk-backup/seed.bin") -// val manager = testContext.readAsset("ldk-backup/manager.bin") -// val monitor = testContext.readAsset("ldk-backup/monitor.bin") -// -// MigrationService(appContext).migrate(seed, manager, listOf(monitor)) -// -// with(lightningService) { -// setup(walletIndex = 0) -// runBlocking { start() } -// -// assertTrue { nodeId == "02cd08b7b375e4263849121f9f0ffb2732a0b88d0fb74487575ac539b374f45a55" } -// assertTrue { channels?.isNotEmpty() == true } -// -// runBlocking { stop() } -// } - } -} From b684ca23bab373b1a4ce178d249c2c2fd737641f Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 12 Jan 2026 15:53:16 +0100 Subject: [PATCH 19/37] refactor: use class for all errors to fix warnings --- .../java/to/bitkit/data/ChatwootHttpClient.kt | 2 +- .../to/bitkit/data/backup/VssBackupClient.kt | 2 +- .../bitkit/data/backup/VssStoreIdProvider.kt | 3 +- .../to/bitkit/data/widgets/BlocksService.kt | 2 +- .../to/bitkit/data/widgets/NewsService.kt | 2 +- .../to/bitkit/data/widgets/PriceService.kt | 4 +- .../to/bitkit/repositories/BlocktankRepo.kt | 16 +++--- .../to/bitkit/repositories/LightningRepo.kt | 2 +- .../java/to/bitkit/repositories/WalletRepo.kt | 3 +- .../to/bitkit/services/AppUpdaterService.kt | 2 +- .../java/to/bitkit/services/CoreService.kt | 2 +- .../to/bitkit/services/CurrencyService.kt | 4 +- .../to/bitkit/services/LightningService.kt | 56 ++++++++++--------- .../services/LspNotificationsService.kt | 2 +- .../java/to/bitkit/services/RNBackupClient.kt | 18 +++--- app/src/main/java/to/bitkit/utils/Crypto.kt | 26 ++++----- app/src/main/java/to/bitkit/utils/Errors.kt | 38 ++++--------- 17 files changed, 85 insertions(+), 99 deletions(-) diff --git a/app/src/main/java/to/bitkit/data/ChatwootHttpClient.kt b/app/src/main/java/to/bitkit/data/ChatwootHttpClient.kt index e4d505a27..eed4eed8d 100644 --- a/app/src/main/java/to/bitkit/data/ChatwootHttpClient.kt +++ b/app/src/main/java/to/bitkit/data/ChatwootHttpClient.kt @@ -60,5 +60,5 @@ class ChatwootHttpClient @Inject constructor( } sealed class ChatwootHttpError(message: String) : AppError(message) { - data class InvalidResponse(override val message: String) : ChatwootHttpError(message) + class InvalidResponse(override val message: String) : ChatwootHttpError(message) } diff --git a/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt b/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt index f5a6e0a0b..b0b95c95b 100644 --- a/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt +++ b/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt @@ -40,7 +40,7 @@ class VssBackupClient @Inject constructor( Logger.verbose("Building VSS client with lnurlAuthServerUrl: '$lnurlAuthServerUrl'") if (lnurlAuthServerUrl.isNotEmpty()) { val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) - ?: throw ServiceError.MnemonicNotFound + ?: throw ServiceError.MnemonicNotFound() val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) vssNewClientWithLnurlAuth( diff --git a/app/src/main/java/to/bitkit/data/backup/VssStoreIdProvider.kt b/app/src/main/java/to/bitkit/data/backup/VssStoreIdProvider.kt index a682ed9bc..5f88f13a9 100644 --- a/app/src/main/java/to/bitkit/data/backup/VssStoreIdProvider.kt +++ b/app/src/main/java/to/bitkit/data/backup/VssStoreIdProvider.kt @@ -19,7 +19,8 @@ class VssStoreIdProvider @Inject constructor( synchronized(this) { cacheMap[walletIndex]?.let { return it } - val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) ?: throw ServiceError.MnemonicNotFound + val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) + ?: throw ServiceError.MnemonicNotFound() val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) val storeId = vssDeriveStoreId( diff --git a/app/src/main/java/to/bitkit/data/widgets/BlocksService.kt b/app/src/main/java/to/bitkit/data/widgets/BlocksService.kt index d0695acc8..f2ba871a2 100644 --- a/app/src/main/java/to/bitkit/data/widgets/BlocksService.kt +++ b/app/src/main/java/to/bitkit/data/widgets/BlocksService.kt @@ -101,5 +101,5 @@ class BlocksService @Inject constructor( * Block-specific error types */ sealed class BlockError(message: String) : AppError(message) { - data class InvalidResponse(override val message: String) : BlockError(message) + class InvalidResponse(override val message: String) : BlockError(message) } diff --git a/app/src/main/java/to/bitkit/data/widgets/NewsService.kt b/app/src/main/java/to/bitkit/data/widgets/NewsService.kt index a04d8f125..108381eb7 100644 --- a/app/src/main/java/to/bitkit/data/widgets/NewsService.kt +++ b/app/src/main/java/to/bitkit/data/widgets/NewsService.kt @@ -52,5 +52,5 @@ class NewsService @Inject constructor( * News-specific error types */ sealed class NewsError(message: String) : AppError(message) { - data class InvalidResponse(override val message: String) : NewsError(message) + class InvalidResponse(override val message: String) : NewsError(message) } diff --git a/app/src/main/java/to/bitkit/data/widgets/PriceService.kt b/app/src/main/java/to/bitkit/data/widgets/PriceService.kt index 6d11929b4..ced46186c 100644 --- a/app/src/main/java/to/bitkit/data/widgets/PriceService.kt +++ b/app/src/main/java/to/bitkit/data/widgets/PriceService.kt @@ -180,6 +180,6 @@ class PriceService @Inject constructor( * Price-specific error types */ sealed class PriceError(message: String) : AppError(message) { - data class InvalidResponse(override val message: String) : PriceError(message) - data class NetworkError(override val message: String) : PriceError(message) + class InvalidResponse(override val message: String) : PriceError(message) + class NetworkError(override val message: String) : PriceError(message) } diff --git a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt index aa007bbe3..83107fede 100644 --- a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt @@ -201,8 +201,8 @@ class BlocktankRepo @Inject constructor( description: String = Env.DEFAULT_INVOICE_MESSAGE, ): Result = withContext(bgDispatcher) { try { - if (coreService.isGeoBlocked()) throw ServiceError.GeoBlocked - val nodeId = lightningService.nodeId ?: throw ServiceError.NodeNotStarted + if (coreService.isGeoBlocked()) throw ServiceError.GeoBlocked() + val nodeId = lightningService.nodeId ?: throw ServiceError.NodeNotStarted() val lspBalance = getDefaultLspBalance(clientBalance = amountSats) val channelSizeSat = amountSats + lspBalance @@ -230,7 +230,7 @@ class BlocktankRepo @Inject constructor( channelExpiryWeeks: UInt = DEFAULT_CHANNEL_EXPIRY_WEEKS, ): Result = withContext(bgDispatcher) { try { - if (coreService.isGeoBlocked()) throw ServiceError.GeoBlocked + if (coreService.isGeoBlocked()) throw ServiceError.GeoBlocked() val options = defaultCreateOrderOptions(clientBalanceSat = spendingBalanceSats) @@ -323,7 +323,7 @@ class BlocktankRepo @Inject constructor( } private suspend fun defaultCreateOrderOptions(clientBalanceSat: ULong): CreateOrderOptions { - val nodeId = lightningService.nodeId ?: throw ServiceError.NodeNotStarted + val nodeId = lightningService.nodeId ?: throw ServiceError.NodeNotStarted() val timestamp = nowTimestamp().toString() val signature = lightningService.sign("channelOpen-$timestamp") @@ -350,7 +350,7 @@ class BlocktankRepo @Inject constructor( } val satsPerEur = getSatsPerEur() - ?: throw ServiceError.CurrencyRateUnavailable + ?: throw ServiceError.CurrencyRateUnavailable() val params = DefaultLspBalanceParams( clientBalanceSat = clientBalance, @@ -363,10 +363,10 @@ class BlocktankRepo @Inject constructor( fun calculateLiquidityOptions(clientBalanceSat: ULong): Result { val blocktankInfo = blocktankState.value.info - ?: return Result.failure(ServiceError.BlocktankInfoUnavailable) + ?: return Result.failure(ServiceError.BlocktankInfoUnavailable()) val satsPerEur = getSatsPerEur() - ?: return Result.failure(ServiceError.CurrencyRateUnavailable) + ?: return Result.failure(ServiceError.CurrencyRateUnavailable()) val existingChannelsTotalSat = totalBtChannelsValueSats(blocktankInfo) @@ -466,7 +466,7 @@ class BlocktankRepo @Inject constructor( } private suspend fun claimGiftCodeWithoutLiquidity(code: String, amount: ULong): GiftClaimResult { - val nodeId = lightningService.nodeId ?: throw ServiceError.NodeNotStarted + val nodeId = lightningService.nodeId ?: throw ServiceError.NodeNotStarted() val order = ServiceQueue.CORE.background { giftOrder(clientNodeId = nodeId, code = "blocktank-gift-code:$code") diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index a8241e194..29a5b23cc 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -628,7 +628,7 @@ class LightningRepo @Inject constructor( callback: String, domain: String, ): Result = runCatching { - val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) ?: throw ServiceError.MnemonicNotFound + val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) ?: throw ServiceError.MnemonicNotFound() val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) val result = lnurlAuth( diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index d46f18eb9..fc5bae3c4 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -347,7 +347,8 @@ class WalletRepo @Inject constructor( count: Int = 20, ): Result> = withContext(bgDispatcher) { return@withContext try { - val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) ?: throw ServiceError.MnemonicNotFound + val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) + ?: throw ServiceError.MnemonicNotFound() val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) diff --git a/app/src/main/java/to/bitkit/services/AppUpdaterService.kt b/app/src/main/java/to/bitkit/services/AppUpdaterService.kt index 4ab68adae..eedf28331 100644 --- a/app/src/main/java/to/bitkit/services/AppUpdaterService.kt +++ b/app/src/main/java/to/bitkit/services/AppUpdaterService.kt @@ -37,5 +37,5 @@ class AppUpdaterService @Inject constructor( } sealed class AppUpdaterError(message: String) : AppError(message) { - data class InvalidResponse(override val message: String) : AppUpdaterError(message) + class InvalidResponse(override val message: String) : AppUpdaterError(message) } diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index 441357454..a46a354eb 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -1387,7 +1387,7 @@ class BlocktankService( } suspend fun open(orderId: String): IBtOrder { - val nodeId = lightningService.nodeId ?: throw ServiceError.NodeNotStarted + val nodeId = lightningService.nodeId ?: throw ServiceError.NodeNotStarted() val latestOrder = ServiceQueue.CORE.background { getOrders(orderIds = listOf(orderId), filter = null, refresh = true).firstOrNull() diff --git a/app/src/main/java/to/bitkit/services/CurrencyService.kt b/app/src/main/java/to/bitkit/services/CurrencyService.kt index 463db9bd6..7ddee3181 100644 --- a/app/src/main/java/to/bitkit/services/CurrencyService.kt +++ b/app/src/main/java/to/bitkit/services/CurrencyService.kt @@ -33,10 +33,10 @@ class CurrencyService @Inject constructor( } } - throw lastError ?: CurrencyError.Unknown + throw lastError ?: CurrencyError.Unknown() } } sealed class CurrencyError(message: String) : AppError(message) { - data object Unknown : CurrencyError("Unknown error occurred while fetching rates") + class Unknown : CurrencyError("Unknown error occurred while fetching rates") } diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index d7585aff4..2a443a68b 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -140,8 +140,10 @@ class LightningService @Inject constructor( ) } + val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) + ?: throw ServiceError.MnemonicNotFound() setEntropyBip39Mnemonic( - mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) ?: throw ServiceError.MnemonicNotFound, + mnemonic = mnemonic, passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name), ) } @@ -193,7 +195,7 @@ class LightningService @Inject constructor( } suspend fun start(timeout: Duration? = null, onEvent: NodeEventHandler? = null) { - val node = this.node ?: throw ServiceError.NodeNotSetup + val node = this.node ?: throw ServiceError.NodeNotSetup() Logger.debug("Starting node…", context = TAG) @@ -227,7 +229,7 @@ class LightningService @Inject constructor( suspend fun stop() { shouldListenForEvents = false - val node = this.node ?: throw ServiceError.NodeNotStarted + val node = this.node ?: throw ServiceError.NodeNotStarted() Logger.debug("Stopping node…", context = TAG) ServiceQueue.LDK.background { @@ -243,14 +245,14 @@ class LightningService @Inject constructor( } fun wipeStorage(walletIndex: Int) { - if (node != null) throw ServiceError.NodeStillRunning + if (node != null) throw ServiceError.NodeStillRunning() Logger.warn("Wiping LDK storage…", context = TAG) Path(Env.ldkStoragePath(walletIndex)).toFile().deleteRecursively() Logger.info("LDK storage wiped", context = TAG) } suspend fun sync() { - val node = this.node ?: throw ServiceError.NodeNotSetup + val node = this.node ?: throw ServiceError.NodeNotSetup() Logger.verbose("Syncing LDK…", context = TAG) ServiceQueue.LDK.background { @@ -263,8 +265,8 @@ class LightningService @Inject constructor( } suspend fun sign(message: String): String { - val node = this.node ?: throw ServiceError.NodeNotSetup - val msg = runCatching { message.uByteList }.getOrNull() ?: throw ServiceError.InvalidNodeSigningMessage + val node = this.node ?: throw ServiceError.NodeNotSetup() + val msg = runCatching { message.uByteList }.getOrNull() ?: throw ServiceError.InvalidNodeSigningMessage() return ServiceQueue.LDK.background { node.signMessage(msg) @@ -272,7 +274,7 @@ class LightningService @Inject constructor( } suspend fun newAddress(): String { - val node = this.node ?: throw ServiceError.NodeNotSetup + val node = this.node ?: throw ServiceError.NodeNotSetup() return ServiceQueue.LDK.background { node.onchainPayment().newAddress() @@ -281,7 +283,7 @@ class LightningService @Inject constructor( // region peers suspend fun connectToTrustedPeers() { - val node = this.node ?: throw ServiceError.NodeNotSetup + val node = this.node ?: throw ServiceError.NodeNotSetup() ServiceQueue.LDK.background { for (peer in trustedPeers) { @@ -317,7 +319,7 @@ class LightningService @Inject constructor( } suspend fun connectPeer(peer: PeerDetails): Result { - val node = this.node ?: throw ServiceError.NodeNotSetup + val node = this.node ?: throw ServiceError.NodeNotSetup() val uri = peer.uri return ServiceQueue.LDK.background { try { @@ -337,7 +339,7 @@ class LightningService @Inject constructor( } suspend fun disconnectPeer(peer: PeerDetails) { - val node = this.node ?: throw ServiceError.NodeNotSetup + val node = this.node ?: throw ServiceError.NodeNotSetup() val uri = peer.uri Logger.debug("Disconnecting peer: $uri", context = TAG) try { @@ -378,7 +380,7 @@ class LightningService @Inject constructor( pushToCounterpartySats: ULong? = null, channelConfig: ChannelConfig? = null, ): Result { - val node = this.node ?: throw ServiceError.NodeNotSetup + val node = this.node ?: throw ServiceError.NodeNotSetup() return ServiceQueue.LDK.background { try { @@ -418,7 +420,7 @@ class LightningService @Inject constructor( force: Boolean = false, forceCloseReason: String? = null, ) { - val node = this.node ?: throw ServiceError.NodeNotStarted + val node = this.node ?: throw ServiceError.NodeNotStarted() val channelId = channel.channelId val userChannelId = channel.userChannelId val counterpartyNodeId = channel.counterpartyNodeId @@ -463,7 +465,7 @@ class LightningService @Inject constructor( } suspend fun receive(sat: ULong? = null, description: String, expirySecs: UInt = 3600u): String { - val node = this.node ?: throw ServiceError.NodeNotSetup + val node = this.node ?: throw ServiceError.NodeNotSetup() val message = description.ifBlank { Env.DEFAULT_INVOICE_MESSAGE } @@ -511,7 +513,7 @@ class LightningService @Inject constructor( utxosToSpend: List? = null, isMaxAmount: Boolean = false, ): Txid { - val node = this.node ?: throw ServiceError.NodeNotSetup + val node = this.node ?: throw ServiceError.NodeNotSetup() Logger.info( "Sending $sats sats to $address, satsPerVByte=$satsPerVByte, isMaxAmount = $isMaxAmount", @@ -537,7 +539,7 @@ class LightningService @Inject constructor( } suspend fun send(bolt11: String, sats: ULong? = null): PaymentId { - val node = this.node ?: throw ServiceError.NodeNotSetup + val node = this.node ?: throw ServiceError.NodeNotSetup() Logger.debug("Paying bolt11: $bolt11", context = TAG) @@ -557,7 +559,7 @@ class LightningService @Inject constructor( } suspend fun estimateRoutingFees(bolt11: String): Result { - val node = this.node ?: throw ServiceError.NodeNotSetup + val node = this.node ?: throw ServiceError.NodeNotSetup() return ServiceQueue.LDK.background { return@background try { @@ -574,7 +576,7 @@ class LightningService @Inject constructor( } suspend fun estimateRoutingFeesForAmount(bolt11: String, amountSats: ULong): Result { - val node = this.node ?: throw ServiceError.NodeNotSetup + val node = this.node ?: throw ServiceError.NodeNotSetup() return ServiceQueue.LDK.background { return@background try { @@ -594,7 +596,7 @@ class LightningService @Inject constructor( // region utxo selection suspend fun listSpendableOutputs(): Result> { - val node = this.node ?: throw ServiceError.NodeNotSetup + val node = this.node ?: throw ServiceError.NodeNotSetup() return ServiceQueue.LDK.background { return@background try { @@ -614,7 +616,7 @@ class LightningService @Inject constructor( algorithm: CoinSelectionAlgorithm, utxos: List?, ): Result> { - val node = this.node ?: throw ServiceError.NodeNotSetup + val node = this.node ?: throw ServiceError.NodeNotSetup() return ServiceQueue.LDK.background { return@background try { @@ -636,7 +638,7 @@ class LightningService @Inject constructor( // region boost suspend fun bumpFeeByRbf(txid: Txid, satsPerVByte: ULong): Txid { - val node = this.node ?: throw ServiceError.NodeNotSetup + val node = this.node ?: throw ServiceError.NodeNotSetup() Logger.info("RBF for txid='$txid' using satsPerVByte='$satsPerVByte'", context = TAG) @@ -657,7 +659,7 @@ class LightningService @Inject constructor( satsPerVByte: ULong, toAddress: Address, ): Txid { - val node = this.node ?: throw ServiceError.NodeNotSetup + val node = this.node ?: throw ServiceError.NodeNotSetup() Logger.info("CPFP for txid='$txid' using satsPerVByte='$satsPerVByte', to address='$toAddress'", context = TAG) @@ -677,7 +679,7 @@ class LightningService @Inject constructor( // region fee suspend fun calculateCpfpFeeRate(parentTxid: Txid): FeeRate { - val node = this.node ?: throw ServiceError.NodeNotSetup + val node = this.node ?: throw ServiceError.NodeNotSetup() Logger.debug("Calculating CPFP fee for parentTxid $parentTxid", context = TAG) @@ -699,7 +701,7 @@ class LightningService @Inject constructor( satsPerVByte: ULong, utxosToSpend: List? = null, ): ULong { - val node = this.node ?: throw ServiceError.NodeNotSetup + val node = this.node ?: throw ServiceError.NodeNotSetup() Logger.verbose( "Calculating fee for $amountSats sats to $address, ${utxosToSpend?.size} UTXOs, satsPerVByte=$satsPerVByte", @@ -732,7 +734,7 @@ class LightningService @Inject constructor( suspend fun listenForEvents(onEvent: NodeEventHandler? = null) = withContext(bgDispatcher) { while (shouldListenForEvents) { val node = this@LightningService.node ?: let { - Logger.error(ServiceError.NodeNotStarted.message.orEmpty(), context = TAG) + Logger.error(ServiceError.NodeNotStarted().message.orEmpty(), context = TAG) return@withContext } val event = node.nextEventAsync() @@ -749,7 +751,7 @@ class LightningService @Inject constructor( // endregion suspend fun getAddressBalance(address: String): ULong { - val node = this.node ?: throw ServiceError.NodeNotSetup + val node = this.node ?: throw ServiceError.NodeNotSetup() return ServiceQueue.LDK.background { try { node.getAddressBalance(addressStr = address) @@ -920,7 +922,7 @@ class LightningService @Inject constructor( } suspend fun exportNetworkGraphToFile(outputDir: String): Result { - val node = this.node ?: return Result.failure(ServiceError.NodeNotSetup) + val node = this.node ?: return Result.failure(ServiceError.NodeNotSetup()) return withContext(bgDispatcher) { runCatching { diff --git a/app/src/main/java/to/bitkit/services/LspNotificationsService.kt b/app/src/main/java/to/bitkit/services/LspNotificationsService.kt index 08215384a..955b775d9 100644 --- a/app/src/main/java/to/bitkit/services/LspNotificationsService.kt +++ b/app/src/main/java/to/bitkit/services/LspNotificationsService.kt @@ -24,7 +24,7 @@ class LspNotificationsService @Inject constructor( private val crypto: Crypto, ) { suspend fun registerDevice(deviceToken: String) = withContext(bgDispatcher) { - val nodeId = lightningService.nodeId ?: throw ServiceError.NodeNotStarted + val nodeId = lightningService.nodeId ?: throw ServiceError.NodeNotStarted() Logger.debug("Registering device for notifications…") diff --git a/app/src/main/java/to/bitkit/services/RNBackupClient.kt b/app/src/main/java/to/bitkit/services/RNBackupClient.kt index fc1062e6c..1613df269 100644 --- a/app/src/main/java/to/bitkit/services/RNBackupClient.kt +++ b/app/src/main/java/to/bitkit/services/RNBackupClient.kt @@ -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) @@ -109,7 +109,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 + ?: throw RNBackupError.NotSetup() val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) val bearer = authenticate(mnemonic, passphrase) @@ -236,7 +236,7 @@ class RNBackupClient @Inject constructor( } if (!challengeResponse.status.isSuccess()) { - throw RNBackupError.AuthFailed + throw RNBackupError.AuthFailed() } val challengeResult = challengeResponse.body() @@ -254,7 +254,7 @@ class RNBackupClient @Inject constructor( } if (!authResponse.status.isSuccess()) { - throw RNBackupError.AuthFailed + throw RNBackupError.AuthFailed() } authResponse.body().also { cachedBearer = it } @@ -355,8 +355,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) } diff --git a/app/src/main/java/to/bitkit/utils/Crypto.kt b/app/src/main/java/to/bitkit/utils/Crypto.kt index dbce66c8e..d040500ff 100644 --- a/app/src/main/java/to/bitkit/utils/Crypto.kt +++ b/app/src/main/java/to/bitkit/utils/Crypto.kt @@ -61,7 +61,7 @@ class Crypto @Inject constructor() { } } } catch (e: Exception) { - throw CryptoError.SecurityProviderSetupFailed + throw CryptoError.SecurityProviderSetupFailed() } } @@ -80,7 +80,7 @@ class Crypto @Inject constructor() { publicKey = publicKey, ) } catch (e: Exception) { - throw CryptoError.KeypairGenerationFailed + throw CryptoError.KeypairGenerationFailed() } } @@ -111,7 +111,7 @@ class Crypto @Inject constructor() { return baseSecret } catch (e: Exception) { - throw CryptoError.SharedSecretGenerationFailed + throw CryptoError.SharedSecretGenerationFailed() } } @@ -139,7 +139,7 @@ class Crypto @Inject constructor() { return cipher.doFinal(encryptedPayload.cipher + encryptedPayload.tag) } catch (e: Exception) { - throw CryptoError.DecryptionFailed + throw CryptoError.DecryptionFailed() } } @@ -171,7 +171,7 @@ class Crypto @Inject constructor() { val recId = calculateRecoveryId(r, s, messageHash, privateKeyBigInt, curve) formatSignature(recId, r, s) } - }.getOrElse { throw CryptoError.SigningFailed } + }.getOrElse { throw CryptoError.SigningFailed() } fun getPublicKey(privateKey: ByteArray): ByteArray = runCatching { val keyFactory = KeyFactory.getInstance("EC", "BC") @@ -180,7 +180,7 @@ class Crypto @Inject constructor() { val publicKeyPoint = params.g.multiply((privateKeyObj as ECPrivateKey).d) publicKeyPoint.getEncoded(true) - }.getOrElse { throw CryptoError.PublicKeyCreationFailed } + }.getOrElse { throw CryptoError.PublicKeyCreationFailed() } private fun calculateRecoveryId( r: BigInteger, @@ -214,7 +214,7 @@ class Crypto @Inject constructor() { continue } } - throw CryptoError.SigningFailed + throw CryptoError.SigningFailed() } private fun formatSignature(recId: Int, r: BigInteger, s: BigInteger): String { @@ -231,10 +231,10 @@ class Crypto @Inject constructor() { } sealed class CryptoError(message: String) : AppError(message) { - data object SharedSecretGenerationFailed : CryptoError("Shared secret generation failed") - data object SecurityProviderSetupFailed : CryptoError("Security provider setup failed") - data object KeypairGenerationFailed : CryptoError("Keypair generation failed") - data object DecryptionFailed : CryptoError("Decryption failed") - data object SigningFailed : CryptoError("Signing failed") - data object PublicKeyCreationFailed : CryptoError("Public key creation failed") + class SharedSecretGenerationFailed : CryptoError("Shared secret generation failed") + class SecurityProviderSetupFailed : CryptoError("Security provider setup failed") + class KeypairGenerationFailed : CryptoError("Keypair generation failed") + class DecryptionFailed : CryptoError("Decryption failed") + class SigningFailed : CryptoError("Signing failed") + class PublicKeyCreationFailed : CryptoError("Public key creation failed") } diff --git a/app/src/main/java/to/bitkit/utils/Errors.kt b/app/src/main/java/to/bitkit/utils/Errors.kt index 9796db4bf..8855617fd 100644 --- a/app/src/main/java/to/bitkit/utils/Errors.kt +++ b/app/src/main/java/to/bitkit/utils/Errors.kt @@ -5,33 +5,17 @@ package to.bitkit.utils import org.lightningdevkit.ldknode.BuildException import org.lightningdevkit.ldknode.NodeException -// TODO add cause as inner exception -open class AppError(override val message: String? = null) : Exception(message) { - companion object { - @Suppress("ConstPropertyName") - private const val serialVersionUID = 1L - } - - constructor(cause: Throwable) : this("${cause::class.simpleName}='${cause.message}'") - - fun readResolve(): Any { - // Return a new instance of the class, or handle it if needed - return this - } -} +open class AppError(override val message: String? = null) : Exception(message) sealed class ServiceError(message: String) : AppError(message) { - data object NodeNotSetup : ServiceError("Node is not setup") - data object NodeNotStarted : ServiceError("Node is not started") - data object NodeStartTimeout : ServiceError("Node took too long to start") - class LdkNodeSqliteAlreadyExists(path: String) : ServiceError("LDK-node SQLite file already exists at $path") - data object LdkToLdkNodeMigration : ServiceError("LDK to LDK-node migration issue") - data object MnemonicNotFound : ServiceError("Mnemonic not found") - data object NodeStillRunning : ServiceError("Node is still running") - data object InvalidNodeSigningMessage : ServiceError("Invalid node signing message") - data object CurrencyRateUnavailable : ServiceError("Currency rate unavailable") - data object BlocktankInfoUnavailable : ServiceError("Blocktank info not available") - data object GeoBlocked : ServiceError("Geo blocked user") + class NodeNotSetup : ServiceError("Node is not setup") + class NodeNotStarted : ServiceError("Node is not started") + class MnemonicNotFound : ServiceError("Mnemonic not found") + class NodeStillRunning : ServiceError("Node is still running") + class InvalidNodeSigningMessage : ServiceError("Invalid node signing message") + class CurrencyRateUnavailable : ServiceError("Currency rate unavailable") + class BlocktankInfoUnavailable : ServiceError("Blocktank info not available") + class GeoBlocked : ServiceError("Geo blocked user") } // region ldk @@ -129,6 +113,4 @@ class LdkError(private val inner: LdkException) : AppError("Unknown LDK error.") } // endregion -/** Check if the throwable is a TxSyncTimeout exception. */ -fun Throwable.isTxSyncTimeout(): Boolean = - this is NodeException.TxSyncTimeout || cause is NodeException.TxSyncTimeout +fun Throwable.isTxSyncTimeout(): Boolean = this is NodeException.TxSyncTimeout || cause is NodeException.TxSyncTimeout From 147d7625c393369c0dcd1d1dcd89545f5e241f7e Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 12 Jan 2026 21:14:33 +0100 Subject: [PATCH 20/37] fix: complete error type refactoring --- app/src/main/java/to/bitkit/repositories/SweepRepo.kt | 6 +++--- .../ui/settings/advanced/sweep/SweepFeeRateScreen.kt | 4 ++-- app/src/main/java/to/bitkit/utils/Errors.kt | 7 ++++++- app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt | 3 +++ 4 files changed, 14 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/SweepRepo.kt b/app/src/main/java/to/bitkit/repositories/SweepRepo.kt index 0f4bb22ee..36209cd1f 100644 --- a/app/src/main/java/to/bitkit/repositories/SweepRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/SweepRepo.kt @@ -32,7 +32,7 @@ class SweepRepo @Inject constructor( suspend fun checkSweepableBalances(): Result = withContext(bgDispatcher) { runCatching { val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) - ?: throw ServiceError.MnemonicNotFound + ?: throw ServiceError.MnemonicNotFound() val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) Logger.debug("Checking sweepable balances...", context = TAG) @@ -56,7 +56,7 @@ class SweepRepo @Inject constructor( ): Result = withContext(bgDispatcher) { runCatching { val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) - ?: throw ServiceError.MnemonicNotFound + ?: throw ServiceError.MnemonicNotFound() val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) Logger.debug("Preparing sweep transaction...", context = TAG) @@ -79,7 +79,7 @@ class SweepRepo @Inject constructor( suspend fun broadcastSweepTransaction(psbt: String): Result = withContext(bgDispatcher) { runCatching { val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) - ?: throw ServiceError.MnemonicNotFound + ?: throw ServiceError.MnemonicNotFound() val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) Logger.debug("Broadcasting sweep transaction...", context = TAG) diff --git a/app/src/main/java/to/bitkit/ui/settings/advanced/sweep/SweepFeeRateScreen.kt b/app/src/main/java/to/bitkit/ui/settings/advanced/sweep/SweepFeeRateScreen.kt index 06a39cae7..59c3ee9b4 100644 --- a/app/src/main/java/to/bitkit/ui/settings/advanced/sweep/SweepFeeRateScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/advanced/sweep/SweepFeeRateScreen.kt @@ -23,7 +23,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController import to.bitkit.R -import to.bitkit.env.TransactionDefaults +import to.bitkit.env.Defaults import to.bitkit.models.FeeRate import to.bitkit.models.PrimaryDisplay import to.bitkit.models.TransactionSpeed @@ -87,7 +87,7 @@ private fun Content( fun isDisabled(speed: TransactionSpeed): Boolean { val fee = getFee(speed).toULong() - return fee + TransactionDefaults.dustLimit > totalBalance + return fee + Defaults.dustLimit > totalBalance } ScreenColumn { diff --git a/app/src/main/java/to/bitkit/utils/Errors.kt b/app/src/main/java/to/bitkit/utils/Errors.kt index 8855617fd..e72a5575f 100644 --- a/app/src/main/java/to/bitkit/utils/Errors.kt +++ b/app/src/main/java/to/bitkit/utils/Errors.kt @@ -5,7 +5,12 @@ package to.bitkit.utils import org.lightningdevkit.ldknode.BuildException import org.lightningdevkit.ldknode.NodeException -open class AppError(override val message: String? = null) : Exception(message) +open class AppError( + override val message: String? = null, + cause: Throwable? = null, +) : Exception(message, cause) { + constructor(cause: Throwable) : this(cause.message, cause) +} sealed class ServiceError(message: String) : AppError(message) { class NodeNotSetup : ServiceError("Node is not setup") diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index 7e79459ec..d0f02d49c 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -18,7 +18,10 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import org.lightningdevkit.ldknode.ChannelDataMigration import org.lightningdevkit.ldknode.PeerDetails import to.bitkit.R import to.bitkit.data.SettingsStore From 5cb7d8367b641c40dd1698582d6b1cb621c8f4d1 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 12 Jan 2026 21:29:57 +0100 Subject: [PATCH 21/37] chore:remove unused rnBackupServerPubKey --- app/src/main/java/to/bitkit/env/Env.kt | 6 ------ 1 file changed, 6 deletions(-) diff --git a/app/src/main/java/to/bitkit/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt index 31f1574cd..37ad83ac5 100644 --- a/app/src/main/java/to/bitkit/env/Env.kt +++ b/app/src/main/java/to/bitkit/env/Env.kt @@ -146,12 +146,6 @@ internal object Env { else -> "https://bitkit.stag0.blocktank.to/backups-ldk" } - val rnBackupServerPubKey: String - get() = when (network) { - Network.BITCOIN -> "0236efd76e37f96cf2dced9d52ff84c97e5b3d4a75e7d494807291971783f38377" - else -> "02c03b8b8c1b5500b622646867d99bf91676fac0f38e2182c91a9ff0d053a21d6d" - } - // endregion // region paths From 1fab9e6148e60c6ca17885e19063f471c4884d01 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 13 Jan 2026 02:52:34 +0100 Subject: [PATCH 22/37] chore: fix detekt issues in Env --- app/src/main/java/to/bitkit/env/Env.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt index 37ad83ac5..a9a288f03 100644 --- a/app/src/main/java/to/bitkit/env/Env.kt +++ b/app/src/main/java/to/bitkit/env/Env.kt @@ -20,7 +20,7 @@ internal object Env { val e2eBackend = BuildConfig.E2E_BACKEND.lowercase() val network = Network.valueOf(BuildConfig.NETWORK) val locales = BuildConfig.LOCALES.split(",") - val walletSyncIntervalSecs = 10_uL + const val walletSyncIntervalSecs = 10_uL val platform = "Android ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})" const val version = "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})" @@ -56,7 +56,7 @@ internal object Env { Network.BITCOIN -> ElectrumServers.MAINNET.FULCRUM Network.REGTEST -> if (isE2eLocal) ElectrumServers.REGTEST.LOCAL else ElectrumServers.REGTEST.STAG Network.TESTNET -> ElectrumServers.TESTNET - else -> throw Error("${network.name} network not implemented") + else -> TODO("${network.name} network not implemented") } } From 5f6fc752a61f4e64fa5f8c0990c843aef04ae336 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 13 Jan 2026 03:58:29 +0100 Subject: [PATCH 23/37] chore: extract remaining non-dev strings --- .../screens/recovery/RecoveryMnemonicViewModel.kt | 8 ++++++-- .../ui/screens/recovery/RecoveryViewModel.kt | 8 ++++---- .../ui/screens/transfer/SpendingConfirmScreen.kt | 5 ++--- .../wallets/receive/ReceiveConfirmScreen.kt | 4 ++-- .../ui/screens/wallets/send/SendRecipientScreen.kt | 14 +++++++++----- .../ui/screens/widgets/blocks/BlocksEditScreen.kt | 10 +++++----- app/src/main/res/values/strings.xml | 14 ++++++++++++++ 7 files changed, 42 insertions(+), 21 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/recovery/RecoveryMnemonicViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/recovery/RecoveryMnemonicViewModel.kt index d32aba3c1..fd53d23a9 100644 --- a/app/src/main/java/to/bitkit/ui/screens/recovery/RecoveryMnemonicViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/recovery/RecoveryMnemonicViewModel.kt @@ -1,13 +1,16 @@ package to.bitkit.ui.screens.recovery +import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import to.bitkit.R import to.bitkit.data.keychain.Keychain import to.bitkit.models.Toast import to.bitkit.ui.shared.toast.ToastEventBus @@ -16,6 +19,7 @@ import javax.inject.Inject @HiltViewModel class RecoveryMnemonicViewModel @Inject constructor( + @ApplicationContext private val context: Context, private val keychain: Keychain, ) : ViewModel() { @@ -40,8 +44,8 @@ class RecoveryMnemonicViewModel @Inject constructor( } ToastEventBus.send( type = Toast.ToastType.ERROR, - title = "Failed to load mnemonic", - description = "Failed to load mnemonic", + title = context.getString(R.string.security__mnemonic_load_error), + description = context.getString(R.string.security__mnemonic_load_error), ) return@launch } diff --git a/app/src/main/java/to/bitkit/ui/screens/recovery/RecoveryViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/recovery/RecoveryViewModel.kt index 1b72b16d4..c0f19f378 100644 --- a/app/src/main/java/to/bitkit/ui/screens/recovery/RecoveryViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/recovery/RecoveryViewModel.kt @@ -75,8 +75,8 @@ class RecoveryViewModel @Inject constructor( } ToastEventBus.send( type = Toast.ToastType.ERROR, - title = "Error", - description = "Failed to create log zip file", + title = context.getString(R.string.common__error), + description = context.getString(R.string.other__logs_export_error), ) } ) @@ -100,8 +100,8 @@ class RecoveryViewModel @Inject constructor( viewModelScope.launch { ToastEventBus.send( type = Toast.ToastType.ERROR, - title = "Error", - description = "Failed to open support links", + title = context.getString(R.string.common__error), + description = context.getString(R.string.settings__support__link_error), ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingConfirmScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingConfirmScreen.kt index 86198aeca..906e6c3de 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingConfirmScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingConfirmScreen.kt @@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable -import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -54,10 +53,10 @@ import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SwipeToConfirm import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.components.settings.SettingsSwitchRow +import to.bitkit.ui.openNotificationSettings import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.DrawerNavIcon import to.bitkit.ui.scaffold.ScreenColumn -import to.bitkit.ui.openNotificationSettings import to.bitkit.ui.theme.AppSwitchDefaults import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors @@ -202,7 +201,7 @@ private fun Content( } SettingsSwitchRow( - title = "Set up in background", + title = stringResource(R.string.settings__bg__setup), isChecked = hasNotificationPermission, colors = AppSwitchDefaults.colorsPurple, onClick = onSwitchClick, 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 442133b71..c7acf7876 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 @@ -36,10 +36,10 @@ import to.bitkit.ui.components.Title import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.components.settings.SettingsSwitchRow import to.bitkit.ui.currencyViewModel +import to.bitkit.ui.openNotificationSettings import to.bitkit.ui.scaffold.SheetTopBar import to.bitkit.ui.shared.modifiers.sheetHeight import to.bitkit.ui.shared.util.gradientBackground -import to.bitkit.ui.openNotificationSettings import to.bitkit.ui.theme.AppSwitchDefaults import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors @@ -169,7 +169,7 @@ private fun Content( FillHeight() SettingsSwitchRow( - title = "Set up in background", + title = stringResource(R.string.settings__bg__setup), isChecked = hasNotificationPermission, colors = AppSwitchDefaults.colorsPurple, onClick = onSystemSettingsClick, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendRecipientScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendRecipientScreen.kt index 640e47aa8..f88ac3df9 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendRecipientScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendRecipientScreen.kt @@ -169,7 +169,7 @@ fun SendRecipientScreen( app?.toast( type = Toast.ToastType.ERROR, title = context.getString(R.string.other__qr_error_header), - description = "Failed to initialize camera: ${e.message}" + description = context.getString(R.string.other__camera_init_error, e.message.orEmpty()) ) isCameraInitialized = false } @@ -388,7 +388,7 @@ private fun CameraPreviewWithControls( } BodyMSB( - "Scan QR", + stringResource(R.string.other__camera_scan_qr), color = Colors.White, modifier = Modifier .padding(top = 31.dp) @@ -425,12 +425,16 @@ private fun PermissionDenied( .background(Colors.Black) .padding(32.dp) ) { - Display("SCAN\nQR CODE".withAccent(accentColor = Colors.Brand), color = Colors.White) + Display( + stringResource(R.string.other__camera_permission_title) + .withAccent(accentColor = Colors.Brand), + color = Colors.White + ) VerticalSpacer(8.dp) BodyM( - "Allow camera access to scan bitcoin invoices and pay more quickly.", + stringResource(R.string.other__camera_permission_description), color = Colors.White64, modifier = Modifier.fillMaxWidth() ) @@ -438,7 +442,7 @@ private fun PermissionDenied( VerticalSpacer(32.dp) PrimaryButton( - text = "Enable camera", + text = stringResource(R.string.other__camera_permission_button), icon = { Icon(painter = painterResource(R.drawable.ic_camera), contentDescription = null) }, diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/blocks/BlocksEditScreen.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/blocks/BlocksEditScreen.kt index 2c4a086d5..7c6cffb50 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/blocks/BlocksEditScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/blocks/BlocksEditScreen.kt @@ -114,7 +114,7 @@ fun BlocksEditContent( // Block number toggle BlockEditOptionRow( - label = "Block", + label = stringResource(R.string.widgets__blocks__field__block), value = block.height, isEnabled = blocksPreferences.showBlock, onClick = onClickShowBlock, @@ -123,7 +123,7 @@ fun BlocksEditContent( // Time toggle BlockEditOptionRow( - label = "Time", + label = stringResource(R.string.widgets__blocks__field__time), value = block.time, isEnabled = blocksPreferences.showTime, onClick = onClickShowTime, @@ -132,7 +132,7 @@ fun BlocksEditContent( // Date toggle BlockEditOptionRow( - label = "Date", + label = stringResource(R.string.widgets__blocks__field__date), value = block.date, isEnabled = blocksPreferences.showDate, onClick = onClickShowDate, @@ -141,7 +141,7 @@ fun BlocksEditContent( // Transactions toggle BlockEditOptionRow( - label = "Transactions", + label = stringResource(R.string.widgets__blocks__field__transactions), value = block.transactionCount, isEnabled = blocksPreferences.showTransactions, onClick = onClickShowTransactions, @@ -150,7 +150,7 @@ fun BlocksEditContent( // Size toggle BlockEditOptionRow( - label = "Size", + label = stringResource(R.string.widgets__blocks__field__size), value = block.size, isEnabled = blocksPreferences.showSize, onClick = onClickShowSize, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 34da703c6..8469dcb26 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -363,7 +363,12 @@ Update Bitkit Permission to use camera Bitkit needs permission to use your camera + Failed to initialize camera: {message} <bold>It appears Bitkit does not have permission to access your camera.</bold>\n\nTo utilize this feature in the future you will need to enable camera permissions for this app from your phone\'s settings. + Enable camera + Allow camera access to scan bitcoin invoices and pay more quickly. + SCAN\n<accent>QR CODE</accent> + Scan QR Clipboard Data Detected Do you want to be redirected to the relevant screen? Coming soon @@ -410,6 +415,7 @@ Incorrect LNURL withdraw params, min/max not set correctly. Withdraw Requested Your withdraw was successfully requested. Waiting for payment. + Failed to create log zip file Error starting: %1$s Setting up wallet… Running @@ -489,6 +495,7 @@ Multiple Devices Don\'t use your Bitkit recovery phrase on multiple phones simultaneously, as this can corrupt your data. <bold>Latest data backup:</bold> {time} + Failed to load mnemonic Increase Security <accent>Protect</accent>\nyour bitcoin To increase wallet security, you can set up a PIN code and Face ID. @@ -634,6 +641,7 @@ Include amount in notifications Notifications Privacy + Set up in background Get paid when Bitkit is closed Branch and Bound Finds exact amount matches to minimize change @@ -681,6 +689,7 @@ Please describe the issue you are experiencing or ask a general question. Email address Issue or question + Failed to open support links satoshi@satoshi.com Describe the issue or ask a question Send @@ -1186,6 +1195,11 @@ Bitcoin Blocks Examine various statistics on newly mined Bitcoin Blocks. Couldn\'t get blocks data + Block + Date + Size + Time + Transactions Bitcoin Facts Discover fun facts about Bitcoin, every time you open your wallet. Bitcoin Calculator From 102bcac441c8ef0701ed9433fd63f1ac26d14f9e Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 13 Jan 2026 12:51:25 +0100 Subject: [PATCH 24/37] chore: add power of 2 to MagicNumber ignoreNumbers --- config/detekt/detekt.yml | 41 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml index cfe81e1e3..e0b85d0ef 100644 --- a/config/detekt/detekt.yml +++ b/config/detekt/detekt.yml @@ -44,8 +44,8 @@ console-reports: output-reports: active: true exclude: - - 'TxtOutputReport' - - 'MdOutputReport' + - 'TxtOutputReport' + - 'MdOutputReport' # - 'XmlOutputReport' # - 'HtmlOutputReport' # - 'SarifOutputReport' @@ -628,7 +628,42 @@ style: MagicNumber: active: true excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**', '**/*.kts' ] - ignoreNumbers: [ '-1', '0', '0.25', '0.5', '1', '2', '3', '4', '5', '10', '20', '25', '30', '40', '50', '100', '250', '500', '1000', '2500', '5000', '10000', '25000', '50000', '100000' ] + ignoreNumbers: + - '-1' + - '0' + - '0.25' + - '0.5' + - '1' + - '2' + - '3' + - '4' + - '5' + - '8' + - '10' + - '12' + - '20' + - '25' + - '30' + - '32' + - '40' + - '50' + - '64' + - '100' + - '128' + - '250' + - '256' + - '500' + - '512' + - '1000' + - '1024' + - '2048' + - '2500' + - '4096' + - '5000' + - '10000' + - '25000' + - '50000' + - '100000' ignoreHashCodeFunction: true ignorePropertyDeclaration: true ignoreLocalVariableDeclaration: true From d9f3fa9217b203ec4c4a11078d940d3025a08350 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 13 Jan 2026 21:31:23 +0100 Subject: [PATCH 25/37] refactor: solve or suppress all lint issue --- app/detekt-baseline.xml | 204 ------------------ .../main/java/to/bitkit/async/ServiceQueue.kt | 2 + .../to/bitkit/data/BlocktankHttpClient.kt | 3 +- .../main/java/to/bitkit/data/CacheStore.kt | 1 + .../main/java/to/bitkit/data/WidgetsStore.kt | 1 + .../to/bitkit/data/backup/VssBackupClient.kt | 8 +- .../java/to/bitkit/data/keychain/Keychain.kt | 6 + .../to/bitkit/data/widgets/BlocksService.kt | 2 +- .../to/bitkit/data/widgets/PriceService.kt | 13 +- .../java/to/bitkit/di/DispatchersModule.kt | 2 - app/src/main/java/to/bitkit/di/EnvModule.kt | 2 - app/src/main/java/to/bitkit/di/HttpModule.kt | 1 + app/src/main/java/to/bitkit/ext/Context.kt | 35 +-- app/src/main/java/to/bitkit/ext/DateTime.kt | 2 - app/src/main/java/to/bitkit/ext/FileSystem.kt | 3 +- .../java/to/bitkit/ext/LightningBalance.kt | 3 +- app/src/main/java/to/bitkit/fcm/FcmService.kt | 1 + .../main/java/to/bitkit/fcm/WakeNodeWorker.kt | 1 + .../main/java/to/bitkit/models/AddressType.kt | 2 + .../models/BlocktankNotificationType.kt | 2 +- .../java/to/bitkit/models/ElectrumServer.kt | 5 +- .../to/bitkit/models/widget/ArticleModel.kt | 1 + .../to/bitkit/repositories/ActivityRepo.kt | 62 ++---- .../java/to/bitkit/repositories/BackupRepo.kt | 15 +- .../to/bitkit/repositories/BlocktankRepo.kt | 84 ++++---- .../to/bitkit/repositories/CurrencyRepo.kt | 9 +- .../java/to/bitkit/repositories/HealthRepo.kt | 1 + .../to/bitkit/repositories/LightningRepo.kt | 23 +- .../java/to/bitkit/repositories/LogsRepo.kt | 39 ++-- .../java/to/bitkit/repositories/WalletRepo.kt | 96 +++------ .../to/bitkit/repositories/WidgetsRepo.kt | 1 + .../java/to/bitkit/services/CoreService.kt | 173 ++++++--------- .../to/bitkit/services/CurrencyService.kt | 6 +- .../to/bitkit/services/LightningService.kt | 11 +- .../java/to/bitkit/services/LnurlService.kt | 43 ++-- .../java/to/bitkit/services/RNBackupClient.kt | 3 - app/src/main/java/to/bitkit/ui/ContentView.kt | 6 +- app/src/main/java/to/bitkit/ui/Locals.kt | 2 - .../main/java/to/bitkit/ui/MainActivity.kt | 1 + .../main/java/to/bitkit/ui/Notifications.kt | 3 +- .../java/to/bitkit/ui/components/AppStatus.kt | 1 + .../to/bitkit/ui/components/AuthCheckView.kt | 1 + .../bitkit/ui/components/BalanceHeaderView.kt | 7 +- .../java/to/bitkit/ui/components/Button.kt | 2 + .../to/bitkit/ui/components/DrawerMenu.kt | 8 +- .../bitkit/ui/components/LightningChannel.kt | 14 +- .../java/to/bitkit/ui/components/Slider.kt | 1 + .../to/bitkit/ui/components/SwipeToConfirm.kt | 1 + .../main/java/to/bitkit/ui/components/Text.kt | 2 + .../bitkit/ui/components/WalletBalanceView.kt | 6 +- .../components/settings/SettingsButtonRow.kt | 13 +- .../ui/onboarding/InitializingWalletView.kt | 1 + .../ui/screens/scanner/QrScanningScreen.kt | 10 +- .../shop/shopDiscover/MapWebViewClient.kt | 1 + .../shop/shopWebView/ShopWebViewClient.kt | 1 + .../shop/shopWebView/ShopWebViewInterface.kt | 8 +- .../screens/transfer/SavingsConfirmScreen.kt | 1 + .../screens/transfer/SavingsProgressScreen.kt | 4 +- .../ui/screens/transfer/SettingUpScreen.kt | 7 +- .../screens/transfer/SpendingConfirmScreen.kt | 1 + .../transfer/components/ProgressSteps.kt | 2 - .../external/ExternalNodeViewModel.kt | 1 + .../bitkit/ui/screens/wallets/HomeScreen.kt | 2 + .../wallets/activity/ActivityDetailScreen.kt | 1 + .../components/ActivityListGrouped.kt | 1 + .../activity/components/ActivityRow.kt | 7 +- .../wallets/receive/ReceiveConfirmScreen.kt | 2 + .../wallets/receive/ReceiveLiquidityScreen.kt | 18 +- .../screens/wallets/receive/ReceiveSheet.kt | 2 +- .../screens/wallets/send/SendAddressScreen.kt | 5 +- .../send/SendCoinSelectionViewModel.kt | 50 +++-- .../screens/wallets/send/SendConfirmScreen.kt | 4 + .../widgets/blocks/BlocksEditScreen.kt | 4 +- .../screens/widgets/headlines/HeadlineCard.kt | 17 +- .../ui/settings/BlocktankRegtestScreen.kt | 49 +++-- .../bitkit/ui/settings/ChannelOrdersScreen.kt | 5 +- .../ui/settings/SecuritySettingsScreen.kt | 6 +- .../to/bitkit/ui/settings/SettingsScreen.kt | 12 +- .../settings/advanced/AddressViewerScreen.kt | 2 +- .../advanced/CoinSelectPreferenceScreen.kt | 28 +-- .../advanced/ElectrumConfigViewModel.kt | 12 +- .../BackgroundPaymentsSettings.kt | 2 +- .../backups/BackupNavSheetViewModel.kt | 34 +-- .../settings/backups/ConfirmMnemonicScreen.kt | 1 + .../settings/backups/ResetAndRestoreScreen.kt | 22 +- .../ui/settings/backups/ShowMnemonicScreen.kt | 1 + .../ui/settings/backups/SuccessScreen.kt | 1 + .../general/DefaultUnitSettingsScreen.kt | 6 +- .../general/LocalCurrencySettingsScreen.kt | 6 +- .../settings/lightning/ChannelDetailScreen.kt | 1 + .../lightning/LightningConnectionsScreen.kt | 5 +- .../LightningConnectionsViewModel.kt | 2 + .../lightning/components/ChannelStatusView.kt | 2 + .../bitkit/ui/settings/pin/PinPromptScreen.kt | 1 + .../ui/settings/support/ReportIssueScreen.kt | 24 +-- .../to/bitkit/ui/shared/util/ShareSheet.kt | 3 +- .../ui/sheets/BoostTransactionViewModel.kt | 12 +- .../java/to/bitkit/ui/sheets/SendSheet.kt | 1 + .../to/bitkit/ui/utils/BiometricPrompt.kt | 2 + .../MonetaryVisualTransformation.kt | 1 + app/src/main/java/to/bitkit/utils/Crypto.kt | 1 + app/src/main/java/to/bitkit/utils/Errors.kt | 2 + .../timedsheets/sheets/AppUpdateTimedSheet.kt | 19 +- .../viewmodels/ActivityDetailViewModel.kt | 9 +- .../viewmodels/ActivityListViewModel.kt | 4 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 6 +- .../bitkit/viewmodels/DevSettingsViewModel.kt | 2 +- .../to/bitkit/viewmodels/LogsViewModel.kt | 7 +- .../to/bitkit/viewmodels/SettingsViewModel.kt | 1 + .../to/bitkit/viewmodels/TransferViewModel.kt | 86 ++++---- .../to/bitkit/viewmodels/WalletViewModel.kt | 1 + .../test/java/to/bitkit/utils/CryptoTest.kt | 11 +- 112 files changed, 658 insertions(+), 816 deletions(-) diff --git a/app/detekt-baseline.xml b/app/detekt-baseline.xml index c641b13bd..4d1a6d7d9 100644 --- a/app/detekt-baseline.xml +++ b/app/detekt-baseline.xml @@ -2,209 +2,5 @@ - ComplexCondition:AuthCheckView.kt$(showBio && isBiometrySupported && !requirePin) || requireBiometrics - ComplexCondition:ElectrumConfigViewModel.kt$ElectrumConfigViewModel$currentState.host.isBlank() || port == null || port <= 0 || protocol == null - ComplexCondition:MapWebViewClient.kt$MapWebViewClient$it.errorCode == ERROR_HOST_LOOKUP || it.errorCode == ERROR_CONNECT || it.errorCode == ERROR_TIMEOUT || it.errorCode == ERROR_FILE_NOT_FOUND - ComplexCondition:ShopWebViewClient.kt$ShopWebViewClient$it.errorCode == ERROR_HOST_LOOKUP || it.errorCode == ERROR_CONNECT || it.errorCode == ERROR_TIMEOUT || it.errorCode == ERROR_FILE_NOT_FOUND - CyclomaticComplexMethod:ActivityListGrouped.kt$private fun groupActivityItems(activityItems: List<Activity>): List<Any> - CyclomaticComplexMethod:ActivityRow.kt$@Composable fun ActivityRow( item: Activity, onClick: (String) -> Unit, testTag: String, ) - CyclomaticComplexMethod:AppViewModel.kt$AppViewModel$private fun observeSendEvents() - CyclomaticComplexMethod:AppViewModel.kt$AppViewModel$private suspend fun handleSanityChecks(amountSats: ULong) - CyclomaticComplexMethod:BlocktankRegtestScreen.kt$@Composable fun BlocktankRegtestScreen( navController: NavController, viewModel: BlocktankRegtestViewModel = hiltViewModel(), ) - CyclomaticComplexMethod:ConfirmMnemonicScreen.kt$@Composable fun ConfirmMnemonicScreen( uiState: BackupContract.UiState, onContinue: () -> Unit, onBack: () -> Unit, ) - CyclomaticComplexMethod:HealthRepo.kt$HealthRepo$private fun collectState() - CyclomaticComplexMethod:HomeScreen.kt$@Composable fun HomeScreen( mainUiState: MainUiState, drawerState: DrawerState, rootNavController: NavController, walletNavController: NavHostController, settingsViewModel: SettingsViewModel, walletViewModel: WalletViewModel, appViewModel: AppViewModel, activityListViewModel: ActivityListViewModel, homeViewModel: HomeViewModel = hiltViewModel(), ) - CyclomaticComplexMethod:SendSheet.kt$@Composable fun SendSheet( appViewModel: AppViewModel, walletViewModel: WalletViewModel, startDestination: SendRoute = SendRoute.Recipient, ) - CyclomaticComplexMethod:SettingsButtonRow.kt$@Composable fun SettingsButtonRow( title: String, modifier: Modifier = Modifier, subtitle: String? = null, value: SettingsButtonValue = SettingsButtonValue.None, description: String? = null, iconRes: Int? = null, iconTint: Color = Color.Unspecified, iconSize: Dp = 32.dp, maxLinesSubtitle: Int = Int.MAX_VALUE, enabled: Boolean = true, loading: Boolean = false, onClick: () -> Unit, ) - CyclomaticComplexMethod:Slider.kt$@Composable fun StepSlider( value: Int, steps: List<Int>, onValueChange: (Int) -> Unit, modifier: Modifier = Modifier, ) - DestructuringDeclarationWithTooManyEntries:ActivityRow.kt$val (_, _, _, _, _, displayUnit, primaryDisplay) = LocalCurrencies.current - DestructuringDeclarationWithTooManyEntries:BalanceHeaderView.kt$val (_, _, _, _, _, displayUnit, primaryDisplay) = LocalCurrencies.current - DestructuringDeclarationWithTooManyEntries:DefaultUnitSettingsScreen.kt$val (_, _, _, selectedCurrency, _, displayUnit, primaryDisplay) = LocalCurrencies.current - DestructuringDeclarationWithTooManyEntries:LocalCurrencySettingsScreen.kt$val (rates, _, _, selectedCurrency) = LocalCurrencies.current - DestructuringDeclarationWithTooManyEntries:WalletBalanceView.kt$val (_, _, _, _, _, displayUnit, primaryDisplay) = LocalCurrencies.current - EnumEntryNameCase:BlocktankNotificationType.kt$BlocktankNotificationType$cjitPaymentArrived - EnumEntryNameCase:BlocktankNotificationType.kt$BlocktankNotificationType$incomingHtlc - EnumEntryNameCase:BlocktankNotificationType.kt$BlocktankNotificationType$mutualClose - EnumEntryNameCase:BlocktankNotificationType.kt$BlocktankNotificationType$orderPaymentConfirmed - EnumEntryNameCase:BlocktankNotificationType.kt$BlocktankNotificationType$wakeToTimeout - EnumNaming:BlocktankNotificationType.kt$BlocktankNotificationType$cjitPaymentArrived - EnumNaming:BlocktankNotificationType.kt$BlocktankNotificationType$incomingHtlc - EnumNaming:BlocktankNotificationType.kt$BlocktankNotificationType$mutualClose - EnumNaming:BlocktankNotificationType.kt$BlocktankNotificationType$orderPaymentConfirmed - EnumNaming:BlocktankNotificationType.kt$BlocktankNotificationType$wakeToTimeout - ForbiddenComment:ActivityDetailScreen.kt$/* TODO: Implement assign functionality */ - ForbiddenComment:BoostTransactionViewModel.kt$BoostTransactionUiState$// TODO: Implement dynamic time estimation - ForbiddenComment:ContentView.kt$// TODO: display as sheet - ForbiddenComment:ExternalNodeViewModel.kt$ExternalNodeViewModel$// TODO: pass customFeeRate to ldk-node when supported - ForbiddenComment:LightningConnectionsViewModel.kt$LightningConnectionsViewModel$// TODO: sort channels to get consistent index; node.listChannels returns a list in random order - ForbiddenComment:LightningService.kt$LightningService$// TODO: cleanup sensitive data after implementing a `SecureString` value holder for Keychain return values - ForbiddenComment:Notifications.kt$// TODO: review if needed: - ForbiddenComment:SuccessScreen.kt$// TODO: verify backup - FunctionOnlyReturningConstant:ShopWebViewInterface.kt$ShopWebViewInterface$@JavascriptInterface fun isReady(): Boolean - ImplicitDefaultLocale:BlocksService.kt$BlocksService$String.format("%.2f", blockInfo.difficulty / 1_000_000_000_000.0) - ImplicitDefaultLocale:PriceService.kt$PriceService$String.format("%.2f", price) - InstanceOfCheckForException:LightningService.kt$LightningService$e is NodeException - LargeClass:AppViewModel.kt$AppViewModel : ViewModel - LargeClass:LightningRepo.kt$LightningRepo - LongMethod:AppViewModel.kt$AppViewModel$private suspend fun proceedWithPayment() - LongMethod:ContentView.kt$@Suppress("LongParameterList") private fun NavGraphBuilder.home( walletViewModel: WalletViewModel, appViewModel: AppViewModel, activityListViewModel: ActivityListViewModel, settingsViewModel: SettingsViewModel, navController: NavHostController, drawerState: DrawerState, ) - LongMethod:ContentView.kt$private fun NavGraphBuilder.widgets( navController: NavHostController, settingsViewModel: SettingsViewModel, currencyViewModel: CurrencyViewModel, ) - LongMethod:CoreService.kt$ActivityService$suspend fun generateRandomTestData(count: Int = 100) - LongMethod:MainActivity.kt$MainActivity$override fun onCreate(savedInstanceState: Bundle?) - LongParameterList:BiometricPrompt.kt$( activity: Context, title: String, cancelButtonText: String, onAuthSucceed: () -> Unit, onAuthFailed: (() -> Unit), onAuthError: ((errorCode: Int, errString: CharSequence) -> Unit), ) - LongParameterList:BiometricPrompt.kt$( activity: Context, title: String, cancelButtonText: String, onAuthSucceeded: () -> Unit, onAuthFailed: (() -> Unit), onAuthError: ((errorCode: Int, errString: CharSequence) -> Unit), onUnsupported: () -> Unit, ) - LongParameterList:CoreService.kt$ActivityService$( filter: ActivityFilter? = null, txType: PaymentType? = null, tags: List<String>? = null, search: String? = null, minDate: ULong? = null, maxDate: ULong? = null, limit: UInt? = null, sortDirection: SortDirection? = null, ) - LongParameterList:CoreService.kt$BlocktankService$( channelSizeSat: ULong, invoiceSat: ULong, invoiceDescription: String, nodeId: String, channelExpiryWeeks: UInt, options: CreateCjitOptions, ) - LongParameterList:CoreService.kt$OnchainService$( mnemonicPhrase: String, derivationPathStr: String?, network: Network?, bip39Passphrase: String?, isChange: Boolean?, startIndex: UInt?, count: UInt?, ) - LongParameterList:WidgetsRepo.kt$WidgetsRepo$( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, private val newsService: NewsService, private val factsService: FactsService, private val blocksService: BlocksService, private val weatherService: WeatherService, private val priceService: PriceService, private val widgetsStore: WidgetsStore, private val settingsStore: SettingsStore, ) - LoopWithTooManyJumpStatements:MonetaryVisualTransformation.kt$MonetaryVisualTransformation.<no name provided>$for - MagicNumber:ActivityListViewModel.kt$ActivityListViewModel$300 - MagicNumber:AddressViewerScreen.kt$1500000L - MagicNumber:AddressViewerScreen.kt$250000L - MagicNumber:AddressViewerViewModel.kt$AddressViewerViewModel$300 - MagicNumber:AppStatus.kt$0.4f - MagicNumber:ArticleModel.kt$24 - MagicNumber:ArticleModel.kt$60 - MagicNumber:BackupNavSheetViewModel.kt$BackupNavSheetViewModel$200 - MagicNumber:ChannelDetailScreen.kt$1.5f - MagicNumber:ConfirmMnemonicScreen.kt$300 - MagicNumber:CoreService.kt$ActivityService$64 - MagicNumber:Crypto.kt$Crypto$16 - MagicNumber:Crypto.kt$Crypto$32 - MagicNumber:ElectrumConfigViewModel.kt$ElectrumConfigViewModel$65535 - MagicNumber:ElectrumServer.kt$50001 - MagicNumber:ElectrumServer.kt$50002 - MagicNumber:ElectrumServer.kt$60001 - MagicNumber:ElectrumServer.kt$60002 - MagicNumber:HomeScreen.kt$0.8f - MagicNumber:HttpModule.kt$HttpModule$30_000 - MagicNumber:HttpModule.kt$HttpModule$60_000 - MagicNumber:InitializingWalletView.kt$99.9 - MagicNumber:PinPromptScreen.kt$0.8f - MagicNumber:ProgressSteps.kt$12f - MagicNumber:ReceiveQrScreen.kt$32 - MagicNumber:RestoreWalletScreen.kt$12 - MagicNumber:SavingsConfirmScreen.kt$300 - MagicNumber:SendConfirmScreen.kt$1_234 - MagicNumber:SendConfirmScreen.kt$300 - MagicNumber:SendConfirmScreen.kt$43 - MagicNumber:SendConfirmScreen.kt$654_321 - MagicNumber:ShowMnemonicScreen.kt$12 - MagicNumber:ShowMnemonicScreen.kt$24 - MagicNumber:ShowMnemonicScreen.kt$300 - MagicNumber:SpendingConfirmScreen.kt$300 - MagicNumber:SwipeToConfirm.kt$1500 - MatchingDeclarationName:AddressType.kt$AddressTypeInfo - MatchingDeclarationName:Button.kt$ButtonSize - MatchingDeclarationName:CoinSelectPreferenceScreen.kt$CoinSelectPreferenceTestTags - MatchingDeclarationName:LightningChannel.kt$ChannelStatusUi - MatchingDeclarationName:ReceiveConfirmScreen.kt$CjitEntryDetails - MatchingDeclarationName:ReportIssueScreen.kt$ReportIssueTestTags - MatchingDeclarationName:ResetAndRestoreScreen.kt$ResetAndRestoreTestTags - MatchingDeclarationName:SavingsProgressScreen.kt$SavingsProgressState - MatchingDeclarationName:SettingsButtonRow.kt$SettingsButtonValue - MaxLineLength:BlocksEditScreen.kt$enabled = blocksPreferences.run { showBlock || showTime || showDate || showTransactions || showSize || showSource } - MaxLineLength:BlocktankRegtestScreen.kt$"Initiating channel close with fundingTxId: $fundingTxId, vout: $vout, forceCloseAfter: $forceCloseAfter" - MaxLineLength:BlocktankRepo.kt$BlocktankRepo$"Buying channel with lspBalanceSat: $receivingBalanceSats, channelExpiryWeeks: $channelExpiryWeeks, options: $options" - MaxLineLength:ChannelOrdersScreen.kt$lnurl = "LNURL1DP68GURN8GHJ7CTSDYH8XARPVUHXYMR0VD4HGCTWDVH8GME0VFKX7CMTW3SKU6E0V9CXJTMKXGHKCTENVV6NVDP4XUEJ6ETRX33Z6DPEXU6Z6C34XQEZ6DT9XENX2WFNXQ6RXDTXGQAH4MLNURL1DP68GURN8GHJ7CTSDYH8XARPVUHXYMR0VD4HGCTWDVH8GME0VFKX7CMTW3SKU6E0V9CXJTMKXGHKCTENVV6NVDP4XUEJ6ETRX33Z6DPEXU6Z6C34XQEZ6DT9XENX2WFNXQ6RXDTXGQAH4M" - MaxLineLength:CryptoTest.kt$CryptoTest$val ciphertext = "l2fInfyw64gO12odo8iipISloQJ45Rc4WjFmpe95brdaAMDq+T/L9ZChcmMCXnR0J6BXd8sSIJe/0bmby8uSZZJuVCzwF76XHfY5oq0Y1/hKzyZTn8nG3dqfiLHnAPy1tZFQfm5ALgjwWnViYJLXoGFpXs7kLMA=".fromBase64() - MaxLineLength:CryptoTest.kt$CryptoTest$val decryptedPayload = """{"source":"blocktank","type":"incomingHtlc","payload":{"secretMessage":"hello"},"createdAt":"2024-09-18T13:33:52.555Z"}""" - MaxLineLength:HeadlineCard.kt$headline = "How Bitcoin changed El Salvador in more ways a big headline to test the text overflooooooow" - MaxLineLength:LightningBalance.kt$is LightningBalance.ClaimableAwaitingConfirmations -> "Claimable Awaiting Confirmations (Height: $confirmationHeight)" - MaxLineLength:LightningConnectionsScreen.kt$if (showClosed) R.string.lightning__conn_closed_hide else R.string.lightning__conn_closed_show - MaxLineLength:LightningRepo.kt$LightningRepo$"accelerateByCpfp error originalTxId: $originalTxId, satsPerVByte: $satsPerVByte destinationAddress: $destinationAddress" - MaxLineLength:LightningRepo.kt$LightningRepo$"accelerateByCpfp success, newDestinationTxId: $newDestinationTxId originalTxId: $originalTxId, satsPerVByte: $satsPerVByte destinationAddress: $destinationAddress" - MaxLineLength:LightningRepo.kt$LightningRepo$"bumpFeeByRbf success, replacementTxId: $replacementTxId originalTxId: $originalTxId, satsPerVByte: $satsPerVByte" - MaxLineLength:ReceiveLiquidityScreen.kt$if (isAdditional) R.string.wallet__receive_liquidity__label_additional else R.string.wallet__receive_liquidity__label - MaxLineLength:ReceiveLiquidityScreen.kt$if (isAdditional) R.string.wallet__receive_liquidity__nav_title_additional else R.string.wallet__receive_liquidity__nav_title - MaxLineLength:ReceiveLiquidityScreen.kt$if (isAdditional) R.string.wallet__receive_liquidity__text_additional else R.string.wallet__receive_liquidity__text - MaxLineLength:SecuritySettingsScreen.kt$if (isPinEnabled) R.string.settings__security__pin_enabled else R.string.settings__security__pin_disabled - MaxLineLength:SendAddressScreen.kt$addressInput = "bitcoin:bc17tq4mtkq86vte7a26e0za560kgflwqsvxznmer5?lightning=LNBC1PQUVNP8KHGPLNF6REGS3VY5F40AJFUN4S2JUDQQNP4TK9MP6LWWLWTC3XX3UUEVYZ4EVQU3X4NQDX348QPP5WJC9DWNTAFN7FZEZFVDC3MHV67SX2LD2MG602E3LEZDMFT29JLWQSP54QKM4G8A2KD5RGEKACA3CH4XV4M2MQDN62F8S2CCRES9QYYSGQCQPCXQRRSSRZJQWQKZS03MNNHSTKR9DN2XQRC8VW5X6CEWAL8C6RW6QQ3T02T3R" - MaxLineLength:SettingsScreen.kt$if (newValue) R.string.settings__dev_enabled_message else R.string.settings__dev_disabled_message - MaxLineLength:WeatherService.kt$WeatherService$val avgFeeUsd = currencyRepo.convertSatsToFiat(avgFeeSats.toLong(), currency = USD_CURRENCY).getOrNull() ?: return FeeCondition.AVERAGE - MaximumLineLength:BlocksEditScreen.kt$ - MaximumLineLength:BlocktankRegtestScreen.kt$ - MaximumLineLength:BlocktankRepo.kt$BlocktankRepo$ - MaximumLineLength:ChannelOrdersScreen.kt$ - MaximumLineLength:CryptoTest.kt$CryptoTest$ - MaximumLineLength:HeadlineCard.kt$ - MaximumLineLength:LightningBalance.kt$ - MaximumLineLength:LightningConnectionsScreen.kt$ - MaximumLineLength:LightningRepo.kt$LightningRepo$ - MaximumLineLength:ReceiveLiquidityScreen.kt$ - MaximumLineLength:SecuritySettingsScreen.kt$ - MaximumLineLength:SendAddressScreen.kt$ - MaximumLineLength:SettingsScreen.kt$ - MaximumLineLength:WeatherService.kt$WeatherService$ - MayBeConst:Env.kt$Env$val walletSyncIntervalSecs = 10_uL // TODO review - MemberNameEqualsClassName:Keychain.kt$Keychain$private val keychain = context.keychainDataStore - NestedBlockDepth:Context.kt$fun Context.copyAssetToStorage(asset: String, dest: String) - NestedBlockDepth:LogsRepo.kt$LogsRepo$private fun createZipBase64(logFiles: List<LogFile>): String - NestedBlockDepth:MonetaryVisualTransformation.kt$MonetaryVisualTransformation$private fun createOffsetMapping(original: String, transformed: String): OffsetMapping - NestedBlockDepth:ShopWebViewInterface.kt$ShopWebViewInterface$@JavascriptInterface fun postMessage(message: String) - NoWildcardImports:LightningChannel.kt$import androidx.compose.foundation.layout.* - PrintStackTrace:ShareSheet.kt$e - ReturnCount:AppViewModel.kt$AppViewModel$private suspend fun handleSanityChecks(amountSats: ULong) - ReturnCount:FcmService.kt$FcmService$private fun decryptPayload(response: EncryptedNotification) - ReturnCount:LightningConnectionsViewModel.kt$LightningConnectionsViewModel$private fun findUpdatedChannel( currentChannel: ChannelDetails, allChannels: List<ChannelDetails>, ): ChannelDetails? - SwallowedException:Crypto.kt$Crypto$e: Exception - TooGenericExceptionCaught:ActivityDetailViewModel.kt$ActivityDetailViewModel$e: Exception - TooGenericExceptionCaught:ActivityRepo.kt$ActivityRepo$e: Exception - TooGenericExceptionCaught:AppViewModel.kt$AppViewModel$e: Exception - TooGenericExceptionCaught:ArticleModel.kt$e: Exception - TooGenericExceptionCaught:BackupNavSheetViewModel.kt$BackupNavSheetViewModel$e: Throwable - TooGenericExceptionCaught:BackupRepo.kt$BackupRepo$e: Throwable - TooGenericExceptionCaught:BiometricPrompt.kt$e: Exception - TooGenericExceptionCaught:BlocktankRegtestScreen.kt$e: Exception - TooGenericExceptionCaught:BlocktankRepo.kt$BlocktankRepo$e: Throwable - TooGenericExceptionCaught:BoostTransactionViewModel.kt$BoostTransactionViewModel$e: Exception - TooGenericExceptionCaught:ChannelOrdersScreen.kt$e: Throwable - TooGenericExceptionCaught:CoreService.kt$ActivityService$e: Exception - TooGenericExceptionCaught:CoreService.kt$CoreService$e: Exception - TooGenericExceptionCaught:Crypto.kt$Crypto$e: Exception - TooGenericExceptionCaught:CurrencyRepo.kt$CurrencyRepo$e: Exception - TooGenericExceptionCaught:CurrencyService.kt$CurrencyService$e: Exception - TooGenericExceptionCaught:ElectrumConfigViewModel.kt$ElectrumConfigViewModel$e: Exception - TooGenericExceptionCaught:LightningRepo.kt$LightningRepo$e: Throwable - TooGenericExceptionCaught:LightningService.kt$LightningService$e: Exception - TooGenericExceptionCaught:LogsRepo.kt$LogsRepo$e: Exception - TooGenericExceptionCaught:LogsViewModel.kt$LogsViewModel$e: Exception - TooGenericExceptionCaught:PriceService.kt$PriceService$e: Exception - TooGenericExceptionCaught:QrScanningScreen.kt$e: Exception - TooGenericExceptionCaught:SendCoinSelectionViewModel.kt$SendCoinSelectionViewModel$e: Throwable - TooGenericExceptionCaught:ServiceQueue.kt$ServiceQueue$e: Exception - TooGenericExceptionCaught:SettingUpScreen.kt$e: Throwable - TooGenericExceptionCaught:ShopWebViewInterface.kt$ShopWebViewInterface$e: Exception - TooGenericExceptionCaught:TransferViewModel.kt$TransferViewModel$e: Throwable - TooGenericExceptionCaught:VssBackupClient.kt$VssBackupClient$e: Throwable - TooGenericExceptionCaught:WakeNodeWorker.kt$WakeNodeWorker$e: Exception - TooGenericExceptionCaught:WalletRepo.kt$WalletRepo$e: Exception - TooGenericExceptionCaught:WalletRepo.kt$WalletRepo$e: Throwable - TooGenericExceptionThrown:BlocktankHttpClient.kt$BlocktankHttpClient$throw Exception("Http error: ${response.status}") - TooGenericExceptionThrown:FileSystem.kt$throw Error("Cannot create path: $this") - TooGenericExceptionThrown:LnurlService.kt$LnurlService$throw Exception("HTTP error: ${response.status}") - TooGenericExceptionThrown:LnurlService.kt$LnurlService$throw Exception("LNURL channel error: ${parsedResponse.reason}") - TooGenericExceptionThrown:LnurlService.kt$LnurlService$throw Exception("LNURL error: ${withdrawResponse.reason}") - TooManyFunctions:ActivityRepo.kt$ActivityRepo - TooManyFunctions:AppViewModel.kt$AppViewModel : ViewModel - TooManyFunctions:BackupNavSheetViewModel.kt$BackupNavSheetViewModel : ViewModel - TooManyFunctions:BlocktankRepo.kt$BlocktankRepo - TooManyFunctions:CacheStore.kt$CacheStore - TooManyFunctions:ContentView.kt$to.bitkit.ui.ContentView.kt - TooManyFunctions:CoreService.kt$ActivityService - TooManyFunctions:CoreService.kt$BlocktankService - TooManyFunctions:DevSettingsViewModel.kt$DevSettingsViewModel : ViewModel - TooManyFunctions:LightningRepo.kt$LightningRepo - TooManyFunctions:LightningService.kt$LightningService : BaseCoroutineScope - TooManyFunctions:SettingsViewModel.kt$SettingsViewModel : ViewModel - TooManyFunctions:Text.kt$to.bitkit.ui.components.Text.kt - TooManyFunctions:TransferViewModel.kt$TransferViewModel : ViewModel - TooManyFunctions:WalletRepo.kt$WalletRepo - TooManyFunctions:WalletViewModel.kt$WalletViewModel : ViewModel - TooManyFunctions:WidgetsRepo.kt$WidgetsRepo - TooManyFunctions:WidgetsStore.kt$WidgetsStore - TopLevelPropertyNaming:DrawerMenu.kt$private const val zIndexMenu = 11f - TopLevelPropertyNaming:DrawerMenu.kt$private const val zIndexScrim = 10f - WildcardImport:LightningChannel.kt$import androidx.compose.foundation.layout.* diff --git a/app/src/main/java/to/bitkit/async/ServiceQueue.kt b/app/src/main/java/to/bitkit/async/ServiceQueue.kt index 5d3cf2e55..29084486b 100644 --- a/app/src/main/java/to/bitkit/async/ServiceQueue.kt +++ b/app/src/main/java/to/bitkit/async/ServiceQueue.kt @@ -19,6 +19,7 @@ enum class ServiceQueue { private val scope by lazy { CoroutineScope(newSingleThreadDispatcher(name) + SupervisorJob()) } + @Suppress("TooGenericExceptionCaught") fun blocking( coroutineContext: CoroutineContext = scope.coroutineContext, functionName: String = Thread.currentThread().callerName, @@ -36,6 +37,7 @@ enum class ServiceQueue { } } + @Suppress("TooGenericExceptionCaught") suspend fun background( coroutineContext: CoroutineContext = scope.coroutineContext, functionName: String = Thread.currentThread().callerName, diff --git a/app/src/main/java/to/bitkit/data/BlocktankHttpClient.kt b/app/src/main/java/to/bitkit/data/BlocktankHttpClient.kt index 02504e0ee..17b9cf237 100644 --- a/app/src/main/java/to/bitkit/data/BlocktankHttpClient.kt +++ b/app/src/main/java/to/bitkit/data/BlocktankHttpClient.kt @@ -6,6 +6,7 @@ import io.ktor.client.request.get import io.ktor.http.isSuccess import to.bitkit.env.Env import to.bitkit.models.FxRateResponse +import to.bitkit.utils.HttpError import to.bitkit.utils.Logger import javax.inject.Inject import javax.inject.Singleton @@ -20,7 +21,7 @@ class BlocktankHttpClient @Inject constructor( return when (response.status.isSuccess()) { true -> response.body() - else -> throw Exception("Http error: ${response.status}") + else -> throw HttpError("fetchLatestRates error: '${response.status.description}'", response.status.value) } } } diff --git a/app/src/main/java/to/bitkit/data/CacheStore.kt b/app/src/main/java/to/bitkit/data/CacheStore.kt index 33a3b315a..890008fb7 100644 --- a/app/src/main/java/to/bitkit/data/CacheStore.kt +++ b/app/src/main/java/to/bitkit/data/CacheStore.kt @@ -24,6 +24,7 @@ private val Context.appCacheDataStore: DataStore by dataStore( serializer = AppCacheSerializer ) +@Suppress("TooManyFunctions") @Singleton class CacheStore @Inject constructor( @ApplicationContext private val context: Context, diff --git a/app/src/main/java/to/bitkit/data/WidgetsStore.kt b/app/src/main/java/to/bitkit/data/WidgetsStore.kt index dd43fc8ae..13487b950 100644 --- a/app/src/main/java/to/bitkit/data/WidgetsStore.kt +++ b/app/src/main/java/to/bitkit/data/WidgetsStore.kt @@ -31,6 +31,7 @@ private val Context.widgetsDataStore: DataStore by dataStore( serializer = WidgetsSerializer, ) +@Suppress("TooManyFunctions") @Singleton class WidgetsStore @Inject constructor( @ApplicationContext private val context: Context, diff --git a/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt b/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt index b0b95c95b..53f66a222 100644 --- a/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt +++ b/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt @@ -30,7 +30,7 @@ class VssBackupClient @Inject constructor( private var isSetup = CompletableDeferred() suspend fun setup(walletIndex: Int = 0) = withContext(ioDispatcher) { - try { + runCatching { withTimeout(30.seconds) { Logger.debug("VSS client setting up…", context = TAG) val vssUrl = Env.vssServerUrl @@ -59,9 +59,9 @@ class VssBackupClient @Inject constructor( isSetup.complete(Unit) Logger.info("VSS client setup with server: '$vssUrl'", context = TAG) } - } catch (e: Throwable) { - isSetup.completeExceptionally(e) - Logger.error("VSS client setup error", e = e, context = TAG) + }.onFailure { + isSetup.completeExceptionally(it) + Logger.error("VSS client setup error", e = it, context = TAG) } } diff --git a/app/src/main/java/to/bitkit/data/keychain/Keychain.kt b/app/src/main/java/to/bitkit/data/keychain/Keychain.kt index 0ab10383d..ad6e82997 100644 --- a/app/src/main/java/to/bitkit/data/keychain/Keychain.kt +++ b/app/src/main/java/to/bitkit/data/keychain/Keychain.kt @@ -33,12 +33,15 @@ class Keychain @Inject constructor( @IoDispatcher private val dispatcher: CoroutineDispatcher, ) : BaseCoroutineScope(dispatcher) { private val keyStore by lazy { AndroidKeyStore(alias = "keychain") } + + @Suppress("MemberNameEqualsClassName") private val keychain = context.keychainDataStore val snapshot get() = runBlocking(this.coroutineContext) { keychain.data.first() } fun loadString(key: String): String? = load(key)?.decodeToString() + @Suppress("TooGenericExceptionCaught", "SwallowedException") fun load(key: String): ByteArray? { try { return snapshot[key.indexed]?.fromBase64()?.let { @@ -51,6 +54,7 @@ class Keychain @Inject constructor( suspend fun saveString(key: String, value: String) = save(key, value.toByteArray()) + @Suppress("TooGenericExceptionCaught", "SwallowedException") suspend fun save(key: String, value: ByteArray) { if (exists(key)) throw KeychainError.FailedToSaveAlreadyExists(key) @@ -64,6 +68,7 @@ class Keychain @Inject constructor( } /** Inserts or replaces a string value associated with a given key in the keychain. */ + @Suppress("TooGenericExceptionCaught", "SwallowedException") suspend fun upsertString(key: String, value: String) { try { val encryptedValue = keyStore.encrypt(value.toByteArray()) @@ -74,6 +79,7 @@ class Keychain @Inject constructor( Logger.info("Upsert in keychain: $key") } + @Suppress("TooGenericExceptionCaught", "SwallowedException") suspend fun delete(key: String) { try { keychain.edit { it.remove(key.indexed) } diff --git a/app/src/main/java/to/bitkit/data/widgets/BlocksService.kt b/app/src/main/java/to/bitkit/data/widgets/BlocksService.kt index f2ba871a2..4195b50aa 100644 --- a/app/src/main/java/to/bitkit/data/widgets/BlocksService.kt +++ b/app/src/main/java/to/bitkit/data/widgets/BlocksService.kt @@ -62,7 +62,7 @@ class BlocksService @Inject constructor( val numberFormat = NumberFormat.getNumberInstance(Locale.US) // Format difficulty (convert to trillions) - val difficulty = String.format("%.2f", blockInfo.difficulty / 1_000_000_000_000.0) + val difficulty = String.format(Locale.US, "%.2f", blockInfo.difficulty / 1_000_000_000_000.0) // Format size (convert to KB) val sizeKb = (blockInfo.size / 1024.0) diff --git a/app/src/main/java/to/bitkit/data/widgets/PriceService.kt b/app/src/main/java/to/bitkit/data/widgets/PriceService.kt index ced46186c..3b859bad0 100644 --- a/app/src/main/java/to/bitkit/data/widgets/PriceService.kt +++ b/app/src/main/java/to/bitkit/data/widgets/PriceService.kt @@ -146,7 +146,7 @@ class PriceService @Inject constructor( } private fun formatPrice(pair: TradingPair, price: Double): String { - return try { + return runCatching { val currency = Currency.getInstance(pair.quote) val numberFormat = NumberFormat.getCurrencyInstance(Locale.US).apply { this.currency = currency @@ -161,14 +161,9 @@ class PriceService @Inject constructor( val formatted = numberFormat.format(price) val currencySymbol = currency.symbol formatted.replace(currencySymbol, "").trim() - } catch (e: Exception) { - Logger.warn( - e = e, - msg = "Error formatting price for ${pair.displayName}", - context = TAG - ) - String.format("%.2f", price) - } + }.onFailure { + Logger.warn("Error formatting price for ${pair.displayName}", e = it, context = TAG) + }.getOrDefault(String.format(Locale.US, "%.2f", price)) } companion object { diff --git a/app/src/main/java/to/bitkit/di/DispatchersModule.kt b/app/src/main/java/to/bitkit/di/DispatchersModule.kt index 408a9ffe6..d9f1794ac 100644 --- a/app/src/main/java/to/bitkit/di/DispatchersModule.kt +++ b/app/src/main/java/to/bitkit/di/DispatchersModule.kt @@ -1,5 +1,3 @@ -@file:Suppress("unused") - package to.bitkit.di import dagger.Module diff --git a/app/src/main/java/to/bitkit/di/EnvModule.kt b/app/src/main/java/to/bitkit/di/EnvModule.kt index 7bd559988..d413c24af 100644 --- a/app/src/main/java/to/bitkit/di/EnvModule.kt +++ b/app/src/main/java/to/bitkit/di/EnvModule.kt @@ -1,5 +1,3 @@ -@file:Suppress("unused") - package to.bitkit.di import dagger.Module diff --git a/app/src/main/java/to/bitkit/di/HttpModule.kt b/app/src/main/java/to/bitkit/di/HttpModule.kt index 1c71137b0..1ffb7fe4a 100644 --- a/app/src/main/java/to/bitkit/di/HttpModule.kt +++ b/app/src/main/java/to/bitkit/di/HttpModule.kt @@ -43,6 +43,7 @@ object HttpModule { } } + @Suppress("MagicNumber") private fun HttpTimeoutConfig.defaultTimeoutConfig() { requestTimeoutMillis = 60_000 connectTimeoutMillis = 30_000 diff --git a/app/src/main/java/to/bitkit/ext/Context.kt b/app/src/main/java/to/bitkit/ext/Context.kt index b262be399..ae11b3989 100644 --- a/app/src/main/java/to/bitkit/ext/Context.kt +++ b/app/src/main/java/to/bitkit/ext/Context.kt @@ -1,5 +1,3 @@ -@file:Suppress("unused") - package to.bitkit.ext import android.app.Activity @@ -16,10 +14,6 @@ import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat import androidx.core.net.toUri import to.bitkit.R -import to.bitkit.utils.Logger -import java.io.File -import java.io.FileOutputStream -import java.io.IOException import java.io.InputStream // System Services @@ -41,24 +35,6 @@ fun Context.requiresPermission(permission: String): Boolean = // File System fun Context.readAsset(path: String) = assets.open(path).use(InputStream::readBytes) -fun Context.copyAssetToStorage(asset: String, dest: String) { - val destFile = File(dest) - - try { - this.assets.open(asset).use { inputStream -> - FileOutputStream(destFile).use { outputStream -> - val buffer = ByteArray(1024) - var length: Int - while (inputStream.read(buffer).also { length = it } > 0) { - outputStream.write(buffer, 0, length) - } - } - } - } catch (e: IOException) { - Logger.error("Failed to copy asset file: $asset", e) - } -} - // Clipboard fun Context.setClipboardText(text: String, label: String = getString(R.string.app_name)) { this.clipboardManager.setPrimaryClip( @@ -72,12 +48,11 @@ fun Context.getClipboardText(): String? { // Other -fun Context.findActivity(): Activity? = - when (this) { - is Activity -> this - is ContextWrapper -> baseContext.findActivity() - else -> null - } +fun Context.findActivity(): Activity? = when (this) { + is Activity -> this + is ContextWrapper -> baseContext.findActivity() + else -> null +} fun Context.startActivityAppSettings() { val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { diff --git a/app/src/main/java/to/bitkit/ext/DateTime.kt b/app/src/main/java/to/bitkit/ext/DateTime.kt index 757024341..b5cc206e6 100644 --- a/app/src/main/java/to/bitkit/ext/DateTime.kt +++ b/app/src/main/java/to/bitkit/ext/DateTime.kt @@ -1,5 +1,3 @@ -@file:Suppress("TooManyFunctions") - package to.bitkit.ext import android.icu.text.DateFormat diff --git a/app/src/main/java/to/bitkit/ext/FileSystem.kt b/app/src/main/java/to/bitkit/ext/FileSystem.kt index 2be98849b..303c54c51 100644 --- a/app/src/main/java/to/bitkit/ext/FileSystem.kt +++ b/app/src/main/java/to/bitkit/ext/FileSystem.kt @@ -1,5 +1,6 @@ package to.bitkit.ext +import to.bitkit.utils.AppError import java.io.File import kotlin.io.path.exists @@ -7,5 +8,5 @@ fun File.ensureDir() = this.also { if (toPath().exists()) return this val path = if (extension.isEmpty()) this else parentFile - if (!path.mkdirs()) throw Error("Cannot create path: $this") + if (!path.mkdirs()) throw AppError("Cannot create path: $this") } diff --git a/app/src/main/java/to/bitkit/ext/LightningBalance.kt b/app/src/main/java/to/bitkit/ext/LightningBalance.kt index a1b2d2a22..5a910fe0e 100644 --- a/app/src/main/java/to/bitkit/ext/LightningBalance.kt +++ b/app/src/main/java/to/bitkit/ext/LightningBalance.kt @@ -27,7 +27,8 @@ fun LightningBalance.channelId(): String { fun LightningBalance.balanceUiText(): String { return when (this) { is LightningBalance.ClaimableOnChannelClose -> "Claimable on Channel Close" - is LightningBalance.ClaimableAwaitingConfirmations -> "Claimable Awaiting Confirmations (Height: $confirmationHeight)" + is LightningBalance.ClaimableAwaitingConfirmations -> + "Claimable Awaiting Confirmations (Height: $confirmationHeight)" is LightningBalance.ContentiousClaimable -> "Contentious Claimable" is LightningBalance.MaybeTimeoutClaimableHtlc -> "Maybe Timeout Claimable HTLC" is LightningBalance.MaybePreimageClaimableHtlc -> "Maybe Preimage Claimable HTLC" diff --git a/app/src/main/java/to/bitkit/fcm/FcmService.kt b/app/src/main/java/to/bitkit/fcm/FcmService.kt index b37a7e655..2e4a737b3 100644 --- a/app/src/main/java/to/bitkit/fcm/FcmService.kt +++ b/app/src/main/java/to/bitkit/fcm/FcmService.kt @@ -95,6 +95,7 @@ class FcmService : FirebaseMessagingService() { Logger.warn("FCM handler not implemented for: $data", context = TAG) } + @Suppress("ReturnCount") private fun decryptPayload(response: EncryptedNotification) { val ciphertext = runCatching { response.cipher.fromBase64() }.getOrElse { Logger.error("Failed to decode cipher", it, context = TAG) diff --git a/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt b/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt index 93957ea27..9c6a12d23 100644 --- a/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt +++ b/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt @@ -61,6 +61,7 @@ class WakeNodeWorker @AssistedInject constructor( private val timeout = 2.minutes private val deliverSignal = CompletableDeferred() + @Suppress("TooGenericExceptionCaught") override suspend fun doWork(): Result { Logger.debug("Node wakeup from notification…", context = TAG) diff --git a/app/src/main/java/to/bitkit/models/AddressType.kt b/app/src/main/java/to/bitkit/models/AddressType.kt index 25540b315..3759011bb 100644 --- a/app/src/main/java/to/bitkit/models/AddressType.kt +++ b/app/src/main/java/to/bitkit/models/AddressType.kt @@ -1,3 +1,5 @@ +@file:Suppress("MatchingDeclarationName") + package to.bitkit.models import com.synonym.bitkitcore.AddressType diff --git a/app/src/main/java/to/bitkit/models/BlocktankNotificationType.kt b/app/src/main/java/to/bitkit/models/BlocktankNotificationType.kt index 162645d53..abc02cb53 100644 --- a/app/src/main/java/to/bitkit/models/BlocktankNotificationType.kt +++ b/app/src/main/java/to/bitkit/models/BlocktankNotificationType.kt @@ -2,7 +2,7 @@ package to.bitkit.models import kotlinx.serialization.Serializable -@Suppress("EnumEntryName") +@Suppress("EnumEntryNameCase", "EnumNaming") @Serializable enum class BlocktankNotificationType { incomingHtlc, diff --git a/app/src/main/java/to/bitkit/models/ElectrumServer.kt b/app/src/main/java/to/bitkit/models/ElectrumServer.kt index cebfc7b64..873c2f4e0 100644 --- a/app/src/main/java/to/bitkit/models/ElectrumServer.kt +++ b/app/src/main/java/to/bitkit/models/ElectrumServer.kt @@ -4,6 +4,8 @@ import kotlinx.serialization.Serializable import org.lightningdevkit.ldknode.Network import to.bitkit.env.Env +const val MAX_VALID_PORT = 65535 + @Serializable data class ElectrumServer( val host: String, @@ -24,8 +26,6 @@ data class ElectrumServer( } companion object { - const val MAX_VALID_PORT = 65535 - fun parse(url: String): ElectrumServer { val url = url.trim() require(url.isNotBlank()) { "URL cannot be blank" } @@ -91,6 +91,7 @@ data class ElectrumServerPeer( val protocol: ElectrumProtocol, ) +@Suppress("MagicNumber") fun ElectrumProtocol.getDefaultPort(): Int { val network = Env.network diff --git a/app/src/main/java/to/bitkit/models/widget/ArticleModel.kt b/app/src/main/java/to/bitkit/models/widget/ArticleModel.kt index 16dd06cc1..ad9d90473 100644 --- a/app/src/main/java/to/bitkit/models/widget/ArticleModel.kt +++ b/app/src/main/java/to/bitkit/models/widget/ArticleModel.kt @@ -29,6 +29,7 @@ fun ArticleDTO.toArticleModel() = ArticleModel( * @param dateString Date string in format "EEE, dd MMM yyyy HH:mm:ss Z" * @return Human-readable time difference (e.g. "5 hours ago") */ +@Suppress("TooGenericExceptionCaught", "MagicNumber") private fun timeAgo(dateString: String): String { return try { val formatters = listOf( diff --git a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt index e3df92524..0dd8dc97e 100644 --- a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt @@ -45,7 +45,7 @@ import com.synonym.bitkitcore.TransactionDetails as BitkitCoreTransactionDetails private const val SYNC_TIMEOUT_MS = 40_000L -@Suppress("LargeClass", "LongParameterList") +@Suppress("LargeClass", "LongParameterList", "TooManyFunctions") @OptIn(ExperimentalTime::class) @Singleton class ActivityRepo @Inject constructor( @@ -145,13 +145,12 @@ class ActivityRepo @Inject constructor( } private fun findOpenChannelForTransaction(txid: String): String? { - return try { + return runCatching { val channels = lightningRepo.lightningState.value.channels if (channels.isEmpty()) return null - channels.firstOrNull { channel -> - channel.fundingTxo?.txid == txid - }?.channelId + channels.firstOrNull { channel -> channel.fundingTxo?.txid == txid } + ?.channelId ?: run { val orders = blocktankRepo.blocktankState.value.orders val matchingOrder = orders.firstOrNull { order -> @@ -163,19 +162,16 @@ class ActivityRepo @Inject constructor( channel.fundingTxo?.txid == orderChannel.fundingTx.id }?.channelId } - } catch (e: Exception) { - Logger.warn("Failed to find open channel for transaction: $txid", e, context = TAG) - null - } + }.onFailure { + Logger.warn("Failed to find open channel for transaction: '$txid'", it, context = TAG) + }.getOrNull() } - private suspend fun findClosedChannelForTransaction(txid: String): String? { - return coreService.activity.findClosedChannelForTransaction(txid, null) - } + private suspend fun findClosedChannelForTransaction(txid: String): String? = + coreService.activity.findClosedChannelForTransaction(txid, null) - suspend fun getOnchainActivityByTxId(txid: String): OnchainActivity? { - return coreService.activity.getOnchainActivityByTxId(txid) - } + suspend fun getOnchainActivityByTxId(txid: String): OnchainActivity? = + coreService.activity.getOnchainActivityByTxId(txid) /** * Checks if a transaction is inbound (received) by looking up the payment direction. @@ -275,51 +271,37 @@ class ActivityRepo @Inject constructor( txType: PaymentType?, retry: Boolean = true, ): Result = withContext(bgDispatcher) { - if (paymentHashOrTxId.isEmpty()) { - return@withContext Result.failure( - IllegalArgumentException("paymentHashOrTxId is empty") - ) - } + runCatching { + require(paymentHashOrTxId.isNotEmpty()) { "paymentHashOrTxId is empty" } - return@withContext try { - suspend fun findActivity(): Activity? = getActivities( - filter = type, - txType = txType, - limit = 10u - ).getOrNull()?.firstOrNull { it.matchesPaymentId(paymentHashOrTxId) } + suspend fun findActivity(): Activity? = getActivities(filter = type, txType = txType, limit = 10u) + .getOrNull() + ?.firstOrNull { it.matchesPaymentId(paymentHashOrTxId) } var activity = findActivity() if (activity == null && retry) { Logger.warn( - "activity with paymentHashOrTxId:$paymentHashOrTxId not found, trying again after sync", + "activity with paymentHashOrTxId:'$paymentHashOrTxId' not found, retrying after sync", context = TAG ) - lightningRepo.sync().onSuccess { - Logger.debug("Syncing LN node SUCCESS", context = TAG) - } + lightningRepo.sync().onSuccess { Logger.debug("Syncing LN node SUCCESS", context = TAG) } syncActivities().onSuccess { Logger.debug( - "Sync success, searching again the activity with paymentHashOrTxId:$paymentHashOrTxId", + "Sync success, searching again the activity with paymentHashOrTxId:'$paymentHashOrTxId'", context = TAG ) activity = findActivity() } } - if (activity != null) { - Result.success(activity) - } else { - Result.failure(IllegalStateException("Activity not found")) - } - } catch (e: Exception) { + checkNotNull(activity) { "Activity not found" } + }.onFailure { Logger.error( - "findActivityByPaymentId error. Parameters:" + - "\n paymentHashOrTxId:$paymentHashOrTxId type:$type txType:$txType", + "findActivityByPaymentId error (paymentHashOrTxId:'$paymentHashOrTxId' type:'$type' txType:'$txType')", context = TAG ) - Result.failure(e) } } diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index 14fcb556b..9088a0e88 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -70,7 +70,7 @@ import kotlin.time.ExperimentalTime * Idle State: running=false, synced≥required * ``` */ -@Suppress("LongParameterList") +@Suppress("LongParameterList", "TooManyFunctions") @OptIn(ExperimentalTime::class) @Singleton class BackupRepo @Inject constructor( @@ -492,7 +492,7 @@ class BackupRepo @Inject constructor( _isRestoring.update { true } - return@withContext try { + val result = runCatching { performRestore(BackupCategory.METADATA) { dataBytes -> val parsed = json.decodeFromString(String(dataBytes)) val cleanCache = parsed.cache.resetBip21() // Force address rotation @@ -503,7 +503,6 @@ class BackupRepo @Inject constructor( Logger.debug("Restored ${parsed.tagMetadata.size} pre-activity metadata", TAG) parsed.createdAt } - performRestore(BackupCategory.SETTINGS) { dataBytes -> val parsed = json.decodeFromString(String(dataBytes)) settingsStore.restoreFromBackup(parsed) @@ -532,13 +531,13 @@ class BackupRepo @Inject constructor( } Logger.info("Full restore success", context = TAG) - Result.success(Unit) - } catch (e: Throwable) { + }.onFailure { e -> Logger.warn("Full restore error", e = e, context = TAG) - Result.failure(e) - } finally { - _isRestoring.update { false } } + + _isRestoring.update { false } + + return@withContext result } suspend fun getLatestBackupTime(): ULong? = withContext(ioDispatcher) { diff --git a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt index 83107fede..9174d01db 100644 --- a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt @@ -55,7 +55,7 @@ import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds @Singleton -@Suppress("LongParameterList") +@Suppress("LongParameterList", "TooManyFunctions") class BlocktankRepo @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, private val coreService: CoreService, @@ -119,7 +119,7 @@ class BlocktankRepo @Inject constructor( } suspend fun refreshInfo() = withContext(bgDispatcher) { - try { + runCatching { // Load from cache first val cachedInfo = coreService.blocktank.info(refresh = false) _blocktankState.update { it.copy(info = cachedInfo) } @@ -129,8 +129,8 @@ class BlocktankRepo @Inject constructor( _blocktankState.update { it.copy(info = info) } Logger.debug("Blocktank info refreshed", context = TAG) - } catch (e: Throwable) { - Logger.error("Failed to refresh blocktank info", e, context = TAG) + }.onFailure { + Logger.error("Failed to refresh blocktank info", it, context = TAG) } } @@ -138,7 +138,7 @@ class BlocktankRepo @Inject constructor( if (isRefreshing) return@withContext isRefreshing = true - try { + runCatching { Logger.verbose("Refreshing blocktank orders…", context = TAG) val paidOrderIds = cacheStore.data.first().paidOrders.keys @@ -172,27 +172,24 @@ class BlocktankRepo @Inject constructor( context = TAG ) openChannelWithPaidOrders() - } catch (e: Throwable) { - Logger.error("Failed to refresh orders", e, context = TAG) - } finally { - isRefreshing = false + }.onFailure { + Logger.error("Failed to refresh orders", it, context = TAG) } + + isRefreshing = false } suspend fun refreshMinCjitSats() = withContext(bgDispatcher) { - try { + runCatching { val lspBalance = getDefaultLspBalance(clientBalance = 0u) - val fees = estimateOrderFee( - spendingBalanceSats = 0u, - receivingBalanceSats = lspBalance, - ).getOrThrow() + val fees = estimateOrderFee(spendingBalanceSats = 0u, receivingBalanceSats = lspBalance).getOrThrow() val minimum = (ceil(fees.feeSat.toDouble() * 1.1 / 1000) * 1000).toInt() _blocktankState.update { it.copy(minCjitSats = minimum) } Logger.debug("Updated minCjitSats to: $minimum", context = TAG) - } catch (e: Throwable) { - Logger.error("Failed to refresh minCjitSats", e, context = TAG) + }.onFailure { + Logger.error("Failed to refresh minCjitSats", it, context = TAG) } } @@ -200,7 +197,7 @@ class BlocktankRepo @Inject constructor( amountSats: ULong, description: String = Env.DEFAULT_INVOICE_MESSAGE, ): Result = withContext(bgDispatcher) { - try { + runCatching { if (coreService.isGeoBlocked()) throw ServiceError.GeoBlocked() val nodeId = lightningService.nodeId ?: throw ServiceError.NodeNotStarted() val lspBalance = getDefaultLspBalance(clientBalance = amountSats) @@ -217,10 +214,9 @@ class BlocktankRepo @Inject constructor( repoScope.launch { refreshOrders() } - Result.success(cjitEntry) - } catch (e: Throwable) { - Logger.error("Failed to create CJIT", e, context = TAG) - Result.failure(e) + return@runCatching cjitEntry + }.onFailure { + Logger.error("Failed to create CJIT", it, context = TAG) } } @@ -229,13 +225,16 @@ class BlocktankRepo @Inject constructor( receivingBalanceSats: ULong = spendingBalanceSats * 2u, channelExpiryWeeks: UInt = DEFAULT_CHANNEL_EXPIRY_WEEKS, ): Result = withContext(bgDispatcher) { - try { + runCatching { if (coreService.isGeoBlocked()) throw ServiceError.GeoBlocked() val options = defaultCreateOrderOptions(clientBalanceSat = spendingBalanceSats) Logger.info( - "Buying channel with lspBalanceSat: $receivingBalanceSats, channelExpiryWeeks: $channelExpiryWeeks, options: $options", + "Buying channel with " + + "lspBalanceSat: '$receivingBalanceSats', " + + "channelExpiryWeeks: '$channelExpiryWeeks', " + + "options: '$options'", context = TAG, ) @@ -247,10 +246,9 @@ class BlocktankRepo @Inject constructor( repoScope.launch { refreshOrders() } - Result.success(order) - } catch (e: Throwable) { - Logger.error("Failed to create order", e, context = TAG) - Result.failure(e) + return@runCatching order + }.onFailure { + Logger.error("Failed to create order", it, context = TAG) } } @@ -261,7 +259,7 @@ class BlocktankRepo @Inject constructor( ): Result = withContext(bgDispatcher) { Logger.info("Estimating order fee for spendingSats=$spendingBalanceSats, receivingSats=$receivingBalanceSats") - try { + runCatching { val options = defaultCreateOrderOptions(clientBalanceSat = spendingBalanceSats) val estimate = coreService.blocktank.estimateFee( @@ -272,15 +270,15 @@ class BlocktankRepo @Inject constructor( Logger.debug("Estimated order fee: '$estimate'") - Result.success(estimate) - } catch (e: Throwable) { - Logger.error("Failed to estimate order fee", e, context = TAG) - Result.failure(e) + return@runCatching estimate + }.onFailure { + Logger.error("Failed to estimate order fee", it, context = TAG) } } + @Suppress("TooGenericExceptionCaught") suspend fun openChannel(orderId: String): Result = withContext(bgDispatcher) { - try { + runCatching { Logger.debug("Opening channel for order: '$orderId'", context = TAG) val order = coreService.blocktank.open(orderId) @@ -293,10 +291,9 @@ class BlocktankRepo @Inject constructor( _blocktankState.update { state -> state.copy(orders = updatedOrders) } - Result.success(order) - } catch (e: Throwable) { - Logger.error("Failed to open channel for order: $orderId", e, context = TAG) - Result.failure(e) + return@runCatching order + }.onFailure { + Logger.error("Failed to open channel for order: $orderId", it, context = TAG) } } @@ -304,15 +301,14 @@ class BlocktankRepo @Inject constructor( orderId: String, refresh: Boolean = false, ): Result = withContext(bgDispatcher) { - try { + runCatching { if (refresh) { refreshOrders() } val order = _blocktankState.value.orders.find { it.id == orderId } - Result.success(order) - } catch (e: Throwable) { - Logger.error("Failed to get order: $orderId", e, context = TAG) - Result.failure(e) + return@runCatching order + }.onFailure { + Logger.error("Failed to get order: $orderId", it, context = TAG) } } @@ -446,8 +442,8 @@ class BlocktankRepo @Inject constructor( Result.success(claimGiftCodeWithoutLiquidity(code, amount)) } }.getOrThrow() - }.onFailure { e -> - Logger.error("Failed to claim gift code", e, context = TAG) + }.onFailure { + Logger.error("Failed to claim gift code", it, context = TAG) } } diff --git a/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt b/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt index 82a39095e..57d347abc 100644 --- a/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt @@ -43,8 +43,8 @@ import javax.inject.Singleton import kotlin.time.Clock import kotlin.time.ExperimentalTime -@Suppress("TooManyFunctions") @OptIn(ExperimentalTime::class) +@Suppress("TooManyFunctions") @Singleton class CurrencyRepo @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, @@ -133,7 +133,7 @@ class CurrencyRepo @Inject constructor( private suspend fun refresh() { if (isRefreshing) return isRefreshing = true - try { + runCatching { val fetchedRates = currencyService.fetchLatestRates() cacheStore.update { it.copy(cachedRates = fetchedRates) } _currencyState.update { @@ -144,7 +144,7 @@ class CurrencyRepo @Inject constructor( ) } Logger.debug("Currency rates refreshed successfully", context = TAG) - } catch (e: Exception) { + }.onFailure { e -> Logger.error("Currency rates refresh failed", e, context = TAG) _currencyState.update { it.copy(error = e) } @@ -152,9 +152,8 @@ class CurrencyRepo @Inject constructor( val isStale = clock.now().toEpochMilliseconds() - lastUpdatedAt > Env.fxRateStaleThreshold _currencyState.update { it.copy(hasStaleData = isStale) } } - } finally { - isRefreshing = false } + isRefreshing = false } suspend fun switchUnit() = withContext(bgDispatcher) { diff --git a/app/src/main/java/to/bitkit/repositories/HealthRepo.kt b/app/src/main/java/to/bitkit/repositories/HealthRepo.kt index 56e8e85ee..ba81e5f56 100644 --- a/app/src/main/java/to/bitkit/repositories/HealthRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/HealthRepo.kt @@ -43,6 +43,7 @@ class HealthRepo @Inject constructor( observeBackupStatus() } + @Suppress("CyclomaticComplexMethod") private fun collectState() { val internetHealthState = connectivityRepo.isOnline.map { connectivityState -> when (connectivityState) { diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 29a5b23cc..7a5118734 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -74,7 +74,7 @@ import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds @Singleton -@Suppress("LongParameterList") +@Suppress("LongParameterList", "TooManyFunctions", "LargeClass") class LightningRepo @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, private val lightningService: LightningService, @@ -149,6 +149,7 @@ class LightningRepo @Inject constructor( return@withContext executeOperation(operationName, operation) } + @Suppress("TooGenericExceptionCaught") private suspend fun executeOperation( operationName: String, operation: suspend () -> Result, @@ -164,6 +165,7 @@ class LightningRepo @Inject constructor( } } + @Suppress("TooGenericExceptionCaught") private suspend fun setup( walletIndex: Int, customServerUrl: String? = null, @@ -190,7 +192,7 @@ class LightningRepo @Inject constructor( Logger.warn("Failed to get trusted peers from blocktank", e = it, context = TAG) }.getOrNull() - @Suppress("LongMethod", "LongParameterList") + @Suppress("LongMethod", "LongParameterList", "TooGenericExceptionCaught") suspend fun start( walletIndex: Int = 0, timeout: Duration? = null, @@ -299,6 +301,7 @@ class LightningRepo @Inject constructor( _lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Initializing) } } + @Suppress("TooGenericExceptionCaught") suspend fun stop(): Result = withContext(bgDispatcher) { if (_lightningState.value.nodeLifecycleState.isStoppedOrStopping()) { return@withContext Result.success(Unit) @@ -390,6 +393,7 @@ class LightningRepo @Inject constructor( } } + @Suppress("TooGenericExceptionCaught") private suspend fun registerClosedChannel(channelId: String, reason: ClosureReason?) = withContext(bgDispatcher) { try { val channel = channelCache[channelId] ?: run { @@ -438,6 +442,7 @@ class LightningRepo @Inject constructor( } } + @Suppress("TooGenericExceptionCaught") suspend fun wipeStorage(walletIndex: Int): Result = withContext(bgDispatcher) { Logger.debug("wipeStorage called, stopping node first", context = TAG) stop().onSuccess { @@ -758,6 +763,7 @@ class LightningRepo @Inject constructor( lightningService.listSpendableOutputs() } + @Suppress("TooGenericExceptionCaught") suspend fun calculateTotalFee( amountSats: ULong, address: Address? = null, @@ -885,6 +891,7 @@ class LightningRepo @Inject constructor( channels: List, ): Pair, List> = lightningService.separateTrustedChannels(channels) + @Suppress("TooGenericExceptionCaught") suspend fun registerForNotifications(token: String? = null) = executeWhenNodeRunning("registerForNotifications") { return@executeWhenNodeRunning try { val token = token ?: firebaseMessaging.token.await() @@ -907,6 +914,7 @@ class LightningRepo @Inject constructor( fun registerForNotificationsAsync(token: String) = scope.launch { registerForNotifications(token) } + @Suppress("TooGenericExceptionCaught") suspend fun bumpFeeByRbf( originalTxId: Txid, satsPerVByte: ULong, @@ -929,7 +937,8 @@ class LightningRepo @Inject constructor( satsPerVByte = satsPerVByte, ) Logger.debug( - "bumpFeeByRbf success, replacementTxId: $replacementTxId originalTxId: $originalTxId, satsPerVByte: $satsPerVByte" + "bumpFeeByRbf success, replacementTxId: $replacementTxId " + + "originalTxId: $originalTxId, satsPerVByte: $satsPerVByte" ) Result.success(replacementTxId) } catch (e: Throwable) { @@ -942,6 +951,7 @@ class LightningRepo @Inject constructor( } } + @Suppress("TooGenericExceptionCaught") suspend fun accelerateByCpfp( originalTxId: Txid, satsPerVByte: ULong, @@ -972,12 +982,15 @@ class LightningRepo @Inject constructor( toAddress = destinationAddress, ) Logger.debug( - "accelerateByCpfp success, newDestinationTxId: $newDestinationTxId originalTxId: $originalTxId, satsPerVByte: $satsPerVByte destinationAddress: $destinationAddress" + "accelerateByCpfp success, newDestinationTxId: $newDestinationTxId " + + "originalTxId: $originalTxId, satsPerVByte: $satsPerVByte " + + "destinationAddress: $destinationAddress" ) Result.success(newDestinationTxId) } catch (e: Throwable) { Logger.error( - "accelerateByCpfp error originalTxId: $originalTxId, satsPerVByte: $satsPerVByte destinationAddress: $destinationAddress", + "accelerateByCpfp error originalTxId: $originalTxId, " + + "satsPerVByte: $satsPerVByte destinationAddress: $destinationAddress", e, context = TAG ) diff --git a/app/src/main/java/to/bitkit/repositories/LogsRepo.kt b/app/src/main/java/to/bitkit/repositories/LogsRepo.kt index 5ac0a51da..fcf6412df 100644 --- a/app/src/main/java/to/bitkit/repositories/LogsRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LogsRepo.kt @@ -34,7 +34,7 @@ class LogsRepo @Inject constructor( private val chatwootHttpClient: ChatwootHttpClient, ) { suspend fun postQuestion(email: String, message: String): Result = withContext(bgDispatcher) { - return@withContext try { + runCatching { val logsBase64 = zipLogs().getOrDefault("") val logsFileName = "bitkit_logs_${System.currentTimeMillis()}.zip" @@ -48,16 +48,15 @@ class LogsRepo @Inject constructor( logsFileName = logsFileName, ) ) - Result.success(Unit) - } catch (e: Exception) { - Logger.error(msg = e.message, e = e, context = TAG) - Result.failure(e) + }.onFailure { + Logger.error(it.message, e = it, context = TAG) } } /** Lists log files sorted by newest first */ + @Suppress("NestedBlockDepth") suspend fun getLogs(): Result> = withContext(bgDispatcher) { - try { + runCatching { val logDir = Env.logDir val logFiles = logDir @@ -81,15 +80,14 @@ class LogsRepo @Inject constructor( ?.sortedByDescending { it.file.lastModified() } ?: emptyList() - return@withContext Result.success(logFiles) - } catch (e: Exception) { - Logger.error("Failed to load logs", e, context = TAG) - Result.failure(e) + return@runCatching logFiles + }.onFailure { + Logger.error("Failed to load logs", it, context = TAG) } } suspend fun loadLogContent(logFile: LogFile): Result> = withContext(bgDispatcher) { - try { + runCatching { if (!logFile.file.exists()) { Logger.error("Logs file not found", context = TAG) return@withContext Result.failure(Exception("Logs file not found")) @@ -101,10 +99,10 @@ class LogsRepo @Inject constructor( lines.add(line.trim()) } } - return@withContext Result.success(lines) - } catch (e: Exception) { - Logger.error("Failed to load log content", e, context = TAG) - return@withContext Result.failure(e) + + return@runCatching lines + }.onFailure { + Logger.error("Failed to load log content", it, context = TAG) } } @@ -139,7 +137,7 @@ class LogsRepo @Inject constructor( limit: Int = 20, source: LogSource? = null, ): Result = withContext(bgDispatcher) { - return@withContext try { + runCatching { val logsResult = getLogs().onFailure { return@withContext Result.failure(it) } @@ -162,14 +160,13 @@ class LogsRepo @Inject constructor( return@withContext Result.failure(Exception("No log files found")) } - val base64String = createZipBase64(logsToZip) - Result.success(base64String) - } catch (e: Exception) { - Logger.error("Failed to zip logs", e, context = TAG) - Result.failure(e) + return@runCatching createZipBase64(logsToZip) + }.onFailure { + Logger.error("Failed to zip logs", it, context = TAG) } } + @Suppress("NestedBlockDepth") private fun createZipBase64(logFiles: List): String { val zipBytes = ByteArrayOutputStream().use { byteArrayOut -> ZipOutputStream(byteArrayOut).use { zipOut -> diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index fc5bae3c4..d507def05 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -41,7 +41,7 @@ import javax.inject.Inject import javax.inject.Singleton import kotlin.coroutines.cancellation.CancellationException -@Suppress("LongParameterList") +@Suppress("LongParameterList", "TooManyFunctions") @Singleton class WalletRepo @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, @@ -75,11 +75,10 @@ class WalletRepo @Inject constructor( } fun loadFromCache() { - // TODO try keeping in sync with cache if performant and reliable repoScope.launch { val cacheData = cacheStore.data.first() - _walletState.update { currentState -> - currentState.copy( + _walletState.update { + it.copy( onchainAddress = cacheData.onchainAddress, bolt11 = cacheData.bolt11, bip21 = cacheData.bip21, @@ -98,12 +97,10 @@ class WalletRepo @Inject constructor( } suspend fun checkAddressUsage(address: String): Result = withContext(bgDispatcher) { - return@withContext try { - val result = coreService.isAddressUsed(address) - Result.success(result) - } catch (e: Exception) { - Logger.error("checkAddressUsage error", e, context = TAG) - Result.failure(e) + runCatching { + coreService.isAddressUsed(address) + }.onFailure { + Logger.error("checkAddressUsage error", it, context = TAG) } } @@ -178,8 +175,8 @@ class WalletRepo @Inject constructor( syncBalances() lightningRepo.sync().onSuccess { syncBalances() - }.onFailure { e -> - if (e is TimeoutCancellationException) { + }.onFailure { + if (it is TimeoutCancellationException) { syncBalances() } } @@ -195,9 +192,9 @@ class WalletRepo @Inject constructor( deriveBalanceStateUseCase().onSuccess { balanceState -> runCatching { cacheStore.cacheBalance(balanceState) } _balanceState.update { balanceState } - }.onFailure { e -> - if (e !is CancellationException) { - Logger.warn("Could not sync balances ${errLogOf(e)}", context = TAG) + }.onFailure { + if (it !is CancellationException) { + Logger.warn("Could not sync balances ${errLogOf(it)}", context = TAG) } } } @@ -285,32 +282,28 @@ class WalletRepo @Inject constructor( suspend fun createWallet(bip39Passphrase: String?): Result = withContext(bgDispatcher) { lightningRepo.setRecoveryMode(enabled = false) - try { + runCatching { val mnemonic = generateEntropyMnemonic() keychain.saveString(Keychain.Key.BIP39_MNEMONIC.name, mnemonic) if (bip39Passphrase != null) { keychain.saveString(Keychain.Key.BIP39_PASSPHRASE.name, bip39Passphrase) } setWalletExistsState() - Result.success(Unit) - } catch (e: Throwable) { - Logger.error("Create wallet error", e, context = TAG) - Result.failure(e) + }.onFailure { + Logger.error("createWallet error", it, context = TAG) } } suspend fun restoreWallet(mnemonic: String, bip39Passphrase: String?): Result = withContext(bgDispatcher) { lightningRepo.setRecoveryMode(enabled = false) - try { + runCatching { keychain.saveString(Keychain.Key.BIP39_MNEMONIC.name, mnemonic) if (bip39Passphrase != null) { keychain.saveString(Keychain.Key.BIP39_PASSPHRASE.name, bip39Passphrase) } setWalletExistsState() - Result.success(Unit) - } catch (e: Throwable) { - Logger.error("Restore wallet error", e) - Result.failure(e) + }.onFailure { + Logger.error("restoreWallet error", it) } } @@ -346,7 +339,7 @@ class WalletRepo @Inject constructor( isChange: Boolean = false, count: Int = 20, ): Result> = withContext(bgDispatcher) { - return@withContext try { + return@withContext runCatching { val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) ?: throw ServiceError.MnemonicNotFound() @@ -375,14 +368,12 @@ class WalletRepo @Inject constructor( ) } - Result.success(addresses) - } catch (e: Exception) { - Logger.error("Error getting addresses", e) - Result.failure(e) + return@runCatching addresses + }.onFailure { + Logger.error("Error getting addresses", it) } } - // Bolt11 management fun getBolt11(): String = _walletState.value.bolt11 suspend fun setBolt11(bolt11: String) { @@ -390,7 +381,6 @@ class WalletRepo @Inject constructor( _walletState.update { it.copy(bolt11 = bolt11) } } - // BIP21 management suspend fun setBip21(bip21: String) { runCatching { cacheStore.setBip21(bip21) } _walletState.update { it.copy(bip21 = bip21) } @@ -410,7 +400,6 @@ class WalletRepo @Inject constructor( ) } - // BIP21 state management fun setBip21AmountSats(amount: ULong?) = _walletState.update { it.copy(bip21AmountSats = amount) } fun setBip21Description(description: String) = _walletState.update { it.copy(bip21Description = description) } @@ -426,7 +415,6 @@ class WalletRepo @Inject constructor( } } - // Payment ID management private suspend fun paymentHash(): String? = withContext(bgDispatcher) { val bolt11 = getBolt11() if (bolt11.isEmpty()) return@withContext null @@ -435,8 +423,8 @@ class WalletRepo @Inject constructor( is Scanner.Lightning -> decoded.invoice.paymentHash.toHex() else -> null } - }.onFailure { e -> - Logger.error("Error extracting payment hash from bolt11", e, context = TAG) + }.onFailure { + Logger.error("Error extracting payment hash from bolt11", it, context = TAG) }.getOrNull() } @@ -447,7 +435,6 @@ class WalletRepo @Inject constructor( return@withContext address.ifEmpty { null } } - // Pre-activity metadata tag management suspend fun addTagToSelected(newTag: String): Result = withContext(bgDispatcher) { val paymentId = paymentId() if (paymentId == null || paymentId.isEmpty()) { @@ -465,8 +452,8 @@ class WalletRepo @Inject constructor( ) } settingsStore.addLastUsedTag(newTag) - }.onFailure { e -> - Logger.error("Failed to add tag to pre-activity metadata", e, context = TAG) + }.onFailure { + Logger.error("Failed to add tag to pre-activity metadata", it, context = TAG) } } @@ -486,8 +473,8 @@ class WalletRepo @Inject constructor( selectedTags = it.selectedTags.filterNot { tagItem -> tagItem == tag } ) } - }.onFailure { e -> - Logger.error("Failed to remove tag from pre-activity metadata", e, context = TAG) + }.onFailure { + Logger.error("Failed to remove tag from pre-activity metadata", it, context = TAG) } } @@ -497,27 +484,9 @@ class WalletRepo @Inject constructor( preActivityMetadataRepo.resetPreActivityMetadataTags(paymentId).onSuccess { _walletState.update { it.copy(selectedTags = emptyList()) } - }.onFailure { e -> - Logger.error("Failed to reset tags for pre-activity metadata", e, context = TAG) - } - } - - suspend fun loadTagsForCurrentInvoice() { - val paymentId = paymentId() - if (paymentId == null || paymentId.isEmpty()) { - _walletState.update { it.copy(selectedTags = emptyList()) } - return + }.onFailure { + Logger.error("Failed to reset tags for pre-activity metadata", it, context = TAG) } - - preActivityMetadataRepo.getPreActivityMetadata(paymentId, searchByAddress = false) - .onSuccess { metadata -> - _walletState.update { - it.copy(selectedTags = metadata?.tags ?: emptyList()) - } - } - .onFailure { e -> - Logger.error("Failed to load tags for current invoice", e, context = TAG) - } } // BIP21 invoice creation and persistence @@ -555,11 +524,12 @@ class WalletRepo @Inject constructor( if (newPaymentId != null && newPaymentId.isNotEmpty() && newBip21Url.isNotEmpty()) { persistPreActivityMetadata(newPaymentId, tagsToMigrate, newBip21Url) } - }.onFailure { e -> - Logger.error("Update BIP21 invoice error", e, context = TAG) + }.onFailure { + Logger.error("Update BIP21 invoice error", it, context = TAG) } } + @Suppress("TooGenericExceptionCaught") suspend fun shouldRequestAdditionalLiquidity(): Result = withContext(bgDispatcher) { return@withContext try { if (coreService.isGeoBlocked()) return@withContext Result.success(false) diff --git a/app/src/main/java/to/bitkit/repositories/WidgetsRepo.kt b/app/src/main/java/to/bitkit/repositories/WidgetsRepo.kt index 9bdd491bd..0c7c22fc4 100644 --- a/app/src/main/java/to/bitkit/repositories/WidgetsRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WidgetsRepo.kt @@ -33,6 +33,7 @@ import to.bitkit.utils.Logger import javax.inject.Inject import javax.inject.Singleton +@Suppress("TooManyFunctions", "LongParameterList") @Singleton class WidgetsRepo @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index a46a354eb..66aa160f4 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -117,19 +117,19 @@ class CoreService @Inject constructor( // Block queue until the init completes forcing any additional calls to wait for it ServiceQueue.CORE.blocking { - try { + runCatching { val result = initDb(basePath = Env.bitkitCoreStoragePath(walletIndex)) Logger.info("bitkit-core database init: $result") - } catch (e: Exception) { - Logger.error("bitkit-core database init failed", e) + }.onFailure { + Logger.error("bitkit-core database init failed", it) } - try { + runCatching { val blocktankUrl = Env.blocktankApiUrl updateBlocktankUrl(newUrl = blocktankUrl) Logger.info("Blocktank URL updated to: $blocktankUrl") - } catch (e: Exception) { - Logger.error("Failed to update Blocktank URL", e) + }.onFailure { + Logger.error("Failed to update Blocktank URL", it) } } } @@ -196,7 +196,7 @@ class CoreService @Inject constructor( // region Activity private const val CHUNK_SIZE = 50 -@Suppress("LargeClass") +@Suppress("LargeClass", "TooManyFunctions") class ActivityService( @Suppress("unused") private val coreService: CoreService, // used to ensure CoreService inits first private val cacheStore: CacheStore, @@ -225,10 +225,8 @@ class ActivityService( } } - suspend fun insert(activity: Activity) { - ServiceQueue.CORE.background { - insertActivity(activity) - } + suspend fun insert(activity: Activity) = ServiceQueue.CORE.background { + insertActivity(activity) } suspend fun upsert(activity: Activity) = ServiceQueue.CORE.background { @@ -269,28 +267,19 @@ class ActivityService( ) } - suspend fun saveTransactionDetails(txid: String, details: TransactionDetails) { - ServiceQueue.CORE.background { - val coreDetails = mapToCoreTransactionDetails(txid, details) - upsertTransactionDetails(listOf(coreDetails)) - } + suspend fun getTransactionDetails(txid: String): BitkitCoreTransactionDetails? = ServiceQueue.CORE.background { + getBitkitCoreTransactionDetails(txid) } - suspend fun getTransactionDetails(txid: String): BitkitCoreTransactionDetails? = - ServiceQueue.CORE.background { - getBitkitCoreTransactionDetails(txid) - } - - suspend fun getActivity(id: String): Activity? { - return ServiceQueue.CORE.background { - getActivityById(id) - } + suspend fun getActivity(id: String): Activity? = ServiceQueue.CORE.background { + getActivityById(id) } suspend fun getOnchainActivityByTxId(txId: String): OnchainActivity? = ServiceQueue.CORE.background { getActivityByTxId(txId = txId) } + @Suppress("LongParameterList") suspend fun get( filter: ActivityFilter? = null, txType: PaymentType? = null, @@ -300,51 +289,34 @@ class ActivityService( maxDate: ULong? = null, limit: UInt? = null, sortDirection: SortDirection? = null, - ): List { - return ServiceQueue.CORE.background { - getActivities(filter, txType, tags, search, minDate, maxDate, limit, sortDirection) - } + ): List = ServiceQueue.CORE.background { + getActivities(filter, txType, tags, search, minDate, maxDate, limit, sortDirection) } - suspend fun update(id: String, activity: Activity) { - ServiceQueue.CORE.background { - updateActivity(id, activity) - } + suspend fun update(id: String, activity: Activity) = ServiceQueue.CORE.background { + updateActivity(id, activity) } - suspend fun delete(id: String): Boolean { - return ServiceQueue.CORE.background { - deleteActivityById(id) - } + suspend fun delete(id: String): Boolean = ServiceQueue.CORE.background { + deleteActivityById(id) } - suspend fun appendTags(toActivityId: String, tags: List): Result { - return try { - ServiceQueue.CORE.background { - addTags(toActivityId, tags) - } - Result.success(Unit) - } catch (e: Exception) { - Result.failure(e) + suspend fun appendTags(toActivityId: String, tags: List): Result = runCatching { + ServiceQueue.CORE.background { + addTags(toActivityId, tags) } } - suspend fun dropTags(fromActivityId: String, tags: List) { - ServiceQueue.CORE.background { - removeTags(fromActivityId, tags) - } + suspend fun dropTags(fromActivityId: String, tags: List) = ServiceQueue.CORE.background { + removeTags(fromActivityId, tags) } - suspend fun tags(forActivityId: String): List { - return ServiceQueue.CORE.background { - getTags(forActivityId) - } + suspend fun tags(forActivityId: String): List = ServiceQueue.CORE.background { + getTags(forActivityId) } - suspend fun allPossibleTags(): List { - return ServiceQueue.CORE.background { - getAllUniqueTags() - } + suspend fun allPossibleTags(): List = ServiceQueue.CORE.background { + getAllUniqueTags() } suspend fun upsertTags(activityTags: List) = ServiceQueue.CORE.background { @@ -400,24 +372,22 @@ class ActivityService( getAllClosedChannels(sortDirection) } - suspend fun handlePaymentEvent(paymentHash: String) { - ServiceQueue.CORE.background { - val payments = lightningService.payments ?: run { - Logger.warn("No payments available for hash $paymentHash", context = TAG) - return@background - } + suspend fun handlePaymentEvent(paymentHash: String) = ServiceQueue.CORE.background { + val payments = lightningService.payments ?: run { + Logger.warn("No payments available for hash $paymentHash", context = TAG) + return@background + } - val payment = payments.firstOrNull { it.id == paymentHash } - if (payment != null) { - // Lightning payments don't need channel IDs, only onchain payments do - val channelIdsByTxId = emptyMap() - processSinglePayment(payment, forceUpdate = false, channelIdsByTxId = channelIdsByTxId) - } else { - Logger.info("Payment not found for hash $paymentHash - syncing all payments", context = TAG) - // For full sync, we need channel IDs for onchain payments - // This will be handled by ActivityRepo.syncLdkNodePayments which calls findChannelsForPayments - syncLdkNodePaymentsToActivities(payments, channelIdsByTxId = emptyMap()) - } + val payment = payments.firstOrNull { it.id == paymentHash } + if (payment != null) { + // Lightning payments don't need channel IDs, only onchain payments do + val channelIdsByTxId = emptyMap() + processSinglePayment(payment, forceUpdate = false, channelIdsByTxId = channelIdsByTxId) + } else { + Logger.info("Payment not found for hash $paymentHash - syncing all payments", context = TAG) + // For full sync, we need channel IDs for onchain payments + // This will be handled by ActivityRepo.syncLdkNodePayments which calls findChannelsForPayments + syncLdkNodePaymentsToActivities(payments, channelIdsByTxId = emptyMap()) } } @@ -425,29 +395,27 @@ class ActivityService( payments: List, forceUpdate: Boolean = false, channelIdsByTxId: Map = emptyMap(), - ) { - ServiceQueue.CORE.background { - val allResults = mutableListOf>() - - payments.chunked(CHUNK_SIZE).forEach { chunk -> - val results = chunk.map { payment -> - async { - runCatching { - processSinglePayment(payment, forceUpdate, channelIdsByTxId) - payment.id - }.onFailure { e -> - Logger.error("Error syncing payment with id: ${payment.id}:", e, context = TAG) - } + ) = ServiceQueue.CORE.background { + val allResults = mutableListOf>() + + payments.chunked(CHUNK_SIZE).forEach { chunk -> + val results = chunk.map { payment -> + async { + runCatching { + processSinglePayment(payment, forceUpdate, channelIdsByTxId) + payment.id + }.onFailure { e -> + Logger.error("Error syncing payment with id: ${payment.id}:", e, context = TAG) } - }.awaitAll() + } + }.awaitAll() - allResults.addAll(results) - } + allResults.addAll(results) + } - val (successful, failed) = allResults.partition { it.isSuccess } + val (successful, failed) = allResults.partition { it.isSuccess } - Logger.info("Synced ${successful.size} payments successfully, ${failed.size} failed", context = TAG) - } + Logger.info("Synced ${successful.size} payments successfully, ${failed.size} failed", context = TAG) } private suspend fun processSinglePayment( @@ -532,16 +500,13 @@ class ActivityService( * Check pre-activity metadata for addresses in the transaction * Returns the first address found in pre-activity metadata that matches a transaction output */ - private suspend fun fetchTransactionDetails(txid: String): BitkitCoreTransactionDetails? = - runCatching { getTransactionDetails(txid) } - .onFailure { e -> - Logger.warn("Failed to fetch stored transaction details for $txid: $e", context = TAG) - } - .getOrNull() + private suspend fun fetchTransactionDetails(txid: String): BitkitCoreTransactionDetails? = runCatching { + getTransactionDetails(txid) + }.onFailure { + Logger.warn("Failed to fetch stored transaction details for $txid: $it", context = TAG) + }.getOrNull() - private suspend fun findAddressInPreActivityMetadata( - details: BitkitCoreTransactionDetails, - ): String? { + private suspend fun findAddressInPreActivityMetadata(details: BitkitCoreTransactionDetails): String? { for (output in details.outputs) { val address = output.scriptpubkeyAddress ?: continue val metadata = coreService.activity.getPreActivityMetadata( @@ -724,6 +689,7 @@ class ActivityService( // MARK: - Test Data Generation (regtest only) + @Suppress("LongMethod") suspend fun generateRandomTestData(count: Int = 100) { if (Env.network != Network.REGTEST) { throw AppError(message = "Regtest only") @@ -1289,6 +1255,7 @@ class ActivityService( // region Blocktank +@Suppress("TooManyFunctions") class BlocktankService( @Suppress("unused") private val coreService: CoreService, // used to ensure CoreService inits first private val lightningService: LightningService, @@ -1316,6 +1283,7 @@ class BlocktankService( return Result.success(fees) } + @Suppress("LongParameterList") suspend fun createCjit( channelSizeSat: ULong, invoiceSat: ULong, @@ -1471,6 +1439,7 @@ class OnchainService { } } + @Suppress("LongParameterList") suspend fun deriveBitcoinAddresses( mnemonicPhrase: String, derivationPathStr: String?, diff --git a/app/src/main/java/to/bitkit/services/CurrencyService.kt b/app/src/main/java/to/bitkit/services/CurrencyService.kt index 7ddee3181..86a650462 100644 --- a/app/src/main/java/to/bitkit/services/CurrencyService.kt +++ b/app/src/main/java/to/bitkit/services/CurrencyService.kt @@ -16,14 +16,14 @@ class CurrencyService @Inject constructor( private val maxRetries = 3 suspend fun fetchLatestRates(): List { - var lastError: Exception? = null + var lastError: Throwable? = null for (attempt in 0 until maxRetries) { - try { + runCatching { val response = ServiceQueue.FOREX.background { blocktankHttpClient.fetchLatestRates() } val rates = response.tickers return rates - } catch (e: Exception) { + }.onFailure { e -> lastError = e if (attempt < maxRetries - 1) { // Wait a bit before retrying, with exponential backoff diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index 2a443a68b..9ccc3134f 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -58,7 +58,7 @@ import kotlin.time.Duration typealias NodeEventHandler = suspend (Event) -> Unit -@Suppress("LargeClass") +@Suppress("LargeClass", "TooManyFunctions") @Singleton class LightningService @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, @@ -120,6 +120,7 @@ class LightningService @Inject constructor( ) } + @Suppress("ForbiddenComment") private suspend fun build( walletIndex: Int, customServerUrl: String?, @@ -194,6 +195,7 @@ class LightningService @Inject constructor( ) } + @Suppress("TooGenericExceptionCaught") suspend fun start(timeout: Duration? = null, onEvent: NodeEventHandler? = null) { val node = this.node ?: throw ServiceError.NodeNotSetup() @@ -558,6 +560,7 @@ class LightningService @Inject constructor( }.getOrThrow() } + @Suppress("TooGenericExceptionCaught", "InstanceOfCheckForException") suspend fun estimateRoutingFees(bolt11: String): Result { val node = this.node ?: throw ServiceError.NodeNotSetup() @@ -575,6 +578,7 @@ class LightningService @Inject constructor( } } + @Suppress("TooGenericExceptionCaught", "InstanceOfCheckForException") suspend fun estimateRoutingFeesForAmount(bolt11: String, amountSats: ULong): Result { val node = this.node ?: throw ServiceError.NodeNotSetup() @@ -595,6 +599,7 @@ class LightningService @Inject constructor( // endregion // region utxo selection + @Suppress("TooGenericExceptionCaught", "InstanceOfCheckForException") suspend fun listSpendableOutputs(): Result> { val node = this.node ?: throw ServiceError.NodeNotSetup() @@ -610,6 +615,7 @@ class LightningService @Inject constructor( } } + @Suppress("TooGenericExceptionCaught", "InstanceOfCheckForException") suspend fun selectUtxosWithAlgorithm( targetAmountSats: ULong, satsPerVByte: ULong, @@ -750,6 +756,7 @@ class LightningService @Inject constructor( } // endregion + @Suppress("TooGenericExceptionCaught") suspend fun getAddressBalance(address: String): ULong { val node = this.node ?: throw ServiceError.NodeNotSetup() return ServiceQueue.LDK.background { @@ -775,7 +782,7 @@ class LightningService @Inject constructor( // endregion // region debug - @Suppress("LongMethod") + @Suppress("LongMethod", "TooGenericExceptionCaught") fun dumpNetworkGraphInfo(bolt11: String) { val node = this.node ?: run { Logger.error("Node not available for network graph dump", context = TAG) diff --git a/app/src/main/java/to/bitkit/services/LnurlService.kt b/app/src/main/java/to/bitkit/services/LnurlService.kt index 4cd8317cf..8458835e1 100644 --- a/app/src/main/java/to/bitkit/services/LnurlService.kt +++ b/app/src/main/java/to/bitkit/services/LnurlService.kt @@ -6,6 +6,8 @@ import io.ktor.client.request.get import io.ktor.client.statement.HttpResponse import io.ktor.http.isSuccess import kotlinx.serialization.Serializable +import to.bitkit.utils.AppError +import to.bitkit.utils.HttpError import to.bitkit.utils.Logger import javax.inject.Inject import javax.inject.Singleton @@ -15,26 +17,26 @@ class LnurlService @Inject constructor( private val client: HttpClient, ) { suspend fun requestLnurlWithdraw(callbackUrl: String): Result = runCatching { - Logger.debug("Requesting LNURL withdraw via: '$callbackUrl'") + Logger.debug("Requesting LNURL withdraw via: '$callbackUrl'", context = TAG) val response: HttpResponse = client.get(callbackUrl) - Logger.debug("Http call: $response") + Logger.debug("Http call: $response", context = TAG) if (!response.status.isSuccess()) { - throw Exception("HTTP error: ${response.status}") + throw HttpError("requestLnurlWithdraw error: '${response.status.description}'", response.status.value) } val withdrawResponse = response.body() when { withdrawResponse.status == "ERROR" -> { - throw Exception("LNURL error: ${withdrawResponse.reason}") + throw AppError("requestLnurlWithdraw error: ${withdrawResponse.reason}") } else -> withdrawResponse } }.onFailure { - Logger.warn("Failed to request LNURL withdraw", e = it, context = TAG) + Logger.warn("Failed to request LNURL withdraw", it, context = TAG) } suspend fun fetchLnurlInvoice( @@ -42,7 +44,7 @@ class LnurlService @Inject constructor( amountSats: ULong, comment: String? = null, ): Result = runCatching { - Logger.debug("Fetching LNURL pay invoice from: $callbackUrl") + Logger.debug("Fetching LNURL pay invoice from: $callbackUrl", context = TAG) val response = client.get(callbackUrl) { url { @@ -52,51 +54,50 @@ class LnurlService @Inject constructor( } } } - Logger.debug("Http call: $response") + Logger.debug("Http call: $response", context = TAG) if (!response.status.isSuccess()) { - throw Exception("HTTP error: ${response.status}") + throw HttpError("fetchLnurlInvoice error: '${response.status.description}'", response.status.value) } return@runCatching response.body() } suspend fun fetchLnurlChannelInfo(url: String): Result = runCatching { - Logger.debug("Fetching LNURL channel info from: $url") + Logger.debug("Fetching LNURL channel info from: $url", context = TAG) val response: HttpResponse = client.get(url) - Logger.debug("Http call: $response") + Logger.debug("Http call: $response", context = TAG) if (!response.status.isSuccess()) { - throw Exception("HTTP error: ${response.status}") + throw HttpError("fetchLnurlChannelInfo error: '${response.status.description}'", response.status.value) } return@runCatching response.body() }.onFailure { - Logger.warn("Failed to fetch channel info", e = it, context = TAG) + Logger.warn("Failed to fetch channel info", it, context = TAG) } - suspend fun requestLnurlChannel( - url: String, - ): Result = runCatching { - Logger.debug("Requesting LNURL channel request via: '$url'") + suspend fun requestLnurlChannel(url: String): Result = runCatching { + Logger.debug("Requesting LNURL channel request via: '$url'", context = TAG) val response: HttpResponse = client.get(url) - Logger.debug("Http call: $response") + Logger.debug("Http call: $response", context = TAG) - if (!response.status.isSuccess()) throw Exception("HTTP error: ${response.status}") + if (!response.status.isSuccess()) { + throw HttpError("requestLnurlChannel error: '${response.status.description}'", response.status.value) + } val parsedResponse = response.body() when { parsedResponse.status == "ERROR" -> { - throw Exception("LNURL channel error: ${parsedResponse.reason}") + throw HttpError("requestLnurlChannel error: '${response.status.description}'", response.status.value) } - else -> parsedResponse } }.onFailure { - Logger.warn("Failed to request LNURL channel", e = it, context = TAG) + Logger.warn("Failed to request LNURL channel", it, context = TAG) } companion object { diff --git a/app/src/main/java/to/bitkit/services/RNBackupClient.kt b/app/src/main/java/to/bitkit/services/RNBackupClient.kt index 61160c3ab..8e2a8462f 100644 --- a/app/src/main/java/to/bitkit/services/RNBackupClient.kt +++ b/app/src/main/java/to/bitkit/services/RNBackupClient.kt @@ -27,7 +27,6 @@ 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 @@ -48,8 +47,6 @@ class RNBackupClient @Inject constructor( 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 diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 5f4fc8b91..96ec209f1 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -1,3 +1,5 @@ +@file:Suppress("TooManyFunctions") + package to.bitkit.ui import android.content.Intent @@ -768,7 +770,7 @@ private fun RootNavHost( } // region destinations -@Suppress("LongParameterList") +@Suppress("LongMethod", "LongParameterList") private fun NavGraphBuilder.home( walletViewModel: WalletViewModel, appViewModel: AppViewModel, @@ -878,6 +880,7 @@ private fun NavGraphBuilder.settings( composableWithDefaultTransitions { SettingsScreen(navController) } + @Suppress("ForbiddenComment") // TODO: display as sheet composableWithDefaultTransitions { QuickPayIntroScreen( @@ -1352,6 +1355,7 @@ private fun NavGraphBuilder.support( } } +@Suppress("LongMethod") private fun NavGraphBuilder.widgets( navController: NavHostController, settingsViewModel: SettingsViewModel, diff --git a/app/src/main/java/to/bitkit/ui/Locals.kt b/app/src/main/java/to/bitkit/ui/Locals.kt index 0d8e7397f..2843e38c4 100644 --- a/app/src/main/java/to/bitkit/ui/Locals.kt +++ b/app/src/main/java/to/bitkit/ui/Locals.kt @@ -1,5 +1,3 @@ -@file:Suppress("CompositionLocalAllowlist") - package to.bitkit.ui import androidx.compose.material3.DrawerState diff --git a/app/src/main/java/to/bitkit/ui/MainActivity.kt b/app/src/main/java/to/bitkit/ui/MainActivity.kt index c868ab197..541eb8cad 100644 --- a/app/src/main/java/to/bitkit/ui/MainActivity.kt +++ b/app/src/main/java/to/bitkit/ui/MainActivity.kt @@ -72,6 +72,7 @@ class MainActivity : FragmentActivity() { private val settingsViewModel by viewModels() private val backupsViewModel by viewModels() + @Suppress("LongMethod") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) diff --git a/app/src/main/java/to/bitkit/ui/Notifications.kt b/app/src/main/java/to/bitkit/ui/Notifications.kt index 029472950..d5af1260b 100644 --- a/app/src/main/java/to/bitkit/ui/Notifications.kt +++ b/app/src/main/java/to/bitkit/ui/Notifications.kt @@ -48,7 +48,8 @@ internal fun Context.notificationBuilder( extra?.let { putExtras(it) } } val flags = FLAG_IMMUTABLE or FLAG_ONE_SHOT - // TODO: review if needed: + + @Suppress("ForbiddenComment") // TODO: review if needed: val pendingIntent = PendingIntent.getActivity(this, 0, intent, flags) return NotificationCompat.Builder(this, channelId) diff --git a/app/src/main/java/to/bitkit/ui/components/AppStatus.kt b/app/src/main/java/to/bitkit/ui/components/AppStatus.kt index 9e5ada2d5..9f73780f5 100644 --- a/app/src/main/java/to/bitkit/ui/components/AppStatus.kt +++ b/app/src/main/java/to/bitkit/ui/components/AppStatus.kt @@ -120,6 +120,7 @@ fun rememberHealthState(): HealthState { return healthState.app } +@Suppress("MagicNumber") @Composable private fun rememberRotationEasing(): Easing { val bezierEasing = remember { CubicBezierEasing(0.4f, 0f, 0.2f, 1f) } diff --git a/app/src/main/java/to/bitkit/ui/components/AuthCheckView.kt b/app/src/main/java/to/bitkit/ui/components/AuthCheckView.kt index 848ae94fa..849033aea 100644 --- a/app/src/main/java/to/bitkit/ui/components/AuthCheckView.kt +++ b/app/src/main/java/to/bitkit/ui/components/AuthCheckView.kt @@ -67,6 +67,7 @@ fun AuthCheckView( ) } +@Suppress("ComplexCondition") @Composable private fun AuthCheckViewContent( isBiometricsEnabled: Boolean, diff --git a/app/src/main/java/to/bitkit/ui/components/BalanceHeaderView.kt b/app/src/main/java/to/bitkit/ui/components/BalanceHeaderView.kt index 0039fff56..565cbd41b 100644 --- a/app/src/main/java/to/bitkit/ui/components/BalanceHeaderView.kt +++ b/app/src/main/java/to/bitkit/ui/components/BalanceHeaderView.kt @@ -24,6 +24,7 @@ import to.bitkit.models.BITCOIN_SYMBOL import to.bitkit.models.ConvertedAmount import to.bitkit.models.PrimaryDisplay import to.bitkit.models.formatToModernDisplay +import to.bitkit.repositories.CurrencyState import to.bitkit.ui.LocalCurrencies import to.bitkit.ui.currencyViewModel import to.bitkit.ui.settingsViewModel @@ -39,6 +40,7 @@ fun BalanceHeaderView( sats: Long, modifier: Modifier = Modifier, onClick: (() -> Unit)? = null, + currencies: CurrencyState = LocalCurrencies.current, prefix: String? = null, showBitcoinSymbol: Boolean = true, useSwipeToHide: Boolean = true, @@ -68,7 +70,10 @@ fun BalanceHeaderView( val settings = settingsViewModel ?: return val currency = currencyViewModel ?: return - val (_, _, _, _, _, displayUnit, primaryDisplay) = LocalCurrencies.current + + val displayUnit = currencies.displayUnit + val primaryDisplay = currencies.primaryDisplay + val converted: ConvertedAmount? = currency.convert(sats = sats) val isSwipeToHideEnabled by settings.enableSwipeToHideBalance.collectAsStateWithLifecycle() diff --git a/app/src/main/java/to/bitkit/ui/components/Button.kt b/app/src/main/java/to/bitkit/ui/components/Button.kt index 6da684202..a939be36e 100644 --- a/app/src/main/java/to/bitkit/ui/components/Button.kt +++ b/app/src/main/java/to/bitkit/ui/components/Button.kt @@ -1,3 +1,5 @@ +@file:Suppress("MatchingDeclarationName") + package to.bitkit.ui.components import androidx.compose.foundation.BorderStroke diff --git a/app/src/main/java/to/bitkit/ui/components/DrawerMenu.kt b/app/src/main/java/to/bitkit/ui/components/DrawerMenu.kt index 0071996ac..5012ae8e3 100644 --- a/app/src/main/java/to/bitkit/ui/components/DrawerMenu.kt +++ b/app/src/main/java/to/bitkit/ui/components/DrawerMenu.kt @@ -52,8 +52,8 @@ import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.theme.InterFontFamily -private const val zIndexScrim = 10f -private const val zIndexMenu = 11f +private const val Z_INDEX_SCRIM = 10f +private const val Z_INDEX_MENU = 11f private val bgScrim = Colors.Black50 private val drawerBg = Colors.Brand private val drawerWidth = 200.dp @@ -77,7 +77,7 @@ fun DrawerMenu( }, modifier = Modifier .fillMaxSize() - .zIndex(zIndexScrim) + .zIndex(Z_INDEX_SCRIM) ) AnimatedVisibility( @@ -91,7 +91,7 @@ fun DrawerMenu( modifier = modifier.then( Modifier .fillMaxHeight() - .zIndex(zIndexMenu) + .zIndex(Z_INDEX_MENU) .blockPointerInputPassthrough() ) ) { diff --git a/app/src/main/java/to/bitkit/ui/components/LightningChannel.kt b/app/src/main/java/to/bitkit/ui/components/LightningChannel.kt index 80770af4e..3f44e409c 100644 --- a/app/src/main/java/to/bitkit/ui/components/LightningChannel.kt +++ b/app/src/main/java/to/bitkit/ui/components/LightningChannel.kt @@ -1,7 +1,19 @@ +@file:Suppress("MatchingDeclarationName") + package to.bitkit.ui.components import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowDownward diff --git a/app/src/main/java/to/bitkit/ui/components/Slider.kt b/app/src/main/java/to/bitkit/ui/components/Slider.kt index adb8e2c0e..e1b5d9749 100644 --- a/app/src/main/java/to/bitkit/ui/components/Slider.kt +++ b/app/src/main/java/to/bitkit/ui/components/Slider.kt @@ -46,6 +46,7 @@ private const val TRACK_HEIGHT_DP = 8 private const val STEP_MARKER_WIDTH_DP = 4 private const val STEP_MARKER_HEIGHT_DP = 16 +@Suppress("CyclomaticComplexMethod") @Composable fun StepSlider( value: Int, diff --git a/app/src/main/java/to/bitkit/ui/components/SwipeToConfirm.kt b/app/src/main/java/to/bitkit/ui/components/SwipeToConfirm.kt index 8208b01aa..698020593 100644 --- a/app/src/main/java/to/bitkit/ui/components/SwipeToConfirm.kt +++ b/app/src/main/java/to/bitkit/ui/components/SwipeToConfirm.kt @@ -215,6 +215,7 @@ fun SwipeToConfirm( } } +@Suppress("MagicNumber") @Preview(showSystemUi = true) @Composable private fun Preview() { diff --git a/app/src/main/java/to/bitkit/ui/components/Text.kt b/app/src/main/java/to/bitkit/ui/components/Text.kt index b98610c3c..8bfa181b4 100644 --- a/app/src/main/java/to/bitkit/ui/components/Text.kt +++ b/app/src/main/java/to/bitkit/ui/components/Text.kt @@ -1,3 +1,5 @@ +@file:Suppress("TooManyFunctions") + package to.bitkit.ui.components import androidx.compose.material3.MaterialTheme diff --git a/app/src/main/java/to/bitkit/ui/components/WalletBalanceView.kt b/app/src/main/java/to/bitkit/ui/components/WalletBalanceView.kt index 01584ab21..db8ca0a9b 100644 --- a/app/src/main/java/to/bitkit/ui/components/WalletBalanceView.kt +++ b/app/src/main/java/to/bitkit/ui/components/WalletBalanceView.kt @@ -30,6 +30,7 @@ import to.bitkit.models.BitcoinDisplayUnit import to.bitkit.models.ConvertedAmount import to.bitkit.models.PrimaryDisplay import to.bitkit.models.formatToModernDisplay +import to.bitkit.repositories.CurrencyState import to.bitkit.ui.LocalCurrencies import to.bitkit.ui.currencyViewModel import to.bitkit.ui.settingsViewModel @@ -44,6 +45,7 @@ fun RowScope.WalletBalanceView( sats: Long, icon: Painter, modifier: Modifier = Modifier, + currencies: CurrencyState = LocalCurrencies.current, ) { val isPreview = LocalInspectionMode.current if (isPreview) { @@ -67,7 +69,9 @@ fun RowScope.WalletBalanceView( val settings = settingsViewModel ?: return val currency = currencyViewModel ?: return - val (_, _, _, _, _, displayUnit, primaryDisplay) = LocalCurrencies.current + + val displayUnit = currencies.displayUnit + val primaryDisplay = currencies.primaryDisplay val converted: ConvertedAmount? = currency.convert(sats = sats) val hideBalance by settings.hideBalance.collectAsStateWithLifecycle() diff --git a/app/src/main/java/to/bitkit/ui/components/settings/SettingsButtonRow.kt b/app/src/main/java/to/bitkit/ui/components/settings/SettingsButtonRow.kt index cf2abc86a..e93e4128b 100644 --- a/app/src/main/java/to/bitkit/ui/components/settings/SettingsButtonRow.kt +++ b/app/src/main/java/to/bitkit/ui/components/settings/SettingsButtonRow.kt @@ -34,12 +34,7 @@ import to.bitkit.ui.shared.modifiers.clickableAlpha import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors -sealed class SettingsButtonValue { - data class BooleanValue(val checked: Boolean) : SettingsButtonValue() - data class StringValue(val value: String) : SettingsButtonValue() - data object None : SettingsButtonValue() -} - +@Suppress("CyclomaticComplexMethod") @Composable fun SettingsButtonRow( title: String, @@ -158,6 +153,12 @@ fun SettingsButtonRow( } } +sealed class SettingsButtonValue { + data class BooleanValue(val checked: Boolean) : SettingsButtonValue() + data class StringValue(val value: String) : SettingsButtonValue() + data object None : SettingsButtonValue() +} + @Preview @Composable private fun Preview() { diff --git a/app/src/main/java/to/bitkit/ui/onboarding/InitializingWalletView.kt b/app/src/main/java/to/bitkit/ui/onboarding/InitializingWalletView.kt index 806c5ff6b..bb49c39ff 100644 --- a/app/src/main/java/to/bitkit/ui/onboarding/InitializingWalletView.kt +++ b/app/src/main/java/to/bitkit/ui/onboarding/InitializingWalletView.kt @@ -47,6 +47,7 @@ import kotlin.math.roundToInt const val LOADING_MS = 2000 const val RESTORING_MS = 8000 +@Suppress("MagicNumber") @SuppressLint("UnusedBoxWithConstraintsScope") @Composable fun InitializingWalletView( diff --git a/app/src/main/java/to/bitkit/ui/screens/scanner/QrScanningScreen.kt b/app/src/main/java/to/bitkit/ui/screens/scanner/QrScanningScreen.kt index ee78f6339..2682f2fde 100644 --- a/app/src/main/java/to/bitkit/ui/screens/scanner/QrScanningScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/scanner/QrScanningScreen.kt @@ -374,9 +374,9 @@ private fun processImageFromGallery( context: Context, uri: Uri, onScanSuccess: (String) -> Unit, - onError: (Exception) -> Unit, + onError: (Throwable) -> Unit, ) { - try { + runCatching { val image = InputImage.fromFilePath(context, uri) val options = BarcodeScannerOptions.Builder() .setBarcodeFormats(Barcode.FORMAT_QR_CODE) @@ -399,8 +399,8 @@ private fun processImageFromGallery( Logger.error("Failed to scan QR code from gallery", e) onError(e) } - } catch (e: Exception) { - Logger.error("Failed to process image from gallery", e) - onError(e) + }.onFailure { + Logger.error("Failed to process image from gallery", it) + onError(it) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/shop/shopDiscover/MapWebViewClient.kt b/app/src/main/java/to/bitkit/ui/screens/shop/shopDiscover/MapWebViewClient.kt index d2c04f2c5..324c07e42 100644 --- a/app/src/main/java/to/bitkit/ui/screens/shop/shopDiscover/MapWebViewClient.kt +++ b/app/src/main/java/to/bitkit/ui/screens/shop/shopDiscover/MapWebViewClient.kt @@ -25,6 +25,7 @@ class MapWebViewClient( onLoadingStateChanged(false) } + @Suppress("ComplexCondition") override fun onReceivedError( view: WebView?, request: WebResourceRequest?, diff --git a/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewClient.kt b/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewClient.kt index a4ae3ecb3..b25cb3db6 100644 --- a/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewClient.kt +++ b/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewClient.kt @@ -49,6 +49,7 @@ class ShopWebViewClient( ) } + @Suppress("ComplexCondition") override fun onReceivedError( view: WebView?, request: WebResourceRequest?, diff --git a/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewInterface.kt b/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewInterface.kt index 99dc56c75..d4acdc27c 100644 --- a/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewInterface.kt +++ b/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewInterface.kt @@ -26,6 +26,7 @@ class ShopWebViewInterface( * * @param message JSON string containing the message data */ + @Suppress("NestedBlockDepth") @JavascriptInterface fun postMessage(message: String) { if (message.isBlank()) { @@ -33,7 +34,7 @@ class ShopWebViewInterface( return } - try { + runCatching { val data = json.decodeFromString(message) when (data.event) { "payment_intent" -> { @@ -51,8 +52,8 @@ class ShopWebViewInterface( Logger.debug("Unknown event type: ${data.event}", context = "WebView") } } - } catch (e: Exception) { - Logger.error("Error parsing message: $message", e) + }.onFailure { + Logger.error("Error parsing message: $message", it) } } @@ -61,6 +62,7 @@ class ShopWebViewInterface( * * @return true if the interface is initialized and ready */ + @Suppress("FunctionOnlyReturningConstant") @JavascriptInterface fun isReady(): Boolean { return true diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsConfirmScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsConfirmScreen.kt index f7351c939..da45ad90f 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsConfirmScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsConfirmScreen.kt @@ -85,6 +85,7 @@ fun SavingsConfirmScreen( ) } +@Suppress("MagicNumber") @Composable private fun SavingsConfirmContent( amount: ULong, diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsProgressScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsProgressScreen.kt index 56fcf5868..3b3521c2e 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsProgressScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsProgressScreen.kt @@ -44,8 +44,6 @@ import to.bitkit.viewmodels.AppViewModel import to.bitkit.viewmodels.TransferViewModel import to.bitkit.viewmodels.WalletViewModel -enum class SavingsProgressState { PROGRESS, SUCCESS, INTERRUPTED } - @Composable fun SavingsProgressScreen( app: AppViewModel, @@ -205,6 +203,8 @@ private fun Content( } } +enum class SavingsProgressState { PROGRESS, SUCCESS, INTERRUPTED } + @Preview(showSystemUi = true) @Composable private fun PreviewProgress() { diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SettingUpScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SettingUpScreen.kt index 9ccb4a8ee..d58d46e90 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/SettingUpScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SettingUpScreen.kt @@ -42,6 +42,7 @@ import to.bitkit.ui.utils.withAccentBoldBright import to.bitkit.utils.Logger import to.bitkit.viewmodels.TransferViewModel +@Suppress("TooGenericExceptionCaught") @Composable fun SettingUpScreen( viewModel: TransferViewModel, @@ -57,12 +58,12 @@ fun SettingUpScreen( if (Env.network == Network.REGTEST) { delay(5000) - try { + runCatching { Logger.debug("Auto-mining a block", context = "SettingUpScreen") regtestMine(1u) Logger.debug("Successfully mined a block", context = "SettingUpScreen") - } catch (e: Throwable) { - Logger.error("Failed to mine block: $e", context = "SettingUpScreen") + }.onFailure { + Logger.error("Failed to mine block: $it", context = "SettingUpScreen") } } } diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingConfirmScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingConfirmScreen.kt index 906e6c3de..ebdf4766f 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingConfirmScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingConfirmScreen.kt @@ -108,6 +108,7 @@ fun SpendingConfirmScreen( ) } +@Suppress("MagicNumber") @Composable private fun Content( onBackClick: () -> Unit, diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/components/ProgressSteps.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/components/ProgressSteps.kt index 987e755df..b3c53885d 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/components/ProgressSteps.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/components/ProgressSteps.kt @@ -24,10 +24,8 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.PathEffect -import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import to.bitkit.R import to.bitkit.ui.components.BodySSB import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalNodeViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalNodeViewModel.kt index 3e6c6479e..efd087df3 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalNodeViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalNodeViewModel.kt @@ -159,6 +159,7 @@ class ExternalNodeViewModel @Inject constructor( viewModelScope.launch { _uiState.update { it.copy(isLoading = true) } + @Suppress("ForbiddenComment") // TODO: pass customFeeRate to ldk-node when supported lightningRepo.openChannel( peer = requireNotNull(_uiState.value.peer), diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt index dd5f376c9..78ef4c388 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt @@ -118,6 +118,7 @@ import to.bitkit.viewmodels.AppViewModel import to.bitkit.viewmodels.SettingsViewModel import to.bitkit.viewmodels.WalletViewModel +@Suppress("CyclomaticComplexMethod") @Composable fun HomeScreen( isRefreshing: Boolean, @@ -275,6 +276,7 @@ fun HomeScreen( ) } +@Suppress("MagicNumber") @OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class) @Composable private fun Content( diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt index b3568a750..8e2a1d532 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt @@ -568,6 +568,7 @@ private fun ActivityDetailContent( horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.fillMaxWidth() ) { + @Suppress("ForbiddenComment") PrimaryButton( text = stringResource(R.string.wallet__activity_assign), size = ButtonSize.Small, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListGrouped.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListGrouped.kt index 3fabceb3a..06cf97711 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListGrouped.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListGrouped.kt @@ -134,6 +134,7 @@ fun ActivityListGrouped( } // region utils +@Suppress("CyclomaticComplexMethod") private fun groupActivityItems(activityItems: List): List { val now = Instant.now() val zoneId = ZoneId.systemDefault() diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt index f6681ec7e..4b5951f39 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt @@ -41,6 +41,7 @@ import to.bitkit.ext.txType import to.bitkit.models.FeeRate.Companion.getFeeShortDescription import to.bitkit.models.PrimaryDisplay import to.bitkit.models.formatToModernDisplay +import to.bitkit.repositories.CurrencyState import to.bitkit.ui.LocalCurrencies import to.bitkit.ui.activityListViewModel import to.bitkit.ui.blocktankViewModel @@ -59,6 +60,7 @@ import java.time.Instant import java.time.LocalDate import java.time.ZoneId +@Suppress("CyclomaticComplexMethod") @Composable fun ActivityRow( item: Activity, @@ -210,6 +212,7 @@ private fun TransactionStatusText( private fun AmountView( item: Activity, prefix: String, + currencies: CurrencyState = LocalCurrencies.current, ) { val amount = item.totalValue() @@ -227,7 +230,9 @@ private fun AmountView( val settings = settingsViewModel ?: return val currency = currencyViewModel ?: return - val (_, _, _, _, _, displayUnit, primaryDisplay) = LocalCurrencies.current + + val primaryDisplay = currencies.primaryDisplay + val displayUnit = currencies.displayUnit val hideBalance by settings.hideBalance.collectAsStateWithLifecycle() 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 c7acf7876..2f07d3a90 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 @@ -1,3 +1,5 @@ +@file:Suppress("MatchingDeclarationName") + package to.bitkit.ui.screens.wallets.receive import androidx.compose.foundation.layout.Arrangement diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveLiquidityScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveLiquidityScreen.kt index 4cfc6b879..40b7895e3 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveLiquidityScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveLiquidityScreen.kt @@ -90,7 +90,11 @@ private fun Content( ) { SheetTopBar( stringResource( - if (isAdditional) R.string.wallet__receive_liquidity__nav_title_additional else R.string.wallet__receive_liquidity__nav_title + if (isAdditional) { + R.string.wallet__receive_liquidity__nav_title_additional + } else { + R.string.wallet__receive_liquidity__nav_title + } ), onBack = onBack ) @@ -104,7 +108,11 @@ private fun Content( ) { BodyM( text = stringResource( - if (isAdditional) R.string.wallet__receive_liquidity__text_additional else R.string.wallet__receive_liquidity__text + if (isAdditional) { + R.string.wallet__receive_liquidity__text_additional + } else { + R.string.wallet__receive_liquidity__text + } ), color = Colors.White64 ) @@ -113,7 +121,11 @@ private fun Content( BodyMB( text = stringResource( - if (isAdditional) R.string.wallet__receive_liquidity__label_additional else R.string.wallet__receive_liquidity__label + if (isAdditional) { + R.string.wallet__receive_liquidity__label_additional + } else { + R.string.wallet__receive_liquidity__label + } ) ) Spacer(modifier = Modifier.height(16.dp)) 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 d948c29e8..ab026d9a6 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 @@ -18,8 +18,8 @@ import androidx.navigation.compose.rememberNavController import kotlinx.serialization.Serializable import to.bitkit.repositories.LightningState import to.bitkit.repositories.WalletState -import to.bitkit.ui.screens.wallets.send.AddTagScreen import to.bitkit.ui.openNotificationSettings +import to.bitkit.ui.screens.wallets.send.AddTagScreen import to.bitkit.ui.shared.modifiers.sheetHeight import to.bitkit.ui.utils.composableWithDefaultTransitions import to.bitkit.ui.walletViewModel diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendAddressScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendAddressScreen.kt index 7ced344e8..e81da9c81 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendAddressScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendAddressScreen.kt @@ -119,7 +119,10 @@ private fun Preview2() { BottomSheetPreview { SendAddressScreen( uiState = SendUiState( - addressInput = "bitcoin:bc17tq4mtkq86vte7a26e0za560kgflwqsvxznmer5?lightning=LNBC1PQUVNP8KHGPLNF6REGS3VY5F40AJFUN4S2JUDQQNP4TK9MP6LWWLWTC3XX3UUEVYZ4EVQU3X4NQDX348QPP5WJC9DWNTAFN7FZEZFVDC3MHV67SX2LD2MG602E3LEZDMFT29JLWQSP54QKM4G8A2KD5RGEKACA3CH4XV4M2MQDN62F8S2CCRES9QYYSGQCQPCXQRRSSRZJQWQKZS03MNNHSTKR9DN2XQRC8VW5X6CEWAL8C6RW6QQ3T02T3R", + addressInput = "bitcoin:bc17tq4mtkq86vte7a26e0za560kgflwqsvxznmer5?lightning=" + + "LNBC1PQUVNP8KHGPLNF6REGS3VY5F40AJFUN4S2JUDQQNP4TK9MP6LWWLWTC3XX3UUEVYZ4EVQU3X4NQDX" + + "348QPP5WJC9DWNTAFN7FZEZFVDC3MHV67SX2LD2MG602E3LEZDMFT29JLWQSP54QKM4G8A2KD5RGEKACA3C" + + "H4XV4M2MQDN62F8S2CCRES9QYYSGQCQPCXQRRSSRZJQWQKZS03MNNHSTKR9DN2XQRC8VW5X6CEWAL8C6RW6QQ3T02T3R", isAddressInputValid = true, ), onBack = {}, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionViewModel.kt index 0cc33ade3..a9167e7f9 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionViewModel.kt @@ -39,34 +39,32 @@ class SendCoinSelectionViewModel @Inject constructor( this.onchainActivities = onchainActivities } - fun loadUtxos(requiredAmount: ULong, address: String) { - viewModelScope.launch { - try { - val sortedUtxos = lightningRepo.listSpendableOutputs().getOrThrow() - .sortedByDescending { it.valueSats } - - val totalRequired = calculateTotalRequired( - address = address, - amountSats = requiredAmount, - utxosToSpend = sortedUtxos, - ) + fun loadUtxos(requiredAmount: ULong, address: String) = viewModelScope.launch { + runCatching { + val sortedUtxos = lightningRepo.listSpendableOutputs().getOrThrow() + .sortedByDescending { it.valueSats } + + val totalRequired = calculateTotalRequired( + address = address, + amountSats = requiredAmount, + utxosToSpend = sortedUtxos, + ) + + val totalSelected = sortedUtxos.sumOf { it.valueSats } - val totalSelected = sortedUtxos.sumOf { it.valueSats } - - _uiState.update { state -> - state.copy( - availableUtxos = sortedUtxos, - selectedUtxos = sortedUtxos, - autoSelectCoinsOn = true, - totalRequiredSat = totalRequired, - totalSelectedSat = totalSelected, - isSelectionValid = validateCoinSelection(totalSelected, totalRequired), - ) - } - } catch (e: Throwable) { - Logger.error("Failed to load UTXOs for coin selection", e) - ToastEventBus.send(Exception("Failed to load UTXOs: ${e.message}")) + _uiState.update { state -> + state.copy( + availableUtxos = sortedUtxos, + selectedUtxos = sortedUtxos, + autoSelectCoinsOn = true, + totalRequiredSat = totalRequired, + totalSelectedSat = totalSelected, + isSelectionValid = validateCoinSelection(totalSelected, totalRequired), + ) } + }.onFailure { + Logger.error("Failed to load UTXOs for coin selection", it) + ToastEventBus.send(Exception("Failed to load UTXOs: ${it.message}")) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt index bf9dc98b8..0d369b08e 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt @@ -85,6 +85,7 @@ import to.bitkit.viewmodels.SendMethod import to.bitkit.viewmodels.SendUiState import java.time.Instant +@Suppress("MagicNumber") @Composable fun SendConfirmScreen( savedStateHandle: SavedStateHandle, @@ -600,6 +601,7 @@ private fun sendUiState() = SendUiState( ), ) +@Suppress("MagicNumber") @Preview(showSystemUi = true, group = "onchain") @Composable private fun PreviewOnChain() { @@ -620,6 +622,7 @@ private fun PreviewOnChain() { } } +@Suppress("MagicNumber") @Preview(showSystemUi = true, group = "onchain", device = Devices.NEXUS_5) @Composable private fun PreviewOnChainLongFeeSmallScreen() { @@ -660,6 +663,7 @@ private fun PreviewOnChainFeeLoading() { } } +@Suppress("MagicNumber") @Preview(showSystemUi = true) @Composable private fun PreviewLightning() { diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/blocks/BlocksEditScreen.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/blocks/BlocksEditScreen.kt index 7c6cffb50..1ebb76e95 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/blocks/BlocksEditScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/blocks/BlocksEditScreen.kt @@ -186,7 +186,9 @@ fun BlocksEditContent( PrimaryButton( text = stringResource(R.string.common__preview), - enabled = blocksPreferences.run { showBlock || showTime || showDate || showTransactions || showSize || showSource }, + enabled = blocksPreferences.run { + showBlock || showTime || showDate || showTransactions || showSize || showSource + }, modifier = Modifier .weight(1f) .testTag("WidgetEditPreview"), diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/headlines/HeadlineCard.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/headlines/HeadlineCard.kt index 0bfb04e58..d3dd27650 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/headlines/HeadlineCard.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/headlines/HeadlineCard.kt @@ -46,7 +46,7 @@ fun HeadlineCard( time: String, headline: String, source: String, - link: String + link: String, ) { val context = LocalContext.current @@ -131,35 +131,36 @@ fun HeadlineCard( private fun Preview() { AppThemeSurface { Column( + verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier .fillMaxSize() - .padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) + .padding(horizontal = 16.dp) ) { + @Suppress("SpellCheckingInspection") HeadlineCard( time = "21 minutes ago", - headline = "How Bitcoin changed El Salvador in more ways a big headline to test the text overflooooooow", + headline = "How Bitcoin changed El Salvador in more ways a big headline to test the text overflooooow", source = "bitcoinmagazine.com", link = "" ) HeadlineCard( showWidgetTitle = false, time = "21 minutes ago", - headline = "How Bitcoin changed El Salvador in more ways a big headline to test the text overflooooooow", + headline = "How Bitcoin changed El Salvador in more ways a big headline", source = "bitcoinmagazine.com", link = "" ) HeadlineCard( showTime = false, time = "21 minutes ago", - headline = "How Bitcoin changed El Salvador in more ways a big headline to test the text overflooooooow", + headline = "How Bitcoin changed El Salvador in more ways a big headline", source = "bitcoinmagazine.com", link = "" ) HeadlineCard( showSource = false, time = "21 minutes ago", - headline = "How Bitcoin changed El Salvador in more ways a big headline to test the text overflooooooow", + headline = "How Bitcoin changed El Salvador in more ways a big headline", source = "bitcoinmagazine.com", link = "" ) @@ -168,7 +169,7 @@ private fun Preview() { showTime = false, showSource = false, time = "21 minutes ago", - headline = "How Bitcoin changed El Salvador in more ways a big headline to test the text overflooooooow", + headline = "How Bitcoin changed El Salvador in more ways a big headline", source = "bitcoinmagazine.com", link = "" ) diff --git a/app/src/main/java/to/bitkit/ui/settings/BlocktankRegtestScreen.kt b/app/src/main/java/to/bitkit/ui/settings/BlocktankRegtestScreen.kt index c09d03807..fcc6ff45c 100644 --- a/app/src/main/java/to/bitkit/ui/settings/BlocktankRegtestScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/BlocktankRegtestScreen.kt @@ -42,6 +42,7 @@ import to.bitkit.ui.theme.Colors import to.bitkit.ui.walletViewModel import to.bitkit.utils.Logger +@Suppress("CyclomaticComplexMethod") @Composable fun BlocktankRegtestScreen( navController: NavController, @@ -107,7 +108,7 @@ fun BlocktankRegtestScreen( coroutineScope.launch { Logger.debug("Initiating regtest deposit with address: $depositAddress, amount: $depositAmount") isDepositing = true - try { + runCatching { val sats = depositAmount.toULongOrNull() ?: error("Invalid deposit amount: $depositAmount") val txId = viewModel.regtestDeposit(depositAddress, sats) Logger.debug("Deposit successful with txId: $txId") @@ -116,17 +117,17 @@ fun BlocktankRegtestScreen( title = "Success", description = "Deposit successful. TxID: $txId", ) - } catch (e: Exception) { - Logger.error("Deposit failed", e) + }.onFailure { + Logger.error("Deposit failed", it) app.toast( type = Toast.ToastType.ERROR, title = "Failed to deposit", - description = e.message.orEmpty(), + description = it.message.orEmpty(), ) - } finally { - isDepositing = false - wallet.refreshState() } + + isDepositing = false + wallet.refreshState() } }, enabled = depositAddress.isNotEmpty() && !isDepositing, @@ -152,7 +153,7 @@ fun BlocktankRegtestScreen( coroutineScope.launch { Logger.debug("Starting regtest mining with block count: $mineBlockCount") isMining = true - try { + runCatching { val count = mineBlockCount.toUIntOrNull() ?: error("Invalid block count: $mineBlockCount") viewModel.regtestMine(count) @@ -162,17 +163,16 @@ fun BlocktankRegtestScreen( title = "Success", description = "Successfully mined $count blocks", ) - } catch (e: Exception) { - Logger.error("Mining failed", e) + }.onFailure { + Logger.error("Mining failed", it) app.toast( type = Toast.ToastType.ERROR, title = "Failed to mine", - description = e.message.orEmpty(), + description = it.message.orEmpty(), ) - } finally { - isMining = false - wallet.refreshState() } + isMining = false + wallet.refreshState() } }, enabled = !isMining, @@ -206,7 +206,7 @@ fun BlocktankRegtestScreen( onClick = { coroutineScope.launch { Logger.debug("Initiating regtest payment with invoice: $paymentInvoice, amount: $paymentAmount") - try { + runCatching { val amount = if (paymentAmount.isEmpty()) null else paymentAmount.toULongOrNull() val paymentId = viewModel.regtestPay(paymentInvoice, amount) Logger.debug("Payment successful with ID: $paymentId") @@ -215,12 +215,12 @@ fun BlocktankRegtestScreen( title = "Success", description = "Payment successful. ID: $paymentId", ) - } catch (e: Exception) { - Logger.error("Payment failed", e) + }.onFailure { + Logger.error("Payment failed", it) app.toast( type = Toast.ToastType.ERROR, title = "Failed to pay invoice from LND", - description = e.message.orEmpty(), + description = it.message.orEmpty(), ) } } @@ -262,9 +262,12 @@ fun BlocktankRegtestScreen( onClick = { coroutineScope.launch { Logger.debug( - "Initiating channel close with fundingTxId: $fundingTxId, vout: $vout, forceCloseAfter: $forceCloseAfter" + "Initiating channel close with " + + "fundingTxId: '$fundingTxId', " + + "vout: '$vout', " + + "forceCloseAfter: '$forceCloseAfter'." ) - try { + runCatching { val voutNum = vout.toUIntOrNull() ?: error("Invalid Vout: $vout") val closeAfter = forceCloseAfter.toULongOrNull() ?: error("Invalid Force Close After: $forceCloseAfter") @@ -279,9 +282,9 @@ fun BlocktankRegtestScreen( title = "Success", description = "Channel closed. Closing TxID: $closingTxId" ) - } catch (e: Exception) { - Logger.error("Channel close failed", e) - app.toast(e) + }.onFailure { + Logger.error("Channel close failed", it) + app.toast(it) } } }, diff --git a/app/src/main/java/to/bitkit/ui/settings/ChannelOrdersScreen.kt b/app/src/main/java/to/bitkit/ui/settings/ChannelOrdersScreen.kt index ed5acd4ed..756371b46 100644 --- a/app/src/main/java/to/bitkit/ui/settings/ChannelOrdersScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/ChannelOrdersScreen.kt @@ -133,6 +133,7 @@ private fun Content( } } +@Suppress("TooGenericExceptionCaught") @Composable fun OrderDetailScreen( orderItem: Routes.OrderDetail, @@ -519,7 +520,9 @@ private val order = IBtOrder( connectionStrings = emptyList(), readonly = true, ), - lnurl = "LNURL1DP68GURN8GHJ7CTSDYH8XARPVUHXYMR0VD4HGCTWDVH8GME0VFKX7CMTW3SKU6E0V9CXJTMKXGHKCTENVV6NVDP4XUEJ6ETRX33Z6DPEXU6Z6C34XQEZ6DT9XENX2WFNXQ6RXDTXGQAH4MLNURL1DP68GURN8GHJ7CTSDYH8XARPVUHXYMR0VD4HGCTWDVH8GME0VFKX7CMTW3SKU6E0V9CXJTMKXGHKCTENVV6NVDP4XUEJ6ETRX33Z6DPEXU6Z6C34XQEZ6DT9XENX2WFNXQ6RXDTXGQAH4M", + lnurl = "LNURL1DP68GURN8GHJ7CTSDYH8XARPVUHXYMR0VD4HGCTWDVH8GME0VFKX7CMTW3SKU6E0V9CXJTMKXGHKCTENVV6NVDP4" + + "XUEJ6ETRX33Z6DPEXU6Z6C34XQEZ6DT9XENX2WFNXQ6RXDTXGQAH4MLNURL1DP68GURN8GHJ7CTSDYH8XARPVUHXYMR0VD4HGCTWDVH8G" + + "ME0VFKX7CMTW3SKU6E0V9CXJTMKXGHKCTENVV6NVDP4XUEJ6ETRX33Z6DPEXU6Z6C34XQEZ6DT9XENX2WFNXQ6RXDTXGQAH4M", payment = IBtPayment( state = BtPaymentState.PAID, state2 = BtPaymentState2.PAID, diff --git a/app/src/main/java/to/bitkit/ui/settings/SecuritySettingsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/SecuritySettingsScreen.kt index c71fd5ece..510baa93b 100644 --- a/app/src/main/java/to/bitkit/ui/settings/SecuritySettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/SecuritySettingsScreen.kt @@ -159,7 +159,11 @@ private fun Content( title = stringResource(R.string.settings__security__pin), value = SettingsButtonValue.StringValue( stringResource( - if (isPinEnabled) R.string.settings__security__pin_enabled else R.string.settings__security__pin_disabled + if (isPinEnabled) { + R.string.settings__security__pin_enabled + } else { + R.string.settings__security__pin_disabled + } ) ), onClick = onPinClick, diff --git a/app/src/main/java/to/bitkit/ui/settings/SettingsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/SettingsScreen.kt index 823a5d24d..f08ac96af 100644 --- a/app/src/main/java/to/bitkit/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/SettingsScreen.kt @@ -78,10 +78,18 @@ fun SettingsScreen( app.toast( type = Toast.ToastType.SUCCESS, title = context.getString( - if (newValue) R.string.settings__dev_enabled_title else R.string.settings__dev_disabled_title + if (newValue) { + R.string.settings__dev_enabled_title + } else { + R.string.settings__dev_disabled_title + } ), description = context.getString( - if (newValue) R.string.settings__dev_enabled_message else R.string.settings__dev_disabled_message + if (newValue) { + R.string.settings__dev_enabled_message + } else { + R.string.settings__dev_disabled_message + } ), ) enableDevModeTapCount = 0 diff --git a/app/src/main/java/to/bitkit/ui/settings/advanced/AddressViewerScreen.kt b/app/src/main/java/to/bitkit/ui/settings/advanced/AddressViewerScreen.kt index 59a555bb5..09353a86c 100644 --- a/app/src/main/java/to/bitkit/ui/settings/advanced/AddressViewerScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/advanced/AddressViewerScreen.kt @@ -292,7 +292,7 @@ private fun AddressItem( } } -@Suppress("SpellCheckingInspection") +@Suppress("SpellCheckingInspection", "MagicNumber") @Preview @Composable private fun Preview() { diff --git a/app/src/main/java/to/bitkit/ui/settings/advanced/CoinSelectPreferenceScreen.kt b/app/src/main/java/to/bitkit/ui/settings/advanced/CoinSelectPreferenceScreen.kt index dbe193a4c..03a879bc8 100644 --- a/app/src/main/java/to/bitkit/ui/settings/advanced/CoinSelectPreferenceScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/advanced/CoinSelectPreferenceScreen.kt @@ -25,17 +25,6 @@ import to.bitkit.ui.scaffold.DrawerNavIcon import to.bitkit.ui.scaffold.ScreenColumn import to.bitkit.ui.theme.AppThemeSurface -object CoinSelectPreferenceTestTags { - const val SCREEN = "coin_select_preference_screen" - const val MANUAL_BUTTON = "manual_button" - const val AUTOPILOT_BUTTON = "autopilot_button" - const val LARGEST_FIRST_BUTTON = "largest_first_button" - const val CONSOLIDATE_BUTTON = "consolidate_button" - const val FIRST_IN_FIRST_OUT_BUTTON = "first_in_first_out_button" - const val BRANCH_AND_BOUND_BUTTON = "branch_and_bound_button" - const val SINGLE_RANDOM_DRAW_BUTTON = "single_random_draw_button" -} - @Composable fun CoinSelectPreferenceScreen( navController: NavController, @@ -166,7 +155,18 @@ private fun Content( } } -@Preview +object CoinSelectPreferenceTestTags { + const val SCREEN = "coin_select_preference_screen" + const val MANUAL_BUTTON = "manual_button" + const val AUTOPILOT_BUTTON = "autopilot_button" + const val LARGEST_FIRST_BUTTON = "largest_first_button" + const val CONSOLIDATE_BUTTON = "consolidate_button" + const val FIRST_IN_FIRST_OUT_BUTTON = "first_in_first_out_button" + const val BRANCH_AND_BOUND_BUTTON = "branch_and_bound_button" + const val SINGLE_RANDOM_DRAW_BUTTON = "single_random_draw_button" +} + +@Preview(showSystemUi = true) @Composable private fun Preview() { AppThemeSurface { @@ -178,9 +178,9 @@ private fun Preview() { } } -@Preview +@Preview(showSystemUi = true) @Composable -private fun Preview2() { +private fun PreviewManual() { AppThemeSurface { Content( uiState = CoinSelectPreferenceUiState( diff --git a/app/src/main/java/to/bitkit/ui/settings/advanced/ElectrumConfigViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/advanced/ElectrumConfigViewModel.kt index dbd0b0eb8..ed41850f1 100644 --- a/app/src/main/java/to/bitkit/ui/settings/advanced/ElectrumConfigViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/advanced/ElectrumConfigViewModel.kt @@ -22,6 +22,7 @@ import to.bitkit.env.Env import to.bitkit.models.ElectrumProtocol import to.bitkit.models.ElectrumServer import to.bitkit.models.ElectrumServerPeer +import to.bitkit.models.MAX_VALID_PORT import to.bitkit.models.Toast import to.bitkit.models.getDefaultPort import to.bitkit.repositories.LightningRepo @@ -122,19 +123,18 @@ class ElectrumConfigViewModel @Inject constructor( } } + @Suppress("ComplexCondition") fun connectToServer() { val currentState = _uiState.value val port = currentState.port.toIntOrNull() val protocol = currentState.protocol - if (currentState.host.isBlank() || port == null || port <= 0 || protocol == null) { - return - } + if (currentState.host.isBlank() || port == null || port <= 0 || protocol == null) return _uiState.update { it.copy(isLoading = true) } viewModelScope.launch(bgDispatcher) { - try { + runCatching { val electrumServer = ElectrumServer.fromUserInput( host = currentState.host, port = port, @@ -153,7 +153,7 @@ class ElectrumConfigViewModel @Inject constructor( } } .onFailure { error -> throw error } - } catch (e: Exception) { + }.onFailure { e -> _uiState.update { it.copy( isLoading = false, @@ -178,7 +178,7 @@ class ElectrumConfigViewModel @Inject constructor( error = context.getString(R.string.settings__es__error_port) } else { val portNumber = port.toIntOrNull() - if (portNumber == null || portNumber <= 0 || portNumber > 65535) { + if (portNumber == null || portNumber <= 0 || portNumber > MAX_VALID_PORT) { error = context.getString(R.string.settings__es__error_port_invalid) } } diff --git a/app/src/main/java/to/bitkit/ui/settings/backgroundPayments/BackgroundPaymentsSettings.kt b/app/src/main/java/to/bitkit/ui/settings/backgroundPayments/BackgroundPaymentsSettings.kt index bb26bad83..3274c2eb3 100644 --- a/app/src/main/java/to/bitkit/ui/settings/backgroundPayments/BackgroundPaymentsSettings.kt +++ b/app/src/main/java/to/bitkit/ui/settings/backgroundPayments/BackgroundPaymentsSettings.kt @@ -25,10 +25,10 @@ import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.components.settings.SettingsButtonRow import to.bitkit.ui.components.settings.SettingsButtonValue import to.bitkit.ui.components.settings.SettingsSwitchRow +import to.bitkit.ui.openNotificationSettings import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.DrawerNavIcon import to.bitkit.ui.shared.util.screen -import to.bitkit.ui.openNotificationSettings import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.RequestNotificationPermissions diff --git a/app/src/main/java/to/bitkit/ui/settings/backups/BackupNavSheetViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/backups/BackupNavSheetViewModel.kt index 48a498c87..3c41f83b5 100644 --- a/app/src/main/java/to/bitkit/ui/settings/backups/BackupNavSheetViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/backups/BackupNavSheetViewModel.kt @@ -27,6 +27,7 @@ import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.Logger import javax.inject.Inject +@Suppress("TooManyFunctions") @HiltViewModel class BackupNavSheetViewModel @Inject constructor( @ApplicationContext private val context: Context, @@ -72,29 +73,28 @@ class BackupNavSheetViewModel @Inject constructor( } } - fun loadMnemonicData() { - viewModelScope.launch { - try { - val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)!! // NPE handled with UI toast - val bip39Passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) ?: "" + fun loadMnemonicData() = viewModelScope.launch { + runCatching { + val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)!! // NPE handled with UI toast + val bip39Passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) ?: "" - _uiState.update { - it.copy( - bip39Mnemonic = mnemonic, - bip39Passphrase = bip39Passphrase, - ) - } - } catch (e: Throwable) { - Logger.error("Error loading mnemonic", e, context = TAG) - ToastEventBus.send( - type = Toast.ToastType.WARNING, - title = context.getString(R.string.security__mnemonic_error), - description = context.getString(R.string.security__mnemonic_error_description), + _uiState.update { + it.copy( + bip39Mnemonic = mnemonic, + bip39Passphrase = bip39Passphrase, ) } + }.onFailure { + Logger.error("Error loading mnemonic", it, context = TAG) + ToastEventBus.send( + type = Toast.ToastType.WARNING, + title = context.getString(R.string.security__mnemonic_error), + description = context.getString(R.string.security__mnemonic_error_description), + ) } } + @Suppress("MagicNumber") fun onRevealMnemonic() { viewModelScope.launch { delay(200) // Small delay for better UX diff --git a/app/src/main/java/to/bitkit/ui/settings/backups/ConfirmMnemonicScreen.kt b/app/src/main/java/to/bitkit/ui/settings/backups/ConfirmMnemonicScreen.kt index 4cf5980ec..3a5f4da7d 100644 --- a/app/src/main/java/to/bitkit/ui/settings/backups/ConfirmMnemonicScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/backups/ConfirmMnemonicScreen.kt @@ -39,6 +39,7 @@ import to.bitkit.ui.shared.util.gradientBackground import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors +@Suppress("CyclomaticComplexMethod") @Composable fun ConfirmMnemonicScreen( uiState: BackupContract.UiState, diff --git a/app/src/main/java/to/bitkit/ui/settings/backups/ResetAndRestoreScreen.kt b/app/src/main/java/to/bitkit/ui/settings/backups/ResetAndRestoreScreen.kt index e9b83d1fc..3b002f311 100644 --- a/app/src/main/java/to/bitkit/ui/settings/backups/ResetAndRestoreScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/backups/ResetAndRestoreScreen.kt @@ -1,3 +1,5 @@ +@file:Suppress("MatchingDeclarationName") + package to.bitkit.ui.settings.backups import androidx.compose.foundation.Image @@ -38,13 +40,6 @@ import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.walletViewModel -object ResetAndRestoreTestTags { - const val SCREEN = "restore_screen" - const val BACKUP_BUTTON = "restore_backup_button" - const val RESET_BUTTON = "restore_reset_button" - const val RESET_DIALOG = "restore_reset_button" -} - @Composable fun ResetAndRestoreScreen( navController: NavController, @@ -137,7 +132,14 @@ private fun Content( } } -@Preview +object ResetAndRestoreTestTags { + const val SCREEN = "restore_screen" + const val BACKUP_BUTTON = "restore_backup_button" + const val RESET_BUTTON = "restore_reset_button" + const val RESET_DIALOG = "restore_reset_button" +} + +@Preview(showSystemUi = true) @Composable private fun Preview() { AppThemeSurface { @@ -152,9 +154,9 @@ private fun Preview() { } } -@Preview +@Preview(showSystemUi = true) @Composable -private fun Preview2() { +private fun PreviewDialog() { AppThemeSurface { Content( showConfirmDialog = true, diff --git a/app/src/main/java/to/bitkit/ui/settings/backups/ShowMnemonicScreen.kt b/app/src/main/java/to/bitkit/ui/settings/backups/ShowMnemonicScreen.kt index b58ded966..0468834aa 100644 --- a/app/src/main/java/to/bitkit/ui/settings/backups/ShowMnemonicScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/backups/ShowMnemonicScreen.kt @@ -246,6 +246,7 @@ private fun PreviewShown() { } } +@Suppress("MagicNumber") @Preview(showSystemUi = true) @Composable private fun Preview24Words() { diff --git a/app/src/main/java/to/bitkit/ui/settings/backups/SuccessScreen.kt b/app/src/main/java/to/bitkit/ui/settings/backups/SuccessScreen.kt index 7d6948412..df40e0d0e 100644 --- a/app/src/main/java/to/bitkit/ui/settings/backups/SuccessScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/backups/SuccessScreen.kt @@ -33,6 +33,7 @@ fun SuccessScreen( ) { SuccessContent( onContinue = { + @Suppress("ForbiddenComment") // TODO: verify backup onContinue() }, diff --git a/app/src/main/java/to/bitkit/ui/settings/general/DefaultUnitSettingsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/general/DefaultUnitSettingsScreen.kt index 623984f03..95893d2f7 100644 --- a/app/src/main/java/to/bitkit/ui/settings/general/DefaultUnitSettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/general/DefaultUnitSettingsScreen.kt @@ -16,6 +16,7 @@ import androidx.navigation.NavController import to.bitkit.R import to.bitkit.models.BitcoinDisplayUnit import to.bitkit.models.PrimaryDisplay +import to.bitkit.repositories.CurrencyState import to.bitkit.ui.LocalCurrencies import to.bitkit.ui.components.settings.SectionFooter import to.bitkit.ui.components.settings.SectionHeader @@ -31,8 +32,11 @@ import to.bitkit.viewmodels.CurrencyViewModel fun DefaultUnitSettingsScreen( currencyViewModel: CurrencyViewModel, navController: NavController, + currencies: CurrencyState = LocalCurrencies.current, ) { - val (_, _, _, selectedCurrency, _, displayUnit, primaryDisplay) = LocalCurrencies.current + val selectedCurrency = currencies.selectedCurrency + val displayUnit = currencies.displayUnit + val primaryDisplay = currencies.primaryDisplay DefaultUnitSettingsScreenContent( selectedCurrency = selectedCurrency, diff --git a/app/src/main/java/to/bitkit/ui/settings/general/LocalCurrencySettingsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/general/LocalCurrencySettingsScreen.kt index 6f887099d..1183efd69 100644 --- a/app/src/main/java/to/bitkit/ui/settings/general/LocalCurrencySettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/general/LocalCurrencySettingsScreen.kt @@ -18,6 +18,7 @@ import androidx.compose.ui.unit.dp import androidx.navigation.NavController import to.bitkit.R import to.bitkit.models.FxRate +import to.bitkit.repositories.CurrencyState import to.bitkit.ui.LocalCurrencies import to.bitkit.ui.components.BodyS import to.bitkit.ui.components.SearchInput @@ -35,8 +36,11 @@ import to.bitkit.viewmodels.CurrencyViewModel fun LocalCurrencySettingsScreen( currencyViewModel: CurrencyViewModel, navController: NavController, + currencies: CurrencyState = LocalCurrencies.current, ) { - val (rates, _, _, selectedCurrency) = LocalCurrencies.current + val rates = currencies.rates + val selectedCurrency = currencies.selectedCurrency + var searchText by remember { mutableStateOf("") } val mostUsedCurrenciesList = remember { listOf("USD", "GBP", "CAD", "CNY", "EUR") } diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt index e67caecf8..04795bd4d 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt @@ -498,6 +498,7 @@ private fun SectionTitle(text: String) { VerticalSpacer(8.dp) } +@Suppress("MagicNumber") @Composable private fun SectionRow( name: String, diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsScreen.kt index d409a81b3..f9611f6cc 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsScreen.kt @@ -225,7 +225,10 @@ private fun Content( VerticalSpacer(16.dp) TertiaryButton( text = stringResource( - if (showClosed) R.string.lightning__conn_closed_hide else R.string.lightning__conn_closed_show + when (showClosed) { + true -> R.string.lightning__conn_closed_hide + else -> R.string.lightning__conn_closed_show + } ), onClick = { showClosed = !showClosed }, modifier = Modifier diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt index 5ad21da33..94174c2d6 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt @@ -156,6 +156,7 @@ class LightningConnectionsViewModel @Inject constructor( _selectedChannel.update { updatedChannel?.mapToUiModel() } } + @Suppress("ReturnCount") private fun findUpdatedChannel( currentChannel: ChannelDetails, allChannels: List, @@ -243,6 +244,7 @@ class LightningConnectionsViewModel @Inject constructor( details = this ) + @Suppress("ForbiddenComment") private fun getChannelName(channel: ChannelDetails): String { val default = channel.inboundScidAlias?.toString() ?: "${channel.channelId.take(10)}…" diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/components/ChannelStatusView.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/components/ChannelStatusView.kt index d77040376..5efcf04b3 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/components/ChannelStatusView.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/components/ChannelStatusView.kt @@ -1,3 +1,5 @@ +@file:Suppress("TooManyFunctions") + package to.bitkit.ui.settings.lightning.components import androidx.compose.foundation.background diff --git a/app/src/main/java/to/bitkit/ui/settings/pin/PinPromptScreen.kt b/app/src/main/java/to/bitkit/ui/settings/pin/PinPromptScreen.kt index df89294e8..333e2f5b5 100644 --- a/app/src/main/java/to/bitkit/ui/settings/pin/PinPromptScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/pin/PinPromptScreen.kt @@ -35,6 +35,7 @@ import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.withAccent +@Suppress("MagicNumber") @Composable fun PinPromptScreen( onContinue: () -> Unit, diff --git a/app/src/main/java/to/bitkit/ui/settings/support/ReportIssueScreen.kt b/app/src/main/java/to/bitkit/ui/settings/support/ReportIssueScreen.kt index cf66d7cee..3e57d86da 100644 --- a/app/src/main/java/to/bitkit/ui/settings/support/ReportIssueScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/support/ReportIssueScreen.kt @@ -29,18 +29,6 @@ import to.bitkit.ui.scaffold.ScreenColumn import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors -object ReportIssueTestTags { - const val SCREEN = "report_issue_screen" - const val TITLE = "report_issue_title" - const val DESCRIPTION = "report_issue_description" - const val EMAIL_LABEL = "report_issue_email_label" - const val EMAIL_INPUT = "report_issue_email_input" - const val MESSAGE_LABEL = "report_issue_message_label" - const val MESSAGE_INPUT = "report_issue_message_input" - const val SEND_BUTTON = "report_issue_send_button" - const val CLOSE_BUTTON = "report_issue_close_button" -} - @Composable fun ReportIssueScreen( viewModel: ReportIssueViewModel = hiltViewModel(), @@ -158,6 +146,18 @@ fun ReportIssueContent( } } +object ReportIssueTestTags { + const val SCREEN = "report_issue_screen" + const val TITLE = "report_issue_title" + const val DESCRIPTION = "report_issue_description" + const val EMAIL_LABEL = "report_issue_email_label" + const val EMAIL_INPUT = "report_issue_email_input" + const val MESSAGE_LABEL = "report_issue_message_label" + const val MESSAGE_INPUT = "report_issue_message_input" + const val SEND_BUTTON = "report_issue_send_button" + const val CLOSE_BUTTON = "report_issue_close_button" +} + @Preview(showBackground = true) @Composable private fun Preview() { diff --git a/app/src/main/java/to/bitkit/ui/shared/util/ShareSheet.kt b/app/src/main/java/to/bitkit/ui/shared/util/ShareSheet.kt index c608f3bda..74c7ed5a6 100644 --- a/app/src/main/java/to/bitkit/ui/shared/util/ShareSheet.kt +++ b/app/src/main/java/to/bitkit/ui/shared/util/ShareSheet.kt @@ -8,6 +8,7 @@ import androidx.core.content.FileProvider import kotlinx.io.IOException import to.bitkit.R import to.bitkit.env.Env +import to.bitkit.utils.Logger import java.io.File import java.io.FileOutputStream @@ -47,7 +48,7 @@ fun shareQrCode(context: Context, bitmap: Bitmap, text: String) { val chooser = Intent.createChooser(intent, "Share Qr code via") context.startActivity(chooser) } catch (e: IOException) { - e.printStackTrace() + Logger.error("Failed to share QR code", e, context = "ShareSheet") // Fallback to text-only sharing shareText(context, text) } diff --git a/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionViewModel.kt b/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionViewModel.kt index e2b5d8004..e814bb10f 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionViewModel.kt @@ -73,7 +73,7 @@ class BoostTransactionViewModel @Inject constructor( private fun initializeFeeEstimates() { viewModelScope.launch { - try { + runCatching { val activityContent = activity?.v1 ?: run { handleError("Activity value is null") return@launch @@ -129,8 +129,8 @@ class BoostTransactionViewModel @Inject constructor( handleError("Failed to get fee estimates: ${error?.message}", error) } } - } catch (e: Exception) { - handleError("Unexpected error during fee estimation", e) + }.onFailure { + handleError("Unexpected error during fee estimation", it) } } } @@ -176,13 +176,13 @@ class BoostTransactionViewModel @Inject constructor( _uiState.update { it.copy(boosting = true) } viewModelScope.launch { - try { + runCatching { when (currentActivity.v1.txType) { PaymentType.SENT -> handleRbfBoost(currentActivity) PaymentType.RECEIVED -> handleCpfpBoost(currentActivity) } - } catch (e: Exception) { - handleError("Unexpected error during boost", e) + }.onFailure { + handleError("Unexpected error during boost", it) } } } diff --git a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt index 2998520c5..404647718 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt @@ -46,6 +46,7 @@ import to.bitkit.viewmodels.SendEffect import to.bitkit.viewmodels.SendEvent import to.bitkit.viewmodels.WalletViewModel +@Suppress("CyclomaticComplexMethod") @Composable fun SendSheet( appViewModel: AppViewModel, diff --git a/app/src/main/java/to/bitkit/ui/utils/BiometricPrompt.kt b/app/src/main/java/to/bitkit/ui/utils/BiometricPrompt.kt index c6a18f7f1..d223ad33f 100644 --- a/app/src/main/java/to/bitkit/ui/utils/BiometricPrompt.kt +++ b/app/src/main/java/to/bitkit/ui/utils/BiometricPrompt.kt @@ -57,6 +57,7 @@ fun BiometricPrompt( } } +@Suppress("LongParameterList") fun verifyBiometric( activity: Context, title: String, @@ -93,6 +94,7 @@ fun rememberBiometricAuthSupported(context: Context = LocalContext.current): Boo return remember(context) { isBiometricAuthSupported(context) } } +@Suppress("TooGenericExceptionCaught", "LongParameterList") private fun launchBiometricPrompt( activity: Context, title: String, diff --git a/app/src/main/java/to/bitkit/ui/utils/visualTransformation/MonetaryVisualTransformation.kt b/app/src/main/java/to/bitkit/ui/utils/visualTransformation/MonetaryVisualTransformation.kt index 6d18eec37..49ec3ce11 100644 --- a/app/src/main/java/to/bitkit/ui/utils/visualTransformation/MonetaryVisualTransformation.kt +++ b/app/src/main/java/to/bitkit/ui/utils/visualTransformation/MonetaryVisualTransformation.kt @@ -91,6 +91,7 @@ class MonetaryVisualTransformation( return if (endsWithDecimal) "$formatted." else formatted } + @Suppress("NestedBlockDepth", "LoopWithTooManyJumpStatements") private fun createOffsetMapping(original: String, transformed: String): OffsetMapping { return object : OffsetMapping { override fun originalToTransformed(offset: Int): Int { diff --git a/app/src/main/java/to/bitkit/utils/Crypto.kt b/app/src/main/java/to/bitkit/utils/Crypto.kt index d040500ff..30903e024 100644 --- a/app/src/main/java/to/bitkit/utils/Crypto.kt +++ b/app/src/main/java/to/bitkit/utils/Crypto.kt @@ -30,6 +30,7 @@ import javax.crypto.spec.SecretKeySpec import javax.inject.Inject import javax.inject.Singleton +@Suppress("SwallowedException", "MagicNumber", "TooGenericExceptionCaught") @Singleton class Crypto @Inject constructor() { @Suppress("ArrayInDataClass") diff --git a/app/src/main/java/to/bitkit/utils/Errors.kt b/app/src/main/java/to/bitkit/utils/Errors.kt index e72a5575f..b831c3439 100644 --- a/app/src/main/java/to/bitkit/utils/Errors.kt +++ b/app/src/main/java/to/bitkit/utils/Errors.kt @@ -23,6 +23,8 @@ sealed class ServiceError(message: String) : AppError(message) { class GeoBlocked : ServiceError("Geo blocked user") } +class HttpError(message: String, val code: Int = 500, cause: Throwable? = null) : AppError(message, cause) + // region ldk class LdkError(private val inner: LdkException) : AppError("Unknown LDK error.") { constructor(inner: BuildException) : this(LdkException.Build(inner)) diff --git a/app/src/main/java/to/bitkit/utils/timedsheets/sheets/AppUpdateTimedSheet.kt b/app/src/main/java/to/bitkit/utils/timedsheets/sheets/AppUpdateTimedSheet.kt index 919dbcb50..0ba35366b 100644 --- a/app/src/main/java/to/bitkit/utils/timedsheets/sheets/AppUpdateTimedSheet.kt +++ b/app/src/main/java/to/bitkit/utils/timedsheets/sheets/AppUpdateTimedSheet.kt @@ -18,21 +18,18 @@ class AppUpdateTimedSheet @Inject constructor( override val priority = 5 override suspend fun shouldShow(): Boolean = withContext(bgDispatcher) { - try { + runCatching { val androidReleaseInfo = appUpdaterService.getReleaseInfo().platforms.android val currentBuildNumber = BuildConfig.VERSION_CODE if (androidReleaseInfo.buildNumber <= currentBuildNumber) return@withContext false - - if (androidReleaseInfo.isCritical) { - return@withContext false - } - - return@withContext true - } catch (e: Exception) { - Logger.warn("Failure fetching new releases", e = e, context = TAG) - return@withContext false - } + if (androidReleaseInfo.isCritical) return@withContext false + }.onFailure { + Logger.warn("Failure fetching new releases", e = it, context = TAG) + }.fold( + onSuccess = { true }, + onFailure = { false }, + ) } override suspend fun onShown() { diff --git a/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt index 3c949489b..ed6db7e07 100644 --- a/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt @@ -200,7 +200,7 @@ class ActivityDetailViewModel @Inject constructor( channelId: String?, txId: String?, ): IBtOrder? = withContext(bgDispatcher) { - try { + runCatching { val orders = blocktankRepo.blocktankState.value.orders if (channelId != null) { @@ -214,10 +214,9 @@ class ActivityDetailViewModel @Inject constructor( } null - } catch (e: Exception) { - Logger.warn("Failed to find order for transfer: channelId=$channelId, txId=$txId", e, context = TAG) - null - } + }.onFailure { + Logger.warn("Failed to find order for transfer: channelId='$channelId', txId='$txId'", it, context = TAG) + }.getOrNull() } private companion object { diff --git a/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt index 97c6fe5d0..22c079054 100644 --- a/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt @@ -88,7 +88,9 @@ class ActivityListViewModel @Inject constructor( activityRepo.activitiesChanged, ) { debouncedSearch, filtersWithoutSearch, _ -> fetchFilteredActivities(filtersWithoutSearch.copy(searchText = debouncedSearch)) - }.collect { _filteredActivities.value = it } + }.collect { + _filteredActivities.value = it + } } private suspend fun refreshActivityState() { diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 2b3b2b3aa..12b48c6c1 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -120,7 +120,7 @@ import kotlin.coroutines.cancellation.CancellationException import kotlin.time.ExperimentalTime @OptIn(ExperimentalTime::class) -@Suppress("LongParameterList") +@Suppress("TooManyFunctions", "LargeClass", "LongParameterList") @HiltViewModel class AppViewModel @Inject constructor( connectivityRepo: ConnectivityRepo, @@ -616,6 +616,7 @@ class AppViewModel @Inject constructor( // region send + @Suppress("CyclomaticComplexMethod") private fun observeSendEvents() { viewModelScope.launch { sendEvents.collect { @@ -881,6 +882,7 @@ class AppViewModel @Inject constructor( } } + @Suppress("LongMethod") private suspend fun onScanOnchain(invoice: OnChainInvoice, scanResult: String) { val lnInvoice: LightningInvoice? = invoice.params?.get("lightning")?.let { bolt11 -> runCatching { decode(bolt11) }.getOrNull() @@ -1231,6 +1233,7 @@ class AppViewModel @Inject constructor( } } + @Suppress("LongMethod", "CyclomaticComplexMethod", "ReturnCount") private suspend fun handleSanityChecks(amountSats: ULong) { if (_sendUiState.value.showSanityWarningDialog != null) return @@ -1299,6 +1302,7 @@ class AppViewModel @Inject constructor( } } + @Suppress("LongMethod") private suspend fun proceedWithPayment() { delay(SCREEN_TRANSITION_DELAY_MS) // wait for screen transitions when applicable diff --git a/app/src/main/java/to/bitkit/viewmodels/DevSettingsViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/DevSettingsViewModel.kt index e8d10c8f6..fbd06bccc 100644 --- a/app/src/main/java/to/bitkit/viewmodels/DevSettingsViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/DevSettingsViewModel.kt @@ -29,7 +29,7 @@ import to.bitkit.repositories.WalletRepo import to.bitkit.ui.shared.toast.ToastEventBus import javax.inject.Inject -@Suppress("LongParameterList") +@Suppress("TooManyFunctions", "LongParameterList") @HiltViewModel class DevSettingsViewModel @Inject constructor( @ApplicationContext private val context: Context, diff --git a/app/src/main/java/to/bitkit/viewmodels/LogsViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/LogsViewModel.kt index 17f07bad6..472c99b5c 100644 --- a/app/src/main/java/to/bitkit/viewmodels/LogsViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/LogsViewModel.kt @@ -51,9 +51,10 @@ class LogsViewModel @Inject constructor( } } + @Suppress("TooGenericExceptionCaught") fun prepareLogForSharing(logFile: LogFile, onReady: (Uri) -> Unit) { viewModelScope.launch { - try { + runCatching { withContext(Dispatchers.IO) { val tempDir = application.externalCacheDir?.resolve("logs")?.apply { mkdirs() } ?: error("External cache dir is not available") @@ -71,8 +72,8 @@ class LogsViewModel @Inject constructor( onReady(contentUri) } } - } catch (e: Exception) { - Logger.error("Error preparing file for sharing", e) + }.onFailure { + Logger.error("Error preparing file for sharing", it) } } } diff --git a/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt index 925e4516d..ba3e42286 100644 --- a/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt @@ -13,6 +13,7 @@ import to.bitkit.data.SettingsStore import to.bitkit.models.TransactionSpeed import javax.inject.Inject +@Suppress("TooManyFunctions") @HiltViewModel class SettingsViewModel @Inject constructor( private val settingsStore: SettingsStore, diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index fe71fcdd7..9a83e0472 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -50,7 +50,7 @@ import kotlin.time.ExperimentalTime const val RETRY_INTERVAL_MS = 1 * 60 * 1000L // 1 minutes in ms const val GIVE_UP_MS = 30 * 60 * 1000L // 30 minutes in ms -@Suppress("LongParameterList") +@Suppress("TooManyFunctions", "LongParameterList") @OptIn(ExperimentalTime::class) @HiltViewModel class TransferViewModel @Inject constructor( @@ -218,50 +218,48 @@ class TransferViewModel @Inject constructor( } } - private suspend fun watchOrder(orderId: String): Result { + private suspend fun watchOrder(orderId: String): Result = runCatching { Logger.debug("Started watching order: '$orderId'", context = TAG) - try { - // Step 0: Starting - settingsStore.update { it.copy(lightningSetupStep = LN_SETUP_STEP_0) } - Logger.debug("LN setup step: $LN_SETUP_STEP_0", context = TAG) - delay(MIN_STEP_DELAY_MS) - - // Poll until payment is confirmed (order state becomes PAID or EXECUTED) - val paidOrder = pollUntil(orderId) { order -> - order.state2 == BtOrderState2.PAID || order.state2 == BtOrderState2.EXECUTED - } ?: return Result.failure(Exception("Order not found or expired")) - - // Step 1: Payment confirmed - settingsStore.update { it.copy(lightningSetupStep = LN_SETUP_STEP_1) } - Logger.debug("LN setup step: $LN_SETUP_STEP_1", context = TAG) - delay(MIN_STEP_DELAY_MS) - - // Try to open channel (idempotent - safe to call multiple times) - blocktankRepo.openChannel(paidOrder.id) - - // Step 2: Channel opening requested - settingsStore.update { it.copy(lightningSetupStep = LN_SETUP_STEP_2) } - Logger.debug("LN setup step: $LN_SETUP_STEP_2", context = TAG) - delay(MIN_STEP_DELAY_MS) - - // Poll until channel is ready (EXECUTED state or channel has state) - pollUntil(orderId) { order -> - order.state2 == BtOrderState2.EXECUTED || order.channel?.state != null - } ?: return Result.failure(Exception("Order not found or expired")) - - // Step 3: Complete - transferRepo.syncTransferStates() - settingsStore.update { it.copy(lightningSetupStep = LN_SETUP_STEP_3) } - Logger.debug("LN setup step: $LN_SETUP_STEP_3", context = TAG) - - Logger.debug("Order settled: '$orderId'", context = TAG) - return Result.success(true) - } catch (e: Throwable) { - Logger.error("Failed to watch order: '$orderId'", e, context = TAG) - return Result.failure(e) - } finally { - Logger.debug("Stopped watching order: '$orderId'", context = TAG) - } + + // Step 0: Starting + settingsStore.update { it.copy(lightningSetupStep = LN_SETUP_STEP_0) } + Logger.debug("LN setup step: $LN_SETUP_STEP_0", context = TAG) + delay(MIN_STEP_DELAY_MS) + + // Poll until payment is confirmed (order state becomes PAID or EXECUTED) + val paidOrder = pollUntil(orderId) { order -> + order.state2 == BtOrderState2.PAID || order.state2 == BtOrderState2.EXECUTED + } ?: return Result.failure(Exception("Order not found or expired")) + + // Step 1: Payment confirmed + settingsStore.update { it.copy(lightningSetupStep = LN_SETUP_STEP_1) } + Logger.debug("LN setup step: $LN_SETUP_STEP_1", context = TAG) + delay(MIN_STEP_DELAY_MS) + + // Try to open channel (idempotent - safe to call multiple times) + blocktankRepo.openChannel(paidOrder.id) + + // Step 2: Channel opening requested + settingsStore.update { it.copy(lightningSetupStep = LN_SETUP_STEP_2) } + Logger.debug("LN setup step: $LN_SETUP_STEP_2", context = TAG) + delay(MIN_STEP_DELAY_MS) + + // Poll until channel is ready (EXECUTED state or channel has state) + pollUntil(orderId) { order -> + order.state2 == BtOrderState2.EXECUTED || order.channel?.state != null + } ?: return Result.failure(Exception("Order not found or expired")) + + // Step 3: Complete + transferRepo.syncTransferStates() + settingsStore.update { it.copy(lightningSetupStep = LN_SETUP_STEP_3) } + Logger.debug("LN setup step: $LN_SETUP_STEP_3", context = TAG) + + Logger.debug("Order settled: '$orderId'", context = TAG) + return@runCatching true + }.onFailure { + Logger.error("Failed to watch order: '$orderId'", it, context = TAG) + }.also { + Logger.debug("Stopped watching order: '$orderId'", context = TAG) } private suspend fun pollUntil(orderId: String, condition: (IBtOrder) -> Boolean): IBtOrder? { diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index d0f02d49c..c748fa0a5 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -43,6 +43,7 @@ import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds +@Suppress("TooManyFunctions", "LongParameterList") @HiltViewModel class WalletViewModel @Inject constructor( @ApplicationContext private val context: Context, diff --git a/app/src/test/java/to/bitkit/utils/CryptoTest.kt b/app/src/test/java/to/bitkit/utils/CryptoTest.kt index 09c2c011f..ebbf8c7ee 100644 --- a/app/src/test/java/to/bitkit/utils/CryptoTest.kt +++ b/app/src/test/java/to/bitkit/utils/CryptoTest.kt @@ -1,3 +1,5 @@ +@file:Suppress("SpacingBetweenDeclarationsWithAnnotations", "Wrapping") + package to.bitkit.utils import org.junit.Before @@ -84,10 +86,15 @@ class CryptoTest { val serverPublicKey = "031e9923e689a181a803486b7d8c0d4a5aad360edb70c8bb413a98458d91652213" val derivationName = "bitkit-notifications" - val ciphertext = "l2fInfyw64gO12odo8iipISloQJ45Rc4WjFmpe95brdaAMDq+T/L9ZChcmMCXnR0J6BXd8sSIJe/0bmby8uSZZJuVCzwF76XHfY5oq0Y1/hKzyZTn8nG3dqfiLHnAPy1tZFQfm5ALgjwWnViYJLXoGFpXs7kLMA=".fromBase64() + @Suppress("MaxLineLength") + val ciphertext = ("l2fInfyw64gO12odo8iipISloQJ45Rc4WjFmpe95brdaAMDq+T/L9ZChcmMCXnR0J6BXd8sSI" + + "Je/0bmby8uSZZJuVCzwF76XHfY5oq0Y1/hKzyZTn8nG3dqfiLHnAPy1tZFQfm5ALgjwWnViYJLXoGFpXs7kLMA=") + .fromBase64() val iv = "2b8ed77fd2198e3ed88cfaa794a246e8" val tag = "caddd13746d6a6aed16176734964d3a3" - val decryptedPayload = """{"source":"blocktank","type":"incomingHtlc","payload":{"secretMessage":"hello"},"createdAt":"2024-09-18T13:33:52.555Z"}""" + @Suppress("MaxLineLength") + val decryptedPayload = """{"source":"blocktank","type":"incomingHtlc","payload":""" + + """{"secretMessage":"hello"},"createdAt":"2024-09-18T13:33:52.555Z"}""" // Without derivationName val sharedSecret = sut.generateSharedSecret(clientPrivateKey.fromHex(), serverPublicKey) From b8ea39858207ae6bf921f65051e91e83c05fe4f1 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 13 Jan 2026 21:31:43 +0100 Subject: [PATCH 26/37] chore: update detekt.yml --- config/detekt/detekt.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml index e0b85d0ef..84c4ea9ae 100644 --- a/config/detekt/detekt.yml +++ b/config/detekt/detekt.yml @@ -652,6 +652,7 @@ style: - '128' - '250' - '256' + - '300' - '500' - '512' - '1000' From f4125ff9d2dfc5dbf2775d1b9f85cda1f720420d Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 13 Jan 2026 21:31:53 +0100 Subject: [PATCH 27/37] chore: update ai rules --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index df87d9b86..6e7c93a5c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -207,6 +207,7 @@ suspend fun getData(): Result = withContext(Dispatchers.IO) { - ALWAYS wrap `ULong` numbers with `USat` in arithmetic operations, to guard against overflows - PREFER to use one-liners with `run { }` when applicable, e.g. `override fun someCall(value: String) = run { this.value = value }` - ALWAYS add imports instead of inline fully-qualified names +- NEVER use file-level `@file:Suppress(...)` annotations; ALWAYS add `@Suppress(...)` as close as possible to the erroring lines (function, property, or statement level) ### Architecture Guidelines From b955a4e30f2022d9963b3328c62acd95dc0a7966 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 14 Jan 2026 02:38:41 +0100 Subject: [PATCH 28/37] chore: cleanup LN repo and service --- .../to/bitkit/repositories/LightningRepo.kt | 201 +++++++----------- .../to/bitkit/services/LightningService.kt | 31 ++- 2 files changed, 92 insertions(+), 140 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 7a5118734..3b7f4de65 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -393,9 +393,8 @@ class LightningRepo @Inject constructor( } } - @Suppress("TooGenericExceptionCaught") private suspend fun registerClosedChannel(channelId: String, reason: ClosureReason?) = withContext(bgDispatcher) { - try { + runCatching { val channel = channelCache[channelId] ?: run { Logger.error("Could not find channel details for closed channel: channelId=$channelId", context = TAG) return@withContext @@ -437,115 +436,105 @@ class LightningRepo @Inject constructor( channelCache.remove(channelId) Logger.info("Registered closed channel: ${channel.userChannelId}", context = TAG) - } catch (e: Throwable) { - Logger.error("Failed to register closed channel: $e", e, context = TAG) + }.onFailure { + Logger.error("Failed to register closed channel: $it", it, context = TAG) } } @Suppress("TooGenericExceptionCaught") suspend fun wipeStorage(walletIndex: Int): Result = withContext(bgDispatcher) { Logger.debug("wipeStorage called, stopping node first", context = TAG) - stop().onSuccess { - return@withContext try { - Logger.debug("node stopped, calling wipeStorage", context = TAG) - lightningService.wipeStorage(walletIndex) - _lightningState.update { - LightningState( - nodeStatus = it.nodeStatus, - nodeLifecycleState = it.nodeLifecycleState, - ) - } - setRecoveryMode(false) - Result.success(Unit) - } catch (e: Throwable) { - Logger.error("Wipe storage error", e, context = TAG) - Result.failure(e) + stop().mapCatching { + Logger.debug("node stopped, calling wipeStorage", context = TAG) + lightningService.wipeStorage(walletIndex) + _lightningState.update { + LightningState( + nodeStatus = it.nodeStatus, + nodeLifecycleState = it.nodeLifecycleState, + ) } - }.onFailure { e -> - return@withContext Result.failure(e) + setRecoveryMode(false) + }.onFailure { + Logger.error("wipeStorage error", it, context = TAG) } } suspend fun restartWithElectrumServer(newServerUrl: String): Result = withContext(bgDispatcher) { - Logger.info("Changing ldk-node electrum server to: '$newServerUrl'") + Logger.info("Changing ldk-node electrum server to: '$newServerUrl'", context = TAG) waitForNodeToStop().onFailure { return@withContext Result.failure(it) } stop().onFailure { - Logger.error("Failed to stop node during electrum server change", it) + Logger.error("Failed to stop node during electrum server change", it, context = TAG) return@withContext Result.failure(it) } - Logger.debug("Starting node with new electrum server: '$newServerUrl'") + Logger.debug("Starting node with new electrum server: '$newServerUrl'", context = TAG) start( shouldRetry = false, customServerUrl = newServerUrl, - ).onFailure { startError -> - Logger.warn("Failed ldk-node config change, attempting recovery…") + ).onFailure { + Logger.warn("Failed ldk-node config change, attempting recovery…", context = TAG) restartWithPreviousConfig() - return@withContext Result.failure(startError) }.onSuccess { settingsStore.update { it.copy(electrumServer = newServerUrl) } - Logger.info("Successfully changed electrum server") - return@withContext Result.success(Unit) + Logger.info("Successfully changed electrum server", context = TAG) } } suspend fun restartWithRgsServer(newRgsUrl: String): Result = withContext(bgDispatcher) { - Logger.info("Changing ldk-node RGS server to: '$newRgsUrl'") + Logger.info("Changing ldk-node RGS server to: '$newRgsUrl'", context = TAG) waitForNodeToStop().onFailure { return@withContext Result.failure(it) } stop().onFailure { - Logger.error("Failed to stop node during RGS server change", it) + Logger.error("Failed to stop node during RGS server change", it, context = TAG) return@withContext Result.failure(it) } - Logger.debug("Starting node with new RGS server: '$newRgsUrl'") + Logger.debug("Starting node with new RGS server: '$newRgsUrl'", context = TAG) start( shouldRetry = false, customRgsServerUrl = newRgsUrl, - ).onFailure { startError -> - Logger.warn("Failed ldk-node config change, attempting recovery…") + ).onFailure { + Logger.warn("Failed ldk-node config change, attempting recovery…", context = TAG) restartWithPreviousConfig() - return@withContext Result.failure(startError) }.onSuccess { settingsStore.update { it.copy(rgsServerUrl = newRgsUrl) } - Logger.info("Successfully changed RGS server") - return@withContext Result.success(Unit) + Logger.info("Successfully changed RGS server", context = TAG) } } private suspend fun restartWithPreviousConfig(): Result = withContext(bgDispatcher) { - Logger.debug("Stopping node for recovery attempt") + Logger.debug("Stopping node for recovery attempt", context = TAG) stop().onFailure { e -> - Logger.error("Failed to stop node during recovery", e) + Logger.error("Failed to stop node during recovery", e, context = TAG) return@withContext Result.failure(e) } - Logger.debug("Starting node with previous config for recovery") + Logger.debug("Starting node with previous config for recovery", context = TAG) start( shouldRetry = false, ).onSuccess { - Logger.debug("Successfully started node with previous config") - }.onFailure { e -> - Logger.error("Failed starting node with previous config", e) + Logger.debug("Successfully started node with previous config", context = TAG) + }.onFailure { + Logger.error("Failed starting node with previous config", it, context = TAG) } } private suspend fun waitForNodeToStop(): Result = withContext(bgDispatcher) { if (_lightningState.value.nodeLifecycleState == NodeLifecycleState.Stopping) { - Logger.debug("Waiting for node to stop…") + Logger.debug("Waiting for node to stop…", context = TAG) val stopped = withTimeoutOrNull(30.seconds) { _lightningState.first { it.nodeLifecycleState == NodeLifecycleState.Stopped } } if (stopped == null) { val error = NodeStopTimeoutException() - Logger.warn(error.message) + Logger.warn(error.message, context = TAG) return@withContext Result.failure(error) } } @@ -558,17 +547,15 @@ class LightningRepo @Inject constructor( } suspend fun connectPeer(peer: PeerDetails): Result = executeWhenNodeRunning("connectPeer") { - lightningService.connectPeer(peer).onFailure { e -> - return@executeWhenNodeRunning Result.failure(e) + lightningService.connectPeer(peer).map { + syncState() } - syncState() - Result.success(Unit) } suspend fun disconnectPeer(peer: PeerDetails): Result = executeWhenNodeRunning("disconnectPeer") { - lightningService.disconnectPeer(peer) - syncState() - Result.success(Unit) + lightningService.disconnectPeer(peer).map { + syncState() + } } suspend fun newAddress(): Result = executeWhenNodeRunning("newAddress") { @@ -586,6 +573,7 @@ class LightningRepo @Inject constructor( Result.success(invoice) } + @Suppress("ForbiddenComment") suspend fun fetchLnurlInvoice( callbackUrl: String, amountSats: ULong, @@ -597,7 +585,11 @@ class LightningRepo @Inject constructor( val decoded = (decode(bolt11) as Scanner.Lightning).invoice return@runCatching decoded }.onFailure { - Logger.error("Error fetching lnurl invoice, url: $callbackUrl, amount: $amountSats, comment: $comment", it) + Logger.error( + "fetchLnurlInvoice error, url: $callbackUrl, amount: $amountSats, comment: $comment", + it, + context = TAG, + ) } } @@ -606,8 +598,8 @@ class LightningRepo @Inject constructor( callback: String, paymentRequest: String, ): Result = executeWhenNodeRunning("requestLnurlWithdraw") { - val callbackUrl = createWithdrawCallbackUrl(k1 = k1, callback = callback, paymentRequest = paymentRequest) - Logger.debug("handleLnurlWithdraw callbackUrl generated: '$callbackUrl'") + val callbackUrl = createWithdrawCallbackUrl(k1, callback, paymentRequest) + Logger.debug("handleLnurlWithdraw callbackUrl generated: '$callbackUrl'", context = TAG) lnurlService.requestLnurlWithdraw(callbackUrl) } @@ -645,11 +637,11 @@ class LightningRepo @Inject constructor( bip39Passphrase = passphrase, ) - Logger.debug("LNURL auth result: '$result'") + Logger.debug("LNURL auth result: '$result'", context = TAG) return@runCatching result }.onFailure { - Logger.error("Error requesting lnurl auth, k1: $k1, callback: $callback, domain: $domain", it) + Logger.error("Error requesting lnurl auth, k1: $k1, callback: $callback, domain: $domain", it, context = TAG) } suspend fun payInvoice( @@ -720,7 +712,6 @@ class LightningRepo @Inject constructor( val settings = settingsStore.data.first() if (settings.coinSelectAuto) { val coinSelectionPreference = settings.coinSelectPreference - val allSpendableUtxos = lightningService.listSpendableOutputs().getOrThrow() if (coinSelectionPreference == CoinSelectionPreference.Consolidate) { @@ -891,9 +882,8 @@ class LightningRepo @Inject constructor( channels: List, ): Pair, List> = lightningService.separateTrustedChannels(channels) - @Suppress("TooGenericExceptionCaught") suspend fun registerForNotifications(token: String? = null) = executeWhenNodeRunning("registerForNotifications") { - return@executeWhenNodeRunning try { + runCatching { val token = token ?: firebaseMessaging.token.await() val cachedToken = keychain.loadString(Keychain.Key.PUSH_NOTIFICATION_TOKEN.name) @@ -905,32 +895,20 @@ class LightningRepo @Inject constructor( } lspNotificationsService.registerDevice(token) - Result.success(Unit) - } catch (e: Throwable) { - Logger.error("Register for notifications error", e) - Result.failure(e) + }.onFailure { + Logger.error("Register for notifications error", it) } } fun registerForNotificationsAsync(token: String) = scope.launch { registerForNotifications(token) } - @Suppress("TooGenericExceptionCaught") suspend fun bumpFeeByRbf( originalTxId: Txid, satsPerVByte: ULong, ): Result = executeWhenNodeRunning("bumpFeeByRbf") { - try { - if (originalTxId.isBlank()) { - return@executeWhenNodeRunning Result.failure( - IllegalArgumentException("originalTxId is null or empty: $originalTxId") - ) - } - - if (satsPerVByte <= 0u) { - return@executeWhenNodeRunning Result.failure( - IllegalArgumentException("satsPerVByte invalid: $satsPerVByte") - ) - } + runCatching { + require(!originalTxId.isBlank()) { "originalTxId is null or empty: $originalTxId" } + require(satsPerVByte > 0u) { "satsPerVByte invalid: $satsPerVByte" } val replacementTxId = lightningService.bumpFeeByRbf( txid = originalTxId, @@ -938,43 +916,26 @@ class LightningRepo @Inject constructor( ) Logger.debug( "bumpFeeByRbf success, replacementTxId: $replacementTxId " + - "originalTxId: $originalTxId, satsPerVByte: $satsPerVByte" + "originalTxId: $originalTxId, satsPerVByte: $satsPerVByte", + context = TAG, ) - Result.success(replacementTxId) - } catch (e: Throwable) { + return@runCatching replacementTxId + }.onFailure { Logger.error( - "bumpFeeByRbf error originalTxId: $originalTxId, satsPerVByte: $satsPerVByte", - e, - context = TAG + "bumpFeeByRbf error originalTxId: $originalTxId, satsPerVByte: $satsPerVByte", it, context = TAG, ) - Result.failure(e) } } - @Suppress("TooGenericExceptionCaught") suspend fun accelerateByCpfp( originalTxId: Txid, satsPerVByte: ULong, destinationAddress: Address, ): Result = executeWhenNodeRunning("accelerateByCpfp") { - try { - if (originalTxId.isBlank()) { - return@executeWhenNodeRunning Result.failure( - IllegalArgumentException("originalTxId is null or empty: $originalTxId") - ) - } - - if (destinationAddress.isBlank()) { - return@executeWhenNodeRunning Result.failure( - IllegalArgumentException("destinationAddress is null or empty: $destinationAddress") - ) - } - - if (satsPerVByte <= 0u) { - return@executeWhenNodeRunning Result.failure( - IllegalArgumentException("satsPerVByte invalid: $satsPerVByte") - ) - } + runCatching { + require(!originalTxId.isBlank()) { "originalTxId is null or empty: $originalTxId" } + require(!destinationAddress.isBlank()) { "destinationAddress is null or empty: $destinationAddress" } + require(satsPerVByte > 0u) { "satsPerVByte invalid: $satsPerVByte" } val newDestinationTxId = lightningService.accelerateByCpfp( txid = originalTxId, @@ -986,39 +947,37 @@ class LightningRepo @Inject constructor( "originalTxId: $originalTxId, satsPerVByte: $satsPerVByte " + "destinationAddress: $destinationAddress" ) - Result.success(newDestinationTxId) - } catch (e: Throwable) { + return@runCatching newDestinationTxId + }.onFailure { Logger.error( "accelerateByCpfp error originalTxId: $originalTxId, " + "satsPerVByte: $satsPerVByte destinationAddress: $destinationAddress", - e, - context = TAG + it, + context = TAG, ) - Result.failure(e) } } - suspend fun estimateRoutingFees(bolt11: String): Result = - executeWhenNodeRunning("estimateRoutingFees") { - Logger.info("Estimating routing fees for bolt11: $bolt11") - lightningService.estimateRoutingFees(bolt11) - .onSuccess { - Logger.info("Routing fees estimated: $it") - } - .onFailure { - Logger.error("Routing fees estimation failed", it) - } - } + suspend fun estimateRoutingFees(bolt11: String): Result = executeWhenNodeRunning("estimateRoutingFees") { + Logger.info("Estimating routing fees for bolt11: $bolt11") + lightningService.estimateRoutingFees(bolt11) + .onSuccess { + Logger.info("Routing fees estimated: $it", context = TAG) + } + .onFailure { + Logger.error("Routing fees estimation failed", it, context = TAG) + } + } suspend fun estimateRoutingFeesForAmount(bolt11: String, amountSats: ULong): Result = executeWhenNodeRunning("estimateRoutingFeesForAmount") { Logger.info("Estimating routing fees for amount: $amountSats") lightningService.estimateRoutingFeesForAmount(bolt11, amountSats) .onSuccess { - Logger.info("Routing fees estimated: $it") + Logger.info("Routing fees estimated: $it", context = TAG) } .onFailure { - Logger.error("Routing fees estimation failed", it) + Logger.error("Routing fees estimation failed", it, context = TAG) } } diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index 9ccc3134f..7fca32e7b 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -323,14 +323,12 @@ class LightningService @Inject constructor( suspend fun connectPeer(peer: PeerDetails): Result { val node = this.node ?: throw ServiceError.NodeNotSetup() val uri = peer.uri + return ServiceQueue.LDK.background { try { Logger.debug("Connecting peer: $uri", context = TAG) - node.connect(peer.nodeId, peer.address, persist = true) - Logger.info("Peer connected: $uri", context = TAG) - Result.success(Unit) } catch (e: NodeException) { val error = LdkError(e) @@ -340,31 +338,26 @@ class LightningService @Inject constructor( } } - suspend fun disconnectPeer(peer: PeerDetails) { + suspend fun disconnectPeer(peer: PeerDetails): Result { val node = this.node ?: throw ServiceError.NodeNotSetup() val uri = peer.uri - Logger.debug("Disconnecting peer: $uri", context = TAG) - try { - ServiceQueue.LDK.background { + + return ServiceQueue.LDK.background { + try { + Logger.debug("Disconnecting peer: $uri", context = TAG) node.disconnect(peer.nodeId) + Logger.info("Peer disconnected: $uri", context = TAG) + Result.success(Unit) + } catch (e: NodeException) { + Logger.warn("Peer disconnect error: $uri", LdkError(e), context = TAG) + Result.failure(e) } - Logger.info("Peer disconnected: $uri", context = TAG) - } catch (e: NodeException) { - Logger.warn("Peer disconnect error: $uri", LdkError(e), context = TAG) } } - fun hasExternalPeers(): Boolean { - val ourPeers = this.peers.orEmpty().map { it.uri } - val lspPeers = this.trustedPeers.map { it.uri }.toSet() - return ourPeers.any { p -> p !in lspPeers } - } - fun getLspPeerNodeIds(): Set = trustedPeers.map { it.nodeId }.toSet() - fun separateTrustedChannels( - channels: List, - ): Pair, List> { + fun separateTrustedChannels(channels: List): Pair, List> { val trustedPeerIds = getLspPeerNodeIds() val trusted = channels.filter { it.counterpartyNodeId in trustedPeerIds } val nonTrusted = channels.filter { it.counterpartyNodeId !in trustedPeerIds } From 9f7a62f974a12b742fae0190132a8cb5dd0fc1b2 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 14 Jan 2026 02:57:59 +0100 Subject: [PATCH 29/37] chore: review --- .../wallets/send/SendAmountContentTest.kt | 2 +- .../to/bitkit/repositories/ActivityRepo.kt | 236 ++++++++---------- .../to/bitkit/repositories/LightningRepo.kt | 20 +- .../viewmodels/ActivityListViewModel.kt | 13 +- .../to/bitkit/viewmodels/WalletViewModel.kt | 206 +++++++-------- app/src/main/res/values/strings.xml | 1 + .../bitkit/repositories/LightningRepoTest.kt | 2 +- .../test/java/to/bitkit/utils/CryptoTest.kt | 3 +- 8 files changed, 224 insertions(+), 259 deletions(-) diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendAmountContentTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendAmountContentTest.kt index 2bde503cf..b0f6392b9 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendAmountContentTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendAmountContentTest.kt @@ -46,7 +46,7 @@ class SendAmountContentTest { fun whenNodeNotRunning_shouldShowSyncView() { composeTestRule.setContent { SendAmountContent( - nodeLifecycleState = nodeLifecycleState, + nodeLifecycleState = NodeLifecycleState.Initializing, uiState = uiState, amountInputViewModel = previewAmountInputViewModel(), ) diff --git a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt index 0dd8dc97e..7fb9a5b85 100644 --- a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt @@ -36,6 +36,7 @@ import to.bitkit.ext.nowTimestamp import to.bitkit.ext.rawId import to.bitkit.models.ActivityBackupV1 import to.bitkit.services.CoreService +import to.bitkit.utils.AppError import to.bitkit.utils.Logger import javax.inject.Inject import javax.inject.Singleton @@ -43,7 +44,7 @@ import kotlin.time.Clock import kotlin.time.ExperimentalTime import com.synonym.bitkitcore.TransactionDetails as BitkitCoreTransactionDetails -private const val SYNC_TIMEOUT_MS = 40_000L +private const val MS_SYNC_TIMEOUT = 40_000L @Suppress("LargeClass", "LongParameterList", "TooManyFunctions") @OptIn(ExperimentalTime::class) @@ -78,7 +79,7 @@ class ActivityRepo @Inject constructor( Logger.debug("syncActivities called", context = TAG) val result = runCatching { - withTimeout(SYNC_TIMEOUT_MS) { + withTimeout(MS_SYNC_TIMEOUT) { Logger.debug("isSyncingLdkNodePayments = ${isSyncingLdkNodePayments.value}", context = TAG) isSyncingLdkNodePayments.first { !it } } @@ -111,12 +112,12 @@ class ActivityRepo @Inject constructor( * Syncs `ldk-node` [PaymentDetails] list to `bitkit-core` [Activity] items. */ suspend fun syncLdkNodePayments(payments: List): Result = withContext(bgDispatcher) { - return@withContext runCatching { + runCatching { val channelIdsByTxId = findChannelsForPayments(payments) coreService.activity.syncLdkNodePaymentsToActivities(payments, channelIdsByTxId = channelIdsByTxId) notifyActivitiesChanged() - }.onFailure { e -> - Logger.error("Error syncing LDK payments:", e, context = TAG) + }.onFailure { + Logger.error("Error syncing LDK payments:", it, context = TAG) } } @@ -136,36 +137,33 @@ class ActivityRepo @Inject constructor( return@withContext channelIdsByTxId } - private suspend fun findChannelForTransaction(txid: String, direction: PaymentDirection): String? { - return if (direction == PaymentDirection.OUTBOUND) { - findOpenChannelForTransaction(txid) - } else { - findClosedChannelForTransaction(txid) - } - } - - private fun findOpenChannelForTransaction(txid: String): String? { - return runCatching { - val channels = lightningRepo.lightningState.value.channels - if (channels.isEmpty()) return null - - channels.firstOrNull { channel -> channel.fundingTxo?.txid == txid } - ?.channelId - ?: run { - val orders = blocktankRepo.blocktankState.value.orders - val matchingOrder = orders.firstOrNull { order -> - order.payment?.onchain?.transactions?.any { it.txId == txid } == true - } ?: return null - - val orderChannel = matchingOrder.channel ?: return null - channels.firstOrNull { channel -> - channel.fundingTxo?.txid == orderChannel.fundingTx.id - }?.channelId - } - }.onFailure { - Logger.warn("Failed to find open channel for transaction: '$txid'", it, context = TAG) - }.getOrNull() - } + private suspend fun findChannelForTransaction( + txid: String, + direction: PaymentDirection, + ): String? = if (direction == PaymentDirection.OUTBOUND) { + findOpenChannelForTransaction(txid) + } else { + findClosedChannelForTransaction(txid) + } + + private fun findOpenChannelForTransaction(txid: String): String? = runCatching { + val channels = lightningRepo.lightningState.value.channels + if (channels.isEmpty()) return null + + channels.firstOrNull { channel -> channel.fundingTxo?.txid == txid } + ?.channelId + ?: run { + val orders = blocktankRepo.blocktankState.value.orders + val matchingOrder = orders.firstOrNull { order -> + order.payment?.onchain?.transactions?.any { it.txId == txid } == true + } ?: return null + + val orderChannel = matchingOrder.channel ?: return null + channels.firstOrNull { it.fundingTxo?.txid == orderChannel.fundingTx.id }?.channelId + } + }.onFailure { + Logger.warn("Failed to find open channel for transaction: '$txid'", it, context = TAG) + }.getOrNull() private suspend fun findClosedChannelForTransaction(txid: String): String? = coreService.activity.findClosedChannelForTransaction(txid, null) @@ -178,9 +176,7 @@ class ActivityRepo @Inject constructor( */ suspend fun isReceivedTransaction(txid: String): Boolean = withContext(bgDispatcher) { lightningRepo.getPayments().getOrNull()?.let { payments -> - payments.firstOrNull { payment -> - (payment.kind as? PaymentKind.Onchain)?.txid == txid - } + payments.firstOrNull { (it.kind as? PaymentKind.Onchain)?.txid == txid } }?.direction == PaymentDirection.INBOUND } @@ -254,13 +250,9 @@ class ActivityRepo @Inject constructor( return coreService.activity.getBoostTxDoesExist(boostTxIds) } - suspend fun isCpfpChildTransaction(txId: String): Boolean { - return coreService.activity.isCpfpChildTransaction(txId) - } + suspend fun isCpfpChildTransaction(txId: String): Boolean = coreService.activity.isCpfpChildTransaction(txId) - suspend fun getTxIdsInBoostTxIds(): Set { - return coreService.activity.getTxIdsInBoostTxIds() - } + suspend fun getTxIdsInBoostTxIds(): Set = coreService.activity.getTxIdsInBoostTxIds() /** * Gets a specific activity by payment hash or txID with retry logic @@ -290,7 +282,7 @@ class ActivityRepo @Inject constructor( syncActivities().onSuccess { Logger.debug( "Sync success, searching again the activity with paymentHashOrTxId:'$paymentHashOrTxId'", - context = TAG + context = TAG, ) activity = findActivity() } @@ -300,7 +292,7 @@ class ActivityRepo @Inject constructor( }.onFailure { Logger.error( "findActivityByPaymentId error (paymentHashOrTxId:'$paymentHashOrTxId' type:'$type' txType:'$txType')", - context = TAG + context = TAG, ) } } @@ -315,9 +307,9 @@ class ActivityRepo @Inject constructor( limit: UInt? = null, sortDirection: SortDirection? = null, ): Result> = withContext(bgDispatcher) { - return@withContext runCatching { + runCatching { coreService.activity.get(filter, txType, tags, search, minDate, maxDate, limit, sortDirection) - }.onFailure { e -> + }.onFailure { Logger.error( "getActivities error. Parameters:" + "\nfilter:$filter " + @@ -328,27 +320,27 @@ class ActivityRepo @Inject constructor( "maxDate:$maxDate " + "limit:$limit " + "sortDirection:$sortDirection", - e = e, - context = TAG + it, + context = TAG, ) } } suspend fun getActivity(id: String): Result = withContext(bgDispatcher) { - return@withContext runCatching { + runCatching { coreService.activity.getActivity(id) - }.onFailure { e -> - Logger.error("getActivity error for ID: $id", e, context = TAG) + }.onFailure { + Logger.error("getActivity error for ID: $id", it, context = TAG) } } suspend fun getClosedChannels( sortDirection: SortDirection = SortDirection.ASC, ): Result> = withContext(bgDispatcher) { - return@withContext runCatching { + runCatching { coreService.activity.closedChannels(sortDirection) - }.onFailure { e -> - Logger.error("Error getting closed channels (sortDirection=$sortDirection)", e, context = TAG) + }.onFailure { + Logger.error("Error getting closed channels (sortDirection=$sortDirection)", it, context = TAG) } } @@ -361,7 +353,7 @@ class ActivityRepo @Inject constructor( activity: Activity, forceUpdate: Boolean = false, ): Result = withContext(bgDispatcher) { - return@withContext runCatching { + runCatching { if (id in cacheStore.data.first().deletedActivities && !forceUpdate) { Logger.debug("Activity $id was deleted", context = TAG) return@withContext Result.failure( @@ -372,8 +364,8 @@ class ActivityRepo @Inject constructor( } coreService.activity.update(id, activity) notifyActivitiesChanged() - }.onFailure { e -> - Logger.error("updateActivity error for ID: $id", e, context = TAG) + }.onFailure { + Logger.error("updateActivity error for ID: $id", it, context = TAG) } } @@ -387,31 +379,20 @@ class ActivityRepo @Inject constructor( activityIdToDelete: String, activity: Activity, ): Result = withContext(bgDispatcher) { - return@withContext updateActivity( + updateActivity( id = id, activity = activity - ).fold( - onSuccess = { - Logger.debug( - "Activity $id updated with success. new data: $activity", - context = TAG - ) - - val tags = coreService.activity.tags(activityIdToDelete) - addTagsToActivity(activityId = id, tags = tags) - - Result.success(Unit) - }, - onFailure = { e -> - Logger.error( - "Update activity fail. Parameters: id:$id, " + - "activityIdToDelete:$activityIdToDelete activity:$activity", - e = e, - context = TAG - ) - Result.failure(e) - } - ) + ).onSuccess { + Logger.debug("Activity $id updated with success. new data: $activity", context = TAG) + val tags = coreService.activity.tags(activityIdToDelete) + addTagsToActivity(activityId = id, tags = tags) + }.onFailure { + Logger.error( + "updateActivity error: id:$id, activityIdToDelete:$activityIdToDelete activity:$activity", + it, + context = TAG, + ) + } } private suspend fun boostPendingActivities() = withContext(bgDispatcher) { @@ -467,7 +448,7 @@ class ActivityRepo @Inject constructor( } suspend fun deleteActivity(id: String): Result = withContext(bgDispatcher) { - return@withContext runCatching { + runCatching { val deleted = coreService.activity.delete(id) if (deleted) { cacheStore.addActivityToDeletedList(id) @@ -475,34 +456,35 @@ class ActivityRepo @Inject constructor( } else { return@withContext Result.failure(Exception("Activity not deleted")) } - }.onFailure { e -> - Logger.error("deleteActivity error for ID: $id", e, context = TAG) + }.onFailure { + Logger.error("deleteActivity error for ID: $id", it, context = TAG) } } suspend fun insertActivity(activity: Activity): Result = withContext(bgDispatcher) { - return@withContext runCatching { + 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.insert(activity) notifyActivitiesChanged() - }.onFailure { e -> - Logger.error("insertActivity error", e, context = TAG) + }.onFailure { + Logger.error("insertActivity error", it, context = TAG) } } 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")) + runCatching { + val id = activity.rawId() + if (id in cacheStore.data.first().deletedActivities) { + Logger.debug("Activity $id was deleted, skipping", context = TAG) + return@withContext Result.failure(AppError("Activity $id was deleted")) } coreService.activity.upsert(activity) notifyActivitiesChanged() - }.onFailure { e -> - Logger.error("upsertActivity error", e, context = TAG) + }.onFailure { + Logger.error("upsertActivity error", it, context = TAG) } } @@ -515,11 +497,9 @@ class ActivityRepo @Inject constructor( ): Result = withContext(bgDispatcher) { runCatching { requireNotNull(cjitEntry) - val amount = channel.amountOnClose val now = nowTimestamp().epochSecond.toULong() - - return@withContext insertActivity( + insertActivity( Activity.Lightning( LightningActivity( id = channel.fundingTxo?.txid.orEmpty(), @@ -536,9 +516,9 @@ class ActivityRepo @Inject constructor( seenAt = null, ) ) - ) - }.onFailure { e -> - Logger.error("insertActivity error", e, context = TAG) + ).getOrThrow() + }.onFailure { + Logger.error("insertActivity error", it, context = TAG) } } @@ -551,7 +531,7 @@ class ActivityRepo @Inject constructor( activityId: String, tags: List, ): Result = withContext(bgDispatcher) { - return@withContext runCatching { + runCatching { checkNotNull(coreService.activity.getActivity(activityId)) { "Activity with ID $activityId not found" } val existingTags = coreService.activity.tags(activityId) @@ -564,8 +544,8 @@ class ActivityRepo @Inject constructor( } else { Logger.info("No new tags to add to activity $activityId", context = TAG) } - }.onFailure { e -> - Logger.error("addTagsToActivity error for activity $activityId", e, context = TAG) + }.onFailure { + Logger.error("addTagsToActivity error for activity $activityId", it, context = TAG) } } @@ -579,7 +559,8 @@ class ActivityRepo @Inject constructor( tags: List, ): Result = withContext(bgDispatcher) { if (tags.isEmpty()) return@withContext Result.failure(IllegalArgumentException("No tags selected")) - return@withContext findActivityByPaymentId( + + findActivityByPaymentId( paymentHashOrTxId = paymentHashOrTxId, type = type, txType = txType @@ -593,14 +574,14 @@ class ActivityRepo @Inject constructor( */ suspend fun removeTagsFromActivity(activityId: String, tags: List): Result = withContext(bgDispatcher) { - return@withContext runCatching { + runCatching { 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) + }.onFailure { + Logger.error("removeTagsFromActivity error for activity $activityId", it, context = TAG) } } @@ -608,20 +589,20 @@ class ActivityRepo @Inject constructor( * Gets all tags for an activity */ suspend fun getActivityTags(activityId: String): Result> = withContext(bgDispatcher) { - return@withContext runCatching { + runCatching { coreService.activity.tags(activityId) - }.onFailure { e -> - Logger.error("getActivityTags error for activity $activityId", e, context = TAG) + }.onFailure { + Logger.error("getActivityTags error for activity $activityId", it, context = TAG) } } suspend fun getAllAvailableTags(): Result> = withContext(bgDispatcher) { - return@withContext runCatching { + runCatching { coreService.activity.allPossibleTags() }.onSuccess { tags -> _state.update { it.copy(tags = tags) } - }.onFailure { e -> - Logger.error("getAllAvailableTags error", e, context = TAG) + }.onFailure { + Logger.error("getAllAvailableTags error", it, context = TAG) } } @@ -629,15 +610,15 @@ class ActivityRepo @Inject constructor( * Get all [ActivityTags] for backup */ suspend fun getAllActivitiesTags(): Result> = withContext(bgDispatcher) { - return@withContext runCatching { + runCatching { coreService.activity.getAllActivitiesTags() - }.onFailure { e -> - Logger.error("getAllActivityTags error", e, context = TAG) + }.onFailure { + Logger.error("getAllActivityTags error", it, context = TAG) } } suspend fun restoreFromBackup(payload: ActivityBackupV1): Result = withContext(bgDispatcher) { - return@withContext runCatching { + runCatching { coreService.activity.upsertList(payload.activities) coreService.activity.upsertTags(payload.activityTags) coreService.activity.upsertClosedChannelList(payload.closedChannels) @@ -652,25 +633,25 @@ class ActivityRepo @Inject constructor( } suspend fun markAllUnseenActivitiesAsSeen(): Result = withContext(bgDispatcher) { - return@withContext runCatching { + runCatching { coreService.activity.markAllUnseenActivitiesAsSeen() notifyActivitiesChanged() - }.onFailure { e -> - Logger.error("Failed to mark all activities as seen: $e", e, context = TAG) + }.onFailure { + Logger.error("Failed to mark all activities as seen: $it", it, context = TAG) } } - // MARK: - Development/Testing Methods + // MARK: - Debug Methods /** * Removes all activities */ suspend fun removeAllActivities(): Result = withContext(bgDispatcher) { - return@withContext runCatching { + runCatching { coreService.activity.removeAll() Logger.info("Removed all activities", context = TAG) - }.onFailure { e -> - Logger.error("removeAllActivities error", e, context = TAG) + }.onFailure { + Logger.error("removeAllActivities error", it, context = TAG) } } @@ -678,8 +659,7 @@ class ActivityRepo @Inject constructor( * Generates random test data (regtest only) with business logic */ suspend fun generateTestData(count: Int = 100): Result = withContext(bgDispatcher) { - return@withContext runCatching { - // Business logic: validate count is reasonable + runCatching { val validatedCount = count.coerceIn(1, 1000) if (validatedCount != count) { Logger.warn("Adjusted test data count from $count to $validatedCount", context = TAG) @@ -687,8 +667,8 @@ class ActivityRepo @Inject constructor( coreService.activity.generateRandomTestData(validatedCount) Logger.info("Generated $validatedCount test activities", context = TAG) - }.onFailure { e -> - Logger.error("generateTestData error", e, context = TAG) + }.onFailure { + Logger.error("generateTestData error", it, context = TAG) } } diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 3b7f4de65..d327b2301 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -346,7 +346,7 @@ class LightningRepo @Inject constructor( lightningService.sync() refreshChannelCache() syncState() - if (syncPending.get()) delay(SYNC_LOOP_DEBOUNCE_MS) + if (syncPending.get()) delay(MS_SYNC_LOOP_DEBOUNCE) } while (syncPending.getAndSet(false)) } finally { _lightningState.update { it.copy(isSyncingWallet = false) } @@ -410,7 +410,7 @@ class LightningRepo @Inject constructor( } val channelName = channel.inboundScidAlias?.toString() - ?: (channel.channelId.take(CHANNEL_ID_PREVIEW_LENGTH) + "…") + ?: (channel.channelId.take(LENGTH_CHANNEL_ID_PREVIEW) + "…") val closedAt = (System.currentTimeMillis() / 1000L).toULong() @@ -922,7 +922,9 @@ class LightningRepo @Inject constructor( return@runCatching replacementTxId }.onFailure { Logger.error( - "bumpFeeByRbf error originalTxId: $originalTxId, satsPerVByte: $satsPerVByte", it, context = TAG, + "bumpFeeByRbf error originalTxId: $originalTxId, satsPerVByte: $satsPerVByte", + it, + context = TAG, ) } } @@ -959,25 +961,25 @@ class LightningRepo @Inject constructor( } suspend fun estimateRoutingFees(bolt11: String): Result = executeWhenNodeRunning("estimateRoutingFees") { - Logger.info("Estimating routing fees for bolt11: $bolt11") + Logger.info("Estimating routing fees for bolt11: $bolt11", context = TAG) lightningService.estimateRoutingFees(bolt11) .onSuccess { Logger.info("Routing fees estimated: $it", context = TAG) } .onFailure { - Logger.error("Routing fees estimation failed", it, context = TAG) + Logger.error("estimateRoutingFees error", it, context = TAG) } } suspend fun estimateRoutingFeesForAmount(bolt11: String, amountSats: ULong): Result = executeWhenNodeRunning("estimateRoutingFeesForAmount") { - Logger.info("Estimating routing fees for amount: $amountSats") + Logger.info("Estimating routing fees for amount: $amountSats", context = TAG) lightningService.estimateRoutingFeesForAmount(bolt11, amountSats) .onSuccess { Logger.info("Routing fees estimated: $it", context = TAG) } .onFailure { - Logger.error("Routing fees estimation failed", it, context = TAG) + Logger.error("estimateRoutingFees error", it, context = TAG) } } @@ -1006,8 +1008,8 @@ class LightningRepo @Inject constructor( companion object { private const val TAG = "LightningRepo" - private const val CHANNEL_ID_PREVIEW_LENGTH = 10 - private const val SYNC_LOOP_DEBOUNCE_MS = 500L + private const val LENGTH_CHANNEL_ID_PREVIEW = 10 + private const val MS_SYNC_LOOP_DEBOUNCE = 500L } } diff --git a/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt index 22c079054..878eff4b9 100644 --- a/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt @@ -89,7 +89,7 @@ class ActivityListViewModel @Inject constructor( ) { debouncedSearch, filtersWithoutSearch, _ -> fetchFilteredActivities(filtersWithoutSearch.copy(searchText = debouncedSearch)) }.collect { - _filteredActivities.value = it + _filteredActivities.update { it } } } @@ -115,8 +115,8 @@ class ActivityListViewModel @Inject constructor( search = filters.searchText.takeIf { it.isNotEmpty() }, minDate = filters.startDate?.let { it / 1000 }?.toULong(), maxDate = filters.endDate?.let { it / 1000 }?.toULong(), - ).getOrElse { e -> - Logger.error("Failed to filter activities", e) + ).getOrElse { + Logger.error("Failed to filter activities", it, context = TAG) return null } @@ -132,9 +132,9 @@ class ActivityListViewModel @Inject constructor( private suspend fun filterOutReplacedSentTransactions(activities: List): List { val txIdsInBoostTxIds = activityRepo.getTxIdsInBoostTxIds() - return activities.filter { activity -> - if (activity is Activity.Onchain) { - val onchain = activity.v1 + return activities.filter { + if (it is Activity.Onchain) { + val onchain = it.v1 if (!onchain.doesExist && onchain.txType == PaymentType.SENT && txIdsInBoostTxIds.contains(onchain.txId) @@ -180,6 +180,7 @@ class ActivityListViewModel @Inject constructor( ): StateFlow = stateIn(viewModelScope, started, initialValue) companion object { + private const val TAG = "ActivityListViewModel" private const val SIZE_LATEST = 3 private const val MS_TIMEOUT_SUB = 5000L } diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index c748fa0a5..737eecbed 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -67,16 +67,13 @@ class WalletViewModel @Inject constructor( @Volatile private var isStarting = false - // Local UI state var walletExists by mutableStateOf(walletRepo.walletExists()) private set val isRecoveryMode = lightningRepo.isRecoveryMode val isShowingMigrationLoading: StateFlow = migrationService.isShowingMigrationLoading - - val isRestoringFromRNRemoteBackup: StateFlow = - migrationService.isRestoringFromRNRemoteBackup + val isRestoringFromRNRemoteBackup: StateFlow = migrationService.isRestoringFromRNRemoteBackup private val _restoreState = MutableStateFlow(RestoreState.Initial) val restoreState: StateFlow = _restoreState.asStateFlow() @@ -92,51 +89,49 @@ class WalletViewModel @Inject constructor( } @Suppress("TooGenericExceptionCaught") - private fun checkAndPerformRNMigration() { - viewModelScope.launch(bgDispatcher) { - val isChecked = migrationService.isMigrationChecked() - if (isChecked) { - loadCacheIfWalletExists() - return@launch - } + private fun checkAndPerformRNMigration() = viewModelScope.launch(bgDispatcher) { + val isChecked = migrationService.isMigrationChecked() + if (isChecked) { + loadCacheIfWalletExists() + return@launch + } - val hasNative = migrationService.hasNativeWalletData() - if (hasNative) { - migrationService.markMigrationChecked() - loadCacheIfWalletExists() - return@launch - } + val hasNative = migrationService.hasNativeWalletData() + if (hasNative) { + migrationService.markMigrationChecked() + loadCacheIfWalletExists() + return@launch + } - val hasRN = migrationService.hasRNWalletData() - if (!hasRN) { - migrationService.markMigrationChecked() - loadCacheIfWalletExists() - return@launch - } + val hasRN = migrationService.hasRNWalletData() + if (!hasRN) { + migrationService.markMigrationChecked() + loadCacheIfWalletExists() + return@launch + } - migrationService.setShowingMigrationLoading(true) + migrationService.setShowingMigrationLoading(true) - runCatching { - migrationService.migrateFromReactNative() - walletRepo.setWalletExistsState() - walletExists = walletRepo.walletExists() - loadCacheIfWalletExists() - if (walletExists) { - val channelMigration = buildChannelMigrationIfAvailable() - startNode(0, channelMigration) - } else { - migrationService.setShowingMigrationLoading(false) - } - }.onFailure { e -> - Logger.error("RN migration failed: $e", e, context = "WalletViewModel") - migrationService.markMigrationChecked() + runCatching { + migrationService.migrateFromReactNative() + walletRepo.setWalletExistsState() + walletExists = walletRepo.walletExists() + loadCacheIfWalletExists() + if (walletExists) { + val channelMigration = buildChannelMigrationIfAvailable() + startNode(0, channelMigration) + } else { migrationService.setShowingMigrationLoading(false) - ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = "Migration Failed", - description = "Please restore your wallet manually using your recovery phrase" - ) } + }.onFailure { + Logger.error("RN migration failed: $it", it, context = "WalletViewModel") + migrationService.markMigrationChecked() + migrationService.setShowingMigrationLoading(false) + ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = "Migration Failed", + description = "Please restore your wallet manually using your recovery phrase" + ) } } @@ -146,13 +141,11 @@ class WalletViewModel @Inject constructor( } } - private fun collectStates() { - viewModelScope.launch { - walletState.collect { state -> - walletExists = state.walletExists - if (state.walletExists && _restoreState.value == RestoreState.InProgress.Wallet) { - restoreFromBackup() - } + private fun collectStates() = viewModelScope.launch { + walletState.collect { + walletExists = it.walletExists + if (it.walletExists && _restoreState.value == RestoreState.InProgress.Wallet) { + restoreFromBackup() } } } @@ -161,8 +154,8 @@ class WalletViewModel @Inject constructor( _restoreState.update { RestoreState.InProgress.Metadata } runCatching { restoreFromMostRecentBackup() - }.onFailure { e -> - Logger.error("Restore from backup failed", e, context = TAG) + }.onFailure { + Logger.error("Restore from backup failed", it, context = TAG) } _restoreState.update { RestoreState.Completed } } @@ -187,28 +180,23 @@ class WalletViewModel @Inject constructor( } } - private suspend fun restoreFromRNRemoteBackup() { - runCatching { - migrationService.restoreFromRNRemoteBackup() - walletRepo.loadFromCache() - }.onFailure { e -> - Logger.warn("RN remote backup restore failed, falling back to VSS", e, context = TAG) - backupRepo.performFullRestoreFromLatestBackup(onCacheRestored = walletRepo::loadFromCache) - } + private suspend fun restoreFromRNRemoteBackup() = runCatching { + migrationService.restoreFromRNRemoteBackup() + walletRepo.loadFromCache() + }.onFailure { + Logger.warn("RN remote backup restore failed, falling back to VSS", it, context = TAG) + backupRepo.performFullRestoreFromLatestBackup(onCacheRestored = walletRepo::loadFromCache) } - fun onRestoreContinue() { - _restoreState.update { RestoreState.Settled } - } + fun onRestoreContinue() = _restoreState.update { RestoreState.Settled } - fun proceedWithoutRestore(onDone: () -> Unit) { - viewModelScope.launch { - // TODO start LDK without trying to restore backup state from VSS if possible - lightningRepo.stop() - delay(LOADING_MS.milliseconds) - _restoreState.update { RestoreState.Settled } - onDone() - } + @Suppress("ForbiddenComment") + fun proceedWithoutRestore(onDone: () -> Unit) = viewModelScope.launch { + // TODO start LDK without trying to restore backup state from VSS if possible + lightningRepo.stop() + delay(LOADING_MS.milliseconds) + _restoreState.update { RestoreState.Settled } + onDone() } fun setInitNodeLifecycleState() = lightningRepo.setInitNodeLifecycleState() @@ -259,10 +247,10 @@ class WalletViewModel @Inject constructor( walletRepo.refreshBip21() } } - .onFailure { error -> - Logger.error("Node startup error", error, context = TAG) - if (error !is RecoveryModeException) { - ToastEventBus.send(error) + .onFailure { + Logger.error("Node startup error", it, context = TAG) + if (it !is RecoveryModeException) { + ToastEventBus.send(it) } } } @@ -272,19 +260,19 @@ class WalletViewModel @Inject constructor( viewModelScope.launch(bgDispatcher) { lightningRepo.stop() - .onFailure { error -> - Logger.error("Node stop error", error) - ToastEventBus.send(error) + .onFailure { + Logger.error("Node stop error", it) + ToastEventBus.send(it) } } } fun refreshState() = viewModelScope.launch { walletRepo.syncNodeAndWallet() - .onFailure { error -> - Logger.error("Failed to refresh state: ${error.message}", error) - if (error is CancellationException || error.isTxSyncTimeout()) return@onFailure - ToastEventBus.send(error) + .onFailure { + Logger.error("Failed to refresh state: ${it.message}", it) + if (it is CancellationException || it.isTxSyncTimeout()) return@onFailure + ToastEventBus.send(it) } } @@ -295,11 +283,11 @@ class WalletViewModel @Inject constructor( lightningRepo.clearPendingSync() syncJob = viewModelScope.launch { - _isRefreshing.value = true + _isRefreshing.update { true } try { walletRepo.syncNodeAndWallet(source = SyncSource.MANUAL) } finally { - _isRefreshing.value = false + _isRefreshing.update { false } } } } @@ -311,30 +299,26 @@ class WalletViewModel @Inject constructor( ToastEventBus.send( type = Toast.ToastType.INFO, title = context.getString(R.string.common__success), - description = "Peer disconnected.," + description = "Peer disconnected." ) } - .onFailure { error -> + .onFailure { ToastEventBus.send( type = Toast.ToastType.ERROR, title = context.getString(R.string.common__error), - description = error.message ?: context.getString(R.string.common__error_desc) + description = it.message ?: context.getString(R.string.common__error_desc) ) } } } - fun updateBip21Invoice( - amountSats: ULong? = walletState.value.bip21AmountSats, - ) { - viewModelScope.launch { - walletRepo.updateBip21Invoice(amountSats).onFailure { error -> - ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.wallet__error_invoice_update), - description = error.message ?: context.getString(R.string.common__error_desc) - ) - } + fun updateBip21Invoice(amountSats: ULong? = walletState.value.bip21AmountSats) = viewModelScope.launch { + walletRepo.updateBip21Invoice(amountSats).onFailure { error -> + ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.wallet__error_invoice_update), + description = error.message ?: context.getString(R.string.common__error_desc) + ) } } @@ -344,11 +328,9 @@ class WalletViewModel @Inject constructor( walletRepo.refreshBip21() } - fun wipeWallet() { - viewModelScope.launch(bgDispatcher) { - walletRepo.wipeWallet().onFailure { error -> - ToastEventBus.send(error) - } + fun wipeWallet() = viewModelScope.launch(bgDispatcher) { + walletRepo.wipeWallet().onFailure { + ToastEventBus.send(it) } } @@ -358,8 +340,8 @@ class WalletViewModel @Inject constructor( .onSuccess { backupRepo.scheduleFullBackup() } - .onFailure { error -> - ToastEventBus.send(error) + .onFailure { + ToastEventBus.send(it) } } @@ -370,22 +352,22 @@ class WalletViewModel @Inject constructor( walletRepo.restoreWallet( mnemonic = mnemonic, bip39Passphrase = bip39Passphrase, - ).onFailure { error -> - ToastEventBus.send(error) + ).onFailure { + ToastEventBus.send(it) } } // region debug methods fun addTagToSelected(newTag: String) = viewModelScope.launch { - walletRepo.addTagToSelected(newTag).onFailure { e -> - ToastEventBus.send(e) + walletRepo.addTagToSelected(newTag).onFailure { + ToastEventBus.send(it) } } fun removeTag(tag: String) = viewModelScope.launch { - walletRepo.removeTag(tag).onFailure { e -> - ToastEventBus.send(e) + walletRepo.removeTag(tag).onFailure { + ToastEventBus.send(it) } } @@ -395,7 +377,7 @@ class WalletViewModel @Inject constructor( fun updateBip21Description(newText: String) { if (newText.isEmpty()) { - Logger.warn("Empty") + Logger.warn(context.getString(R.string.common__empty)) } walletRepo.setBip21Description(newText) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8469dcb26..890c336bb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -47,6 +47,7 @@ ₿ / vbyte ₿/vbyte Edit + Empty Copy Share Search diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index a113f3e17..2cd88e6c5 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -359,7 +359,7 @@ class LightningRepoTest : BaseUnitTest() { fun `disconnectPeer should succeed when node is running`() = test { startNodeForTesting() val testPeer = PeerDetails.of("nodeId", "host", "9735") - whenever(lightningService.disconnectPeer(any())).thenReturn(Unit) + whenever(lightningService.disconnectPeer(any())).thenReturn(Result.success(Unit)) val result = sut.disconnectPeer(testPeer) assertTrue(result.isSuccess) diff --git a/app/src/test/java/to/bitkit/utils/CryptoTest.kt b/app/src/test/java/to/bitkit/utils/CryptoTest.kt index ebbf8c7ee..e19b5d69c 100644 --- a/app/src/test/java/to/bitkit/utils/CryptoTest.kt +++ b/app/src/test/java/to/bitkit/utils/CryptoTest.kt @@ -1,5 +1,3 @@ -@file:Suppress("SpacingBetweenDeclarationsWithAnnotations", "Wrapping") - package to.bitkit.utils import org.junit.Before @@ -13,6 +11,7 @@ import to.bitkit.fcm.EncryptedNotification import kotlin.test.assertContentEquals import kotlin.test.assertEquals +@Suppress("SpacingBetweenDeclarationsWithAnnotations", "Wrapping") class CryptoTest { private lateinit var sut: Crypto From fa9a9de3e12ebdf1e39e175eacdd43523be09fb1 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 14 Jan 2026 03:38:32 +0100 Subject: [PATCH 30/37] chore: cleanup LN repo pass 2 --- .../to/bitkit/repositories/LightningRepo.kt | 230 ++++++++---------- .../to/bitkit/viewmodels/WalletViewModel.kt | 4 +- 2 files changed, 100 insertions(+), 134 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index d327b2301..1cf655a8c 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -144,55 +144,53 @@ class LightningRepo @Inject constructor( true } ?: false - if (!nodeRunning) return@withContext Result.failure(NodeRunTimeoutException(operationName)) + if (!nodeRunning) return@withContext Result.failure(NodeRunTimeoutError(operationName)) return@withContext executeOperation(operationName, operation) } - @Suppress("TooGenericExceptionCaught") private suspend fun executeOperation( operationName: String, operation: suspend () -> Result, - ): Result { - return try { - operation() - } catch (e: CancellationException) { - // Cancellation is expected during pull-to-refresh, rethrow per Kotlin best practices - throw e - } catch (e: Throwable) { - Logger.error("Error executing '$operationName'", e, context = TAG) - Result.failure(e) - } + ): Result = runCatching { + operation().getOrThrow() + }.onFailure { + // Cancellation is expected during pull-to-refresh, rethrow per Kotlin best practices + if (it is CancellationException) throw it + + Logger.error("Error executing '$operationName'", it, context = TAG) } - @Suppress("TooGenericExceptionCaught") private suspend fun setup( walletIndex: Int, customServerUrl: String? = null, customRgsServerUrl: String? = null, channelMigration: ChannelDataMigration? = null, ) = withContext(bgDispatcher) { - return@withContext try { - val trustedPeers = getTrustedPeersFromBlocktank() - lightningService.setup(walletIndex, customServerUrl, customRgsServerUrl, trustedPeers, channelMigration) - Result.success(Unit) - } catch (e: Throwable) { - Logger.error("Node setup error", e, context = TAG) - Result.failure(e) + runCatching { + val trustedPeers = fetchTrustedPeers() + lightningService.setup( + walletIndex, + customServerUrl, + customRgsServerUrl, + trustedPeers, + channelMigration + ) + }.onFailure { + Logger.error("Node setup error", it, context = TAG) } } - private suspend fun getTrustedPeersFromBlocktank(): List? = runCatching { - val info = coreService.blocktank.info(refresh = false) - ?: coreService.blocktank.info(refresh = true) + private suspend fun fetchTrustedPeers(): List? = runCatching { + val info = coreService.blocktank.info(refresh = false) ?: coreService.blocktank.info(refresh = true) info?.nodes?.toPeerDetailsList()?.also { - Logger.info("Loaded ${it.size} trusted peers from blocktank", context = TAG) + Logger.info("Fetched ${it.size} trusted peers from remote", context = TAG) } }.onFailure { - Logger.warn("Failed to get trusted peers from blocktank", e = it, context = TAG) + Logger.warn("fetchTrustedPeers error", it, context = TAG) }.getOrNull() - @Suppress("LongMethod", "LongParameterList", "TooGenericExceptionCaught") + @Suppress("LongMethod", "LongParameterList") suspend fun start( walletIndex: Int = 0, timeout: Duration? = null, @@ -203,7 +201,7 @@ class LightningRepo @Inject constructor( channelMigration: ChannelDataMigration? = null, ): Result = withContext(bgDispatcher) { if (_isRecoveryMode.value) { - return@withContext Result.failure(RecoveryModeException()) + return@withContext Result.failure(RecoveryModeError()) } eventHandler?.let { _eventHandlers.add(it) } @@ -214,21 +212,21 @@ class LightningRepo @Inject constructor( return@withContext Result.success(Unit) } - try { + runCatching { _lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Starting) } // Setup if needed if (lightningService.node == null) { - val setupResult = setup(walletIndex, customServerUrl, customRgsServerUrl, channelMigration) - if (setupResult.isFailure) { + val setup = setup(walletIndex, customServerUrl, customRgsServerUrl, channelMigration) + if (setup.isFailure) { _lightningState.update { it.copy( nodeLifecycleState = NodeLifecycleState.ErrorStarting( - setupResult.exceptionOrNull() ?: Exception("Unknown setup error") + setup.exceptionOrNull() ?: NodeSetupError() ) ) } - return@withContext setupResult + return@withContext setup } } @@ -250,19 +248,18 @@ class LightningRepo @Inject constructor( refreshChannelCache() // Post-startup tasks - connectToTrustedPeers().onFailure { e -> - Logger.error("Failed to connect to trusted peers", e) + connectToTrustedPeers().onFailure { + Logger.error("Failed to connect to trusted peers", it, context = TAG) } sync() - registerForNotifications() - - Result.success(Unit) - } catch (e: Throwable) { + registerForNotifications().getOrThrow() + }.onFailure { e -> if (shouldRetry) { - Logger.warn("Start error, retrying after two seconds...", e = e, context = TAG) + val retryDelay = 2.seconds + Logger.warn("Start error, retrying after $retryDelay...", e, context = TAG) _lightningState.update { it.copy(nodeLifecycleState = initialLifecycleState) } - delay(2.seconds) + delay(retryDelay) return@withContext start( walletIndex = walletIndex, timeout = timeout, @@ -276,7 +273,6 @@ class LightningRepo @Inject constructor( _lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.ErrorStarting(e)) } - Result.failure(e) } } } @@ -287,9 +283,7 @@ class LightningRepo @Inject constructor( _nodeEvents.emit(event) } - fun setRecoveryMode(enabled: Boolean) { - _isRecoveryMode.value = enabled - } + fun setRecoveryMode(enabled: Boolean) = _isRecoveryMode.update { enabled } suspend fun updateGeoBlockState() = withContext(bgDispatcher) { _lightningState.update { @@ -301,34 +295,18 @@ class LightningRepo @Inject constructor( _lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Initializing) } } - @Suppress("TooGenericExceptionCaught") suspend fun stop(): Result = withContext(bgDispatcher) { if (_lightningState.value.nodeLifecycleState.isStoppedOrStopping()) { return@withContext Result.success(Unit) } - try { + runCatching { _lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Stopping) } lightningService.stop() _lightningState.update { LightningState(nodeLifecycleState = NodeLifecycleState.Stopped) } - Result.success(Unit) - } catch (e: Throwable) { - Logger.error("Node stop error", e, context = TAG) - Result.failure(e) - } - } - - suspend fun restart(): Result = withContext(bgDispatcher) { - stop().onFailure { - Logger.error("Failed to stop node during restart", it, context = TAG) - return@withContext Result.failure(it) - } - delay(500) - start(shouldRetry = false).onFailure { - Logger.error("Failed to start node during restart", it, context = TAG) - return@withContext Result.failure(it) + }.onFailure { + Logger.error("Node stop error", it, context = TAG) } - Result.success(Unit) } suspend fun sync(): Result = executeWhenNodeRunning("sync") { @@ -363,9 +341,7 @@ class LightningRepo @Inject constructor( } /** Clear pending sync flag. Called when manual pull-to-refresh takes priority. */ - fun clearPendingSync() { - syncPending.set(false) - } + fun clearPendingSync() = syncPending.set(false) private suspend fun refreshChannelCache() = withContext(bgDispatcher) { val channels = lightningService.channels ?: return@withContext @@ -376,20 +352,15 @@ class LightningRepo @Inject constructor( private fun handleLdkEvent(event: Event) { when (event) { - is Event.ChannelPending, - is Event.ChannelReady, - -> scope.launch { + is Event.ChannelPending, is Event.ChannelReady -> scope.launch { refreshChannelCache() } is Event.ChannelClosed -> scope.launch { - registerClosedChannel( - channelId = event.channelId, - reason = event.reason, - ) + registerClosedChannel(channelId = event.channelId, reason = event.reason) } - else -> Unit // Other events don't need special handling + else -> Unit } } @@ -404,13 +375,13 @@ class LightningRepo @Inject constructor( if (fundingTxo == null) { Logger.error( "Channel has no funding transaction, cannot persist closed channel: channelId=$channelId", - context = TAG + context = TAG, ) return@withContext } - val channelName = channel.inboundScidAlias?.toString() - ?: (channel.channelId.take(LENGTH_CHANNEL_ID_PREVIEW) + "…") + val channelName = + channel.inboundScidAlias?.toString() ?: (channel.channelId.take(LENGTH_CHANNEL_ID_PREVIEW) + "…") val closedAt = (System.currentTimeMillis() / 1000L).toULong() @@ -441,7 +412,6 @@ class LightningRepo @Inject constructor( } } - @Suppress("TooGenericExceptionCaught") suspend fun wipeStorage(walletIndex: Int): Result = withContext(bgDispatcher) { Logger.debug("wipeStorage called, stopping node first", context = TAG) stop().mapCatching { @@ -533,7 +503,7 @@ class LightningRepo @Inject constructor( _lightningState.first { it.nodeLifecycleState == NodeLifecycleState.Stopped } } if (stopped == null) { - val error = NodeStopTimeoutException() + val error = NodeStopTimeoutError() Logger.warn(error.message, context = TAG) return@withContext Result.failure(error) } @@ -628,20 +598,18 @@ class LightningRepo @Inject constructor( val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) ?: throw ServiceError.MnemonicNotFound() val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) - val result = lnurlAuth( + lnurlAuth( k1 = k1, callback = callback, domain = domain, network = Env.network.toCoreNetwork(), bip32Mnemonic = mnemonic, bip39Passphrase = passphrase, - ) - - Logger.debug("LNURL auth result: '$result'", context = TAG) - - return@runCatching result + ).also { + Logger.debug("LNURL auth result: '$it'", context = TAG) + } }.onFailure { - Logger.error("Error requesting lnurl auth, k1: $k1, callback: $callback, domain: $domain", it, context = TAG) + Logger.error("requestLnurlAuth error, k1: $k1, callback: $callback, domain: $domain", it, context = TAG) } suspend fun payInvoice( @@ -739,8 +707,7 @@ class LightningRepo @Inject constructor( } suspend fun getPayments(): Result> = executeWhenNodeRunning("getPayments") { - val payments = lightningService.payments - ?: return@executeWhenNodeRunning Result.failure(GetPaymentsException()) + val payments = lightningService.payments ?: return@executeWhenNodeRunning Result.failure(GetPaymentsError()) Result.success(payments) } @@ -754,7 +721,6 @@ class LightningRepo @Inject constructor( lightningService.listSpendableOutputs() } - @Suppress("TooGenericExceptionCaught") suspend fun calculateTotalFee( amountSats: ULong, address: Address? = null, @@ -762,10 +728,9 @@ class LightningRepo @Inject constructor( utxosToSpend: List? = null, feeRates: FeeRates? = null, ): Result = withContext(bgDispatcher) { - return@withContext try { + runCatching { val transactionSpeed = speed ?: settingsStore.data.first().defaultTransactionSpeed val satsPerVByte = getFeeRateForSpeed(transactionSpeed, feeRates).getOrThrow() - val addressOrDefault = address ?: cacheStore.data.first().onchainAddress val fee = lightningService.calculateTotalFee( @@ -774,13 +739,12 @@ class LightningRepo @Inject constructor( satsPerVByte = satsPerVByte, utxosToSpend = utxosToSpend, ) - Result.success(fee) - } catch (e: CancellationException) { - throw e - } catch (e: Throwable) { + return@runCatching fee + }.recoverCatching { + if (it is CancellationException) throw it val fallbackFee = 1000uL - Logger.warn("Error calculating fee, using fallback of $fallbackFee ${errLogOf(e)}", context = TAG) - Result.success(fallbackFee) + Logger.warn("calculateTotalFee error, using fallback of '$fallbackFee', ${errLogOf(it)}", context = TAG) + return@recoverCatching fallbackFee } } @@ -788,13 +752,13 @@ class LightningRepo @Inject constructor( speed: TransactionSpeed, feeRates: FeeRates? = null, ): Result = withContext(bgDispatcher) { - return@withContext runCatching { + runCatching { val fees = feeRates ?: coreService.blocktank.getFees().getOrThrow() val satsPerVByte = fees.getSatsPerVByteFor(speed) satsPerVByte.toULong() - }.onFailure { e -> - if (e !is CancellationException) { - Logger.error("Error getFeeRateForSpeed. speed:$speed", e, context = TAG) + }.onFailure { + if (it !is CancellationException) { + Logger.error("getFeeRateForSpeed error: speed:$speed", it, context = TAG) } } } @@ -802,7 +766,7 @@ class LightningRepo @Inject constructor( suspend fun calculateCpfpFeeRate( parentTxId: Txid, ): Result = executeWhenNodeRunning("calculateCpfpFeeRate") { - Result.success(lightningService.calculateCpfpFeeRate(parentTxid = parentTxId).toSatPerVbCeil()) + Result.success(lightningService.calculateCpfpFeeRate(parentTxId).toSatPerVbCeil()) } suspend fun openChannel( @@ -811,9 +775,9 @@ class LightningRepo @Inject constructor( pushToCounterpartySats: ULong? = null, channelConfig: ChannelConfig? = null, ): Result = executeWhenNodeRunning("openChannel") { - val result = lightningService.openChannel(peer, channelAmountSats, pushToCounterpartySats, channelConfig) - syncState() - result + lightningService.openChannel(peer, channelAmountSats, pushToCounterpartySats, channelConfig).also { + syncState() + } } suspend fun closeChannel( @@ -896,7 +860,7 @@ class LightningRepo @Inject constructor( lspNotificationsService.registerDevice(token) }.onFailure { - Logger.error("Register for notifications error", it) + Logger.error("Register for notifications error", it, context = TAG) } } @@ -915,8 +879,10 @@ class LightningRepo @Inject constructor( satsPerVByte = satsPerVByte, ) Logger.debug( - "bumpFeeByRbf success, replacementTxId: $replacementTxId " + - "originalTxId: $originalTxId, satsPerVByte: $satsPerVByte", + "bumpFeeByRbf success, " + + "replacementTxId: $replacementTxId " + + "originalTxId: $originalTxId, " + + "satsPerVByte: $satsPerVByte", context = TAG, ) return@runCatching replacementTxId @@ -945,15 +911,19 @@ class LightningRepo @Inject constructor( toAddress = destinationAddress, ) Logger.debug( - "accelerateByCpfp success, newDestinationTxId: $newDestinationTxId " + - "originalTxId: $originalTxId, satsPerVByte: $satsPerVByte " + + "accelerateByCpfp success, " + + "newDestinationTxId: $newDestinationTxId " + + "originalTxId: $originalTxId, " + + "satsPerVByte: $satsPerVByte " + "destinationAddress: $destinationAddress" ) return@runCatching newDestinationTxId }.onFailure { Logger.error( - "accelerateByCpfp error originalTxId: $originalTxId, " + - "satsPerVByte: $satsPerVByte destinationAddress: $destinationAddress", + "accelerateByCpfp error: " + + "originalTxId: $originalTxId, " + + "satsPerVByte: $satsPerVByte, " + + "destinationAddress: $destinationAddress", it, context = TAG, ) @@ -962,25 +932,21 @@ class LightningRepo @Inject constructor( suspend fun estimateRoutingFees(bolt11: String): Result = executeWhenNodeRunning("estimateRoutingFees") { Logger.info("Estimating routing fees for bolt11: $bolt11", context = TAG) - lightningService.estimateRoutingFees(bolt11) - .onSuccess { - Logger.info("Routing fees estimated: $it", context = TAG) - } - .onFailure { - Logger.error("estimateRoutingFees error", it, context = TAG) - } + lightningService.estimateRoutingFees(bolt11).onSuccess { + Logger.info("Routing fees estimated: $it", context = TAG) + }.onFailure { + Logger.error("estimateRoutingFees error", it, context = TAG) + } } suspend fun estimateRoutingFeesForAmount(bolt11: String, amountSats: ULong): Result = executeWhenNodeRunning("estimateRoutingFeesForAmount") { Logger.info("Estimating routing fees for amount: $amountSats", context = TAG) - lightningService.estimateRoutingFeesForAmount(bolt11, amountSats) - .onSuccess { - Logger.info("Routing fees estimated: $it", context = TAG) - } - .onFailure { - Logger.error("estimateRoutingFees error", it, context = TAG) - } + lightningService.estimateRoutingFeesForAmount(bolt11, amountSats).onSuccess { + Logger.info("Routing fees estimated: $it", context = TAG) + }.onFailure { + Logger.error("estimateRoutingFees error", it, context = TAG) + } } // region debug @@ -1003,8 +969,7 @@ class LightningRepo @Inject constructor( } Logger.info("LDK node restarted successfully", context = TAG) Result.success(Unit) - } - // endregion + } // endregion companion object { private const val TAG = "LightningRepo" @@ -1013,10 +978,11 @@ class LightningRepo @Inject constructor( } } -class RecoveryModeException : AppError("App in recovery mode, skipping node start") -class NodeStopTimeoutException : AppError("Timeout waiting for node to stop") -class NodeRunTimeoutException(opName: String) : AppError("Timeout waiting for node to run and execute: '$opName'") -class GetPaymentsException : AppError("It wasn't possible get the payments") +class RecoveryModeError : AppError("App in recovery mode, skipping node start") +class NodeSetupError : AppError("Unknown node setup error") +class NodeStopTimeoutError : AppError("Timeout waiting for node to stop") +class NodeRunTimeoutError(opName: String) : AppError("Timeout waiting for node to run and execute: '$opName'") +class GetPaymentsError : AppError("It wasn't possible get the payments") data class LightningState( val nodeId: String = "", diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index 737eecbed..5444e5976 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -30,7 +30,7 @@ import to.bitkit.models.Toast import to.bitkit.repositories.BackupRepo import to.bitkit.repositories.BlocktankRepo import to.bitkit.repositories.LightningRepo -import to.bitkit.repositories.RecoveryModeException +import to.bitkit.repositories.RecoveryModeError import to.bitkit.repositories.SyncSource import to.bitkit.repositories.WalletRepo import to.bitkit.services.MigrationService @@ -249,7 +249,7 @@ class WalletViewModel @Inject constructor( } .onFailure { Logger.error("Node startup error", it, context = TAG) - if (it !is RecoveryModeException) { + if (it !is RecoveryModeError) { ToastEventBus.send(it) } } From 9d05e20d8912c1cafa20f66a171055664f9efd5b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 14 Jan 2026 03:55:22 +0100 Subject: [PATCH 31/37] chore: fix instrumentation build --- .../androidTest/java/to/bitkit/services/UtxoSelectionTests.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/androidTest/java/to/bitkit/services/UtxoSelectionTests.kt b/app/src/androidTest/java/to/bitkit/services/UtxoSelectionTests.kt index bbd2efde9..e239093ce 100644 --- a/app/src/androidTest/java/to/bitkit/services/UtxoSelectionTests.kt +++ b/app/src/androidTest/java/to/bitkit/services/UtxoSelectionTests.kt @@ -196,7 +196,7 @@ class UtxoSelectionTests { // Send transaction spending only the selected UTXOs val destinationAddress = "bcrt1qs04g2ka4pr9s3mv73nu32tvfy7r3cxd27wkyu8" val sendAmount = 10_000uL // Send 10,000 sats - val feeRate = 1u // 1 sat/vbyte + val feeRate = 1uL // 1 sat/vbyte println("Sending $sendAmount sats to $destinationAddress using specific UTXOs") val txId = lightningService.send( @@ -320,7 +320,7 @@ class UtxoSelectionTests { // Test parameters val targetAmountSats = 25_000uL // Target amount for selection - val feeRate = 1u // 1 sat/vbyte + val feeRate = 1uL // 1 sat/vbyte // Test each coin selection algorithm val algorithms: List = CoinSelectionAlgorithm.entries From 72375a1cdaa93538ced52cd1fd78d7e7cb18e318 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 14 Jan 2026 04:38:26 +0100 Subject: [PATCH 32/37] refactor: run registerForNotifications in bg --- .../to/bitkit/repositories/LightningRepo.kt | 38 ++++++++++--------- .../java/to/bitkit/repositories/WalletRepo.kt | 4 +- app/src/main/java/to/bitkit/utils/Logger.kt | 7 ++-- 3 files changed, 25 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 1cf655a8c..6aa5b1ada 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -63,7 +63,7 @@ import to.bitkit.services.NodeEventHandler import to.bitkit.utils.AppError import to.bitkit.utils.Logger import to.bitkit.utils.ServiceError -import to.bitkit.utils.errLogOf +import to.bitkit.utils.errorLogOf import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject @@ -128,14 +128,12 @@ class LightningRepo @Inject constructor( // If node is not in a state that can become running, fail fast if (!nodeLifecycleState.canRun()) { return@withContext Result.failure( - Exception("Cannot execute '$operationName': node is '$nodeLifecycleState' and not starting") + AppError("Cannot execute '$operationName': node is '$nodeLifecycleState' and not starting") ) } val nodeRunning = withTimeoutOrNull(waitTimeout) { - if (nodeLifecycleState.isRunning()) { - return@withTimeoutOrNull true - } + if (nodeLifecycleState.isRunning()) return@withTimeoutOrNull true // Otherwise, wait for it to transition to running state Logger.verbose("Waiting for node to run before executing '$operationName'", context = TAG) @@ -182,7 +180,8 @@ class LightningRepo @Inject constructor( } private suspend fun fetchTrustedPeers(): List? = runCatching { - val info = coreService.blocktank.info(refresh = false) ?: coreService.blocktank.info(refresh = true) + val info = coreService.blocktank.info(refresh = false) + ?: coreService.blocktank.info(refresh = true) info?.nodes?.toPeerDetailsList()?.also { Logger.info("Fetched ${it.size} trusted peers from remote", context = TAG) } @@ -217,16 +216,16 @@ class LightningRepo @Inject constructor( // Setup if needed if (lightningService.node == null) { - val setup = setup(walletIndex, customServerUrl, customRgsServerUrl, channelMigration) - if (setup.isFailure) { + val setupResult = setup(walletIndex, customServerUrl, customRgsServerUrl, channelMigration) + if (setupResult.isFailure) { _lightningState.update { it.copy( nodeLifecycleState = NodeLifecycleState.ErrorStarting( - setup.exceptionOrNull() ?: NodeSetupError() + setupResult.exceptionOrNull() ?: NodeSetupError() ) ) } - return@withContext setup + return@withContext setupResult } } @@ -247,12 +246,13 @@ class LightningRepo @Inject constructor( updateGeoBlockState() refreshChannelCache() - // Post-startup tasks + // Post-startup tasks (non-blocking) connectToTrustedPeers().onFailure { Logger.error("Failed to connect to trusted peers", it, context = TAG) } - sync() - registerForNotifications().getOrThrow() + sync().getOrThrow().also { + scope.launch { registerForNotifications() } + } }.onFailure { e -> if (shouldRetry) { val retryDelay = 2.seconds @@ -279,7 +279,9 @@ class LightningRepo @Inject constructor( private suspend fun onEvent(event: Event) { handleLdkEvent(event) - _eventHandlers.toList().forEach { it.invoke(event) } + _eventHandlers.toList().forEach { + runCatching { it.invoke(event) } + } _nodeEvents.emit(event) } @@ -408,7 +410,7 @@ class LightningRepo @Inject constructor( Logger.info("Registered closed channel: ${channel.userChannelId}", context = TAG) }.onFailure { - Logger.error("Failed to register closed channel: $it", it, context = TAG) + Logger.error("Failed to register closed channel: ${errorLogOf(it)}", it, context = TAG) } } @@ -743,7 +745,7 @@ class LightningRepo @Inject constructor( }.recoverCatching { if (it is CancellationException) throw it val fallbackFee = 1000uL - Logger.warn("calculateTotalFee error, using fallback of '$fallbackFee', ${errLogOf(it)}", context = TAG) + Logger.warn("calculateTotalFee error, using fallback of '$fallbackFee', ${errorLogOf(it)}", context = TAG) return@recoverCatching fallbackFee } } @@ -933,7 +935,7 @@ class LightningRepo @Inject constructor( suspend fun estimateRoutingFees(bolt11: String): Result = executeWhenNodeRunning("estimateRoutingFees") { Logger.info("Estimating routing fees for bolt11: $bolt11", context = TAG) lightningService.estimateRoutingFees(bolt11).onSuccess { - Logger.info("Routing fees estimated: $it", context = TAG) + Logger.info("Routing fees estimated: '$it'", context = TAG) }.onFailure { Logger.error("estimateRoutingFees error", it, context = TAG) } @@ -941,7 +943,7 @@ class LightningRepo @Inject constructor( suspend fun estimateRoutingFeesForAmount(bolt11: String, amountSats: ULong): Result = executeWhenNodeRunning("estimateRoutingFeesForAmount") { - Logger.info("Estimating routing fees for amount: $amountSats", context = TAG) + Logger.info("Estimating routing fees for amount: '$amountSats'", context = TAG) lightningService.estimateRoutingFeesForAmount(bolt11, amountSats).onSuccess { Logger.info("Routing fees estimated: $it", context = TAG) }.onFailure { diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index d507def05..2b09f58f0 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -35,7 +35,7 @@ import to.bitkit.usecases.WipeWalletUseCase import to.bitkit.utils.Bip21Utils import to.bitkit.utils.Logger import to.bitkit.utils.ServiceError -import to.bitkit.utils.errLogOf +import to.bitkit.utils.errorLogOf import to.bitkit.utils.measured import javax.inject.Inject import javax.inject.Singleton @@ -194,7 +194,7 @@ class WalletRepo @Inject constructor( _balanceState.update { balanceState } }.onFailure { if (it !is CancellationException) { - Logger.warn("Could not sync balances ${errLogOf(it)}", context = TAG) + Logger.warn("Could not sync balances ${errorLogOf(it)}", context = TAG) } } } diff --git a/app/src/main/java/to/bitkit/utils/Logger.kt b/app/src/main/java/to/bitkit/utils/Logger.kt index 8a69aa448..c7c650f12 100644 --- a/app/src/main/java/to/bitkit/utils/Logger.kt +++ b/app/src/main/java/to/bitkit/utils/Logger.kt @@ -8,7 +8,6 @@ import kotlinx.coroutines.launch import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.encodeToJsonElement import kotlinx.serialization.json.put import kotlinx.serialization.serializer import org.lightningdevkit.ldknode.LogRecord @@ -146,7 +145,7 @@ class LoggerImpl( path: String = getCallerPath(), line: Int = getCallerLine(), ) { - val errMsg = e?.let { errLogOf(it) }.orEmpty() + val errMsg = e?.let { errorLogOf(it) }.orEmpty() val message = formatLog(LogLevel.WARN, "$msg $errMsg", context, path, line) if (compact) Log.w(tag, message) else Log.w(tag, message, e) saver.save(message) @@ -159,7 +158,7 @@ class LoggerImpl( path: String = getCallerPath(), line: Int = getCallerLine(), ) { - val errMsg = e?.let { errLogOf(it) }.orEmpty() + val errMsg = e?.let { errorLogOf(it) }.orEmpty() val message = formatLog(LogLevel.ERROR, "$msg $errMsg", context, path, line) if (compact) Log.e(tag, message) else Log.e(tag, message, e) saver.save(message) @@ -355,4 +354,4 @@ inline fun jsonLogOf(value: T): String { }.toString() } -fun errLogOf(e: Throwable): String = "[${e::class.simpleName}='${e.message}']" +fun errorLogOf(e: Throwable): String = "[${e::class.simpleName}='${e.message}']" From f2a26256fd952e76a62ba931265f23767d13ac83 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 14 Jan 2026 04:49:06 +0100 Subject: [PATCH 33/37] chore: cleanup LN repo pass 3 --- .../to/bitkit/repositories/LightningRepo.kt | 77 +++++++------------ 1 file changed, 27 insertions(+), 50 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 6aa5b1ada..5e57c74af 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -118,7 +118,7 @@ class LightningRepo @Inject constructor( waitTimeout: Duration = 1.minutes, operation: suspend () -> Result, ): Result = withContext(bgDispatcher) { - Logger.verbose("Operation called: $operationName", context = TAG) + Logger.verbose("Operation called: '$operationName'", context = TAG) val nodeLifecycleState = _lightningState.value.nodeLifecycleState if (nodeLifecycleState.isRunning()) { @@ -337,8 +337,8 @@ class LightningRepo @Inject constructor( } fun syncAsync() = scope.launch { - sync().onFailure { error -> - Logger.warn("Sync failed", e = error, context = TAG) + sync().onFailure { + Logger.warn("Sync failed", it, context = TAG) } } @@ -346,22 +346,15 @@ class LightningRepo @Inject constructor( fun clearPendingSync() = syncPending.set(false) private suspend fun refreshChannelCache() = withContext(bgDispatcher) { - val channels = lightningService.channels ?: return@withContext - channels.forEach { channel -> - channelCache[channel.channelId] = channel + lightningService.channels?.forEach { + channelCache[it.channelId] = it } } private fun handleLdkEvent(event: Event) { when (event) { - is Event.ChannelPending, is Event.ChannelReady -> scope.launch { - refreshChannelCache() - } - - is Event.ChannelClosed -> scope.launch { - registerClosedChannel(channelId = event.channelId, reason = event.reason) - } - + is Event.ChannelPending, is Event.ChannelReady -> scope.launch { refreshChannelCache() } + is Event.ChannelClosed -> scope.launch { registerClosedChannel(event.channelId, event.reason) } else -> Unit } } @@ -369,21 +362,21 @@ class LightningRepo @Inject constructor( private suspend fun registerClosedChannel(channelId: String, reason: ClosureReason?) = withContext(bgDispatcher) { runCatching { val channel = channelCache[channelId] ?: run { - Logger.error("Could not find channel details for closed channel: channelId=$channelId", context = TAG) + Logger.error("Could not find details for closed channel: channelId='$channelId'", context = TAG) return@withContext } val fundingTxo = channel.fundingTxo if (fundingTxo == null) { Logger.error( - "Channel has no funding transaction, cannot persist closed channel: channelId=$channelId", + "Channel has no funding transaction, cannot persist closed channel: channelId='$channelId'", context = TAG, ) return@withContext } - val channelName = - channel.inboundScidAlias?.toString() ?: (channel.channelId.take(LENGTH_CHANNEL_ID_PREVIEW) + "…") + val channelName = channel.inboundScidAlias?.toString() + ?: (channel.channelId.take(LENGTH_CHANNEL_ID_PREVIEW) + "…") val closedAt = (System.currentTimeMillis() / 1000L).toULong() @@ -514,8 +507,7 @@ class LightningRepo @Inject constructor( } suspend fun connectToTrustedPeers(): Result = executeWhenNodeRunning("connectToTrustedPeers") { - lightningService.connectToTrustedPeers() - Result.success(Unit) + runCatching { lightningService.connectToTrustedPeers() } } suspend fun connectPeer(peer: PeerDetails): Result = executeWhenNodeRunning("connectPeer") { @@ -531,8 +523,7 @@ class LightningRepo @Inject constructor( } suspend fun newAddress(): Result = executeWhenNodeRunning("newAddress") { - val address = lightningService.newAddress() - Result.success(address) + runCatching { lightningService.newAddress() } } suspend fun createInvoice( @@ -541,8 +532,7 @@ class LightningRepo @Inject constructor( expirySeconds: UInt = 86_400u, ): Result = executeWhenNodeRunning("createInvoice") { updateGeoBlockState() - val invoice = lightningService.receive(amountSats, description, expirySeconds) - Result.success(invoice) + runCatching { lightningService.receive(amountSats, description, expirySeconds) } } @Suppress("ForbiddenComment") @@ -618,9 +608,9 @@ class LightningRepo @Inject constructor( bolt11: String, sats: ULong? = null, ): Result = executeWhenNodeRunning("payInvoice") { - val paymentId = lightningService.send(bolt11 = bolt11, sats = sats) - syncState() - Result.success(paymentId) + runCatching { lightningService.send(bolt11, sats) }.also { + syncState() + } } @Suppress("LongParameterList") @@ -641,20 +631,11 @@ class LightningRepo @Inject constructor( val satsPerVByte = getFeeRateForSpeed(transactionSpeed, feeRates).getOrThrow() // use passed utxos if specified, otherwise run auto coin select if enabled - val finalUtxosToSpend = utxosToSpend ?: determineUtxosToSpend( - sats = sats, - satsPerVByte = satsPerVByte, - ) + val finalUtxosToSpend = utxosToSpend ?: determineUtxosToSpend(sats, satsPerVByte) Logger.debug("UTXOs selected to spend: $finalUtxosToSpend", context = TAG) - val txId = lightningService.send( - address = address, - sats = sats, - satsPerVByte = satsPerVByte, - utxosToSpend = finalUtxosToSpend, - isMaxAmount = isMaxAmount - ) + val txId = lightningService.send(address, sats, satsPerVByte, finalUtxosToSpend, isMaxAmount) val preActivityMetadata = PreActivityMetadata( paymentId = txId, @@ -760,7 +741,7 @@ class LightningRepo @Inject constructor( satsPerVByte.toULong() }.onFailure { if (it !is CancellationException) { - Logger.error("getFeeRateForSpeed error: speed:$speed", it, context = TAG) + Logger.error("getFeeRateForSpeed error: speed: '$speed'", it, context = TAG) } } } @@ -787,13 +768,9 @@ class LightningRepo @Inject constructor( force: Boolean = false, forceCloseReason: String? = null, ): Result = executeWhenNodeRunning("closeChannel") { - lightningService.closeChannel( - channel, - force, - forceCloseReason, - ) - syncState() - Result.success(Unit) + runCatching { lightningService.closeChannel(channel, force, forceCloseReason) }.also { + syncState() + } } fun syncState() { @@ -856,13 +833,13 @@ class LightningRepo @Inject constructor( require(token.isNotEmpty()) { "FCM token is empty or null" } if (cachedToken == token) { - Logger.debug("Skipped registering for notifications, current device token already registered") + Logger.debug("registerForNotifications skipped, device token already registered") return@executeWhenNodeRunning Result.success(Unit) } lspNotificationsService.registerDevice(token) }.onFailure { - Logger.error("Register for notifications error", it, context = TAG) + Logger.error("registerForNotifications error", it, context = TAG) } } @@ -945,9 +922,9 @@ class LightningRepo @Inject constructor( executeWhenNodeRunning("estimateRoutingFeesForAmount") { Logger.info("Estimating routing fees for amount: '$amountSats'", context = TAG) lightningService.estimateRoutingFeesForAmount(bolt11, amountSats).onSuccess { - Logger.info("Routing fees estimated: $it", context = TAG) + Logger.info("Routing fees estimated: '$it'", context = TAG) }.onFailure { - Logger.error("estimateRoutingFees error", it, context = TAG) + Logger.error("estimateRoutingFeesForAmount error", it, context = TAG) } } From eabd2683309bd6cc7724688c4d168fa42db72b6d Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 14 Jan 2026 04:56:37 +0100 Subject: [PATCH 34/37] chore: update ai rules --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 6e7c93a5c..99291cca2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -207,7 +207,7 @@ suspend fun getData(): Result = withContext(Dispatchers.IO) { - ALWAYS wrap `ULong` numbers with `USat` in arithmetic operations, to guard against overflows - PREFER to use one-liners with `run { }` when applicable, e.g. `override fun someCall(value: String) = run { this.value = value }` - ALWAYS add imports instead of inline fully-qualified names -- NEVER use file-level `@file:Suppress(...)` annotations; ALWAYS add `@Suppress(...)` as close as possible to the erroring lines (function, property, or statement level) +- ALWAYS place `@Suppress(...)` annotations at the narrowest possible scope (statement, property, or function level); NEVER use `@file:Suppress(...)` when a more targeted suppression is possible. ### Architecture Guidelines From b3502be7fd30c0ec0aa40fb44281015895eaffcf Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 14 Jan 2026 06:00:23 +0100 Subject: [PATCH 35/37] chore: cleanup and reorder translation keys --- .../androidServices/LightningNodeService.kt | 4 +- .../commands/NotifyPaymentReceivedHandler.kt | 6 +- .../main/java/to/bitkit/ext/PeerDetails.kt | 4 +- .../main/java/to/bitkit/fcm/WakeNodeWorker.kt | 71 +- .../to/bitkit/models/ActivityBannerType.kt | 4 +- .../main/java/to/bitkit/ui/MainActivity.kt | 4 +- .../main/java/to/bitkit/ui/NodeInfoScreen.kt | 6 +- .../to/bitkit/ui/components/ActivityBanner.kt | 2 +- .../ui/components/NotificationPreview.kt | 2 +- .../BackgroundPaymentsSettings.kt | 2 +- app/src/main/java/to/bitkit/utils/Perf.kt | 3 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 4 +- .../to/bitkit/viewmodels/WalletViewModel.kt | 4 +- app/src/main/res/values/strings.xml | 2172 ++++++++--------- .../LightningNodeServiceTest.kt | 8 +- .../NotifyPaymentReceivedHandlerTest.kt | 2 +- 16 files changed, 1151 insertions(+), 1147 deletions(-) diff --git a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt index ad77c1f50..2bfe8257f 100644 --- a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt +++ b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt @@ -100,7 +100,7 @@ class LightningNodeService : Service() { } private fun createNotification( - contentText: String = getString(R.string.notification_running_in_background), + contentText: String = getString(R.string.notification__service__body), ): Notification { val notificationIntent = Intent(this, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP @@ -120,7 +120,7 @@ class LightningNodeService : Service() { .setContentIntent(pendingIntent) .addAction( R.drawable.ic_x, - getString(R.string.notification_stop_app), + getString(R.string.notification__service__stop), stopPendingIntent ) .build() diff --git a/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt b/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt index d3058a9dd..fb17d1adf 100644 --- a/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt +++ b/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt @@ -96,11 +96,11 @@ class NotifyPaymentReceivedHandler @Inject constructor( private suspend fun buildNotificationContent(sats: Long): NotificationDetails { val settings = settingsStore.data.first() - val title = context.getString(R.string.notification_received_title) + val title = context.getString(R.string.notification__received__title) val body = if (settings.showNotificationDetails) { formatNotificationAmount(sats, settings) } else { - context.getString(R.string.notification_received_body_hidden) + context.getString(R.string.notification__received__body_hidden) } return NotificationDetails(title, body) } @@ -117,7 +117,7 @@ class NotifyPaymentReceivedHandler @Inject constructor( } } ?: "$BITCOIN_SYMBOL ${sats.formatToModernDisplay()}" - return context.getString(R.string.notification_received_body_amount, amountText) + return context.getString(R.string.notification__received__body_amount, amountText) } companion object { diff --git a/app/src/main/java/to/bitkit/ext/PeerDetails.kt b/app/src/main/java/to/bitkit/ext/PeerDetails.kt index 6ed4e5543..ac7d22119 100644 --- a/app/src/main/java/to/bitkit/ext/PeerDetails.kt +++ b/app/src/main/java/to/bitkit/ext/PeerDetails.kt @@ -9,7 +9,7 @@ val PeerDetails.port get() = address.substringAfter(":") val PeerDetails.uri get() = "$nodeId@$address" -/*** Creates a [PeerDetails] object from a URI string.*/ +/** Creates a [PeerDetails] object from a URI string.*/ fun PeerDetails.Companion.of(uri: String): PeerDetails { val parts = uri.split("@") require(parts.size == 2) { "Invalid uri format, expected: '@:', got: '$uri'" } @@ -31,7 +31,7 @@ fun PeerDetails.Companion.of(uri: String): PeerDetails { ) } -/*** Creates a [PeerDetails] object from a node ID, host, and port.*/ +/** Creates a [PeerDetails] object from a node ID, host, and port.*/ fun PeerDetails.Companion.of(nodeId: String, host: String, port: String) = PeerDetails( nodeId = nodeId, address = "$host:$port", diff --git a/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt b/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt index 9c6a12d23..a3a12243b 100644 --- a/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt +++ b/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt @@ -61,7 +61,6 @@ class WakeNodeWorker @AssistedInject constructor( private val timeout = 2.minutes private val deliverSignal = CompletableDeferred() - @Suppress("TooGenericExceptionCaught") override suspend fun doWork(): Result { Logger.debug("Node wakeup from notification…", context = TAG) @@ -77,8 +76,8 @@ class WakeNodeWorker @AssistedInject constructor( Logger.warn("Notification type is null, proceeding with node wake", context = TAG) } - try { - measured(TAG) { + return runCatching { + measured(label = "doWork", context = TAG) { lightningRepo.start( walletIndex = 0, timeout = timeout, @@ -93,31 +92,35 @@ class WakeNodeWorker @AssistedInject constructor( Logger.error("Missing orderId", context = TAG) } else { Logger.info("Open channel request for order $orderId", context = TAG) - blocktankRepo.openChannel(orderId).onFailure { e -> - Logger.error("Failed to open channel", e, context = TAG) + blocktankRepo.openChannel(orderId).onFailure { + Logger.error("Failed to open channel", it, context = TAG) bestAttemptContent = NotificationDetails( - title = appContext.getString(R.string.notification_channel_open_failed_title), - body = e.message ?: appContext.getString(R.string.common__error_desc), + title = appContext.getString(R.string.notification__channel_open_failed_title), + body = it.message ?: appContext.getString(R.string.common__error_body), ) deliver() } } } } - withTimeout(timeout) { deliverSignal.await() } // Stops node on timeout & avoids notification replay by OS - return Result.success() - } catch (e: Exception) { - val reason = e.message ?: appContext.getString(R.string.common__error_desc) + // Stops node on timeout & avoids notification replay by OS + withTimeout(timeout) { deliverSignal.await() } + } + .fold( + onSuccess = { Result.success() }, + onFailure = { e -> + val reason = e.message ?: appContext.getString(R.string.common__error_body) - bestAttemptContent = NotificationDetails( - title = appContext.getString(R.string.notification_lightning_error_title), - body = reason, - ) - Logger.error("Lightning error", e, context = TAG) - deliver() + bestAttemptContent = NotificationDetails( + title = appContext.getString(R.string.notification__lightning_error_title), + body = reason, + ) + Logger.error("Lightning error", e, context = TAG) + deliver() - return Result.failure(workDataOf("Reason" to reason)) - } + Result.failure(workDataOf("Reason" to reason)) + } + ) } /** @@ -126,14 +129,14 @@ class WakeNodeWorker @AssistedInject constructor( */ private suspend fun handleLdkEvent(event: Event) { val showDetails = settingsStore.data.first().showNotificationDetails - val hiddenBody = appContext.getString(R.string.notification_received_body_hidden) + val hiddenBody = appContext.getString(R.string.notification__received__body_hidden) when (event) { is Event.PaymentReceived -> onPaymentReceived(event, showDetails, hiddenBody) is Event.ChannelPending -> { bestAttemptContent = NotificationDetails( - title = appContext.getString(R.string.notification_channel_opened_title), - body = appContext.getString(R.string.notification_channel_pending_body), + title = appContext.getString(R.string.notification__channel_opened_title), + body = appContext.getString(R.string.notification__channel_pending_body), ) // Don't deliver, give a chance for channelReady event to update the content if it's a turbo channel } @@ -143,7 +146,7 @@ class WakeNodeWorker @AssistedInject constructor( is Event.PaymentFailed -> { bestAttemptContent = NotificationDetails( - title = appContext.getString(R.string.notification_payment_failed_title), + title = appContext.getString(R.string.notification__payment_failed_title), body = "⚡ ${event.reason}", ) @@ -159,18 +162,18 @@ class WakeNodeWorker @AssistedInject constructor( private suspend fun onChannelClosed(event: Event.ChannelClosed) { bestAttemptContent = when (notificationType) { mutualClose -> NotificationDetails( - title = appContext.getString(R.string.notification_channel_closed_title), - body = appContext.getString(R.string.notification_channel_closed_mutual_body), + title = appContext.getString(R.string.notification__channel_closed__title), + body = appContext.getString(R.string.notification__channel_closed__mutual_body), ) orderPaymentConfirmed -> NotificationDetails( - title = appContext.getString(R.string.notification_channel_open_bg_failed_title), - body = appContext.getString(R.string.notification_please_try_again_body), + title = appContext.getString(R.string.notification__channel_open_bg_failed_title), + body = appContext.getString(R.string.notification__please_try_again_body), ) else -> NotificationDetails( - title = appContext.getString(R.string.notification_channel_closed_title), - body = appContext.getString(R.string.notification_channel_closed_reason_body, event.reason), + title = appContext.getString(R.string.notification__channel_closed__title), + body = appContext.getString(R.string.notification__channel_closed__reason_body, event.reason), ) } @@ -194,7 +197,7 @@ class WakeNodeWorker @AssistedInject constructor( ) val content = if (showDetails) "$BITCOIN_SYMBOL $sats" else hiddenBody bestAttemptContent = NotificationDetails( - title = appContext.getString(R.string.notification_received_title), + title = appContext.getString(R.string.notification__received__title), body = content, ) if (notificationType == incomingHtlc) { @@ -207,10 +210,10 @@ class WakeNodeWorker @AssistedInject constructor( showDetails: Boolean, hiddenBody: String, ) { - val viaNewChannel = appContext.getString(R.string.notification_via_new_channel_body) + val viaNewChannel = appContext.getString(R.string.notification__received__body_channel) if (notificationType == cjitPaymentArrived) { bestAttemptContent = NotificationDetails( - title = appContext.getString(R.string.notification_received_title), + title = appContext.getString(R.string.notification__received__title), body = viaNewChannel, ) @@ -236,8 +239,8 @@ class WakeNodeWorker @AssistedInject constructor( } } else if (notificationType == orderPaymentConfirmed) { bestAttemptContent = NotificationDetails( - title = appContext.getString(R.string.notification_channel_opened_title), - body = appContext.getString(R.string.notification_channel_ready_body), + title = appContext.getString(R.string.notification__channel_opened_title), + body = appContext.getString(R.string.notification__channel_ready_body), ) } deliver() diff --git a/app/src/main/java/to/bitkit/models/ActivityBannerType.kt b/app/src/main/java/to/bitkit/models/ActivityBannerType.kt index d13d6df9c..cd366c294 100644 --- a/app/src/main/java/to/bitkit/models/ActivityBannerType.kt +++ b/app/src/main/java/to/bitkit/models/ActivityBannerType.kt @@ -14,11 +14,11 @@ enum class ActivityBannerType( SPENDING( color = Colors.Purple, icon = R.drawable.ic_transfer, - title = R.string.activity_banner__transfer_in_progress + title = R.string.lightning__transfer_in_progress ), SAVINGS( color = Colors.Brand, icon = R.drawable.ic_transfer, - title = R.string.activity_banner__transfer_in_progress + title = R.string.lightning__transfer_in_progress ) } diff --git a/app/src/main/java/to/bitkit/ui/MainActivity.kt b/app/src/main/java/to/bitkit/ui/MainActivity.kt index 541eb8cad..445559a94 100644 --- a/app/src/main/java/to/bitkit/ui/MainActivity.kt +++ b/app/src/main/java/to/bitkit/ui/MainActivity.kt @@ -79,8 +79,8 @@ class MainActivity : FragmentActivity() { initNotificationChannel() initNotificationChannel( id = CHANNEL_ID_NODE, - name = getString(R.string.notification_channel_node_name), - desc = getString(R.string.notification_channel_node_desc), + name = getString(R.string.notification__channel_node__name), + desc = getString(R.string.notification__channel_node__body), importance = NotificationManager.IMPORTANCE_LOW ) appViewModel.handleDeeplinkIntent(intent) diff --git a/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt b/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt index ff439a5b7..5e81df27d 100644 --- a/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt +++ b/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt @@ -201,7 +201,7 @@ private fun NodeStateSection( nodeStatus?.let { status -> SettingsTextButtonRow( - title = stringResource(R.string.common_ready), + title = stringResource(R.string.common__ready), value = if (status.isRunning) "✅" else "⏳", ) SettingsTextButtonRow( @@ -345,7 +345,7 @@ private fun ChannelsSection( VerticalSpacer(8.dp) ChannelDetailRow( - title = stringResource(R.string.common_ready), + title = stringResource(R.string.common__ready), value = if (channel.isChannelReady) "✅" else "❌", ) ChannelDetailRow( @@ -377,7 +377,7 @@ private fun ChannelsSection( value = "₿ ${(channel.nextOutboundHtlcMinimumMsat / 1000u).formatToModernDisplay()}", ) ChannelDetailRow( - title = stringResource(R.string.common_confirmations), + title = stringResource(R.string.common__confirmations), value = "${channel.confirmations ?: 0}/${channel.confirmationsRequired ?: 0}", ) diff --git a/app/src/main/java/to/bitkit/ui/components/ActivityBanner.kt b/app/src/main/java/to/bitkit/ui/components/ActivityBanner.kt index 00d35fbee..5d03ad156 100644 --- a/app/src/main/java/to/bitkit/ui/components/ActivityBanner.kt +++ b/app/src/main/java/to/bitkit/ui/components/ActivityBanner.kt @@ -183,7 +183,7 @@ private fun Preview() { items(items = ActivityBannerType.entries) { item -> ActivityBanner( gradientColor = item.color, - title = stringResource(R.string.activity_banner__transfer_in_progress), + title = stringResource(R.string.lightning__transfer_in_progress), icon = item.icon, onClick = {}, modifier = Modifier.fillMaxWidth() diff --git a/app/src/main/java/to/bitkit/ui/components/NotificationPreview.kt b/app/src/main/java/to/bitkit/ui/components/NotificationPreview.kt index ab850572f..2169aa50b 100644 --- a/app/src/main/java/to/bitkit/ui/components/NotificationPreview.kt +++ b/app/src/main/java/to/bitkit/ui/components/NotificationPreview.kt @@ -53,7 +53,7 @@ fun NotificationPreview( BodySSB(text = title, color = Colors.Black) val textDescription = when (showDetails) { true -> description - else -> stringResource(R.string.notification_received_body_hidden) + else -> stringResource(R.string.notification__received__body_hidden) } AnimatedContent(targetState = textDescription) { text -> Footnote(text = text, color = Colors.Gray3) diff --git a/app/src/main/java/to/bitkit/ui/settings/backgroundPayments/BackgroundPaymentsSettings.kt b/app/src/main/java/to/bitkit/ui/settings/backgroundPayments/BackgroundPaymentsSettings.kt index 3274c2eb3..9242f686f 100644 --- a/app/src/main/java/to/bitkit/ui/settings/backgroundPayments/BackgroundPaymentsSettings.kt +++ b/app/src/main/java/to/bitkit/ui/settings/backgroundPayments/BackgroundPaymentsSettings.kt @@ -107,7 +107,7 @@ private fun Content( NotificationPreview( enabled = hasPermission, - title = stringResource(R.string.notification_received_title), + title = stringResource(R.string.notification__received__title), description = "₿ 21 000", showDetails = showDetails, modifier = Modifier.fillMaxWidth() diff --git a/app/src/main/java/to/bitkit/utils/Perf.kt b/app/src/main/java/to/bitkit/utils/Perf.kt index d1a120f37..b5ef217aa 100644 --- a/app/src/main/java/to/bitkit/utils/Perf.kt +++ b/app/src/main/java/to/bitkit/utils/Perf.kt @@ -15,12 +15,13 @@ fun Duration.formatted(): String = toComponents { hours, minutes, seconds, nanos internal inline fun measured( label: String, + context: String, block: () -> T, ): T { var result: T val elapsed = measureTime { result = block() } - Logger.perf("$label took ${elapsed.formatted()}") + Logger.perf("$label took ${elapsed.formatted()}", context = context) return result } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 12b48c6c1..71ac4dcb1 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -1359,7 +1359,7 @@ class AppViewModel @Inject constructor( toast( type = Toast.ToastType.ERROR, title = context.getString(R.string.wallet__error_sending_title), - description = e.message ?: context.getString(R.string.common__error_desc) + description = e.message ?: context.getString(R.string.common__error_body) ) hideSheet() } @@ -1815,7 +1815,7 @@ class AppViewModel @Inject constructor( toast( type = Toast.ToastType.ERROR, title = context.getString(R.string.common__error), - description = error.message ?: context.getString(R.string.common__error_desc) + description = error.message ?: context.getString(R.string.common__error_body) ) } diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index 5444e5976..2dc53c7be 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -306,7 +306,7 @@ class WalletViewModel @Inject constructor( ToastEventBus.send( type = Toast.ToastType.ERROR, title = context.getString(R.string.common__error), - description = it.message ?: context.getString(R.string.common__error_desc) + description = it.message ?: context.getString(R.string.common__error_body) ) } } @@ -317,7 +317,7 @@ class WalletViewModel @Inject constructor( ToastEventBus.send( type = Toast.ToastType.ERROR, title = context.getString(R.string.wallet__error_invoice_update), - description = error.message ?: context.getString(R.string.common__error_desc) + description = error.message ?: context.getString(R.string.common__error_body) ) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 890c336bb..ea8614b1f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,421 +1,456 @@ - Back up Store your bitcoin - Discount + Back up + Please try again + Failed + Buy some bitcoin + Buy Spending Balance - QuickPay - Scan and pay - Invite + Discount Share Bitkit - Spend - Instant payments - Transfer - In progress... - Connecting + Invite Ready in ±10m - Ready + Connecting Connected! - Transfer - Ready in ±{duration} - Initiating - Keep app open - Secure + Ready + In progress... + Transfer + Instant payments + Spend + When Bitkit is closed + Get paid Set up a PIN code - Shop + Secure + Scan and pay + QuickPay Shop with Bitcoin - Profile + Shop Add your details - Support - Get assistance - Buy - Buy some bitcoin - Failed - Please try again - Get paid - When Bitkit is closed + Profile Suggestions - TRANSFER IN PROGRESS + Get assistance + Support + Keep app open + Initiating + Ready in ±{duration} + Transfer Advanced - Continue + Announced + Are You Sure? + Back Cancel Close - Are You Sure? - Yes, Proceed - Try Again - No, Cancel - ₿ / vbyte - ₿/vbyte - Edit - Empty + Confirmations + Connect + Continue + Copied To Clipboard Copy - Share - Search - Discard - Save - Done + Default Delete Yes, Delete + No, Cancel + Discard + Done + Edit + Empty + Error + Unknown error + Later + Learn More + Max + Min + Never + No OK Awesome!\nNice!\nCool!\nGreat!\nFantastic!\nSweet!\nExcellent!\nTerrific! + Preview + Ready Reset Retry - Later + ₿ / vbyte + ₿/vbyte + Save + Search + Share Skip - Copied To Clipboard - Yes - No - Back - Learn More - Never - Understood - Connect - Min - Max - Default - Preview - Error - Unknown error Success - Instant - ±2-10 seconds - 2-10s - ±2s - Fast + Try Again + Understood + Usable + Yes + Yes, Proceed + Depends on the fee + Depends on the fee + Depends on the fee + Custom ±10-20 minutes - 10-20m ±10m - Normal + 10-20m + Fast + ±2-10 seconds + ±2s + 2-10s + Instant + +2 hours + +2h + +2h + Minimum ±20-60 minutes - 20-60m ±20m - Slow + 20-60m + Normal ±1-2 hours - 1-2h ±1h - Minimum - +2 hours - +2h - +2h - Custom - Depends on the fee - Depends on the fee - Depends on the fee - Spending\n<accent>Balance</accent> - Fund your spending balance to enjoy instant and cheap transactions with friends, family, and merchants. - Get Started + 1-2h + Slow + Funds transfer to savings is usually instant, but settlement may take up to <accent>14 days</accent> under certain network conditions. + Funds\n<accent>availability</accent> + Balance + Spending base fee + Block Height + Channel ID + Peer ID + You can now pay anyone, anywhere, instantly. + Spending Balance Ready + Channel point + Close + Close Connection + Transfer Failed + Unable to transfer your funds back to savings. Please try again. + Your funds are being transferred back to your savings. + Transfer Initiated + The fee to close this Lightning Connection and transfer your funds back to your savings depends on network conditions.\n\nFunds transfer to savings is usually instant, but settlement may take up to <accent>14 days</accent> under certain network conditions. + Closed on + Closure reason + Add Connection + Export Logs + Closed connections + Hide Closed & Failed + Show Closed & Failed + Failed connections + Open connections + Pending connections + Connection + The funds on your spending balance have been transferred to your savings. + Connection Closed + Lightning Connections + Created on + Bitkit could not add the Lightning peer. + Unable To Add Lightning Peer + Bitkit cannot add Tor nodes. + The URI appears to be invalid. + Instant Payments Setup Failed + Receiving amount needs to be greater than ${usdValue} + An error occurred when setting up your instant balance. {raw} + Unable To Decode Invoice + Failed to Create Invoice + Log Export Failed + Bitkit was not able to export the LDK logs. + Unable to save lightning peer + Unable To Save Lightning Peer + Export Lightning Logs + Lightning Connection + Spending\n<accent>balance</accent> + Host + Node ID + Paste Node URI + Port + Scan QR + You can use an external node to manually open a Lightning connection. Enter the node details to continue. + <accent>Manual setup</accent> + Lightning connection initiated. You will be able to use your spending balance in <accent>±30 minutes</accent> (depends on node configuration). + Connection\n<accent>initiated</accent> + Fee rate + Fee Rate Cache Update Time + Fees + Force Transfer + Some connections could not be closed. + Unable to transfer your funds back to savings. Please try again. + Force Transfer Failed + Your funds will be accessible in ±14 days. + Force Transfer Initiated + Force Transfer + Could not initiate transfer. Do you want to force this transfer? You won’t be able to use these funds for ±14 days (!) + Force\n<accent>Transfer</accent> + Transfer from Savings + Use Other Wallet + Advanced Spending Balance - Fund your <accent>spending balance</accent> You can use your Bitkit savings or send bitcoin from a different wallet. Bitkit does not currently provide Lightning services in your country, but you can still connect to other nodes directly. Bitkit does not currently provide Lightning services in your country, but you can still connect to other nodes directly. - Transfer from Savings - Use Other Wallet - Advanced - Spending Balance - Advanced <accent>setup</accent> - Scan a QR to claim your LNURL Channel from another LSP, or choose manual setup. + Fund your <accent>spending balance</accent> LNURL Channel QR Manual Setup - No Available Funds + Spending Balance + Scan a QR to claim your LNURL Channel from another LSP, or choose manual setup. + Advanced <accent>setup</accent> + Inbound Capacity + Inbound HTLC Max + Inbound HTLC Min + Copied Invoice to Clipboard + Is Usable + Lightning Balances + Spending Balance Liquidity + Your Spending Balance uses the Lightning Network to make your payments cheaper, faster, and more private.\n\nThis works like internet access, but you pay for liquidity & routing instead of bandwidth.\n\nThis setup includes some one-time costs. + Liquidity\n<accent>& routing</accent> + Channel Monitor Archival Height + Next Outbound HTLC Limit + Next Outbound HTLC Min Before you can transfer funds from your savings balance, you need to send bitcoin to your Bitkit wallet. Fund wallet - Transfer Funds - Please\n<accent>confirm</accent> - Custom <accent>fee</accent> - Swipe To Transfer - Transfer\n<accent>to spending</accent> - Transfer funds to your spending balance to enjoy instant and cheap transactions with friends, family, and merchants. - Get Started - Transfer Failure - Transfer\n<accent>failed</accent> - Sorry, Bitkit could not set up your spending balance or complete your transfer. Please try again. - Inspect Error - Try Again - Transfer\n<accent>to spending</accent> - 25% - Savings Balance Minimum - A minimum of ₿ {amount} is needed to set up your spending balance. - Spending Balance Maximum - The amount you can transfer to your spending balance is currently limited to ₿ {amount}. - Your transfer to the spending balance is limited due to liquidity policy. For details, visit the Help Center. - Network fees - Service fees - To spending - Total - Use Defaults - Receiving\n<accent>capacity</accent> - Liquidity fee - Liquidity\n<accent>& routing</accent> - Your Spending Balance uses the Lightning Network to make your payments cheaper, faster, and more private.\n\nThis works like internet access, but you pay for liquidity & routing instead of bandwidth.\n\nThis setup includes some one-time costs. - Spending Balance Liquidity - Transfer\n<accent>to savings</accent> - Transfer your spending balance to your savings to store your Bitcoin long-term and secure your future. - Get Started - Funds\n<accent>availability</accent> - Funds transfer to savings is usually instant, but settlement may take up to <accent>14 days</accent> under certain network conditions. - Transfer to savings - Transfer all - Select funds\n<accent>to transfer</accent> + No Available Funds + disconnected + Bitkit failed to initialize the Lightning node. + LDK Node ID + Lightning Node + Lightning Wallet Sync Time + Onchain Wallet Sync Time + Opened on + Order ID + Order Details + The setup process expired. Please try again. + Instant Payments Setup Failed + Order Expiry + Order fee + The setup process expired. Please try again. + Instant Payments Setup Failed + Processing payment + Connection closed + Connection closing + Order expired + Given up + Connection inactive + Connection open + Opening connection + Payment successful + Payment canceled + Queued for opening + Refund available + Payment refunded + Other + The Lightning peer was successfully added and saved. + Receiving capacity + Reserve balance (not spendable) + Rapid Gossip Sync Snapshot Time + Savings You can transfer part of your spending balance to savings, because you have multiple active Lightning Connections. + Select funds\n<accent>to transfer</accent> Total selected - Funds\n<accent>in transfer</accent> - Please wait, your funds transfer is in progress. This should take <accent>±10 seconds.</accent> + Transfer to savings + Transfer all Transfer\n<accent>interrupted</accent> - Keep Bitkit\n<accent>open</accent> Funds were not transferred yet. Bitkit will try to initiate the transfer during the next <accent>30 minutes</accent>. Please keep your app open. - Transfer Successful - Funds moved\n<accent>to spending</accent> - Your funds have been transferred and your spending balance is ready to use. - Funds moved\n<accent>to savings</accent> - Your funds have been transferred. You are able to use these funds immediately. - Lightning Connection - <accent>Manual setup</accent> - You can use an external node to manually open a Lightning connection. Enter the node details to continue. - Node ID - Host - Port - Paste Node URI - Scan QR - Spending\n<accent>balance</accent> - Connection\n<accent>initiated</accent> - Lightning connection initiated. You will be able to use your spending balance in <accent>±30 minutes</accent> (depends on node configuration). - Instant Payments Setup Failed - An error occurred when setting up your instant balance. {raw} - Receiving amount needs to be greater than ${usdValue} - Spending - Savings - Spending balance - Receiving capacity + Keep Bitkit\n<accent>open</accent> + Get Started + Transfer your spending balance to your savings to store your Bitcoin long-term and secure your future. + Transfer\n<accent>to savings</accent> + Please wait, your funds transfer is in progress. This should take <accent>±10 seconds.</accent> + Funds\n<accent>in transfer</accent> + Pathfinding Scores Sync Time + Continue Using Bitkit In transfer - Please wait, your funds transfer is in progress. This should take <accent>±10 minutes.</accent> Processing Payment Payment Successful Queued For Opening Opening Connection - Continue Using Bitkit - Lightning Node - LDK Node ID - disconnected - Bitkit failed to initialize the Lightning node. - Lightning Connections - Pending connections - Open connections - Closed connections - Failed connections - Show Closed & Failed - Hide Closed & Failed - Export Logs - Add Connection - Export Lightning Logs - Log Export Failed - Bitkit was not able to export the LDK logs. - Failed to Create Invoice - Unable To Add Lightning Peer - Bitkit could not add the Lightning peer. - The URI appears to be invalid. - Bitkit cannot add Tor nodes. - Unable To Save Lightning Peer - Unable to save lightning peer - Unable To Decode Invoice - The Lightning peer was successfully added and saved. - Copied Invoice to Clipboard - Connection + Please wait, your funds transfer is in progress. This should take <accent>±10 minutes.</accent> + Spendable Onchain + Spending + Liquidity fee + Receiving\n<accent>capacity</accent> + The amount you can transfer to your spending balance is currently limited to ₿ {amount}. + Your transfer to the spending balance is limited due to liquidity policy. For details, visit the Help Center. + Spending Balance Maximum + A minimum of ₿ {amount} is needed to set up your spending balance. + Savings Balance Minimum + 25% + Transfer\n<accent>to spending</accent> + To spending + Use Defaults + Service fees + Network fees + Total + Get Started + Transfer funds to your spending balance to enjoy instant and cheap transactions with friends, family, and merchants. + Transfer\n<accent>to spending</accent> + Spending balance Status - Order Details - Order ID - Created on - Order Expiry - Transaction - Order fee - Balance - Reserve balance (not spendable) - Total channel size - Fees - Spending base fee - Fee rate - Other - Is Usable - Opened on - Closed on - Peer ID - Channel ID - Channel point - Closure reason Support - Connection Closed - The funds on your spending balance have been transferred to your savings. - Close Connection - Transfer Failed - Unable to transfer your funds back to savings. Please try again. - Transfer Initiated - Your funds are being transferred back to your savings. - The fee to close this Lightning Connection and transfer your funds back to your savings depends on network conditions.\n\nFunds transfer to savings is usually instant, but settlement may take up to <accent>14 days</accent> under certain network conditions. - Close - Force Transfer - Force\n<accent>Transfer</accent> - Could not initiate transfer. Do you want to force this transfer? You won’t be able to use these funds for ±14 days (!) - Force Transfer - Force Transfer Initiated - Your funds will be accessible in ±14 days. - Some connections could not be closed. - Force Transfer Failed - Unable to transfer your funds back to savings. Please try again. - Spending Balance Ready - You can now pay anyone, anywhere, instantly. - Instant Payments Setup Failed - The setup process expired. Please try again. - Instant Payments Setup Failed - The setup process expired. Please try again. - Processing payment - Payment canceled - Payment successful - Refund available - Payment refunded - Queued for opening - Opening connection - Connection closing - Given up - Order expired - Connection closed - Connection open - Connection inactive - Please wait for Bitkit to connect to the payment network (±10 seconds). + Total Anchor Channels Reserve + Total Lightning + Total Onchain + Total channel size + Transaction + Please\n<accent>confirm</accent> + Custom <accent>fee</accent> + Transfer Funds + Swipe To Transfer + Inspect Error + Try Again + Transfer Failure + Sorry, Bitkit could not set up your spending balance or complete your transfer. Please try again. + Transfer\n<accent>failed</accent> + TRANSFER IN PROGRESS + Get Started + Fund your spending balance to enjoy instant and cheap transactions with friends, family, and merchants. + Spending\n<accent>Balance</accent> + Transfer Successful + Your funds have been transferred. You are able to use these funds immediately. + Your funds have been transferred and your spending balance is ready to use. + Funds moved\n<accent>to savings</accent> + Funds moved\n<accent>to spending</accent> Connecting & Syncing... - Bitkit\n<accent>terms of use</accent> - Terms of use - I declare that I have read and accept the terms of use. + Please wait for Bitkit to connect to the payment network (±10 seconds). + Wallet Balances + Please wait while your old wallet data migrates to this new Bitkit version... + Wallet Migration + MIGRATING\n<accent>WALLET</accent> + Balance moved from spending to savings + Reason: %s + Channel closed + Notification channel for Lightning node foreground service + Lightning node notification + Channel failed to open in the background + Channel open failed + Channel opened + Pending + Ready to send + Lightning error + Payment failed + Please try again + Received %s + Via new channel + Open Bitkit to see details + Payment Received + Bitkit is running in background so you can receive Lightning payments + Stop App + Advanced + Advanced Setup + Create New Wallet + To get\nstarted\n<accent>send\nBitcoin</accent>\nto your\nwallet + Wallet Creation Failed + Get Started + Setting up\n<accent>your wallet</accent> + <accent>Caution:</accent>\nmultiple devices + Don\'t install your Bitkit recovery phrase into multiple phones simultaneously, as this can corrupt your data. + New Wallet + Passphrase + Secure with <accent>Passphrase</accent> + You can add a secret passphrase to the 12-word recovery phrase. If you do, make sure you don’t forget. Privacy Policy I declare that I have read and accept the <accent>privacy policy.</accent> - You can ₿ \n<accent>the change</accent> - Use Bitkit to pay anyone, anywhere, any time, and spend your bitcoin on the things you value in life. - Get Started - Skip Intro + Restore + Bitkit could not restore your wallet from backup or recovery phrase. + Wallet Restore Failed + Spending balance <accent>error</accent> + Bitkit restored your savings, but failed to restore your current spending balance (Lightning state) and wallet data. + <accent>Restore</accent>\nyour wallet + The checksum for the recovery phrase appears to be incorrect. Please double check your recovery phrase. + Proceed Without Backup + If you previously had a lightning backup it will be overwritten and lost. This could result in a loss of funds. + *Optional, enter only if you’ve set up one. + Passphrase* + Please type in your recovery phrase from any (paper) backup. + If a word is shown in <accent>red</accent>, it means that it was not found in the recovery phrase dictionary. Check for spelling errors. + Wallet <accent>restored</accent> + You have successfully restored your wallet from backup. Enjoy Bitkit! + SUGGESTIONS + Restore Wallet Skip + Skip Intro Freedom in\n<accent>your pocket</accent> Bitkit hands you the keys to manage your money. Spend now or save for later. The choice is yours. Instant\n<accent>payments</accent> - Spend bitcoin faster than ever. Enjoy instant and cheap payments with friends, family, and merchants. *Bitkit does not currently provide Lightning services in your country, but you can still connect to other nodes. + Spend bitcoin faster than ever. Enjoy instant and cheap payments with friends, family, and merchants. Bitcoiners,\n<accent>borderless</accent> Take charge of your digital life with portable profiles and payable contacts. Privacy is\n<accent>not a crime</accent> Swipe to hide your balance, enjoy more private payments, and protect your wallet by enabling security features. Your keys,\n<accent>your coins</accent> - Let’s create your wallet. Please be aware that Bitkit is mobile software. <accent>Don’t store all your money in Bitkit.</accent> - New Wallet - Restore - Restore Wallet - Advanced Setup - Advanced - Secure with <accent>Passphrase</accent> - You can add a secret passphrase to the 12-word recovery phrase. If you do, make sure you don’t forget. - Passphrase - Create New Wallet - <accent>Restore</accent>\nyour wallet - Please type in your recovery phrase from any (paper) backup. - If a word is shown in <accent>red</accent>, it means that it was not found in the recovery phrase dictionary. Check for spelling errors. - The checksum for the recovery phrase appears to be incorrect. Please double check your recovery phrase. - SUGGESTIONS - Passphrase* - *Optional, enter only if you’ve set up one. - Wallet Restore Failed - Bitkit could not restore your wallet from backup or recovery phrase. - Setting up\n<accent>your wallet</accent> - Wallet <accent>restored</accent> - You have successfully restored your wallet from backup. Enjoy Bitkit! - Spending balance <accent>error</accent> - Bitkit restored your savings, but failed to restore your current spending balance (Lightning state) and wallet data. - Proceed Without Backup - If you previously had a lightning backup it will be overwritten and lost. This could result in a loss of funds. - To get\nstarted\n<accent>send\nBitcoin</accent>\nto your\nwallet - Wallet Creation Failed - <accent>Caution:</accent>\nmultiple devices - Don\'t install your Bitkit recovery phrase into multiple phones simultaneously, as this can corrupt your data. - Scan QR Code - Unable To Read QR - Bitkit is not able to read this QR code. - Unable to Read Data - Bitkit could not read the provided data. - Incorrect Network - Bitkit is currently set to {selectedNetwork} but data is for {dataNetwork}. - Paste QR Code - Buy some\n<accent>Bitcoin</accent> - Don’t have any Bitcoin or need more? - Choose Exchange - Data Connection Issue - Could not load primary key from keychain. - Electrum Connection Restored - Bitkit successfully reconnected to Electrum. - Internet Connectivity Issues - It appears you’re disconnected, trying to reconnect... - Reconnecting To Electrum - Lost connection to Electrum, trying to reconnect... - Internet Connection Restored - Bitkit successfully reconnected to the Internet. - High Balance - High\n<accent>Balance</accent> - <accent>Your wallet balance exceeds $500.</accent>\nFor your security, consider moving some of your savings to an offline wallet. - Understood - Learn More - Critical Update - Update\n<accent>Bitkit now</accent> - There is a critical update for Bitkit. You must update to continue using Bitkit. - Update Bitkit - Permission to use camera + Let’s create your wallet. Please be aware that Bitkit is mobile software. <accent>Don’t store all your money in Bitkit.</accent> + Terms of use + I declare that I have read and accept the terms of use. + Bitkit\n<accent>terms of use</accent> + Use Bitkit to pay anyone, anywhere, any time, and spend your bitcoin on the things you value in life. + You can ₿ \n<accent>the change</accent> + You need {delta} more to complete this transaction. + Unable to add LSP node as a peer at this time. + Transfer Failed + An error occurred when moving funds. {raw} + Choose Exchange + Buy some\n<accent>Bitcoin</accent> + Don’t have any Bitcoin or need more? Bitkit needs permission to use your camera + Permission to use camera Failed to initialize camera: {message} <bold>It appears Bitkit does not have permission to access your camera.</bold>\n\nTo utilize this feature in the future you will need to enable camera permissions for this app from your phone\'s settings. Enable camera Allow camera access to scan bitcoin invoices and pay more quickly. SCAN\n<accent>QR CODE</accent> Scan QR - Clipboard Data Detected Do you want to be redirected to the relevant screen? + Clipboard Data Detected Coming soon - Insufficient Savings - Insufficient Spending Balance - More ₿ needed to pay this Bitcoin invoice. - ₿ {amount} more needed to pay this Bitcoin invoice. - ₿ {amount} more needed to pay this Lightning invoice. - Swipe To Confirm - Decoding Error - Unable To Interpret Provided Data - This QR code does not appear to contain payment data. - Bitcoin Price Update Failed - Bitkit could not update the current Bitcoin exchange rate. Using price from {date} - Bitkit could not update the current Bitcoin exchange rate. Please try again later. - Unable To Pay (LNURL) - Could not start local Lightning node. Please try again or restart Bitkit. - Not enough outbound/sending capacity to complete lnurl-pay request. + Bitkit successfully reconnected to the Internet. + Internet Connection Restored + Internet Connectivity Issues + It appears you’re disconnected, trying to reconnect... + Lost connection to Electrum, trying to reconnect... + Reconnecting To Electrum + Bitkit successfully reconnected to Electrum. + Electrum Connection Restored + EARLIER + Data Connection Issue + Could not load primary key from keychain. + Claiming your Bitkit gift code... + Claiming Gift + OK + Bitkit couldn\'t claim the funds. Please try again later or contact us. + Gift Code Error + OK + This Bitkit gift code has already been used, and the funds have been paid out. + Used Code + Sorry, you\'re too late! All gifts for this code have already been claimed. + Out of Gifts + Learn More + Understood + High Balance + <accent>Your wallet balance exceeds $500.</accent>\nFor your security, consider moving some of your savings to an offline wallet. + High\n<accent>Balance</accent> + Sign In Failed (LNURL) + An error occurred when you attempted to sign in. {raw} + Log In + Log in to {domain}? + Log In + You successfully signed in to {domain}. + You successfully signed in. + Signed In + Bitkit could not connect to Blocktank. Unable To Open Channel (LNURL) An error occured when you tried paying: {raw} Lightning Connection - New\nlightning\n<accent>connection</accent> - Do you want open a Lightning connection with this Lightning Service Provider? + Host Lightning service provider + Do you want open a Lightning connection with this Lightning Service Provider? Node ID - Host Port - Bitkit could not connect to Blocktank. - Channel Requested - Successfully requested channel from: {peer} Successfully requested channel. - Log In - Log in to {domain}? - Log In - Sign In Failed (LNURL) - An error occurred when you attempted to sign in. {raw} - Signed In - You successfully signed in to {domain}. - You successfully signed in. + Successfully requested channel from: {peer} + Channel Requested + New\nlightning\n<accent>connection</accent> + Could not start local Lightning node. Please try again or restart Bitkit. + Unable To Pay (LNURL) + Not enough outbound/sending capacity to complete lnurl-pay request. Withdraw Failed (LNURL) - Sorry, an error occurred. - Not enough receiving capacity to complete withdraw request. Could not create invoice for withdraw. + Sorry, an error occurred. Incorrect LNURL withdraw params, min/max not set correctly. - Withdraw Requested + Not enough receiving capacity to complete withdraw request. Your withdraw was successfully requested. Waiting for payment. + Withdraw Requested Failed to create log zip file Error starting: %1$s Setting up wallet… @@ -423,777 +458,801 @@ Starting Stopped Stopping + Insufficient Savings + ₿ {amount} more needed to pay this Bitcoin invoice. + More ₿ needed to pay this Bitcoin invoice. + Insufficient Spending Balance + ₿ {amount} more needed to pay this Lightning invoice. Open Phone Settings - Transfer Failed - Unable to add LSP node as a peer at this time. - An error occurred when moving funds. {raw} - You need {delta} more to complete this transaction. - EARLIER - Update Available - Update\n<accent>Bitkit</accent> - Please update Bitkit to the latest version for new features and bug fixes! - Update - Please try again. - Important: Bitkit Transfer - Open Bitkit to complete your transfer - Bitkit is unable to read the provided data. + Unable To Read QR + Incorrect Network + Bitkit is currently set to {selectedNetwork} but data is for {dataNetwork}. + Unable to Read Data + Bitkit could not read the provided data. + Bitkit is not able to read this QR code. + Paste QR Code + Scan QR Code + Bitkit could not update the current Bitcoin exchange rate. Using price from {date} + Bitkit could not update the current Bitcoin exchange rate. Please try again later. + Bitcoin Price Update Failed This Lightning invoice has expired. - Claiming Gift - Claiming your Bitkit gift code... - Gift Code Error - Bitkit couldn\'t claim the funds. Please try again later or contact us. - OK - Used Code - This Bitkit gift code has already been used, and the funds have been paid out. - OK - Out of Gifts - Sorry, you\'re too late! All gifts for this code have already been claimed. - Shop - Get your life on the Bitcoin standard. Spend your Bitcoin on digital gift cards, eSIMs, phone refills, and more. - Get Started - Shop - Shop - Map - Gift card categories - Gift Cards - Shop with Bitcoin - ESims + Bitkit is unable to read the provided data. + Decoding Error + Unable To Interpret Provided Data + This QR code does not appear to contain payment data. Go borderless - Phone Refill + ESims + Shop with Bitcoin + Gift Cards + Gift card categories + Shop Top up your phone - Travel + Phone Refill + Map + Shop Book your ₿ holiday + Travel + Get Started + Get your life on the Bitcoin standard. Spend your Bitcoin on digital gift cards, eSIMs, phone refills, and more. + Shop Shop - Wallet Backup - <accent>Safely store</accent> your Bitcoin + Swipe To Confirm + Open Bitkit to complete your transfer + Important: Bitkit Transfer + Please try again. + Update + Update Bitkit + Critical Update + There is a critical update for Bitkit. You must update to continue using Bitkit. + Update\n<accent>Bitkit now</accent> + Update Available + Please update Bitkit to the latest version for new features and bug fixes! + Update\n<accent>Bitkit</accent> + Authorize + Authorizing... + This service claims to be + Deny + Make sure you trust this service before granting permission to manage your data. + Unable to auth with Pubky service + Pubky Auth Error + Unable to retrieve Pubky key + Pubky Error + Requested Permissions + Success + Authorization + Back Up Now that you have some funds in your wallet, it is time to back up your money! There are no funds in your wallet yet, but you can create a backup if you wish. - Back Up - passphrase - Your Passphrase - You added a passphrase to your recovery phrase during wallet setup. - <accent>Never share</accent> your passphrase with anyone as this may result in the loss of funds. - Confirm Passphrase - Enter the passphrase you added while setting up and creating your wallet. - <accent>Passphrase:</accent> {passphrase} - Mnemonic Phrase - Your Recovery Phrase - Wallet Failure - Bitkit could not read your recovery phrase. - Write down these {length} words in the right order and store them in a safe place. - Use the 12 words below to recover your money at a later date. - Tap To Reveal - <accent>Never share</accent> your recovery phrase with anyone as this may result in the loss of funds. + <accent>Safely store</accent> your Bitcoin + Wallet Backup + Biometrics + PIN code set. Would you like to use {biometricsName} instead of your PIN code? + Authenticate with {biometricsName} + Confirm {biometricsName} + Bitkit could not set up {type} for your device. + Biometrics Setup Failed + Face ID + Loading... + It appears that your device does not support Biometric security. + Looks like you haven’t set up biometric security yet (or it is not supported). Try to enable it in your phone settings. + Phone Settings + Touch ID + Use {biometricsName} + Close Bitkit + Contact Support + You have successfully changed your PIN to a new 4-digit combination. + PIN changed + Please retype your 4-digit PIN to complete the setup process. + Retype New PIN + Please use a PIN you will remember. If you forget your PIN you can reset it, but that will require restoring your wallet. + Set New PIN + You can change your PIN code to a new\n4-digit combination. Please enter your current PIN code first. + Change PIN + Try again, this is not the same PIN. + Show Seed Phrase + Export + Bitkit could not create the backup file. + Bitkit could not export the backup file to your phone. + Backup Failed + Password + Share backup file + Bitkit successfully exported the backup file to your phone. + Backup Exported + You can export a copy of your wallet data as a .ZIP file. This file is encrypted with the password you set below. + Export To Phone Confirm Recovery Phrase Tap the 12 words in the correct order. Mnemonic copied to clipboard - Successful - Make sure you store your recovery phrase in a <accent>secure place</accent>, as this is the <accent>only way to recover</accent> your money! - Keep It Safe - Remember, <accent>never share your recovery phrase</accent> with anyone! If you do, they can steal your money, profile and other data. Wallet Data Your profile, contacts, accounts, tags, and activity will be backed up automatically to our free cloud service. - Multiple Devices - Don\'t use your Bitkit recovery phrase on multiple phones simultaneously, as this can corrupt your data. + Wallet Failure + Bitkit could not read your recovery phrase. + Keep It Safe + Remember, <accent>never share your recovery phrase</accent> with anyone! If you do, they can steal your money, profile and other data. <bold>Latest data backup:</bold> {time} Failed to load mnemonic - Increase Security - <accent>Protect</accent>\nyour bitcoin - To increase wallet security, you can set up a PIN code and Face ID. - Secure Wallet + Multiple Devices + Don\'t use your Bitkit recovery phrase on multiple phones simultaneously, as this can corrupt your data. + <accent>Never share</accent> your recovery phrase with anyone as this may result in the loss of funds. + Mnemonic Phrase + Successful + Make sure you store your recovery phrase in a <accent>secure place</accent>, as this is the <accent>only way to recover</accent> your money! + Tap To Reveal + Use the 12 words below to recover your money at a later date. + Write down these {length} words in the right order and store them in a safe place. + Your Recovery Phrase + passphrase + Confirm Passphrase + Enter the passphrase you added while setting up and creating your wallet. + <accent>Never share</accent> your passphrase with anyone as this may result in the loss of funds. + <accent>Passphrase:</accent> {passphrase} + You added a passphrase to your recovery phrase during wallet setup. + Your Passphrase + {attemptsRemaining} attempts remaining. Forgot your PIN? Choose 4-Digit PIN Please use a PIN you will remember. If you forget your PIN you can reset it, but that will require restoring your wallet. - Retype 4-Digit PIN - Please retype your 4-digit PIN to complete the setup process. - Try again, this is not the same PIN. - Disable PIN - PIN code is currently enabled. If you want to disable your PIN, you need to enter your current PIN code first. Disable PIN + PIN code is currently enabled. If you want to disable your PIN, you need to enter your current PIN code first. + Disable PIN Please enter your PIN code - Last attempt. Entering the wrong PIN again will reset your wallet. - {attemptsRemaining} attempts remaining. Forgot your PIN? - Forgot PIN? - Forgot your PIN? Reset and recover your Bitkit wallet with your recovery phrase. Set a new PIN after completing recovery. Reset (Requires Recovery Phrase) + Forgot your PIN? Reset and recover your Bitkit wallet with your recovery phrase. Set a new PIN after completing recovery. + Forgot PIN? + Last attempt. Entering the wrong PIN again will reset your wallet. + Try again, this is not the same PIN. + Retype 4-Digit PIN + Please retype your 4-digit PIN to complete the setup process. + Secure Wallet + Increase Security + To increase wallet security, you can set up a PIN code and Face ID. + <accent>Protect</accent>\nyour bitcoin Please enter your PIN code to confirm and send out this payment. Enter PIN Code Use {biometricsName} - Biometrics - Authenticate with {biometricsName} - It appears that your device does not support Biometric security. - Face ID - Touch ID - Confirm {biometricsName} - Biometrics Setup Failed - Bitkit could not set up {type} for your device. - Loading... - Looks like you haven’t set up biometric security yet (or it is not supported). Try to enable it in your phone settings. - PIN code set. Would you like to use {biometricsName} instead of your PIN code? - Use {biometricsName} - Phone Settings - Wallet Secured - You have successfully set up a PIN code and {biometricsName} to improve wallet security. - You have successfully set up a PIN code to improve your wallet security. - Also require for payments - Reset And Restore - Back up your wallet first to avoid loss of your funds and wallet data. Resetting will overwrite your current Bitkit setup. + Recovery + You\'ve entered Bitkit\'s recovery mode. Here are some actions to perform when running into issues that prevent the app from fully functioning. Restart the app for a normal startup. Back Up First Reset Wallet - Reset Bitkit? - Are you sure you want to reset your Bitkit Wallet? Do you have a backup of your recovery phrase and wallet data? Yes, Reset - Recovery - You\'ve entered Bitkit\'s recovery mode. Here are some actions to perform when running into issues that prevent the app from fully functioning. Restart the app for a normal startup. - Show Seed Phrase - Contact Support - Wipe App - Close Bitkit - Export To Phone - You can export a copy of your wallet data as a .ZIP file. This file is encrypted with the password you set below. - Password - Export - Share backup file - Backup Exported - Bitkit successfully exported the backup file to your phone. - Backup Failed - Bitkit could not export the backup file to your phone. - Bitkit could not create the backup file. - Change PIN - You can change your PIN code to a new\n4-digit combination. Please enter your current PIN code first. - Retype New PIN - Please retype your 4-digit PIN to complete the setup process. - Set New PIN - Please use a PIN you will remember. If you forget your PIN you can reset it, but that will require restoring your wallet. - Try again, this is not the same PIN. - PIN changed - You have successfully changed your PIN to a new 4-digit combination. + Are you sure you want to reset your Bitkit Wallet? Do you have a backup of your recovery phrase and wallet data? + Reset Bitkit? + Back up your wallet first to avoid loss of your funds and wallet data. Resetting will overwrite your current Bitkit setup. + Reset And Restore + You have successfully set up a PIN code and {biometricsName} to improve wallet security. + You have successfully set up a PIN code to improve your wallet security. + Also require for payments + Wallet Secured Use PIN code - Wallet Data Deleted + Wipe App Bitkit has been reset and all wallet data has been deleted. - Authorization - Make sure you trust this service before granting permission to manage your data. - Authorize - Deny - Authorizing... - Success - This service claims to be - Requested Permissions - Pubky Error - Unable to retrieve Pubky key - Pubky Auth Error - Unable to auth with Pubky service - Settings - Dev Options Enabled - Developer options are now enabled throughout the app. - Dev Options Disabled - Developer options are now disabled throughout the app. - General - Security and Privacy - Back up or Restore - Advanced - About - Support - About Bitkit - Thank you for being a responsible Bitcoiner.\nChange your wallet, change the world.\n\nBitkit hands you the keys to your money, profile, contacts, and web accounts.\n\nBitkit was crafted by Synonym Software Ltd. + Wallet Data Deleted Legal Share - Version Change your wallet, change the world. Download Bitkit for iPhone {appStoreUrl} or Android {playStoreUrl} - Dev Settings - Local currency - Local Currency - Prices powered by Bitfinex & CoinGecko. - Most Used - Other Currencies - Default unit - Default Unit - Display amounts in - Bitcoin - Tip: Quickly toggle between Bitcoin and {currency} by tapping on your wallet balance. - Bitcoin denomination - Modern (₿ 10 000) - Classic (₿ 0.00010000) - Transaction speed - Transaction Speed - Default Transaction Speed - Set Custom Fee - ₿ {feeSats} for the average transaction - ₿ {feeSats} for the average transaction ({fiatSymbol}{fiatFormatted}) - Tags - Previously used tags - Widgets - Widgets - Show Widget Titles - QuickPay - <accent>Frictionless</accent>\npayments - Bitkit QuickPay makes checking out faster by automatically paying QR codes when scanned. - Enable QuickPay - If enabled, scanned invoices below ${amount} will be paid automatically without requiring your confirmation or PIN*. - Quickpay threshold - * Bitkit QuickPay exclusively supports payments from your Spending Balance. - Background Payments - On - Off - Background payments are enabled. You can receive funds even when the app is closed (if your device is connected to the internet). - Background payments are disabled, because you have denied notifications. - GET PAID\n<accent>PASSIVELY</accent> - Turn on notifications to get paid, even when your Bitkit app is closed. - Customize in Android Bitkit Settings - Include amount in notifications - Notifications - Privacy - Set up in background - Get paid when Bitkit is closed - Branch and Bound - Finds exact amount matches to minimize change - Single Random Draw - Random selection for privacy - Language - Security And Privacy - Swipe balance to hide - Hide balance on open - Read clipboard for ease of use - Warn when sending over $100 - PIN Code - Change PIN Code - Require PIN on launch - Require PIN when idle - Require PIN for payments - Enabled - Disabled - Use {biometryTypeName} instead - When enabled, you can use {biometryTypeName} instead of your PIN code to unlock your wallet or send payments. - Back Up Or Restore - Back up your wallet - Export wallet data to phone - Reset and restore wallet - Data Backup Failure - Bitkit failed to back up wallet data. Retrying in {interval, plural, one {# minute} other {# minutes}}. - latest data backups - Running - Failed Backup: {time} - Latest Backup: {time} - Connections - Connection Receipts - Transaction Log - Boosts & Transfers - Settings - Widgets - Tags - Profile - Contacts - Support - Need help? Report your issue from within Bitkit, visit the help center, check the status, or reach out to us on our social channels. - Report Issue - Help Center - App Status - Please describe the issue you are experiencing or ask a general question. - Email address - Issue or question - Failed to open support links - satoshi@satoshi.com - Describe the issue or ask a question - Send - Sent Successfully - Thank you for contacting us! We will try to get back to you as soon as possible. - OK - Failed To Send - Something went wrong while trying to send your issue or question. Please try again. - Try Again - App Status - Internet - Connected - Reconnecting... - Disconnected - Bitcoin Node - Connected - Connecting... - Could not connect to Electrum - Lightning Node - Synced - Syncing... - Could not initiate - Lightning Connection - Open - Opening... - No open connections - Latest Full Data Backup - Backed up - Backing up... - Failed to complete a full backup - Payments - Networks - Other + Thank you for being a responsible Bitcoiner.\nChange your wallet, change the world.\n\nBitkit hands you the keys to your money, profile, contacts, and web accounts.\n\nBitkit was crafted by Synonym Software Ltd. + About Bitkit + Version + About + Change Addresses + Receiving Addresses + Check Balances + Copied to clipboard + Generate 20 More + Index: {index} + Loading Addresses... + No Addresses To Display + No addresses found when searching for \"{searchTxt}\" + No addresses with funds found when searching for \"{searchTxt}\" + No funds found under the {addressType} address type, change addresses up to index {index}. + No funds found under the {addressType} address type, receiving addresses up to index {index}. + Path: {path} + Hide Private Key + Private Key: {privateKey} + View Private Key + Rescan Failed + Bitkit was not able to check the address balances. + ₿ {totalBalance} found + {count, plural, one {Spend All Funds From # address} other {Spend All Funds From # addresses}} + {count, plural, one {Spend ₿ {fundsToSpend} From # address} other {Spend ₿ {fundsToSpend} From # addresses}} Bitcoin Address Type - Monitored Address Types - Monitored Address Types Updated - Changes will take full effect after app restarts. - Address Gap Limit + Address Viewer + Bitcoin Network Coin Selection - Coin Selection Method - Manual Autopilot Autopilot Mode - Smallest First - Sort by and use smallest UTXO first. Potentially higher fee, but hides your largest UTXOs. - Largest First - Sort by and use largest UTXO first. Potentially lower fee, but reveals your largest UTXOs. Consolidate Use all available UTXOs regardless of the amount being sent. Use this method when fees are low in order to reduce fees in future transactions. First-In First-Out Use older UTXOs first (by block height). Last-In Last-Out Use newer UTXOs first (by block height). + Manual + Smallest First + Sort by and use smallest UTXO first. Potentially higher fee, but hides your largest UTXOs. + Coin Selection Method + Largest First + Sort by and use largest UTXO first. Potentially lower fee, but reveals your largest UTXOs. + Electrum Server + Address Gap Limit + Lightning Connections + Lightning Node + Monitored Address Types + Changes will take full effect after app restarts. + Monitored Address Types Updated Payment Preference - Choose how you prefer to receive money when users send funds to your profile key. - * This requires sharing payment data. - Payment preference (drag to reorder) Pay to/from contacts Enable payments with contacts* - Address Viewer + Payment preference (drag to reorder) + * This requires sharing payment data. + Choose how you prefer to receive money when users send funds to your profile key. Rescan Addresses - Reset Suggestions - Reset Suggestions? - Are you sure you want to reset the suggestions? They will reappear in case you have removed them from your Bitkit wallet overview. Yes, Reset - Lightning Connections - Lightning Node - Electrum Server + Are you sure you want to reset the suggestions? They will reappear in case you have removed them from your Bitkit wallet overview. + Reset Suggestions? Rapid-Gossip-Sync + Networks + Other + Payments + Reset Suggestions Slashtags Web Relay - Bitcoin Network - Fast (more expensive) - Fast - ± 10-20 minutes - Normal - Normal - ± 20-60 minutes - Slow (cheaper) - Slow - ± 1-2 hours - Custom - Custom - Depends on fee - No Addresses To Display - Loading Addresses... - No funds found under the {addressType} address type, receiving addresses up to index {index}. - No funds found under the {addressType} address type, change addresses up to index {index}. - No addresses with funds found when searching for \"{searchTxt}\" - No addresses found when searching for \"{searchTxt}\" - Rescan Failed - Bitkit was not able to check the address balances. - {count, plural, one {Spend ₿ {fundsToSpend} From # address} other {Spend ₿ {fundsToSpend} From # addresses}} - {count, plural, one {Spend All Funds From # address} other {Spend All Funds From # addresses}} - Index: {index} - Path: {path} - Hide Private Key - View Private Key - Private Key: {privateKey} - Change Addresses - Receiving Addresses - ₿ {totalBalance} found - Generate 20 More - Check Balances - Copied to clipboard - Please specify a host and port to connect to. + Advanced + Connection Receipts + Connections + Contacts + Profile + Settings + Tags + Transaction Log + Boosts & Transfers + Widgets + Export wallet data to phone + Bitkit failed to back up wallet data. Retrying in {interval, plural, one {# minute} other {# minutes}}. + Data Backup Failure + latest data backups + Reset and restore wallet + Failed Backup: {time} + Running + Latest Backup: {time} + Back Up Or Restore + Back up your wallet + Back up or Restore + Customize in Android Bitkit Settings + Background payments are disabled, because you have denied notifications. + Background payments are enabled. You can receive funds even when the app is closed (if your device is connected to the internet). + Include amount in notifications + Turn on notifications to get paid, even when your Bitkit app is closed. + GET PAID\n<accent>PASSIVELY</accent> + Notifications + Off + On + Privacy + Set up in background + Get paid when Bitkit is closed + Background Payments + Finds exact amount matches to minimize change + Branch and Bound + Random selection for privacy + Single Random Draw + Developer options are now disabled throughout the app. + Dev Options Disabled + Developer options are now enabled throughout the app. + Dev Options Enabled + Dev Settings + Connect To Host + Reset To Default + Currently connected to + disconnected Please specify a host to connect to. - Please specify a port to connect to. - Please specify a valid port. + Please specify a host and port to connect to. Please specify a valid url. Electrum Error - Electrum Server Updated - Successfully connected to {host}:{port} - Electrum Connection Failed - Bitkit could not establish a connection to Electrum. - Currently connected to - disconnected + Please specify a port to connect to. + Please specify a valid port. Host Port Protocol - Reset To Default - Connect To Host - Save - Reset - Address Gap Limit Updated + Electrum Connection Failed + Bitkit could not establish a connection to Electrum. + Successfully connected to {host}:{port} + Electrum Server Updated + Depends on fee + Custom + Custom + ± 10-20 minutes + Fast (more expensive) + Fast + ± 20-60 minutes + Normal + Normal + ± 1-2 hours + Slow (cheaper) + Slow Changes will take full effect after app restarts. - Look Behind + Address Gap Limit Updated Look Ahead - Look Behind Change Look Ahead Change - Rapid-Gossip-Sync Server URL + Look Behind + Look Behind Change + Reset + Save + Prices powered by Bitfinex & CoinGecko. + Local currency + Local Currency + Most Used + Other Currencies + Classic (₿ 0.00010000) + Bitcoin denomination + Modern (₿ 10 000) + Transaction speed + Default Transaction Speed + Set Custom Fee + ₿ {feeSats} for the average transaction + ₿ {feeSats} for the average transaction ({fiatSymbol}{fiatFormatted}) + Transaction Speed + Tags + Previously used tags + Default unit + Bitcoin + Display amounts in + Tip: Quickly toggle between Bitcoin and {currency} by tapping on your wallet balance. + Default Unit + General + Language + Bitkit QuickPay makes checking out faster by automatically paying QR codes when scanned. + <accent>Frictionless</accent>\npayments + QuickPay + Quickpay threshold + * Bitkit QuickPay exclusively supports payments from your Spending Balance. + If enabled, scanned invoices below ${amount} will be paid automatically without requiring your confirmation or PIN*. + Enable QuickPay Connect - Rapid-Gossip-Sync Server Updated + Rapid-Gossip-Sync Server URL You may need to restart the app once or twice for this change to take effect. - Web Relay Error - Please specify a URL to connect to. - Not a valid HTTPS url. + Rapid-Gossip-Sync Server Updated + Read clipboard for ease of use + When enabled, you can use {biometryTypeName} instead of your PIN code to unlock your wallet or send payments. + Hide balance on open + PIN Code + Change PIN Code + Disabled + Enabled + Require PIN when idle + Require PIN on launch + Require PIN for payments + Swipe balance to hide + Security And Privacy + Use {biometryTypeName} instead + Warn when sending over $100 + Security and Privacy + Settings + Failed to complete a full backup + Backing up... + Backed up + Latest Full Data Backup + Could not connect to Electrum + Connecting... + Connected + Bitcoin Node + Disconnected + Reconnecting... + Connected + Internet + No open connections + Opening... + Open + Lightning Connection + Could not initiate + Syncing... + Synced + Lightning Node + App Status + Help Center + Email address + Issue or question + Failed to open support links + satoshi@satoshi.com + Describe the issue or ask a question + Report Issue + Please describe the issue you are experiencing or ask a general question. + App Status + Need help? Report your issue from within Bitkit, visit the help center, check the status, or reach out to us on our social channels. + Send + Thank you for contacting us! We will try to get back to you as soon as possible. + OK + Something went wrong while trying to send your issue or question. Please try again. + Try Again + Support + Sent Successfully + Failed To Send + Support + Widgets + Show Widget Titles + Widgets Healthcheck Failed - Web Relay Updated + Not a valid HTTPS url. + Please specify a URL to connect to. + Web Relay Error Successfully connected to {url} - Your name - Your Name - Contact Name + Web Relay Updated Contact - Contacts Add contact + Add Add Contact Add a new contact by scanning their QR or by pasting their key below. - Add - Paste a key - You cannot add yourself as a contact. - This is not a valid key. + Assign Contact + Profile Key Copied To Clipboard + Are you sure you want to delete {name} from your contacts? + Delete {name}? + Yes, Delete Edit Contact - Contact\'s name - Your public\nprofile name - Retrieving\ncontact info... + This is not a valid key. + You cannot add yourself as a contact. + Paste a key + Lightning is not ready yet + Contact Name No links added yet... Unable To Pay Contact - Lightning is not ready yet + Retrieving\ncontact info... + Select Contact Share Profile Key this contact - Profile Key Copied To Clipboard - Delete {name}? - Are you sure you want to delete {name} from your contacts? - Yes, Delete - Select Contact - Assign Contact + Your public\nprofile name + Contacts + Contact\'s name No Contacts found Slashtags disabled - Dynamic\n<accent>contacts</accent> - Get automatic updates from your Bitkit contacts, pay them, and follow their public profiles. + Unable To Delete Profile + The contact you’re trying to send to hasn’t enabled payments. + Unable To Pay Contact + Unable To Save Contact + Unable To Save Profile + My profile + Enable payments with contacts* + * This requires sharing payment data. Add First Contact + Dynamic\n<accent>contacts</accent> Own your\n<accent>profile</accent> Set up your public profile and links, so your Bitkit contacts can reach you or pay you anytime, anywhere. Pay <accent>Bitkit\ncontacts</accent> You and your contacts can use Bitkit to send payments directly, without banks, anytime, anywhere. - My profile + Get automatic updates from your Bitkit contacts, pay them, and follow their public profiles. Profile - Save - Pay Your Contacts - Create Profile - Please note that all your profile information will be publicly available and visible. - Short bio. Say a bit about yourself. Add Link + Short bio. Say a bit about yourself. + Create Profile + Delete + Are you sure you want to delete all of your Bitkit profile information? + Delete Profile Information? + Yes, Delete + Your Bitkit profile information has been deleted. + Profile Deleted + Edit Profile Label For example \'Website\' - Link or text Note: Any link you add will be publicly visible. Suggestions Suggestions To Add + Link or text + Pay Your Contacts + Please note that all your profile information will be publicly available and visible. + Save Scan to add {name} - Edit Profile - Delete - Delete Profile Information? - Are you sure you want to delete all of your Bitkit profile information? - Yes, Delete - Profile Deleted - Your Bitkit profile information has been deleted. - Enable payments with contacts* - * This requires sharing payment data. - Unable To Save Contact - Unable To Save Profile - Unable To Delete Profile - Unable To Pay Contact - The contact you’re trying to send to hasn’t enabled payments. - Wallet - Activity - Contacts - Profile - Widgets - Shop - Settings - App Status - Send - Receive - <text> (</text><pending/><text> pending)</text> - Please reopen the app and try again. - Wallet \"{walletName}\" already exists. - Invalid recovery phrase. - Please double-check if your recovery phrase is accurate. - Send Bitcoin - To - Contact - Paste Invoice - Enter Manually - Scan QR - Invoice - Enter an invoice, address, or profile key - Clipboard Empty - Please copy an address or an invoice. - Bitcoin Amount - MAX - DONE - Available - Available (spending) - Available (savings) - Reserve Balance - The maximum spendable amount is a bit lower due to a required reserve balance. - Review & Send - Confirming in - Invoice expiration - Swipe To Pay - Yes, Send - It appears you are sending over $100. Do you want to continue? - It appears you are sending over 50% of your total balance. Do you want to continue? - The transaction fee appears to be over 50% of the amount you are sending. Do you want to continue? - The transaction fee appears to be over $10. Do you want to continue? - Fee is potentially too low - Current network conditions require that your fee should be greater than {minimumFee} ₿/vbyte. This transaction may fail, take a while to confirm, or get trimmed from the mempool. Do you wish to proceed? - Bitcoin Sent - Payment Pending - QuickPay - Paying\n<accent>invoice...</accent> - This payment is taking a bit longer than expected. You can continue using Bitkit. - Instant Payment Failed - Regular Payment - Unfortunately contact could not be paid instantly. You can try a regular payment (more expensive, slower). - Transaction Failed - Unable to broadcast the transaction. Please try again. - Removing Tag Failed - Bitkit was unable to find the transaction data. - No lightning invoice found. - No transaction is available to broadcast. - Please check your transaction info and try again. - Minimum relay fee not met - Please increase your fee and try again. - Amount Invalid - The amount is greater than your current balance. - The amount indicated does not allow a fee to be included. - Details - Speed and fee - Speed - Fee Invalid - Fee Exceeds Maximum Limit - Lower the custom fee and try again. - Fee Below Minimum Limit - Increase the custom fee and try again. - sats/vbyte - Send Amount Too Small - Please increase your send amount to proceed. - Please remove UTXOs or increase your send amount to proceed. - Unable to decrease the fee any further. - Unable to increase the fee any further. Otherwise, it will exceed half the current input balance. - Set Custom Fee - ₿ {feeSats} for this transaction - ₿ {feeSats} for this transaction ({fiatSymbol}{fiatFormatted}) - Note - Comment - Optional comment to receiver - Tags - Add Tag - Add - Adding Tag Failed - Bitkit was unable to find the transaction data. - New tag - Enter a new tag - Previously used tags - Filter activity using tags - Select Tag - No tags available yet - Payment Sent - Your instant payment was sent successfully. - Payment Failed - Your instant payment failed. Please try again. - Received Transaction Replaced - Your received transaction was replaced by a fee bump - Transaction Removed - Transaction was removed from mempool - Transaction Replaced - Your transaction was replaced by a fee bump - Transaction Unconfirmed - Transaction became unconfirmed due to blockchain reorganization - Coin Selection - Auto - Total required - Total selected - Receive Bitcoin - Edit Invoice - Invoice copied to clipboard - Share receiving address - Bitcoin invoice - Lightning invoice - Optional note to payer - Receive Lightning funds - Show QR Code - Show Details - Savings - Auto - Spending - Want to receive <accent>Lightning</accent> funds? - Receive on Spending Balance - To set up your spending balance, a <accent>{networkFee}</accent> network fee and <accent>{serviceFee}</accent> service provider fee will be deducted. - To receive more instant Bitcoin, Bitkit has to increase your liquidity. A <accent>{networkFee}</accent> network fee and <accent>{serviceFee}</accent> service provider fee will be deducted from the amount you specified. - Spending Balance Setup - Increase Receiving Capacity - Your Spending Balance uses the Lightning Network to make your payments cheaper, faster, and more private.\n\nThis works like internet access, but you pay for liquidity & routing instead of bandwidth.\n\nThis setup includes some one-time costs. - Your Spending Balance uses the Lightning Network to make your payments cheaper, faster, and more private.\n\nThis works like internet access, but you pay for liquidity & routing instead of bandwidth.\n\nBitkit needs to increase the receiving capacity of your spending balance to process this payment. - Spending Balance Liquidity - Additional Spending Balance Liquidity - Enable background setup to safely exit Bitkit while your balance is being configured. - Set up in background - Transaction Failed - Failed to send funds to your spending account. - You will receive - Spending Balance Initializing... - Receive on <accent>spending balance</accent> - Enjoy instant and cheap\ntransactions with friends, family,\nand merchants. - Generating QR ... - Instant Payments Unavailable - Bitkit does not provide Lightning services in your country, but you can still connect to other nodes. - MINIMUM + Your name + Your Name + %1$s sats + Please wait while Bitkit looks for funds in unsupported addresses (Legacy, Nested SegWit, and Taproot). + LOOKING FOR FUNDS... + Looking For Funds + BROADCASTING… + Confirm Sweep + PREPARING… + Retry + Swipe to confirm + To Address + Custom Fee + for this transaction + Error + Network Fee + Bitkit found funds in unsupported addresses (Legacy, Nested SegWit, and Taproot). + FUNDS FOUND + Found Funds + Legacy (P2PKH) + Sweep Funds + Bitkit checked unsupported address types and found no funds to sweep. + No Funds To Sweep + Bitkit found funds on unsupported Bitcoin address types. Sweep to move the funds to your new wallet balance. + Sweep + SWEEP OLD\n<accent>BITKIT FUNDS</accent> + SegWit (P2SH) + Your funds have been swept and will be added to your wallet balance. + Sweep Complete + Wallet Overview + Taproot (P2TR) + Sweep To Wallet + Total + %1$s, %2$d UTXO + %1$s, %2$d UTXOs Activity - Show All Activity - No activity yet - Receive some funds to get started - Sent - Sent to myself - Received - Pending - Failed + Address + All Activity + Assign + Received Bitcoin + Sent Bitcoin + Boost Boost Fee Boosted incoming transaction - Transfer - From Spending ({duration}) - From Spending - From Savings ({duration}) - From Savings - To Spending - To Savings - Transfer ({duration}) + Already Boosted + BOOSTED TRANSACTION {num} (CPFP) + BOOSTED TRANSACTION {num} (RBF) + Boosting + Confirmed + Confirming Confirms in {feeRateDescription} Boosting. Confirms in {feeRateDescription} - Fee potentially too low - Sent Bitcoin - Received Bitcoin + Contact + Date + Detach Transaction Retrieval Failed Bitkit was not able to fetch the transaction data. - The transaction was not found. - Activity not found Failed to load activity - Confirming - Confirmed - Removed from Mempool - Transactions Removed From Mempool - Please check your activity list. The {count} impacted transaction(s) will be highlighted in red. - Boosting + Activity not found + The transaction was not found. + Explore + Open Block Explorer + Failed Fee Fee (prepaid) - Payment - Status - Date - Time - Contact - Assign - Detach - Tag - Boost - Already Boosted - Explore - Transaction ID - Preimage - Payment hash - Address {count, plural, one {INPUT} other {INPUTS (#)}} + Invoice + Comment + Invoice note + Fee potentially too low + Next month + No activity yet + Receive some funds to get started {count, plural, one {OUTPUT} other {OUTPUTS (#)}} - BOOSTED TRANSACTION {num} (CPFP) - BOOSTED TRANSACTION {num} (RBF) - Open Block Explorer + Payment + Payment hash + Pending + Preimage + Previous month + Received + Removed from Mempool + Please check your activity list. The {count} impacted transaction(s) will be highlighted in red. + Transactions Removed From Mempool + Sent + Sent to myself + Show All Activity + Status Successful - Invoice note - Comment - Invoice - All Activity All - Sent - Received Other - Next month - Previous month + Received + Sent + Tag + Time + Transfer + Transfer ({duration}) + From Spending + From Spending ({duration}) + From Savings + From Savings ({duration}) + To Savings + To Spending + Transaction ID + Swipe your wallet balance to reveal it again. + Wallet Balance Hidden + <text> (</text><pending/><text> pending)</text> + Tap your wallet balance to switch it back to {unit}. + Switched to {unit} + Boost + Reduce fee + Bitkit was unable to boost the transaction. + Unable to increase the fee any further. Otherwise, it will exceed half the current input balance + Boost Failed + Your transaction may settle faster if you include an additional network fee. Set your custom fee below. + Your transaction may settle faster if you include an additional network fee. Here is a recommendation: + Increase fee + Use Suggested Fee + The transaction was successfully boosted. + Boosted! + Swipe To Boost + Boost Transaction + Please reopen the app and try again. + Wallet \"{walletName}\" already exists. + Invalid recovery phrase. + Please double-check if your recovery phrase is accurate. Savings Spending - Savings + Incoming Transfer: + Activity + Contacts + Profile + Settings + Shop + App Status + Wallet + Widgets + Transaction Broadcast Failed + Please check your connection and try again.\n{message} + An error occurred when broadcasting your transaction. {raw} + Transaction Creation Failed + An error occurred. Please try again {raw} + You do not have enough funds to send this payment. + Insufficient Funds + Invalid bitcoin send address + Error updating invoice + Error fetching lnurl invoice + Please increase your fee and try again. + Minimum relay fee not met + No lightning invoice found. + Please check your transaction info and try again. + No transaction is available to broadcast. + Error Sending + Apply + Clear + Select Range + Received Instant Bitcoin + Lightning Startup Error + Lightning Sync Error + Maximum amount + Pay Bitcoin + The minimum amount for this invoice is ₿ {amount}. + Amount Too Low + Comment + Optional comment to receiver + Withdraw + AvailablE TO WITHDRAW + The funds you withdraw will be deposited into your Bitkit spending balance. + Withdraw Bitcoin + Fee Exceeds Maximum Limit + Lower the custom fee and try again. + Fee Below Minimum Limit + Increase the custom fee and try again. + MINIMUM + Note + Received Bitcoin + Peer disconnected. + Receive + Receive Lightning funds + Receive Bitcoin + Bitcoin invoice + Transaction Failed + Failed to send funds to your spending account. + To receive more instant Bitcoin, Bitkit has to increase your liquidity. A <accent>{networkFee}</accent> network fee and <accent>{serviceFee}</accent> service provider fee will be deducted from the amount you specified. + To set up your spending balance, a <accent>{networkFee}</accent> network fee and <accent>{serviceFee}</accent> service provider fee will be deducted. + Invoice copied to clipboard + Payments to your spending balance might fail if you switch between apps. + Keep Bitkit In Foreground + Bitkit does not provide Lightning services in your country, but you can still connect to other nodes. + Instant Payments Unavailable + Insufficient receiving capacity to receive this amount over Lightning. + Insufficient receiving balance. + Spending Balance Initializing... + Lightning invoice + Enable background setup to safely exit Bitkit while your balance is being configured. + Set up in background + Spending Balance Liquidity + Additional Spending Balance Liquidity + Spending Balance Setup + Increase Receiving Capacity + Your Spending Balance uses the Lightning Network to make your payments cheaper, faster, and more private.\n\nThis works like internet access, but you pay for liquidity & routing instead of bandwidth.\n\nThis setup includes some one-time costs. + Your Spending Balance uses the Lightning Network to make your payments cheaper, faster, and more private.\n\nThis works like internet access, but you pay for liquidity & routing instead of bandwidth.\n\nBitkit needs to increase the receiving capacity of your spending balance to process this payment. + Optional note to payer + Enjoy instant and cheap\ntransactions with friends, family,\nand merchants. + Receive on <accent>spending balance</accent> + Generating QR ... + Share receiving address + Show Details + Show QR Code + Edit Invoice + Receive on Spending Balance + Auto + Savings + Spending + Want to receive <accent>Lightning</accent> funds? + You will receive + Contact + Paste Invoice + Enter Manually + Scan QR + An error occurred and couldn\'t refresh the wallet. Please try again. + Electrum server connection is throttled. Please wait several minutes before trying again. + Connection Throttled + Refresh Failed + Blockchain Reorg Detected + {count, plural, one {# of your transactions is no longer confirmed.} other {# of your transactions are no longer confirmed.}} Please wait. + Please check your activity list for affected transactions. + sats/vbyte <accent>Send\nbitcoin</accent>\nto your\nsavings balance - Spending + Savings + Auto + Coin Selection + Total required + Total selected + Send + Enter an invoice, address, or profile key + Bitcoin Amount + The amount is greater than your current balance. + The amount indicated does not allow a fee to be included. + Amount Invalid + Available + Available (savings) + Available (spending) + Send Bitcoin + Please copy an address or an invoice. + Clipboard Empty + Please remove UTXOs or increase your send amount to proceed. + Confirming in + Details + It appears you are sending over $100. Do you want to continue? + It appears you are sending over 50% of your total balance. Do you want to continue? + The transaction fee appears to be over 50% of the amount you are sending. Do you want to continue? + The transaction fee appears to be over $10. Do you want to continue? + Current network conditions require that your fee should be greater than {minimumFee} ₿/vbyte. This transaction may fail, take a while to confirm, or get trimmed from the mempool. Do you wish to proceed? + Fee is potentially too low + DONE + Unable to broadcast the transaction. Please try again. + Unfortunately contact could not be paid instantly. You can try a regular payment (more expensive, slower). + Transaction Failed + Speed and fee + Set Custom Fee + Fee Invalid + Unable to increase the fee any further. Otherwise, it will exceed half the current input balance. + Unable to decrease the fee any further. + Speed + ₿ {feeSats} for this transaction + ₿ {feeSats} for this transaction ({fiatSymbol}{fiatFormatted}) + Instant Payment Failed + Invoice + Invoice expiration + MAX + The maximum spendable amount is a bit lower due to a required reserve balance. + Reserve Balance + Please increase your send amount to proceed. + Send Amount Too Small + Payment Pending + This payment is taking a bit longer than expected. You can continue using Bitkit. + QuickPay + Paying\n<accent>invoice...</accent> + Regular Payment + Review & Send + Bitcoin Sent + Swipe To Pay + To + Yes, Send <accent>Send\nbitcoin</accent>\nto your\nspending balance - Incoming Transfer: + Spending + Bitkit was unable to find the transaction data. + Removing Tag Failed + Tags + Add Tag + Add + Bitkit was unable to find the transaction data. + Adding Tag Failed + Filter activity using tags + Select Tag + New tag + Enter a new tag + No tags available yet + Previously used tags + Your instant payment failed. Please try again. + Payment Failed + Your instant payment was sent successfully. + Payment Sent + Your received transaction was replaced by a fee bump + Received Transaction Replaced + Transaction was removed from mempool + Transaction Removed + Your transaction was replaced by a fee bump + Transaction Replaced + Transaction became unconfirmed due to blockchain reorganization + Transaction Unconfirmed Transfer To Savings Transfer To Spending Transaction Invalid - Boost - Boost Transaction - Boosted! - The transaction was successfully boosted. - Boost Failed - Bitkit was unable to boost the transaction. - Unable to increase the fee any further. Otherwise, it will exceed half the current input balance - Your transaction may settle faster if you include an additional network fee. Set your custom fee below. - Your transaction may settle faster if you include an additional network fee. Here is a recommendation: - Use Suggested Fee - Swipe To Boost - Reduce fee - Increase fee - Received Bitcoin - Received Instant Bitcoin - Peer disconnected. - Transaction Creation Failed - An error occurred. Please try again {raw} - Transaction Broadcast Failed - An error occurred when broadcasting your transaction. {raw} - Please check your connection and try again.\n{message} - Insufficient Funds - You do not have enough funds to send this payment. - Invalid bitcoin send address - Error updating invoice - Error fetching lnurl invoice - Error Sending Your withdrawal was unsuccessful. Please scan the QR code again or contact support. - Select Range - Clear - Apply - Blockchain Reorg Detected - {count, plural, one {# of your transactions is no longer confirmed.} other {# of your transactions are no longer confirmed.}} Please wait. - Please check your activity list for affected transactions. - Withdraw Bitcoin - AvailablE TO WITHDRAW - The funds you withdraw will be deposited into your Bitkit spending balance. - Withdraw - Pay Bitcoin - Amount Too Low - The minimum amount for this invoice is ₿ {amount}. - Maximum amount - Wallet Balance Hidden - Swipe your wallet balance to reveal it again. - Switched to {unit} - Tap your wallet balance to switch it back to {unit}. - Refresh Failed - An error occurred and couldn\'t refresh the wallet. Please try again. - Connection Throttled - Electrum server connection is throttled. Please wait several minutes before trying again. - Lightning Sync Error - Lightning Startup Error - Insufficient receiving balance. - Insufficient receiving capacity to receive this amount over Lightning. - Keep Bitkit In Foreground - Payments to your spending balance might fail if you switch between apps. - Widgets - Hello,\n<accent>Widgets</accent> - Enjoy decentralized feeds from your favorite web services, by adding fun and useful widgets to your Bitkit wallet. - Widget - Widget Feed - Default - Custom - Please select which fields you want to display in the {name} widget. - Source Add Widget - Delete Widget? - Are you sure you want to delete ‘{name}‘ from your widgets? - Bitcoin Price - Check the latest Bitcoin exchange rates for a variety of fiat currencies. - Couldn\'t get price data - Bitcoin Headlines - Read the latest & greatest Bitcoin headlines from various news sites. - Couldn\'t get the latest news - Bitcoin Blocks Examine various statistics on newly mined Bitcoin Blocks. Couldn\'t get blocks data Block @@ -1201,96 +1260,37 @@ Size Time Transactions - Bitcoin Facts - Discover fun facts about Bitcoin, every time you open your wallet. - Bitcoin Calculator + Bitcoin Blocks Convert ₿ amounts to {fiatSymbol} or vice versa. - Bitcoin Weather - Find out when it’s a good time to transact on the Bitcoin blockchain. - Favorable Conditions - All clear. Now would be a good time to transact on the blockchain. - Average Conditions + Bitcoin Calculator + Are you sure you want to delete ‘{name}‘ from your widgets? + Delete Widget? + Discover fun facts about Bitcoin, every time you open your wallet. + Bitcoin Facts + Read the latest & greatest Bitcoin headlines from various news sites. + Couldn\'t get the latest news + Bitcoin Headlines + Enjoy decentralized feeds from your favorite web services, by adding fun and useful widgets to your Bitkit wallet. + Hello,\n<accent>Widgets</accent> + Check the latest Bitcoin exchange rates for a variety of fiat currencies. + Couldn\'t get price data + Bitcoin Price The next block rate is close to the monthly averages. - Poor Conditions + Average Conditions + All clear. Now would be a good time to transact on the blockchain. + Favorable Conditions If you are not in a hurry to transact, it may be better to wait a bit. + Poor Conditions Current average fee - Next block inclusion + Find out when it’s a good time to transact on the Bitcoin blockchain. Couldn\'t get current fee weather - Balance moved from spending to savings - Reason: %s - Channel closed - Channel failed to open in the background - Channel open failed - Channel opened - Pending - Ready to send - Lightning node notification - Channel for LightningNodeService - Lightning error - Payment failed - Please try again - Received %s - Open Bitkit to see details - Payment Received - Bitkit is running in background so you can receive Lightning payments - Stop App - Via new channel - Lightning Wallet Sync Time - Ready - Block Height - Onchain Wallet Sync Time - Rapid Gossip Sync Snapshot Time - Fee Rate Cache Update Time - Channel Monitor Archival Height - Pathfinding Scores Sync Time - Wallet Balances - Total Onchain - Spendable Onchain - Total Anchor Channels Reserve - Total Lightning - Lightning Balances - Usable - Announced - Inbound Capacity - Inbound HTLC Max - Inbound HTLC Min - Next Outbound HTLC Limit - Next Outbound HTLC Min - Confirmations - Please wait while your old wallet data migrates to this new Bitkit version... - Wallet Migration - MIGRATING\n<accent>WALLET</accent> - %1$s sats - Please wait while Bitkit looks for funds in unsupported addresses (Legacy, Nested SegWit, and Taproot). - LOOKING FOR FUNDS... - Looking For Funds - BROADCASTING… - Confirm Sweep - PREPARING… - Retry - Swipe to confirm - To Address - Custom Fee - for this transaction - Error - Network Fee - Bitkit found funds in unsupported addresses (Legacy, Nested SegWit, and Taproot). - FUNDS FOUND - Found Funds - Legacy (P2PKH) - Sweep Funds - Bitkit checked unsupported address types and found no funds to sweep. - No Funds To Sweep - Bitkit found funds on unsupported Bitcoin address types. Sweep to move the funds to your new wallet balance. - Sweep - SWEEP OLD\n<accent>BITKIT FUNDS</accent> - SegWit (P2SH) - Your funds have been swept and will be added to your wallet balance. - Sweep Complete - Wallet Overview - Taproot (P2TR) - Sweep To Wallet - Total - %1$s, %2$d UTXO - %1$s, %2$d UTXOs + Bitcoin Weather + Next block inclusion + Widget Feed + Custom + Default + Please select which fields you want to display in the {name} widget. + Widget + Source + Widgets diff --git a/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt b/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt index 4e9e7fb58..200705b97 100644 --- a/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt +++ b/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt @@ -105,7 +105,7 @@ class LightningNodeServiceTest : BaseUnitTest() { sats = 100L, ) val notification = NotificationDetails( - title = context.getString(R.string.notification_received_title), + title = context.getString(R.string.notification__received__title), body = "Received ₿ 100 ($0.10)", ) whenever(notifyPaymentReceivedHandler.invoke(any())) @@ -147,7 +147,7 @@ class LightningNodeServiceTest : BaseUnitTest() { val shadows = Shadows.shadowOf(notificationManager) val paymentNotification = shadows.allNotifications.find { - it.extras.getString(Notification.EXTRA_TITLE) == context.getString(R.string.notification_received_title) + it.extras.getString(Notification.EXTRA_TITLE) == context.getString(R.string.notification__received__title) } assertNotNull("Payment notification should be present", paymentNotification) @@ -184,7 +184,7 @@ class LightningNodeServiceTest : BaseUnitTest() { val shadows = Shadows.shadowOf(notificationManager) val paymentNotification = shadows.allNotifications.find { - it.extras.getString(Notification.EXTRA_TITLE) == context.getString(R.string.notification_received_title) + it.extras.getString(Notification.EXTRA_TITLE) == context.getString(R.string.notification__received__title) } assertNull("Payment notification should NOT be present in foreground", paymentNotification) @@ -212,7 +212,7 @@ class LightningNodeServiceTest : BaseUnitTest() { val shadows = Shadows.shadowOf(notificationManager) val paymentNotification = shadows.allNotifications.find { - it.extras.getString(Notification.EXTRA_TITLE) == context.getString(R.string.notification_received_title) + it.extras.getString(Notification.EXTRA_TITLE) == context.getString(R.string.notification__received__title) } assertNotNull("Payment notification should be present", paymentNotification) diff --git a/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt b/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt index 3862b7f20..a1ed26584 100644 --- a/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt +++ b/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt @@ -39,7 +39,7 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { @Before fun setUp() { - whenever(context.getString(R.string.notification_received_title)).thenReturn("Payment Received") + whenever(context.getString(R.string.notification__received__title)).thenReturn("Payment Received") whenever(context.getString(any(), any())).thenReturn("Received amount") whenever(settingsStore.data).thenReturn(flowOf(SettingsData(showNotificationDetails = true))) whenever(currencyRepo.convertSatsToFiat(any(), anyOrNull())).thenReturn( From 21f8c24be6a86f788b5f5d1bc193f42a5e88efa6 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 14 Jan 2026 07:07:55 +0100 Subject: [PATCH 36/37] refactor: localize article timestamps --- .../main/java/to/bitkit/async/ServiceQueue.kt | 8 ++- app/src/main/java/to/bitkit/ext/DateTime.kt | 20 ++++--- .../to/bitkit/models/widget/ArticleModel.kt | 56 ++++++----------- .../java/to/bitkit/repositories/WalletRepo.kt | 2 +- .../ui/settings/BackupSettingsScreen.kt | 2 +- .../settings/appStatus/AppStatusViewModel.kt | 60 +++++++++---------- .../java/to/bitkit/ext/DateTimeExtTest.kt | 2 +- 7 files changed, 67 insertions(+), 83 deletions(-) diff --git a/app/src/main/java/to/bitkit/async/ServiceQueue.kt b/app/src/main/java/to/bitkit/async/ServiceQueue.kt index 29084486b..05cbd07d9 100644 --- a/app/src/main/java/to/bitkit/async/ServiceQueue.kt +++ b/app/src/main/java/to/bitkit/async/ServiceQueue.kt @@ -27,7 +27,7 @@ enum class ServiceQueue { ): T { return runBlocking(coroutineContext) { try { - measured(functionName) { + measured(label = functionName, context = TAG) { block() } } catch (e: Exception) { @@ -45,7 +45,7 @@ enum class ServiceQueue { ): T { return withContext(coroutineContext) { try { - measured(functionName) { + measured(label = functionName, context = TAG) { block() } } catch (e: Exception) { @@ -54,6 +54,10 @@ enum class ServiceQueue { } } } + + companion object { + private const val TAG = "ServiceQueue" + } } fun newSingleThreadDispatcher(id: String): ExecutorCoroutineDispatcher { diff --git a/app/src/main/java/to/bitkit/ext/DateTime.kt b/app/src/main/java/to/bitkit/ext/DateTime.kt index b5cc206e6..7b432847e 100644 --- a/app/src/main/java/to/bitkit/ext/DateTime.kt +++ b/app/src/main/java/to/bitkit/ext/DateTime.kt @@ -61,11 +61,11 @@ fun Long.toDateUTC(): String { return dateTime.format(DateTimeFormatter.ofPattern("dd/MM/yyyy")) } -fun Long.toLocalizedTimestamp(): String { - val uLocale = ULocale.forLocale(Locale.US) - val formatter = DateFormat.getDateTimeInstance(DateFormat.LONG, DateFormat.SHORT, uLocale) - ?: return SimpleDateFormat("MMMM d, yyyy 'at' h:mm a", Locale.US).format(Date(this)) - return formatter.format(Date(this)) +fun Long.toLocalizedTimestamp(locale: Locale = Locale.US): String { + val date = Date(this) + val formatter = DateFormat.getDateTimeInstance(DateFormat.LONG, DateFormat.SHORT, ULocale.forLocale(locale)) + ?: return SimpleDateFormat("MMMM d, yyyy 'at' h:mm a", locale).format(date) + return formatter.format(date) } @Suppress("LongMethod") @@ -73,7 +73,9 @@ fun Long.toLocalizedTimestamp(): String { fun Long.toRelativeTimeString( locale: Locale = Locale.getDefault(), clock: Clock = Clock.System, -): String { + style: RelativeDateTimeFormatter.Style = RelativeDateTimeFormatter.Style.LONG, + capitalizationContext: DisplayContext = DisplayContext.CAPITALIZATION_FOR_MIDDLE_OF_SENTENCE, + ): String { val now = nowMillis(clock) val diffMillis = now - this @@ -83,9 +85,9 @@ fun Long.toRelativeTimeString( val formatter = RelativeDateTimeFormatter.getInstance( uLocale, numberFormat, - RelativeDateTimeFormatter.Style.LONG, - DisplayContext.CAPITALIZATION_FOR_MIDDLE_OF_SENTENCE, - ) ?: return toLocalizedTimestamp() + style, + capitalizationContext, + ) ?: return toLocalizedTimestamp(locale) val seconds = diffMillis / Factor.MILLIS_TO_SECONDS val minutes = seconds / Factor.SECONDS_TO_MINUTES diff --git a/app/src/main/java/to/bitkit/models/widget/ArticleModel.kt b/app/src/main/java/to/bitkit/models/widget/ArticleModel.kt index ad9d90473..fc41ddccb 100644 --- a/app/src/main/java/to/bitkit/models/widget/ArticleModel.kt +++ b/app/src/main/java/to/bitkit/models/widget/ArticleModel.kt @@ -2,72 +2,54 @@ package to.bitkit.models.widget import kotlinx.serialization.Serializable import to.bitkit.data.dto.ArticleDTO +import to.bitkit.ext.toRelativeTimeString import to.bitkit.utils.Logger +import to.bitkit.utils.errorLogOf import java.time.OffsetDateTime import java.time.format.DateTimeFormatter import java.time.format.DateTimeParseException -import java.time.temporal.ChronoUnit import java.util.Locale +import kotlin.time.ExperimentalTime @Serializable data class ArticleModel( val title: String, val timeAgo: String, val link: String, - val publisher: String + val publisher: String, ) fun ArticleDTO.toArticleModel() = ArticleModel( title = this.title, timeAgo = timeAgo(this.publishedDate), link = this.link, - publisher = this.publisher.title + publisher = this.publisher.title, ) -/** - * Converts a date string to a human-readable time ago format - * @param dateString Date string in format "EEE, dd MMM yyyy HH:mm:ss Z" - * @return Human-readable time difference (e.g. "5 hours ago") - */ -@Suppress("TooGenericExceptionCaught", "MagicNumber") +private const val TAG = "ArticleModel" + +@OptIn(ExperimentalTime::class) private fun timeAgo(dateString: String): String { - return try { + return runCatching { val formatters = listOf( - DateTimeFormatter.RFC_1123_DATE_TIME, // Handles "EEE, dd MMM yyyy HH:mm:ss zzz" (like GMT) - DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss Z", Locale.ENGLISH) // Handles "+0000" + DateTimeFormatter.RFC_1123_DATE_TIME, + DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss Z", Locale.ENGLISH) ) var parsedDateTime: OffsetDateTime? = null for (formatter in formatters) { try { parsedDateTime = OffsetDateTime.parse(dateString, formatter) - break // Successfully parsed, stop trying other formatters - } catch (e: DateTimeParseException) { - // Continue to the next formatter if this one fails + break + } catch (_: DateTimeParseException) { + // Continue to the next formatter } } - if (parsedDateTime == null) { - Logger.debug("Failed to parse date: Unparseable date: $dateString") - return "" - } - - val now = OffsetDateTime.now() + requireNotNull(parsedDateTime) { "Unparseable date: '$dateString'" } - val diffMinutes = ChronoUnit.MINUTES.between(parsedDateTime, now) - val diffHours = ChronoUnit.HOURS.between(parsedDateTime, now) - val diffDays = ChronoUnit.DAYS.between(parsedDateTime, now) - val diffMonths = ChronoUnit.MONTHS.between(parsedDateTime, now) - - return when { - diffMinutes < 1 -> "just now" - diffMinutes < 60 -> "$diffMinutes minutes ago" - diffHours < 24 -> "$diffHours hours ago" - diffDays < 30 -> "$diffDays days ago" // Approximate for months - else -> "$diffMonths months ago" - } - } catch (e: Exception) { - Logger.warn("An unexpected error occurred while parsing date: ${e.message}") - "" - } + parsedDateTime.toInstant().toEpochMilli().toRelativeTimeString() + }.onFailure { + Logger.warn("Failed to parse date: ${errorLogOf(it)}", it, context = TAG) + }.getOrDefault("") } diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index 2b09f58f0..8eb0ed1eb 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -171,7 +171,7 @@ class WalletRepo @Inject constructor( val startHeight = lightningRepo.lightningState.value.block()?.height Logger.debug("Sync $sourceLabel started at block height=$startHeight", context = TAG) - val result = measured("Sync $sourceLabel") { + val result = measured(label = "Sync $sourceLabel", context = TAG) { syncBalances() lightningRepo.sync().onSuccess { syncBalances() 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 903b61124..329dc6b1b 100644 --- a/app/src/main/java/to/bitkit/ui/settings/BackupSettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/BackupSettingsScreen.kt @@ -243,7 +243,7 @@ private fun BackupRetryButton(onClick: () -> Unit) { } } -@Preview +@Preview(showSystemUi = true) @Composable private fun Preview() { val categories = BackupCategory.entries diff --git a/app/src/main/java/to/bitkit/ui/settings/appStatus/AppStatusViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/appStatus/AppStatusViewModel.kt index 5c2f1d03c..5c9ec0d3f 100644 --- a/app/src/main/java/to/bitkit/ui/settings/appStatus/AppStatusViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/appStatus/AppStatusViewModel.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import to.bitkit.R import to.bitkit.data.CacheStore @@ -36,45 +37,40 @@ class AppStatusViewModel @Inject constructor( collectState() } - private fun collectState() { - viewModelScope.launch { - combine( - healthRepo.healthState, - cacheStore.backupStatuses, - lightningRepo.lightningState, - ) { healthState, backupStatuses, lightningState -> - AppStatusUiState( - health = healthState, - backupSubtitle = computeBackupSubtitle(healthState.backups, backupStatuses), - nodeSubtitle = when (healthState.node) { - HealthState.ERROR -> context.getString(R.string.settings__status__lightning_node__error) - else -> lightningState.nodeLifecycleState.uiText(context) - }, - ) - }.collect { newState -> - _uiState.value = newState - } + private fun collectState() = viewModelScope.launch { + combine( + healthRepo.healthState, + cacheStore.backupStatuses, + lightningRepo.lightningState, + ) { healthState, backupStatuses, lightningState -> + AppStatusUiState( + health = healthState, + backupSubtitle = computeBackupSubtitle(healthState.backups, backupStatuses), + nodeSubtitle = when (healthState.node) { + HealthState.ERROR -> context.getString(R.string.settings__status__lightning_node__error) + else -> lightningState.nodeLifecycleState.uiText(context) + }, + ) + }.collect { newState -> + _uiState.update { newState } } } private fun computeBackupSubtitle( backupHealthState: HealthState, backupStatuses: Map, - ): String { - return when (backupHealthState) { - HealthState.ERROR -> context.getString(R.string.settings__status__backup__error) - else -> { - val syncTimes = BackupCategory.entries - .filter { it != BackupCategory.LIGHTNING_CONNECTIONS } - .map { category -> backupStatuses[category]?.synced ?: 0L } + ) = when (backupHealthState) { + HealthState.ERROR -> context.getString(R.string.settings__status__backup__error) + else -> { + val syncTimes = BackupCategory.entries + .filter { it != BackupCategory.LIGHTNING_CONNECTIONS } + .map { category -> backupStatuses[category]?.synced ?: 0L } - when (val maxSyncTime = syncTimes.max()) { - 0L -> context.getString(R.string.settings__status__backup__ready) - else -> runCatching { maxSyncTime.toLocalizedTimestamp() } - .getOrDefault( - context.getString(R.string.settings__status__backup__ready) - ) - } + when (val maxSyncTime = syncTimes.max()) { + 0L -> context.getString(R.string.settings__status__backup__ready) + else -> runCatching { maxSyncTime.toLocalizedTimestamp() }.getOrDefault( + context.getString(R.string.settings__status__backup__ready) + ) } } } diff --git a/app/src/test/java/to/bitkit/ext/DateTimeExtTest.kt b/app/src/test/java/to/bitkit/ext/DateTimeExtTest.kt index f748a3ee7..710d1e408 100644 --- a/app/src/test/java/to/bitkit/ext/DateTimeExtTest.kt +++ b/app/src/test/java/to/bitkit/ext/DateTimeExtTest.kt @@ -135,7 +135,7 @@ class DateTimeExtTest : BaseUnitTest() { fun `toRelativeTimeString preserves backward compatibility with default locale`() { val twoDaysAgo = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(2) val resultWithoutParam = twoDaysAgo.toRelativeTimeString() - val resultWithDefaultParam = twoDaysAgo.toRelativeTimeString(Locale.getDefault()) + val resultWithDefaultParam = twoDaysAgo.toRelativeTimeString() assertEquals(resultWithDefaultParam, resultWithoutParam) } From f8c5ad558fb3ff257b5058f6cca1b405fdd98695 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 14 Jan 2026 07:56:29 +0100 Subject: [PATCH 37/37] refactor: use runCatching and add listenerJob --- app/src/main/java/to/bitkit/ext/DateTime.kt | 2 +- .../to/bitkit/repositories/LightningRepo.kt | 5 +- .../java/to/bitkit/repositories/WalletRepo.kt | 61 +++++++------- .../java/to/bitkit/services/CoreService.kt | 35 ++++---- .../to/bitkit/services/CurrencyService.kt | 4 +- .../to/bitkit/services/LightningService.kt | 81 ++++++++----------- 6 files changed, 87 insertions(+), 101 deletions(-) diff --git a/app/src/main/java/to/bitkit/ext/DateTime.kt b/app/src/main/java/to/bitkit/ext/DateTime.kt index 7b432847e..530de7f4f 100644 --- a/app/src/main/java/to/bitkit/ext/DateTime.kt +++ b/app/src/main/java/to/bitkit/ext/DateTime.kt @@ -75,7 +75,7 @@ fun Long.toRelativeTimeString( clock: Clock = Clock.System, style: RelativeDateTimeFormatter.Style = RelativeDateTimeFormatter.Style.LONG, capitalizationContext: DisplayContext = DisplayContext.CAPITALIZATION_FOR_MIDDLE_OF_SENTENCE, - ): String { +): String { val now = nowMillis(clock) val diffMillis = now - this diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 5e57c74af..0b265526d 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -172,7 +172,7 @@ class LightningRepo @Inject constructor( customServerUrl, customRgsServerUrl, trustedPeers, - channelMigration + channelMigration, ) }.onFailure { Logger.error("Node setup error", it, context = TAG) @@ -948,7 +948,8 @@ class LightningRepo @Inject constructor( } Logger.info("LDK node restarted successfully", context = TAG) Result.success(Unit) - } // endregion + } + // endregion companion object { private const val TAG = "LightningRepo" diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index 8eb0ed1eb..635a868a9 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -308,7 +308,7 @@ class WalletRepo @Inject constructor( } suspend fun wipeWallet(walletIndex: Int = 0): Result = withContext(bgDispatcher) { - return@withContext wipeWalletUseCase( + wipeWalletUseCase( walletIndex = walletIndex, resetWalletState = ::resetState, onSuccess = ::setWalletExistsState, @@ -329,7 +329,7 @@ class WalletRepo @Inject constructor( } suspend fun newAddress(): Result = withContext(bgDispatcher) { - return@withContext lightningRepo.newAddress() + lightningRepo.newAddress() .onSuccess { address -> setOnchainAddress(address) } .onFailure { error -> Logger.error("Error generating new address", error) } } @@ -339,7 +339,7 @@ class WalletRepo @Inject constructor( isChange: Boolean = false, count: Int = 20, ): Result> = withContext(bgDispatcher) { - return@withContext runCatching { + runCatching { val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) ?: throw ServiceError.MnemonicNotFound() @@ -418,7 +418,8 @@ class WalletRepo @Inject constructor( private suspend fun paymentHash(): String? = withContext(bgDispatcher) { val bolt11 = getBolt11() if (bolt11.isEmpty()) return@withContext null - return@withContext runCatching { + + runCatching { when (val decoded = decode(bolt11)) { is Scanner.Lightning -> decoded.invoice.paymentHash.toHex() else -> null @@ -432,41 +433,39 @@ class WalletRepo @Inject constructor( val hash = paymentHash() if (hash != null) return@withContext hash val address = getOnchainAddress() + return@withContext address.ifEmpty { null } } suspend fun addTagToSelected(newTag: String): Result = withContext(bgDispatcher) { val paymentId = paymentId() if (paymentId == null || paymentId.isEmpty()) { - Logger.warn("Cannot add tag: payment ID not available", context = TAG) - return@withContext Result.failure( - IllegalStateException("Cannot add tag: payment ID not available") - ) + val exception = IllegalStateException("Cannot add tag: payment ID not available") + Logger.warn(exception.message, context = TAG) + return@withContext Result.failure(exception) } - return@withContext preActivityMetadataRepo.addPreActivityMetadataTags(paymentId, listOf(newTag)) - .onSuccess { - _walletState.update { - it.copy( - selectedTags = (it.selectedTags + newTag).distinct() - ) - } - settingsStore.addLastUsedTag(newTag) - }.onFailure { - Logger.error("Failed to add tag to pre-activity metadata", it, context = TAG) + preActivityMetadataRepo.addPreActivityMetadataTags(paymentId, listOf(newTag)).onSuccess { + _walletState.update { + it.copy( + selectedTags = (it.selectedTags + newTag).distinct() + ) } + settingsStore.addLastUsedTag(newTag) + }.onFailure { + Logger.error("Failed to add tag to pre-activity metadata", it, context = TAG) + } } suspend fun removeTag(tag: String): Result = withContext(bgDispatcher) { val paymentId = paymentId() if (paymentId == null || paymentId.isEmpty()) { - Logger.warn("Cannot remove tag: payment ID not available", context = TAG) - return@withContext Result.failure( - IllegalStateException("Cannot remove tag: payment ID not available") - ) + val exception = IllegalStateException("Cannot remove tag: payment ID not available") + Logger.warn(exception.message, context = TAG) + return@withContext Result.failure(exception) } - return@withContext preActivityMetadataRepo.removePreActivityMetadataTags(paymentId, listOf(tag)) + preActivityMetadataRepo.removePreActivityMetadataTags(paymentId, listOf(tag)) .onSuccess { _walletState.update { it.copy( @@ -494,7 +493,7 @@ class WalletRepo @Inject constructor( amountSats: ULong? = walletState.value.bip21AmountSats, description: String = walletState.value.bip21Description, ): Result = withContext(bgDispatcher) { - return@withContext runCatching { + runCatching { val oldPaymentId = paymentId() val tagsToMigrate = if (oldPaymentId != null && oldPaymentId.isNotEmpty()) { preActivityMetadataRepo @@ -529,20 +528,18 @@ class WalletRepo @Inject constructor( } } - @Suppress("TooGenericExceptionCaught") suspend fun shouldRequestAdditionalLiquidity(): Result = withContext(bgDispatcher) { - return@withContext try { - if (coreService.isGeoBlocked()) return@withContext Result.success(false) + runCatching { + if (coreService.isGeoBlocked()) return@runCatching false val channels = lightningRepo.lightningState.value.channels - if (channels.filterOpen().isEmpty()) return@withContext Result.success(false) + if (channels.filterOpen().isEmpty()) return@runCatching false val inboundBalanceSats = channels.sumOf { it.inboundCapacityMsat / 1000u } - Result.success((_walletState.value.bip21AmountSats ?: 0uL) >= inboundBalanceSats) - } catch (e: Exception) { - Logger.error("shouldRequestAdditionalLiquidity error", e, context = TAG) - Result.failure(e) + return@runCatching (_walletState.value.bip21AmountSats ?: 0uL) >= inboundBalanceSats + }.onFailure { + Logger.error("shouldRequestAdditionalLiquidity error", it, context = TAG) } } diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index 66aa160f4..1424a20ee 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -118,55 +118,56 @@ class CoreService @Inject constructor( // Block queue until the init completes forcing any additional calls to wait for it ServiceQueue.CORE.blocking { runCatching { - val result = initDb(basePath = Env.bitkitCoreStoragePath(walletIndex)) - Logger.info("bitkit-core database init: $result") + val result = initDb(Env.bitkitCoreStoragePath(walletIndex)) + Logger.info("bitkit-core database init: '$result'", context = TAG) }.onFailure { - Logger.error("bitkit-core database init failed", it) + Logger.error("bitkit-core database init failed", it, context = TAG) } runCatching { val blocktankUrl = Env.blocktankApiUrl updateBlocktankUrl(newUrl = blocktankUrl) - Logger.info("Blocktank URL updated to: $blocktankUrl") + Logger.info("Blocktank URL updated to: '$blocktankUrl'", context = TAG) }.onFailure { - Logger.error("Failed to update Blocktank URL", it) + Logger.error("Failed to update Blocktank URL", it, context = TAG) } } } @Suppress("KotlinConstantConditions") suspend fun isGeoBlocked(): Boolean { + val tag = "GeoCheck" if (!Env.isGeoblockingEnabled) { - Logger.verbose("Geoblocking disabled via build config", context = "GeoCheck") + Logger.verbose("Geoblocking disabled via build config", context = tag) return false } return ServiceQueue.CORE.background { runCatching { - Logger.verbose("Checking geo status…", context = "GeoCheck") + Logger.verbose("Checking geo status…", context = tag) val response = httpClient.get(Env.geoCheckUrl) when (response.status.value) { HttpStatusCode.OK.value -> { - Logger.verbose("Region allowed", context = "GeoCheck") + Logger.verbose("Region allowed", context = tag) false } HttpStatusCode.Forbidden.value -> { - Logger.warn("Region blocked", context = "GeoCheck") + Logger.warn("Region blocked", context = tag) true } else -> { Logger.warn( "Unexpected status code: ${response.status.value}, defaulting to false", - context = "GeoCheck" + context = tag ) false } } }.onFailure { - Logger.warn("Error. defaulting isGeoBlocked to false", context = "GeoCheck") + Logger.warn("Error. defaulting isGeoBlocked to false", context = tag) }.getOrDefault(false) } } @@ -174,16 +175,14 @@ class CoreService @Inject constructor( suspend fun wipeData(): Result = ServiceQueue.CORE.background { runCatching { val result = wipeAllDatabases() - Logger.info("Core DB wipe: $result", context = TAG) - }.onFailure { e -> - Logger.error("Core DB wipe error", e, context = TAG) + Logger.info("Core DB wipe: '$result'", context = TAG) + }.onFailure { + Logger.error("Core DB wipe error", it, context = TAG) } } - suspend fun isAddressUsed(address: String): Boolean { - return ServiceQueue.CORE.background { - com.synonym.bitkitcore.isAddressUsed(address = address) - } + suspend fun isAddressUsed(address: String): Boolean = ServiceQueue.CORE.background { + com.synonym.bitkitcore.isAddressUsed(address = address) } companion object { diff --git a/app/src/main/java/to/bitkit/services/CurrencyService.kt b/app/src/main/java/to/bitkit/services/CurrencyService.kt index 86a650462..5ea9cfc81 100644 --- a/app/src/main/java/to/bitkit/services/CurrencyService.kt +++ b/app/src/main/java/to/bitkit/services/CurrencyService.kt @@ -23,8 +23,8 @@ class CurrencyService @Inject constructor( val response = ServiceQueue.FOREX.background { blocktankHttpClient.fetchLatestRates() } val rates = response.tickers return rates - }.onFailure { e -> - lastError = e + }.onFailure { + lastError = it if (attempt < maxRetries - 1) { // Wait a bit before retrying, with exponential backoff val waitTime = 2.0.pow(attempt.toDouble()).toLong() * 1000L diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index 7fca32e7b..f1bcdd50f 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -1,6 +1,8 @@ package to.bitkit.services import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow @@ -75,6 +77,8 @@ class LightningService @Inject constructor( private lateinit var trustedPeers: List + private var listenerJob: Job? = null + suspend fun setup( walletIndex: Int, customServerUrl: String? = null, @@ -195,7 +199,6 @@ class LightningService @Inject constructor( ) } - @Suppress("TooGenericExceptionCaught") suspend fun start(timeout: Duration? = null, onEvent: NodeEventHandler? = null) { val node = this.node ?: throw ServiceError.NodeNotSetup() @@ -212,16 +215,16 @@ class LightningService @Inject constructor( // start event listener after node started onEvent?.let { eventHandler -> shouldListenForEvents = true - launch { - try { + listenerJob = launch { + runCatching { Logger.debug("LDK event listener started", context = TAG) if (timeout != null) { withTimeout(timeout) { listenForEvents(eventHandler) } } else { listenForEvents(eventHandler) } - } catch (e: Exception) { - Logger.error("LDK event listener error", e, context = TAG) + }.onFailure { + Logger.error("LDK event listener error", it, context = TAG) } } } @@ -231,6 +234,8 @@ class LightningService @Inject constructor( suspend fun stop() { shouldListenForEvents = false + listenerJob?.cancelAndJoin() + listenerJob = null val node = this.node ?: throw ServiceError.NodeNotStarted() Logger.debug("Stopping node…", context = TAG) @@ -539,7 +544,7 @@ class LightningService @Inject constructor( Logger.debug("Paying bolt11: $bolt11", context = TAG) val bolt11Invoice = runCatching { Bolt11Invoice.fromStr(bolt11) } - .getOrElse { e -> throw LdkError(e as NodeException) } + .getOrElse { throw LdkError(it as NodeException) } return ServiceQueue.LDK.background { runCatching { @@ -553,62 +558,52 @@ class LightningService @Inject constructor( }.getOrThrow() } - @Suppress("TooGenericExceptionCaught", "InstanceOfCheckForException") suspend fun estimateRoutingFees(bolt11: String): Result { val node = this.node ?: throw ServiceError.NodeNotSetup() return ServiceQueue.LDK.background { - return@background try { + return@background runCatching { val invoice = Bolt11Invoice.fromStr(bolt11) val feesMsat = node.bolt11Payment().estimateRoutingFees(invoice) val feeSat = feesMsat / 1000u Result.success(feeSat) - } catch (e: Exception) { - Result.failure( - if (e is NodeException) LdkError(e) else e - ) + }.getOrElse { + Result.failure(if (it is NodeException) LdkError(it) else it) } } } - @Suppress("TooGenericExceptionCaught", "InstanceOfCheckForException") suspend fun estimateRoutingFeesForAmount(bolt11: String, amountSats: ULong): Result { val node = this.node ?: throw ServiceError.NodeNotSetup() return ServiceQueue.LDK.background { - return@background try { + return@background runCatching { val invoice = Bolt11Invoice.fromStr(bolt11) val amountMsat = amountSats * 1000u val feesMsat = node.bolt11Payment().estimateRoutingFeesUsingAmount(invoice, amountMsat) val feeSat = feesMsat / 1000u Result.success(feeSat) - } catch (e: Exception) { - Result.failure( - if (e is NodeException) LdkError(e) else e - ) + }.getOrElse { + Result.failure(if (it is NodeException) LdkError(it) else it) } } } // endregion // region utxo selection - @Suppress("TooGenericExceptionCaught", "InstanceOfCheckForException") suspend fun listSpendableOutputs(): Result> { val node = this.node ?: throw ServiceError.NodeNotSetup() return ServiceQueue.LDK.background { - return@background try { + return@background runCatching { val result = node.onchainPayment().listSpendableOutputs() Result.success(result) - } catch (e: Exception) { - Result.failure( - if (e is NodeException) LdkError(e) else e - ) + }.getOrElse { + Result.failure(if (it is NodeException) LdkError(it) else it) } } } - @Suppress("TooGenericExceptionCaught", "InstanceOfCheckForException") suspend fun selectUtxosWithAlgorithm( targetAmountSats: ULong, satsPerVByte: ULong, @@ -618,7 +613,7 @@ class LightningService @Inject constructor( val node = this.node ?: throw ServiceError.NodeNotSetup() return ServiceQueue.LDK.background { - return@background try { + runCatching { val result = node.onchainPayment().selectUtxosWithAlgorithm( targetAmountSats = targetAmountSats, feeRate = FeeRate.fromSatPerVbUnchecked(satsPerVByte), @@ -626,10 +621,8 @@ class LightningService @Inject constructor( utxos = utxos, ) Result.success(result) - } catch (e: Exception) { - Result.failure( - if (e is NodeException) LdkError(e) else e - ) + }.getOrElse { + Result.failure(if (it is NodeException) LdkError(it) else it) } } } @@ -749,21 +742,17 @@ class LightningService @Inject constructor( } // endregion - @Suppress("TooGenericExceptionCaught") suspend fun getAddressBalance(address: String): ULong { val node = this.node ?: throw ServiceError.NodeNotSetup() return ServiceQueue.LDK.background { - try { + runCatching { node.getAddressBalance(addressStr = address) - } catch (e: Exception) { - Logger.error("Error getting address balance for address: $address", e, context = TAG) - throw e - } + }.onFailure { + Logger.error("Error getting address balance for address: '$address'", it, context = TAG) + }.getOrThrow() } } - // endregion - // region state val nodeId: String? get() = node?.nodeId() val balances: BalanceDetails? get() = node?.listBalances() @@ -775,7 +764,7 @@ class LightningService @Inject constructor( // endregion // region debug - @Suppress("LongMethod", "TooGenericExceptionCaught") + @Suppress("LongMethod") fun dumpNetworkGraphInfo(bolt11: String) { val node = this.node ?: run { Logger.error("Node not available for network graph dump", context = TAG) @@ -787,13 +776,13 @@ class LightningService @Inject constructor( sb.appendLine("\n\n=== ROUTE NOT FOUND - NETWORK GRAPH DUMP ===\n") // 1. Invoice Info - try { + runCatching { val invoice = Bolt11Invoice.fromStr(bolt11) sb.appendLine("Invoice Info:") sb.appendLine(" - Payment Hash: ${invoice.paymentHash()}") sb.appendLine(" - Invoice: $bolt11") - } catch (e: Exception) { - sb.appendLine("Failed to parse bolt11 invoice: $e") + }.getOrElse { + sb.appendLine("Failed to parse bolt11 invoice: $it") } // 2. Our Node Info @@ -916,8 +905,8 @@ class LightningService @Inject constructor( channelCount = graph.listChannels().size, latestRgsSyncTimestamp = node.status().latestRgsSnapshotTimestamp, ) - }.onFailure { e -> - Logger.error("Failed to get network graph info", e, context = TAG) + }.onFailure { + Logger.error("Failed to get network graph info", it, context = TAG) }.getOrNull() } @@ -942,8 +931,8 @@ class LightningService @Inject constructor( Logger.info("Exported ${nodes.size} nodes to ${outputFile.absolutePath}", context = TAG) outputFile - }.onFailure { e -> - Logger.error("Failed to export network graph to file", e, context = TAG) + }.onFailure { + Logger.error("Failed to export network graph to file", it, context = TAG) } } }