diff --git a/app/detekt-baseline.xml b/app/detekt-baseline.xml index 8584fc7fd..9ece0cf15 100644 --- a/app/detekt-baseline.xml +++ b/app/detekt-baseline.xml @@ -79,21 +79,21 @@ CyclomaticComplexMethod:AmountInput.kt$@Composable fun AmountInput( modifier: Modifier = Modifier, defaultValue: Long = 0, primaryDisplay: PrimaryDisplay, showConversion: Boolean = false, overrideSats: Long? = null, onSatsChange: (Long) -> Unit, ) CyclomaticComplexMethod:AppStatusScreen.kt$@Composable private fun Content( uiState: AppStatusUiState = AppStatusUiState(), onBack: () -> Unit = {}, onClose: () -> Unit = {}, onInternetClick: () -> Unit = {}, onElectrumClick: () -> Unit = {}, onNodeClick: () -> Unit = {}, onChannelsClick: () -> Unit = {}, onBackupClick: () -> Unit = {}, ) 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:ChannelDetailScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable private fun Content( channel: ChannelUi, blocktankOrders: List<IBtOrder> = emptyList(), cjitEntries: List<IcJitEntry> = emptyList(), txDetails: TxDetails? = null, isRefreshing: Boolean = false, onBack: () -> Unit = {}, onClose: () -> Unit = {}, onRefresh: () -> Unit = {}, onCopyText: (String) -> Unit = {}, onOpenUrl: (String) -> Unit = {}, onSupport: (Any) -> Unit = {}, onCloseConnection: () -> Unit = {}, ) CyclomaticComplexMethod:ConfirmMnemonicScreen.kt$@Composable fun ConfirmMnemonicScreen( uiState: BackupContract.UiState, onContinue: () -> Unit, onBack: () -> Unit, ) CyclomaticComplexMethod:ContentView.kt$@Composable fun ContentView( appViewModel: AppViewModel, walletViewModel: WalletViewModel, blocktankViewModel: BlocktankViewModel, currencyViewModel: CurrencyViewModel, activityListViewModel: ActivityListViewModel, transferViewModel: TransferViewModel, settingsViewModel: SettingsViewModel, backupsViewModel: BackupsViewModel, ) - CyclomaticComplexMethod:CoreService.kt$ActivityService$suspend fun syncLdkNodePayments(payments: List<PaymentDetails>, forceUpdate: Boolean = false) + CyclomaticComplexMethod:CoreService.kt$ActivityService$private suspend fun processOnchainPayment( kind: PaymentKind.Onchain, payment: PaymentDetails, forceUpdate: Boolean, ) 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:HomeScreen.kt$@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class) @Composable private fun Content( mainUiState: MainUiState, homeUiState: HomeUiState, rootNavController: NavController, walletNavController: NavController, drawerState: DrawerState, hazeState: HazeState = rememberHazeState(), latestActivities: List<Activity>?, onClickProfile: () -> Unit = {}, onRefresh: () -> Unit = {}, onRemoveSuggestion: (Suggestion) -> Unit = {}, onClickSuggestion: (Suggestion) -> Unit = {}, onClickAddWidget: () -> Unit = {}, onClickEnableEdit: () -> Unit = {}, onClickConfirmEdit: () -> Unit = {}, onClickEditWidget: (WidgetType) -> Unit = {}, onClickDeleteWidget: (WidgetType) -> Unit = {}, onMoveWidget: (Int, Int) -> Unit = { _, _ -> }, onDismissEmptyState: () -> Unit = {}, onDismissHighBalanceSheet: () -> Unit = {}, onClickEmptyActivityRow: () -> Unit = {}, balances: BalanceState = LocalBalances.current, ) - CyclomaticComplexMethod:InactivityTracker.kt$@Composable fun InactivityTracker( app: AppViewModel, settings: SettingsViewModel, modifier: Modifier = Modifier, content: @Composable () -> Unit, ) + CyclomaticComplexMethod:HomeScreen.kt$@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class) @Composable private fun Content( mainUiState: MainUiState, homeUiState: HomeUiState, rootNavController: NavController, walletNavController: NavController, drawerState: DrawerState, hazeState: HazeState = rememberHazeState(), latestActivities: List<Activity>?, onClickProfile: () -> Unit = {}, onRefresh: () -> Unit = {}, onRemoveSuggestion: (Suggestion) -> Unit = {}, onClickSuggestion: (Suggestion) -> Unit = {}, onClickAddWidget: () -> Unit = {}, onClickEditWidgetList: () -> Unit = {}, onClickEditWidget: (WidgetType) -> Unit = {}, onClickDeleteWidget: (WidgetType) -> Unit = {}, onMoveWidget: (Int, Int) -> Unit = { _, _ -> }, onDismissEmptyState: () -> Unit = {}, onDismissHighBalanceSheet: () -> Unit = {}, onClickEmptyActivityRow: () -> Unit = {}, balances: BalanceState = LocalBalances.current, ) CyclomaticComplexMethod:LightningService.kt$LightningService$private fun logEvent(event: Event) - CyclomaticComplexMethod:NumberPadTextField.kt$@Composable fun AmountInputHandler( input: String, primaryDisplay: PrimaryDisplay, displayUnit: BitcoinDisplayUnit, onInputChanged: (String) -> Unit, onAmountCalculated: (String) -> Unit, currencyVM: CurrencyViewModel, overrideSats: Long? = null, ) - CyclomaticComplexMethod:NumberPadTextField.kt$@Composable fun NumberPadTextField( input: String, displayUnit: BitcoinDisplayUnit, primaryDisplay: PrimaryDisplay, modifier: Modifier = Modifier, ) + CyclomaticComplexMethod:NumberPadTextField.kt$@Composable fun AmountInputHandler( input: String, primaryDisplay: PrimaryDisplay, displayUnit: BitcoinDisplayUnit, onInputChanged: (String) -> Unit, onAmountCalculated: (String) -> Unit, currencyVM: CurrencyViewModel = hiltViewModel(), overrideSats: Long? = null, ) + CyclomaticComplexMethod:NumberPadTextField.kt$@Composable fun NumberPadTextField( input: String, displayUnit: BitcoinDisplayUnit, primaryDisplay: PrimaryDisplay, modifier: Modifier = Modifier, showSecondaryField: Boolean = true, ) CyclomaticComplexMethod:ReceiveQrScreen.kt$@Composable fun ReceiveQrScreen( cjitInvoice: MutableState<String?>, cjitActive: MutableState<Boolean>, walletState: MainUiState, onCjitToggle: (Boolean) -> Unit, onClickEditInvoice: () -> Unit, onClickReceiveOnSpending: () -> Unit, modifier: Modifier = Modifier, ) CyclomaticComplexMethod:RestoreWalletScreen.kt$@Composable fun RestoreWalletView( onBackClick: () -> Unit, onRestoreClick: (mnemonic: String, passphrase: String?) -> Unit, ) - CyclomaticComplexMethod:SendSheet.kt$@Composable fun SendSheet( appViewModel: AppViewModel, walletViewModel: WalletViewModel, startDestination: SendRoute = SendRoute.Recipient, onComplete: (NewTransactionSheetDetails?) -> Unit, ) + 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, ) CyclomaticComplexMethod:WakeNodeWorker.kt$WakeNodeWorker$private suspend fun handleLdkEvent(event: Event) @@ -115,24 +115,18 @@ ForbiddenComment:ActivityDetailScreen.kt$/* TODO: Implement assign functionality */ ForbiddenComment:ActivityListViewModel.kt$ActivityListViewModel$// TODO: sync only on specific events for better performance ForbiddenComment:ActivityRow.kt$// TODO: calculate confirmsIn text - ForbiddenComment:AppViewModel.kt$AppViewModel$// TODO: fee is not the sats sent. Need to get this amount from elsewhere like send flow or something. ForbiddenComment:AppViewModel.kt$AppViewModel$// TODO: handle ONLY cjit as payment received. This makes it look like any channel confirmed is a received payment. ForbiddenComment:BackupNavSheetViewModel.kt$BackupNavSheetViewModel$// TODO: get from actual repository state ForbiddenComment:BackupRepo.kt$BackupRepo$// TODO: Add other backup categories as they get implemented: ForbiddenComment:BoostTransactionViewModel.kt$BoostTransactionUiState$// TODO: Implement dynamic time estimation ForbiddenComment:ChannelStatusView.kt$// TODO: handle closed channels marking & detection ForbiddenComment:ContentView.kt$// TODO: display as sheet - ForbiddenComment:CoreService.kt$ActivityService$// TODO: find address - ForbiddenComment:CoreService.kt$ActivityService$// TODO: get from linked order - ForbiddenComment:CoreService.kt$ActivityService$// TODO: get from somewhere - ForbiddenComment:CoreService.kt$ActivityService$// TODO: handle when paying for order ForbiddenComment:Env.kt$Env$// TODO: remove this to load from BT API instead 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:LightningNodeService.kt$LightningNodeService$// TODO: Get from resources ForbiddenComment:Notifications.kt$// TODO: review if needed: ForbiddenComment:SuccessScreen.kt$// TODO: verify backup - ForbiddenComment:TransferIntroScreen.kt$// TODO: show on first LN suggestion card click ForbiddenComment:TransferViewModel.kt$TransferViewModel$// TODO: showBottomSheet: forceTransfer FunctionOnlyReturningConstant:RepoModule.kt$RepoModule$@Provides @Named("enablePolling") fun provideEnablePolling(): Boolean FunctionOnlyReturningConstant:ShopWebViewInterface.kt$ShopWebViewInterface$@JavascriptInterface fun isReady(): Boolean @@ -172,23 +166,23 @@ LambdaParameterInRestartableEffect:SendPinCheckScreen.kt$onSuccess LambdaParameterInRestartableEffect:SendQuickPayScreen.kt$onPaymentComplete LambdaParameterInRestartableEffect:SendQuickPayScreen.kt$onShowError - LambdaParameterInRestartableEffect:SendSheet.kt$onComplete LambdaParameterInRestartableEffect:SheetHost.kt$onDismiss LambdaParameterInRestartableEffect:SpendingAmountScreen.kt$onOrderCreated LambdaParameterInRestartableEffect:SpendingAmountScreen.kt$toast LambdaParameterInRestartableEffect:SpendingAmountScreen.kt$toastException LargeClass:AppViewModel.kt$AppViewModel : ViewModel LargeClass:LightningRepo.kt$LightningRepo + LongMethod:ActivityRepo.kt$ActivityRepo$private suspend fun syncTagsMetadata() LongMethod:AppViewModel.kt$AppViewModel$private fun observeLdkNodeEvents() LongMethod:AppViewModel.kt$AppViewModel$private suspend fun proceedWithPayment() LongMethod:ContentView.kt$private fun NavGraphBuilder.widgets( navController: NavHostController, settingsViewModel: SettingsViewModel, currencyViewModel: CurrencyViewModel, ) LongMethod:CoreService.kt$ActivityService$suspend fun generateRandomTestData(count: Int = 100) - LongMethod:CoreService.kt$ActivityService$suspend fun syncLdkNodePayments(payments: List<PaymentDetails>, forceUpdate: Boolean = false) LongMethod:LightningRepo.kt$LightningRepo$suspend fun start( walletIndex: Int = 0, timeout: Duration? = null, shouldRetry: Boolean = true, eventHandler: NodeEventHandler? = null, customServer: ElectrumServer? = null, customRgsServerUrl: String? = null, ): Result<Unit> LongMethod:LightningService.kt$LightningService$private fun logEvent(event: Event) LongMethod:MainActivity.kt$MainActivity$override fun onCreate(savedInstanceState: Bundle?) LongMethod:WakeNodeWorker.kt$WakeNodeWorker$private suspend fun handleLdkEvent(event: Event) LongParameterList:ActivityRepo.kt$ActivityRepo$( 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:ActivityRepo.kt$ActivityRepo$( id: String, paymentHash: String? = null, txId: String? = null, address: String, isReceive: Boolean, tags: List<String>, ) LongParameterList:AppViewModel.kt$AppViewModel$( @ApplicationContext private val context: Context, @BgDispatcher private val bgDispatcher: CoroutineDispatcher, private val keychain: Keychain, private val lightningRepo: LightningRepo, private val walletRepo: WalletRepo, private val coreService: CoreService, private val ldkNodeEventBus: LdkNodeEventBus, private val settingsStore: SettingsStore, private val currencyRepo: CurrencyRepo, private val activityRepo: ActivityRepo, private val blocktankRepo: BlocktankRepo, connectivityRepo: ConnectivityRepo, healthRepo: HealthRepo, ) 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, ) @@ -200,12 +194,10 @@ LongParameterList:LightningRepo.kt$LightningRepo$( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, private val lightningService: LightningService, private val ldkNodeEventBus: LdkNodeEventBus, private val settingsStore: SettingsStore, private val coreService: CoreService, private val blocktankNotificationsService: BlocktankNotificationsService, private val firebaseMessaging: FirebaseMessaging, private val keychain: Keychain, private val lnurlService: LnurlService, private val cacheStore: CacheStore, ) LongParameterList:LightningRepo.kt$LightningRepo$( address: Address, sats: ULong, speed: TransactionSpeed? = null, utxosToSpend: List<SpendableUtxo>? = null, feeRates: FeeRates? = null, isTransfer: Boolean = false, channelId: String? = null, ) LongParameterList:LightningRepo.kt$LightningRepo$( walletIndex: Int = 0, timeout: Duration? = null, shouldRetry: Boolean = true, eventHandler: NodeEventHandler? = null, customServer: ElectrumServer? = null, customRgsServerUrl: String? = null, ) - LongParameterList:Nav.kt$( typeMap: Map<KType, NavType<*>> = emptyMap(), deepLinks: List<NavDeepLink> = emptyList(), noinline enterTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition?)? = { screenSlideIn }, noinline exitTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition?)? = { screenScaleOut }, noinline popEnterTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition?)? = { screenScaleIn }, noinline popExitTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition?)? = { screenSlideOut }, noinline content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit, ) LongParameterList:Notifications.kt$( title: String?, text: String?, extras: Bundle? = null, bigText: String? = null, id: Int = Random.nextInt(), context: Context, ) LongParameterList:TransferViewModel.kt$TransferViewModel$( @ApplicationContext private val context: Context, private val lightningRepo: LightningRepo, private val blocktankRepo: BlocktankRepo, private val walletRepo: WalletRepo, private val currencyRepo: CurrencyRepo, private val settingsStore: SettingsStore, private val cacheStore: CacheStore, ) LongParameterList:WalletRepo.kt$WalletRepo$( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, private val db: AppDb, private val keychain: Keychain, private val coreService: CoreService, private val settingsStore: SettingsStore, private val addressChecker: AddressChecker, private val lightningRepo: LightningRepo, private val cacheStore: CacheStore, ) 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:CoreService.kt$ActivityService$for LoopWithTooManyJumpStatements:MonetaryVisualTransformation.kt$MonetaryVisualTransformation.<no name provided>$for LoopWithTooManyJumpStatements:TransferViewModel.kt$TransferViewModel$while MagicNumber:ActivityDetailScreen.kt$40 @@ -231,8 +223,6 @@ MagicNumber:AppStatus.kt$0.6f MagicNumber:AppStatus.kt$2000 MagicNumber:AppStatus.kt$600 - MagicNumber:AppViewModel.kt$AppViewModel$0.5 - MagicNumber:AppViewModel.kt$AppViewModel$10 MagicNumber:AppViewModel.kt$AppViewModel$1000 MagicNumber:AppViewModel.kt$AppViewModel$250 MagicNumber:AppViewModel.kt$AppViewModel$300 @@ -304,7 +294,6 @@ MagicNumber:HomeScreen.kt$3 MagicNumber:HttpModule.kt$HttpModule$30_000 MagicNumber:HttpModule.kt$HttpModule$60_000 - MagicNumber:InactivityTracker.kt$1000 MagicNumber:InitializingWalletView.kt$500 MagicNumber:InitializingWalletView.kt$99.9 MagicNumber:Keyboard.kt$0.2f @@ -316,7 +305,6 @@ MagicNumber:Logger.kt$Logger$1024L MagicNumber:Logger.kt$Logger$4 MagicNumber:LogsRepo.kt$LogsRepo$3 - MagicNumber:MainActivity.kt$MainActivity$4 MagicNumber:MigrationService.kt$MigrationService$1000 MagicNumber:MigrationService.kt$MigrationService$1000L MagicNumber:NewsService.kt$NewsService$10 @@ -354,7 +342,10 @@ MagicNumber:SavingsConfirmScreen.kt$300 MagicNumber:SavingsProgressScreen.kt$2500 MagicNumber:SavingsProgressScreen.kt$5000 + MagicNumber:SendConfirmScreen.kt$1_234 MagicNumber:SendConfirmScreen.kt$300 + MagicNumber:SendConfirmScreen.kt$43 + MagicNumber:SendConfirmScreen.kt$654_321 MagicNumber:SettingUpScreen.kt$3 MagicNumber:SettingUpScreen.kt$5000 MagicNumber:SettingsButtonRow.kt$0.5f @@ -399,7 +390,6 @@ MatchingDeclarationName:SavingsProgressScreen.kt$SavingsProgressState MatchingDeclarationName:SettingsButtonRow.kt$SettingsButtonValue MaxLineLength:ActivityDetailScreen.kt$description = "Unable to increase the fee any further. Otherwise, it will exceed half the current input balance" - MaxLineLength:AppViewModel.kt$AppViewModel$// TODO: fee is not the sats sent. Need to get this amount from elsewhere like send flow or something. MaxLineLength:AppViewModel.kt$AppViewModel$// TODO: handle ONLY cjit as payment received. This makes it look like any channel confirmed is a received payment. MaxLineLength:Bip39Test.kt$Bip39Test$"AbAnDoN abandon ABANDON abandon abandon abandon abandon abandon abandon abandon abandon about".toWordList().validBip39Checksum() MaxLineLength:Bip39Test.kt$Bip39Test$"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art" to true @@ -431,14 +421,12 @@ MaxLineLength:LightningService.kt$LightningService$"⛔ Channel closed: channelId: $channelId userChannelId: $userChannelId counterpartyNodeId: $counterpartyNodeId reason: $reason" MaxLineLength:LightningService.kt$LightningService$"👐 Channel ready: channelId: $channelId userChannelId: $userChannelId counterpartyNodeId: $counterpartyNodeId" MaxLineLength:LightningService.kt$LightningService$"🫰 Payment claimable: paymentId: $paymentId paymentHash: $paymentHash claimableAmountMsat: $claimableAmountMsat" - MaxLineLength:Nav.kt$* Adds the [androidx.compose.runtime.Composable] to the [androidx.navigation.NavGraphBuilder] with the default screen transitions. 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:VssStoreIdProvider.kt$VssStoreIdProvider$"Do not run this on mainnet until VSS auth is implemented. Below hack is a temporary fix and not safe for mainnet." MaxLineLength:WeatherService.kt$WeatherService$val avgFeeUsd = currencyRepo.convertSatsToFiat(avgFeeSats.toLong(), currency = USD_CURRENCY).getOrNull() ?: return FeeCondition.AVERAGE MaximumLineLength:ActivityDetailScreen.kt$ MaximumLineLength:Bip39Test.kt$Bip39Test$ @@ -457,7 +445,6 @@ MaximumLineLength:SecuritySettingsScreen.kt$ MaximumLineLength:SendAddressScreen.kt$ MaximumLineLength:SettingsScreen.kt$ - MaximumLineLength:VssStoreIdProvider.kt$VssStoreIdProvider$ MaximumLineLength:WeatherService.kt$WeatherService$ MayBeConst:Env.kt$Env$val walletSyncIntervalSecs = 10_uL // TODO review MayBeConst:Env.kt$Env.TransactionDefaults$/** * Minimum value in sats for an output. Outputs below the dust limit may not be processed because the fees * required to include them in a block would be greater than the value of the transaction itself. * */ val dustLimit = 546u @@ -584,7 +571,6 @@ ParameterNaming:RestoreWalletScreen.kt$onValueChanged ParameterNaming:SendAmountScreen.kt$onInputChanged ParameterNaming:SpendingAdvancedScreen.kt$onOrderCreated - ParameterNaming:SpendingAmountScreen.kt$onAmountChanged ParameterNaming:SpendingAmountScreen.kt$onOrderCreated ParameterNaming:TransactionSpeedSettingsScreen.kt$onSpeedSelected PreviewPublic:CameraPermissionView.kt$PreviewDenied @@ -611,12 +597,10 @@ SpreadOperator:RestoreWalletScreen.kt$(*Array(24) { "" }) SwallowedException:Crypto.kt$Crypto$e: Exception SwallowedException:FcmService.kt$FcmService$e: SerializationException - ThrowingExceptionsWithoutMessageOrCause:ActivityRepo.kt$ActivityRepo$Exception() TooGenericExceptionCaught:ActivityDetailViewModel.kt$ActivityDetailViewModel$e: Exception TooGenericExceptionCaught:ActivityDetailViewModel.kt$ActivityDetailViewModel$e: Throwable TooGenericExceptionCaught:ActivityListViewModel.kt$ActivityListViewModel$e: Exception TooGenericExceptionCaught:ActivityRepo.kt$ActivityRepo$e: Exception - TooGenericExceptionCaught:ActivityRepo.kt$ActivityRepo$e: Throwable TooGenericExceptionCaught:AddressChecker.kt$AddressChecker$e: Exception TooGenericExceptionCaught:AppViewModel.kt$AppViewModel$e: Exception TooGenericExceptionCaught:AppViewModel.kt$AppViewModel$e: Throwable @@ -630,7 +614,6 @@ TooGenericExceptionCaught:ChannelOrdersScreen.kt$e: Throwable TooGenericExceptionCaught:ContentView.kt$e: Throwable TooGenericExceptionCaught:CoreService.kt$ActivityService$e: Exception - TooGenericExceptionCaught:CoreService.kt$ActivityService$e: Throwable TooGenericExceptionCaught:CoreService.kt$CoreService$e: Exception TooGenericExceptionCaught:Crypto.kt$Crypto$e: Exception TooGenericExceptionCaught:CurrencyRepo.kt$CurrencyRepo$e: Exception @@ -642,7 +625,6 @@ TooGenericExceptionCaught:Logger.kt$Logger$e: Throwable TooGenericExceptionCaught:LogsRepo.kt$LogsRepo$e: Exception TooGenericExceptionCaught:LogsViewModel.kt$LogsViewModel$e: Exception - TooGenericExceptionCaught:MainActivity.kt$MainActivity$e: Throwable TooGenericExceptionCaught:NewTransactionSheetDetails.kt$NewTransactionSheetDetails.Companion$e: Exception TooGenericExceptionCaught:PriceService.kt$PriceService$e: Exception TooGenericExceptionCaught:QrScanningScreen.kt$e: Exception @@ -691,6 +673,7 @@ TooManyFunctions:SendConfirmScreen.kt$to.bitkit.ui.screens.wallets.send.SendConfirmScreen.kt TooManyFunctions:SettingsViewModel.kt$SettingsViewModel : ViewModel TooManyFunctions:TOS.kt$to.bitkit.ui.onboarding.TOS.kt + TooManyFunctions:TagMetadataDao.kt$TagMetadataDao TooManyFunctions:Text.kt$to.bitkit.ui.components.Text.kt TooManyFunctions:Text.kt$to.bitkit.ui.utils.Text.kt TooManyFunctions:TransferViewModel.kt$TransferViewModel : ViewModel @@ -700,8 +683,6 @@ TooManyFunctions:WidgetsStore.kt$WidgetsStore TopLevelPropertyNaming:DrawerMenu.kt$private const val zIndexMenu = 11f TopLevelPropertyNaming:DrawerMenu.kt$private const val zIndexScrim = 10f - UnusedParameter:HomeScreen.kt$onClickEnableEdit: () -> Unit = {} - UnusedPrivateProperty:ActivityListViewModel.kt$ActivityListViewModel$private val lightningRepo: LightningRepo UnusedPrivateProperty:ActivityRepoTest.kt$ActivityRepoTest$private val testOnChainActivity = mock<Activity.Onchain> { on { v1 } doReturn testOnChainActivityV1 } UnusedPrivateProperty:CurrencyRepoTest.kt$CurrencyRepoTest$private val toastEventBus: ToastEventBus = mock() UseCheckOrError:CurrencyRepo.kt$CurrencyRepo$throw IllegalStateException( "Rate not found for currency: $targetCurrency. Available currencies: ${ _currencyState.value.rates.joinToString { it.quote } }" ) @@ -710,7 +691,7 @@ ViewModelForwarding:ContentView.kt$LnurlAuthSheet(sheet, appViewModel) ViewModelForwarding:ContentView.kt$PinSheet(sheet, appViewModel) ViewModelForwarding:ContentView.kt$RootNavHost( navController = navController, walletViewModel = walletViewModel, appViewModel = appViewModel, activityListViewModel = activityListViewModel, settingsViewModel = settingsViewModel, currencyViewModel = currencyViewModel, transferViewModel = transferViewModel, ) - ViewModelForwarding:ContentView.kt$SendSheet( appViewModel = appViewModel, walletViewModel = walletViewModel, startDestination = sheet.route, onComplete = { txSheet -> appViewModel.hideSheet() appViewModel.clearClipboardForAutoRead() txSheet?.let { appViewModel.showNewTransactionSheet(it) } } ) + ViewModelForwarding:ContentView.kt$SendSheet( appViewModel = appViewModel, walletViewModel = walletViewModel, startDestination = sheet.route, ) ViewModelForwarding:ContentView.kt$SettingUpScreen( viewModel = transferViewModel, onCloseClick = { navController.popBackStack<Routes.TransferRoot>(inclusive = true) }, onContinueClick = { navController.popBackStack<Routes.TransferRoot>(inclusive = true) }, ) ViewModelForwarding:ContentView.kt$SpendingAdvancedScreen( viewModel = transferViewModel, onBackClick = { navController.popBackStack() }, onCloseClick = { navController.navigateToHome() }, onOrderCreated = { navController.popBackStack<Routes.SpendingConfirm>(inclusive = false) }, ) ViewModelForwarding:ContentView.kt$SpendingAmountScreen( viewModel = transferViewModel, onBackClick = { navController.popBackStack() }, onCloseClick = { navController.navigateToHome() }, onOrderCreated = { navController.navigate(Routes.SpendingConfirm) }, toastException = { appViewModel.toast(it) }, toast = { title, description -> appViewModel.toast( type = Toast.ToastType.ERROR, title = title, description = description ) }, ) diff --git a/app/src/androidTest/java/to/bitkit/services/RoutingFeeEstimationTest.kt b/app/src/androidTest/java/to/bitkit/services/RoutingFeeEstimationTest.kt new file mode 100644 index 000000000..c70725734 --- /dev/null +++ b/app/src/androidTest/java/to/bitkit/services/RoutingFeeEstimationTest.kt @@ -0,0 +1,343 @@ +package to.bitkit.services + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.lightningdevkit.ldknode.Bolt11Invoice +import org.lightningdevkit.ldknode.NodeException +import to.bitkit.data.CacheStore +import to.bitkit.data.keychain.Keychain +import to.bitkit.env.Env +import to.bitkit.repositories.WalletRepo +import to.bitkit.utils.LdkError +import javax.inject.Inject +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +@HiltAndroidTest +@RunWith(AndroidJUnit4::class) +class RoutingFeeEstimationTest { + + companion object { + private const val NODE_STARTUP_MAX_RETRIES = 10 + private const val NODE_STARTUP_RETRY_DELAY_MS = 1000L + private const val DEFAULT_INVOICE_EXPIRY_SECS = 3600u + } + + @get:Rule + var hiltRule = HiltAndroidRule(this) + + @Inject + lateinit var keychain: Keychain + + @Inject + lateinit var lightningService: LightningService + + @Inject + lateinit var walletRepo: WalletRepo + + @Inject + lateinit var cacheStore: CacheStore + + private val walletIndex = 0 + private val appContext = ApplicationProvider.getApplicationContext() + + @Before + fun setUp() { + // Use unique storage path per test to avoid DataStore conflicts + val testStoragePath = "${appContext.filesDir.absolutePath}/${System.currentTimeMillis()}" + Env.initAppStoragePath(testStoragePath) + hiltRule.inject() + println("Starting RoutingFeeEstimation test setup with storage: $testStoragePath") + + runBlocking { + println("Wiping keychain before test") + keychain.wipe() + println("Keychain wiped successfully") + } + } + + @After + fun tearDown() { + runBlocking { + println("Tearing down RoutingFeeEstimation test") + + if (lightningService.status?.isRunning == true) { + try { + lightningService.stop() + } catch (e: Exception) { + println("Error stopping lightning service: ${e.message}") + } + } + try { + lightningService.wipeStorage(walletIndex = walletIndex) + } catch (e: Exception) { + println("Error wiping lightning storage: ${e.message}") + } + + println("Resetting cache store to clear DataStore") + try { + cacheStore.reset() + println("Cache store reset successfully") + } catch (e: Exception) { + println("Error resetting cache store: ${e.message}") + } + + println("Wiping keychain after test") + keychain.wipe() + println("Keychain wiped successfully") + } + } + + + @Test + fun testNewRoutingFeeMethodsExist() = runBlocking { + runNode() + + val paymentAmountSats = 1000uL + val invoice = createInvoiceWithAmount( + amountSats = paymentAmountSats, + description = "Method existence test" + ) + + // Just test that the methods exist and can be called - don't worry about results + val bolt11Payment = lightningService.node!!.bolt11Payment() + + println("Testing estimateRoutingFees method...") + runCatching { + bolt11Payment.estimateRoutingFees(invoice) + }.fold( + onSuccess = { fees -> + println("estimateRoutingFees returned: $fees msat") + assertTrue(true, "Method exists and returned a value") + }, + onFailure = { error -> + println("estimateRoutingFees threw: ${error.message}") + // Method exists if it throws a specific error rather than NoSuchMethodError + assertTrue( + !(error.message?.contains("NoSuchMethodError") == true), + "Method should exist (got: ${error.message})" + ) + } + ) + + val zeroInvoice = createZeroAmountInvoice("Zero amount method test") + println("Testing estimateRoutingFeesUsingAmount method...") + runCatching { + bolt11Payment.estimateRoutingFeesUsingAmount(zeroInvoice, 1_000_000uL) + }.fold( + onSuccess = { fees -> + println("estimateRoutingFeesUsingAmount returned: $fees msat") + assertTrue(true, "Method exists and returned a value") + }, + onFailure = { error -> + println("estimateRoutingFeesUsingAmount threw: ${error.message}") + // Method exists if it throws a specific error rather than NoSuchMethodError + assertTrue( + !(error.message?.contains("NoSuchMethodError") == true), + "Method should exist (got: ${error.message})" + ) + } + ) + } + + @Test + fun estimateRoutingFeesForVariableAmountInvoice() = runBlocking { + runNode() + + val invoice = createZeroAmountInvoice("Variable amount fee estimation test") + val paymentAmountMsat = 5_000_000uL + + runCatching { + lightningService.node!!.bolt11Payment() + .estimateRoutingFeesUsingAmount(invoice, paymentAmountMsat) + }.fold( + onSuccess = { estimatedFeesMsat -> + assertFeesAreReasonable(estimatedFeesMsat, paymentAmountMsat) + }, + onFailure = { error -> + handleExpectedRoutingError(error as NodeException) + } + ) + } + + @Test + fun routeNotFoundErrorIsHandledProperly() = runBlocking { + runNode() + + val largeAmountSats = 1_000_000uL + val invoiceToSelf = createInvoiceWithAmount( + amountSats = largeAmountSats, + description = "Route error handling test" + ) + + runCatching { + lightningService.node!!.bolt11Payment().estimateRoutingFees(invoiceToSelf) + }.fold( + onSuccess = { estimatedFeesMsat -> + assertTrue( + estimatedFeesMsat >= 0u, + "If routing unexpectedly succeeds, fees should be non-negative" + ) + }, + onFailure = { error -> + assertRoutingErrorOccurred(error as NodeException) + } + ) + } + + @Test + fun zeroAmountPaymentIsHandledGracefully() = runBlocking { + runNode() + + val invoice = createZeroAmountInvoice("Zero amount validation test") + val zeroAmountMsat = 0uL + + runCatching { + lightningService.node!!.bolt11Payment() + .estimateRoutingFeesUsingAmount(invoice, zeroAmountMsat) + }.fold( + onSuccess = { estimatedFeesMsat -> + assertEquals( + 0uL, + estimatedFeesMsat, + "Zero amount should result in zero fees" + ) + }, + onFailure = { + assertTrue( + true, + "Zero amount payment throwing an error is acceptable" + ) + } + ) + } + + @Test + fun routingFeesScaleWithPaymentAmount() = runBlocking { + runNode() + + val invoice = createZeroAmountInvoice("Fee scaling test") + val smallAmountMsat = 1_000_000uL + val largeAmountMsat = 10_000_000uL + + runCatching { + val smallAmountFeesMsat = lightningService.node!!.bolt11Payment() + .estimateRoutingFeesUsingAmount(invoice, smallAmountMsat) + + val largeAmountFeesMsat = lightningService.node!!.bolt11Payment() + .estimateRoutingFeesUsingAmount(invoice, largeAmountMsat) + + Pair(smallAmountFeesMsat, largeAmountFeesMsat) + }.fold( + onSuccess = { (smallAmountFeesMsat, largeAmountFeesMsat) -> + assertFeesScaleProperly( + smallAmountFeesMsat, + largeAmountFeesMsat, + smallAmountMsat, + largeAmountMsat + ) + }, + onFailure = { error -> + handleExpectedRoutingError(error as NodeException) + } + ) + } + + // region utils + + private suspend fun runNode() { + println("Creating new wallet") + walletRepo.createWallet(bip39Passphrase = null) + + println("Setting up lightning service") + lightningService.setup(walletIndex = walletIndex) + + println("Starting lightning node") + lightningService.start() + println("Lightning node started successfully") + + waitForNodeInitialization() + + println("Syncing wallet") + lightningService.sync() + println("Wallet sync complete") + } + + private suspend fun waitForNodeInitialization() { + repeat(NODE_STARTUP_MAX_RETRIES) { + if (lightningService.node != null) return + delay(NODE_STARTUP_RETRY_DELAY_MS) + } + assertNotNull(lightningService.node, "Node should be initialized within timeout") + } + + private suspend fun createInvoiceWithAmount(amountSats: ULong, description: String): Bolt11Invoice { + val invoiceString = lightningService.receive( + sat = amountSats, + description = description, + expirySecs = DEFAULT_INVOICE_EXPIRY_SECS + ) + return Bolt11Invoice.fromStr(invoiceString) + } + + private suspend fun createZeroAmountInvoice(description: String): Bolt11Invoice { + val invoiceString = lightningService.receive( + sat = null, + description = description, + expirySecs = DEFAULT_INVOICE_EXPIRY_SECS + ) + return Bolt11Invoice.fromStr(invoiceString) + } + + private fun assertFeesAreReasonable(estimatedFeesMsat: ULong, paymentAmountMsat: ULong) { + assertTrue( + estimatedFeesMsat >= 0u, + "Estimated fees should be non-negative, got: $estimatedFeesMsat" + ) + assertTrue( + estimatedFeesMsat < paymentAmountMsat, + "Estimated fees should be less than payment amount. Fees: $estimatedFeesMsat, Amount: $paymentAmountMsat msat" + ) + } + + private fun handleExpectedRoutingError(error: NodeException) { + when (error) { + is NodeException.RouteNotFound -> { + assertTrue(true, "RouteNotFound error is acceptable in test environment") + } + else -> throw LdkError(error) + } + } + + private fun assertRoutingErrorOccurred(error: NodeException) { + assertIs(error) + } + + private fun assertFeesScaleProperly( + smallFees: ULong, + largeFees: ULong, + smallAmount: ULong, + largeAmount: ULong, + ) { + assertTrue( + largeFees >= smallFees, + "Fees for larger amounts should be >= fees for smaller amounts. Small: $smallFees, Large: $largeFees" + ) + assertFeesAreReasonable(smallFees, smallAmount) + assertFeesAreReasonable(largeFees, largeAmount) + } + + // endregion +} diff --git a/app/src/main/java/to/bitkit/data/CacheStore.kt b/app/src/main/java/to/bitkit/data/CacheStore.kt index 40751c16d..6e7a2dc21 100644 --- a/app/src/main/java/to/bitkit/data/CacheStore.kt +++ b/app/src/main/java/to/bitkit/data/CacheStore.kt @@ -2,8 +2,7 @@ package to.bitkit.data import android.content.Context import androidx.datastore.core.DataStore -import androidx.datastore.core.DataStoreFactory -import androidx.datastore.dataStoreFile +import androidx.datastore.dataStore import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first @@ -21,14 +20,16 @@ import to.bitkit.utils.Logger import javax.inject.Inject import javax.inject.Singleton +private val Context.appCacheDataStore: DataStore by dataStore( + fileName = "app_cache.json", + serializer = AppCacheSerializer +) + @Singleton class CacheStore @Inject constructor( @ApplicationContext private val context: Context, ) { - private val store: DataStore = DataStoreFactory.create( - serializer = AppCacheSerializer, - produceFile = { context.dataStoreFile("app_cache.json") }, - ) + private val store = context.appCacheDataStore val data: Flow = store.data diff --git a/app/src/main/java/to/bitkit/data/SettingsStore.kt b/app/src/main/java/to/bitkit/data/SettingsStore.kt index f7ccb9a0a..422baf333 100644 --- a/app/src/main/java/to/bitkit/data/SettingsStore.kt +++ b/app/src/main/java/to/bitkit/data/SettingsStore.kt @@ -2,8 +2,7 @@ package to.bitkit.data import android.content.Context import androidx.datastore.core.DataStore -import androidx.datastore.core.DataStoreFactory -import androidx.datastore.dataStoreFile +import androidx.datastore.dataStore import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.Flow import kotlinx.serialization.Serializable @@ -19,14 +18,16 @@ import to.bitkit.utils.Logger import javax.inject.Inject import javax.inject.Singleton +private val Context.settingsDataStore: DataStore by dataStore( + fileName = "settings.json", + serializer = SettingsSerializer +) + @Singleton class SettingsStore @Inject constructor( @ApplicationContext private val context: Context, ) { - private val store: DataStore = DataStoreFactory.create( - serializer = SettingsSerializer, - produceFile = { context.dataStoreFile("settings.json") }, - ) + private val store = context.settingsDataStore val data: Flow = store.data diff --git a/app/src/main/java/to/bitkit/data/WidgetsStore.kt b/app/src/main/java/to/bitkit/data/WidgetsStore.kt index 50b9d7143..27f5dfee2 100644 --- a/app/src/main/java/to/bitkit/data/WidgetsStore.kt +++ b/app/src/main/java/to/bitkit/data/WidgetsStore.kt @@ -2,8 +2,7 @@ package to.bitkit.data import android.content.Context import androidx.datastore.core.DataStore -import androidx.datastore.core.DataStoreFactory -import androidx.datastore.dataStoreFile +import androidx.datastore.dataStore import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first @@ -26,14 +25,16 @@ import to.bitkit.utils.Logger import javax.inject.Inject import javax.inject.Singleton +private val Context.widgetsDataStore: DataStore by dataStore( + fileName = "widgets.json", + serializer = WidgetsSerializer +) + @Singleton class WidgetsStore @Inject constructor( @ApplicationContext private val context: Context, ) { - private val store: DataStore = DataStoreFactory.create( - serializer = WidgetsSerializer, - produceFile = { context.dataStoreFile("widgets.json") }, - ) + private val store = context.widgetsDataStore val data: Flow = store.data val articlesFlow: Flow> = data.map { it.articles } diff --git a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt index 1ece70d48..5928011c6 100644 --- a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt @@ -601,7 +601,6 @@ class ActivityRepo @Inject constructor( tags: List, ): Result = withContext(bgDispatcher) { return@withContext runCatching { - require(tags.isNotEmpty()) val entity = TagMetadataEntity( @@ -622,7 +621,6 @@ class ActivityRepo @Inject constructor( } } - // MARK: - Development/Testing Methods /** diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index a76db343c..86dadd38a 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -805,6 +805,30 @@ class LightningRepo @Inject constructor( } } + 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 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") + } + .onFailure { + Logger.error("Routing fees estimation failed", it) + } + } + private companion object { const val TAG = "LightningRepo" } diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index 26def7951..d578a9633 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -449,6 +449,41 @@ class LightningService @Inject constructor( } } } + + suspend fun estimateRoutingFees(bolt11: String): Result { + val node = this.node ?: throw ServiceError.NodeNotSetup + + return ServiceQueue.LDK.background { + return@background try { + 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 + ) + } + } + } + + suspend fun estimateRoutingFeesForAmount(bolt11: String, amountSats: ULong): Result { + val node = this.node ?: throw ServiceError.NodeNotSetup + + return ServiceQueue.LDK.background { + return@background try { + 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 + ) + } + } + } // endregion // region utxo selection 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 4473862be..e2ea8888b 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 @@ -96,7 +96,6 @@ fun ActivityDetailScreen( return } - val app = appViewModel ?: return val copyToastTitle = stringResource(R.string.common__copied) 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 aa9706a41..d5f9baf5b 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 @@ -51,6 +51,7 @@ import to.bitkit.ext.DatePattern import to.bitkit.ext.commentAllowed import to.bitkit.ext.formatted import to.bitkit.models.FeeRate +import to.bitkit.models.TransactionSpeed import to.bitkit.ui.components.BalanceHeaderView import to.bitkit.ui.components.BiometricsView import to.bitkit.ui.components.BodySSB @@ -74,9 +75,10 @@ import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.rememberBiometricAuthSupported import to.bitkit.ui.utils.withAccent -import to.bitkit.viewmodels.SanityWarning import to.bitkit.viewmodels.LnurlParams +import to.bitkit.viewmodels.SanityWarning import to.bitkit.viewmodels.SendEvent +import to.bitkit.viewmodels.SendFee import to.bitkit.viewmodels.SendMethod import to.bitkit.viewmodels.SendUiState import java.time.Instant @@ -272,7 +274,9 @@ private fun LnurlCommentSection( onValueChange = { onEvent(SendEvent.CommentChange(it)) }, minLines = 3, maxLines = 3, - modifier = Modifier.fillMaxWidth().testTag("CommentInput") + modifier = Modifier + .fillMaxWidth() + .testTag("CommentInput") ) } @@ -363,11 +367,16 @@ private fun OnChainDescription( tint = fee.color, modifier = Modifier.size(16.dp) ) - uiState.fee.takeIf { it > 0 } - ?.let { rememberMoneyText(it) } - ?.let { + (uiState.fee as? SendFee.OnChain)?.value + ?.takeIf { it > 0 } + ?.let { feeSat -> + val feeText = let { + val prefix = stringResource(fee.title) + val value = rememberMoneyText(feeSat, showSymbol = true) + "$prefix ($value)" + } BodySSB( - text = "${stringResource(fee.title)} ($it)".withAccent(accentColor = Colors.White), + text = feeText.withAccent(accentColor = Colors.White), maxLines = 1, overflow = TextOverflow.MiddleEllipsis, ) @@ -470,7 +479,20 @@ private fun LightningDescription( tint = Colors.Purple, modifier = Modifier.size(16.dp) ) - BodySSB(text = "Instant (±$0.01)") // TODO GET FROM STATE + (uiState.fee as? SendFee.Lightning)?.value + ?.takeIf { it > 0 } + ?.let { feeSat -> + val feeText = let { + val prefix = stringResource(R.string.fee__instant__title) + val value = rememberMoneyText(feeSat, showSymbol = true) + "$prefix (± $value)" + } + BodySSB( + text = feeText.withAccent(accentColor = Colors.White), + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, + ) + } ?: BodySSB(text = stringResource(R.string.fee__instant__title)) } Spacer(modifier = Modifier.weight(1f)) HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp)) @@ -534,7 +556,6 @@ private fun sendUiState() = SendUiState( payeeNodeId = null, description = "Some invoice description", ), - fee = 45554, ) @Preview(showSystemUi = true, group = "onchain") @@ -545,6 +566,8 @@ private fun PreviewOnChain() { Content( uiState = sendUiState().copy( selectedTags = listOf("car", "house", "uber"), + fee = SendFee.OnChain(1_234), + speed = TransactionSpeed.Medium, ), isLoading = false, showBiometrics = false, @@ -561,8 +584,10 @@ private fun PreviewOnChainLongFeeSmallScreen() { BottomSheetPreview { Content( uiState = sendUiState().copy( + amount = 2_345_678u, selectedTags = listOf("car", "house", "uber"), - fee = 654321, + fee = SendFee.OnChain(654_321), + speed = TransactionSpeed.Custom(12_345u), ), isLoading = false, showBiometrics = false, @@ -580,7 +605,7 @@ private fun PreviewOnChainFeeLoading() { Content( uiState = sendUiState().copy( selectedTags = listOf("car", "house", "uber"), - fee = 0, + fee = null, ), isLoading = false, showBiometrics = false, @@ -600,6 +625,7 @@ private fun PreviewLightning() { amount = 6_543u, payMethod = SendMethod.LIGHTNING, selectedTags = emptyList(), + fee = SendFee.Lightning(43), ), isLoading = false, showBiometrics = false, diff --git a/app/src/main/java/to/bitkit/utils/Errors.kt b/app/src/main/java/to/bitkit/utils/Errors.kt index da57e7b8a..605ba382e 100644 --- a/app/src/main/java/to/bitkit/utils/Errors.kt +++ b/app/src/main/java/to/bitkit/utils/Errors.kt @@ -122,6 +122,7 @@ class LdkError(private val inner: LdkException) : AppError("Unknown LDK error.") is NodeException.NoSpendableOutputs -> "No spendable outputs" is NodeException.TransactionAlreadyConfirmed -> "Transaction already confirmed" is NodeException.TransactionNotFound -> "Transaction not found" + is NodeException.RouteNotFound -> "Failed to find a route for fee estimation." else -> exception.message }?.let { "LDK Node error: $it" } } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 86bd8ecfc..4ee11ab6e 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -258,7 +258,6 @@ class AppViewModel @Inject constructor( }.onFailure { e -> Logger.warn("Failed displaying sheet for event: $Event", e) } - } is Event.PaymentClaimable -> Unit @@ -397,7 +396,7 @@ class AppViewModel @Inject constructor( _sendUiState.update { it.copy( speed = speed, - fee = fee, + fee = SendFee.OnChain(fee), selectedUtxos = if (shouldResetUtxos) null else it.selectedUtxos, ) } @@ -439,6 +438,8 @@ class AppViewModel @Inject constructor( } refreshOnchainSendIfNeeded() + estimateLightningRoutingFeesIfNeeded() + setSendEffect(SendEffect.NavigateToConfirm) } @@ -1216,11 +1217,31 @@ class AppViewModel @Inject constructor( _sendUiState.update { it.copy( fees = feesMap, - fee = currentFee, + fee = SendFee.OnChain(currentFee), ) } } + private suspend fun estimateLightningRoutingFeesIfNeeded() { + val currentState = _sendUiState.value + if (currentState.payMethod != SendMethod.LIGHTNING) return + val decodedInvoice = currentState.decodedInvoice ?: return + + val feeResult = if (decodedInvoice.amountSatoshis > 0uL) { + lightningRepo.estimateRoutingFees(decodedInvoice.bolt11) + } else { + lightningRepo.estimateRoutingFeesForAmount(decodedInvoice.bolt11, currentState.amount) + } + + feeResult.onSuccess { fee -> + _sendUiState.update { + it.copy( + fee = SendFee.Lightning(fee.toLong()) + ) + } + } + } + private suspend fun getFeeEstimate(speed: TransactionSpeed? = null): Long { val currentState = _sendUiState.value return lightningRepo.calculateTotalFee( @@ -1506,7 +1527,7 @@ data class SendUiState( val speed: TransactionSpeed = TransactionSpeed.default(), val comment: String = "", val feeRates: FeeRates? = null, - val fee: Long = 0, + val fee: SendFee? = null, val fees: Map = emptyMap(), ) @@ -1518,6 +1539,11 @@ enum class SanityWarning(@StringRes val message: Int, val testTag: String) { // TODO SendDialog5 https://github.com/synonymdev/bitkit/blob/master/src/screens/Wallets/Send/ReviewAndSend.tsx#L457-L466 } +sealed class SendFee(open val value: Long) { + data class OnChain(override val value: Long) : SendFee(value) + data class Lightning(override val value: Long) : SendFee(value) +} + enum class SendMethod { ONCHAIN, LIGHTNING } sealed class SendEffect { diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index fac264218..90c742276 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -495,4 +495,80 @@ class LightningRepoTest : BaseUnitTest() { assertEquals(3, result.size) assertEquals(mockUtxos, result) } + + @Test + fun `estimateRoutingFees should fail when node is not running`() = test { + val result = sut.estimateRoutingFees("lnbc1u1p0abcde") + assertTrue(result.isFailure) + } + + @Test + fun `estimateRoutingFees should succeed when node is running`() = test { + startNodeForTesting() + val testBolt11 = "lnbc1u1p0abcde" + val expectedFeesSats = 50uL + + whenever(lightningService.estimateRoutingFees(testBolt11)) + .thenReturn(Result.success(expectedFeesSats)) + + val result = sut.estimateRoutingFees(testBolt11) + + assertTrue(result.isSuccess) + assertEquals(expectedFeesSats, result.getOrNull()) + verify(lightningService).estimateRoutingFees(testBolt11) + } + + @Test + fun `estimateRoutingFees should handle service failure`() = test { + startNodeForTesting() + val testBolt11 = "lnbc1u1p0abcde" + val serviceError = RuntimeException("Service error") + + whenever(lightningService.estimateRoutingFees(testBolt11)) + .thenReturn(Result.failure(serviceError)) + + val result = sut.estimateRoutingFees(testBolt11) + + assertTrue(result.isFailure) + assertEquals(serviceError, result.exceptionOrNull()) + } + + @Test + fun `estimateRoutingFeesForAmount should fail when node is not running`() = test { + val result = sut.estimateRoutingFeesForAmount("lnbc1u1p0abcde", 1000uL) + assertTrue(result.isFailure) + } + + @Test + fun `estimateRoutingFeesForAmount should succeed when node is running`() = test { + startNodeForTesting() + val testBolt11 = "lnbc1u1p0abcde" + val testAmount = 1000uL + val expectedFeesSats = 25uL + + whenever(lightningService.estimateRoutingFeesForAmount(testBolt11, testAmount)) + .thenReturn(Result.success(expectedFeesSats)) + + val result = sut.estimateRoutingFeesForAmount(testBolt11, testAmount) + + assertTrue(result.isSuccess) + assertEquals(expectedFeesSats, result.getOrNull()) + verify(lightningService).estimateRoutingFeesForAmount(testBolt11, testAmount) + } + + @Test + fun `estimateRoutingFeesForAmount should handle service failure`() = test { + startNodeForTesting() + val testBolt11 = "lnbc1u1p0abcde" + val testAmount = 1000uL + val serviceError = RuntimeException("Service error") + + whenever(lightningService.estimateRoutingFeesForAmount(testBolt11, testAmount)) + .thenReturn(Result.failure(serviceError)) + + val result = sut.estimateRoutingFeesForAmount(testBolt11, testAmount) + + assertTrue(result.isFailure) + assertEquals(serviceError, result.exceptionOrNull()) + } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b63197897..7f8c23be8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] accompanistPermissions = "0.36.0" activityCompose = "1.10.1" -agp = "8.12.0" +agp = "8.13.0" appcompat = "1.7.0" barcodeScanning = "17.3.0" biometric = "1.4.0-alpha02" @@ -85,7 +85,7 @@ ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "k ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } #ldk-node-android = { module = "org.lightningdevkit:ldk-node-android", version = "0.6.2" } # upstream -ldk-node-android = { module = "com.github.synonymdev:ldk-node", version = "v0.6.2-rc.1" } # fork +ldk-node-android = { module = "com.github.synonymdev:ldk-node", version = "v0.6.2-rc.3" } # fork lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycle" } lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" } lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" }