diff --git a/.github/img/detekt.png b/.github/img/detekt.png new file mode 100644 index 000000000..792abe76e Binary files /dev/null and b/.github/img/detekt.png differ diff --git a/.gitignore b/.gitignore index 3ddcd85bc..fb2999375 100644 --- a/.gitignore +++ b/.gitignore @@ -19,4 +19,4 @@ google-services.json *.keystore !debug.keystore keystore.* -!*.template +!keystore.properties.template diff --git a/AGENTS.md b/AGENTS.md index ed2897729..e7a0b0da0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -162,11 +162,12 @@ suspend fun getData(): Result = withContext(Dispatchers.IO) { - ALWAYS run `./gradlew detekt` after code changes to check for new lint issues and fix accordingly - ALWAYS ask clarifying questions to ensure an optimal plan when encountering functional or technical uncertainties in requests - ALWAYS when fixing lint or test failures prefer to do the minimal amount of changes to fix the issues -- USE single-line commit messages under 50 chars; use template format: `feat: add something new` +- USE single-line commit messages under 50 chars; use conventional commit messages template format: `feat: add something new` - USE `git diff HEAD sourceFilePath` to diff an uncommitted file against the last commit +- ALWAYS run `git status` to check ALL uncommitted changes after completing any code edits, then provide exactly 3 commit message suggestions covering the ENTIRE uncommitted diff - ALWAYS check existing code patterns before implementing new features - USE existing extensions and utilities rather than creating new ones -- ALWAYS consider applying YAGNI (You Aren't Gonna Need It) principle for new code +- ALWAYS consider applying YAGNI (You Ain't Gonna Need It) principle for new code - ALWAYS reuse existing constants - ALWAYS ensure a method exist before calling it - ALWAYS remove unused code after refactors @@ -175,6 +176,7 @@ suspend fun getData(): Result = withContext(Dispatchers.IO) { - ALWAYS acknowledge datastore async operations run synchronously in a suspend context - NEVER use `runBlocking` in suspend functions - ALWAYS pass the TAG as context to `Logger` calls, e.g. `Logger.debug("message", context = TAG)` +- ALWAYS log errors at the final handling layer where the error is acted upon, not in intermediate layers that just propagate it - ALWAYS use the Result API instead of try-catch - NEVER wrap methods returning `Result` in try-catch - PREFER to use `it` instead of explicit named parameters in lambdas e.g. `fn().onSuccess { log(it) }.onFailure { log(it) }` @@ -182,7 +184,9 @@ suspend fun getData(): Result = withContext(Dispatchers.IO) { - NEVER hardcode strings and always preserve string resources - ALWAYS localize in ViewModels using injected `@ApplicationContext`, e.g. `context.getString()` - ALWAYS use `remember` for expensive Compose computations -- ALWAYS add modifiers to the last place in the argument list when calling `@Composable` functions +- ALWAYS add modifiers to the last place in the argument list when calling composable functions +- NEVER add parameters with default values BEFORE the `modifier` parameter in composable functions - modifier must be the FIRST optional parameter +- ALWAYS prefer `VerticalSpacer`, `HorizontalSpacer`, `FillHeight` and `FillWidth` over `Spacer` when applicable - PREFER declaring small dependant classes, constants, interfaces or top-level functions in the same file with the core class where these are used - ALWAYS create data classes for state AFTER viewModel class in same file - ALWAYS return early where applicable, PREFER guard-like `if` conditions like `if (condition) return` @@ -195,18 +199,17 @@ 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 - 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 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 -- ALWAYS use services to wrap RUST code exposed via bindings - ALWAYS order upstream architectural data flow this way: `UI -> ViewModel -> Repository -> RUST` and vice-versa for downstream - ALWAYS add new localizable string string resources in alphabetical order in `strings.xml` - NEVER add string resources for strings used only in dev settings screens and previews and never localize acronyms - ALWAYS use template in `.github/pull_request_template.md` for PR descriptions - 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 }` +- 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 +- PREFER to place `@Suppress()` annotations at the narrowest possible scope ### Architecture Guidelines @@ -214,3 +217,5 @@ suspend fun getData(): Result = withContext(Dispatchers.IO) { - Use `LightningService` to wrap node's RUST APIs and manage the inner lifecycle of the node - Use `LightningRepo` to defining the business logic for the node operations, usually delegating to `LightningService` - Use `WakeNodeWorker` to manage the handling of remote notifications received via cloud messages +- Use `*Services` to wrap rust library code exposed via bindings +- Use CQRS pattern of Command + Handler like it's done in the `NotifyPaymentReceived` + `NotifyPaymentReceivedHandler` setup diff --git a/README.md b/README.md index e33fad3a6..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. @@ -43,23 +43,24 @@ See also: - [bitkit-core android bindings](https://github.com/synonymdev/bitkit-core/tree/master/bindings/android#installation) - [vss-rust-client-ffi android bindings](https://github.com/synonymdev/vss-rust-client-ffi/tree/master/bindings/android#installation) -### Related Repositories +### References -- [bitkit-ios](https://github.com/synonymdev/bitkit-ios) - Native iOS Bitkit app -- [bitkit-core](https://github.com/synonymdev/bitkit-core) - Shared Core Rust library with FFI bindings -- [ldk-node](https://github.com/synonymdev/ldk-node) - Fork of ldk-node -- [vss-server](https://github.com/synonymdev/vss-server) - Versioned Storage Service backend -- [vss-rust-client-ffi](https://github.com/synonymdev/vss-rust-client-ffi) - FFI bindings for vss-rust-client -- [bitkit-e2e-tests](https://github.com/synonymdev/bitkit-e2e-tests) - End-to-end tests (WebdriverIO + Appium) -- [bitkit-docker](https://github.com/synonymdev/bitkit-docker) - Docker setup for LNURL dev testing and local backend for integrations development +- For LNURL dev testing see [bitkit-docker](https://github.com/synonymdev/bitkit-docker) ### Lint This project uses detekt with default ktlint and compose-rules for android code linting. -Recommended Android Studio plugins: -- EditorConfig -- Detekt +### IDE Plugins +The following IDE plugins are recommended for development with Android Studio or IntelliJ IDEA: +- [Compose Color Preview](https://plugins.jetbrains.com/plugin/21298-compose-color-preview) +- [Compose Stability Analyzer](https://plugins.jetbrains.com/plugin/28767-compose-stability-analyzer) +- [detekt](https://plugins.jetbrains.com/plugin/10761-detekt) +
+ See screenshot on how to setup the Detekt plugin after installation. + + ![Detekt plugin setup][img_detekt] +
**Commands** ```sh @@ -112,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 @@ -152,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 @@ -223,3 +221,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/app/build.gradle.kts b/app/build.gradle.kts index 7a4c66e35..876f5205c 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) @@ -218,6 +219,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/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/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() } -// } - } -} 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/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 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..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 @@ -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.Initializing, + 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/androidServices/LightningNodeService.kt b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt index 2bfe8257f..741a37d54 100644 --- a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt +++ b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt @@ -79,7 +79,7 @@ class LightningNodeService : Service() { if (event !is Event.PaymentReceived && event !is Event.OnchainTransactionReceived) return val command = NotifyPaymentReceived.Command.from(event, includeNotification = true) ?: return - notifyPaymentReceivedHandler(command).onSuccess { + notifyPaymentReceivedHandler.invoke(command).onSuccess { Logger.debug("Payment notification result: $it", context = TAG) if (it !is NotifyPaymentReceived.Result.ShowNotification) return showPaymentNotification(it.sheet, it.notification) diff --git a/app/src/main/java/to/bitkit/async/ServiceQueue.kt b/app/src/main/java/to/bitkit/async/ServiceQueue.kt index 759957601..b359cd73a 100644 --- a/app/src/main/java/to/bitkit/async/ServiceQueue.kt +++ b/app/src/main/java/to/bitkit/async/ServiceQueue.kt @@ -8,7 +8,6 @@ import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import to.bitkit.ext.callerName import to.bitkit.utils.AppError -import to.bitkit.utils.Logger import to.bitkit.utils.measured import java.util.concurrent.Executors import java.util.concurrent.ThreadFactory @@ -23,34 +22,24 @@ enum class ServiceQueue { coroutineContext: CoroutineContext = scope.coroutineContext, functionName: String = Thread.currentThread().callerName, block: suspend CoroutineScope.() -> T, - ): T { - return runBlocking(coroutineContext) { - try { - measured(label = functionName, context = TAG) { - block() - } - } catch (e: Exception) { - Logger.error("ServiceQueue.$name error", e) - throw AppError(e) + ): T = runBlocking(coroutineContext) { + runCatching { + measured(label = functionName, context = TAG) { + block() } - } + }.getOrElse { throw AppError(it) } } suspend fun background( coroutineContext: CoroutineContext = scope.coroutineContext, functionName: String = Thread.currentThread().callerName, block: suspend CoroutineScope.() -> T, - ): T { - return withContext(coroutineContext) { - try { - measured(label = functionName, context = TAG) { - block() - } - } catch (e: Exception) { - Logger.error("ServiceQueue.$name error", e) - throw AppError(e) + ): T = withContext(coroutineContext) { + runCatching { + measured(label = functionName, context = TAG) { + block() } - } + }.getOrElse { throw AppError(it) } } companion object { 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/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/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/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 f5a6e0a0b..2550958f1 100644 --- a/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt +++ b/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt @@ -30,17 +30,17 @@ 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 val lnurlAuthServerUrl = Env.lnurlAuthServerUrl val vssStoreId = vssStoreIdProvider.getVssStoreId(walletIndex) - Logger.verbose("Building VSS client with vssUrl: '$vssUrl'") - Logger.verbose("Building VSS client with lnurlAuthServerUrl: '$lnurlAuthServerUrl'") + Logger.verbose("Building VSS client with vssUrl: '$vssUrl'", context = TAG) + Logger.verbose("Building VSS client with lnurlAuthServerUrl: '$lnurlAuthServerUrl'", context = TAG) 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( @@ -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) } } @@ -85,9 +85,9 @@ class VssBackupClient @Inject constructor( value = data, ) }.onSuccess { - Logger.verbose("VSS 'putObject' success for '$key' at version: ${it.version}", context = TAG) - }.onFailure { e -> - Logger.verbose("VSS 'putObject' error for '$key'", e = e, context = TAG) + Logger.verbose("VSS 'putObject' success for '$key' at version: '${it.version}'", context = TAG) + }.onFailure { + Logger.verbose("VSS 'putObject' error for '$key'", it, context = TAG) } } @@ -104,8 +104,8 @@ class VssBackupClient @Inject constructor( } else { Logger.verbose("VSS 'getObject' success for '$key'", context = TAG) } - }.onFailure { e -> - Logger.verbose("VSS 'getObject' error for '$key'", e = e, context = TAG) + }.onFailure { + Logger.verbose("VSS 'getObject' error for '$key'", it, context = TAG) } } @@ -116,8 +116,8 @@ class VssBackupClient @Inject constructor( vssListKeys(prefix = prefix) }.onSuccess { Logger.verbose("VSS 'listKeys' success - found ${it.size} key(s)", context = TAG) - }.onFailure { e -> - Logger.verbose("VSS 'listKeys' error", e = e, context = TAG) + }.onFailure { + Logger.verbose("VSS 'listKeys' error", it, context = TAG) } } @@ -132,8 +132,8 @@ class VssBackupClient @Inject constructor( } else { Logger.verbose("VSS 'deleteObject' success for '$key' - key did not exist", context = TAG) } - }.onFailure { e -> - Logger.verbose("VSS 'deleteObject' error for '$key'", e = e, context = TAG) + }.onFailure { + Logger.verbose("VSS 'deleteObject' error for '$key'", it, context = TAG) } } @@ -150,12 +150,12 @@ class VssBackupClient @Inject constructor( deletedCount }.onSuccess { Logger.verbose("VSS 'deleteAllKeys' success - deleted $it key(s)", context = TAG) - }.onFailure { e -> - Logger.verbose("VSS 'deleteAllKeys' error", e = e, context = TAG) + }.onFailure { + Logger.verbose("VSS 'deleteAllKeys' error", it, context = TAG) } } - companion object Companion { + companion object { private const val TAG = "VssBackupClient" } } 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/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 d0695acc8..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) @@ -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..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 { @@ -180,6 +175,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/data/widgets/WeatherService.kt b/app/src/main/java/to/bitkit/data/widgets/WeatherService.kt index 4c5f8b3e1..81869f75d 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,8 @@ 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.USD import to.bitkit.models.WidgetType import to.bitkit.repositories.CurrencyRepo import to.bitkit.utils.AppError @@ -18,26 +20,47 @@ 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 { + @Volatile + private var cachedFeeEstimates: FeeEstimates? = null + + @Volatile + private var feeEstimatesTimestamp: Long = 0L + + @Volatile + private var cachedHistoricalData: List? = null + + @Volatile + private var historicalDataTimestamp: Long = 0L + + companion object { private const val TAG = "WeatherService" - private const val AVERAGE_SEGWIT_VBYTES_SIZE = 140 + + private const val AVERAGE_SEGWIT_VB_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() @@ -47,43 +70,51 @@ class WeatherService @Inject constructor( val condition = calculateCondition(feeEstimates.normal, history) // Calculate average fee for display - val avgFeeSats = (feeEstimates.normal * AVERAGE_SEGWIT_VBYTES_SIZE).toInt() + val avgFeeSats = (feeEstimates.normal * AVERAGE_SEGWIT_VB_SIZE).toLong() val currentFee = formatFeeForDisplay(avgFeeSats) WeatherDTO( condition = condition, currentFee = currentFee, - nextBlockFee = feeEstimates.fast + nextBlockFee = feeEstimates.fast, ) }.onFailure { - Logger.warn(e = it, msg = "Failed to fetch weather data", context = TAG) + Logger.warn("Failed to fetch weather data", 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() @@ -93,12 +124,11 @@ class WeatherService @Inject constructor( val highThreshold = historicalFees[floor(historicalFees.size * PERCENTILE_HIGH).toInt()] // 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 avgFeeSats = currentFeeRate * AVERAGE_SEGWIT_VB_SIZE + val avgFeeUsd = currencyRepo.convertSatsToFiat(avgFeeSats.toLong(), USD).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 +138,12 @@ class WeatherService @Inject constructor( } } - private suspend fun formatFeeForDisplay(satoshis: Int): String { - val usdValue = currencyRepo.convertSatsToFiat(satoshis.toLong(), currency = USD_CURRENCY).getOrNull() + private fun formatFeeForDisplay(sats: Long): String { + val usdValue = currencyRepo.convertSatsToFiat(sats, USD).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/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/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt index 30e815741..a9a288f03 100644 --- a/app/src/main/java/to/bitkit/env/Env.kt +++ b/app/src/main/java/to/bitkit/env/Env.kt @@ -6,21 +6,21 @@ 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 + 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})" @@ -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 + val isE2eLocal = isE2eTest && e2eBackend == "local" return when (network) { - Network.REGTEST -> ElectrumServers.REGTEST + Network.BITCOIN -> ElectrumServers.MAINNET.FULCRUM + Network.REGTEST -> if (isE2eLocal) ElectrumServers.REGTEST.LOCAL else ElectrumServers.REGTEST.STAG Network.TESTNET -> ElectrumServers.TESTNET - Network.BITCOIN -> ElectrumServers.BITCOIN else -> TODO("${network.name} network not implemented") } } @@ -194,18 +146,53 @@ 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 + + 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 /** @@ -216,19 +203,21 @@ object TransactionDefaults { } 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" + } + + object REGTEST { + const val STAG = "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/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 0b877161d..530de7f4f 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 @@ -36,6 +34,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 { @@ -110,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/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/ext/PeerDetails.kt b/app/src/main/java/to/bitkit/ext/PeerDetails.kt index cb419a395..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,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/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/fcm/FcmService.kt b/app/src/main/java/to/bitkit/fcm/FcmService.kt index a9ab62d45..2e4a737b3 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 @@ -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) @@ -105,7 +106,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/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/Currency.kt b/app/src/main/java/to/bitkit/models/Currency.kt index 1b5774e7f..fe4e8157c 100644 --- a/app/src/main/java/to/bitkit/models/Currency.kt +++ b/app/src/main/java/to/bitkit/models/Currency.kt @@ -19,7 +19,8 @@ const val FIAT_GROUPING_SEPARATOR = ',' const val DECIMAL_SEPARATOR = '.' const val CLASSIC_DECIMALS = 8 const val FIAT_DECIMALS = 2 -const val EUR_CURRENCY = "EUR" +const val EUR = "EUR" +const val USD = "USD" @Serializable data class FxRateResponse( 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/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/models/widget/ArticleModel.kt b/app/src/main/java/to/bitkit/models/widget/ArticleModel.kt index 4db972e6a..66494e0f2 100644 --- a/app/src/main/java/to/bitkit/models/widget/ArticleModel.kt +++ b/app/src/main/java/to/bitkit/models/widget/ArticleModel.kt @@ -10,36 +10,36 @@ import java.time.format.DateTimeParseException import java.util.Locale import kotlin.time.ExperimentalTime +private const val TAG = "ArticleModel" + @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 ) -private const val TAG = "ArticleModel" - @OptIn(ExperimentalTime::class) private fun timeAgo(dateString: String): String { return runCatching { val formatters = listOf( - DateTimeFormatter.RFC_1123_DATE_TIME, - DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss Z", Locale.ENGLISH) + 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" ) var parsedDateTime: OffsetDateTime? = null for (formatter in formatters) { try { parsedDateTime = OffsetDateTime.parse(dateString, formatter) - break + break // Successfully parsed, stop trying other formatters } catch (_: DateTimeParseException) { // Continue to the next formatter } diff --git a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt index e3df92524..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,9 +44,9 @@ 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") +@Suppress("LargeClass", "LongParameterList", "TooManyFunctions") @OptIn(ExperimentalTime::class) @Singleton class ActivityRepo @Inject constructor( @@ -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,55 +137,46 @@ 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 try { - 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 - } - } catch (e: Exception) { - Logger.warn("Failed to find open channel for transaction: $txid", e, context = TAG) - null - } - } + 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? { - 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. */ 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 } @@ -258,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 @@ -275,51 +263,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", - context = TAG + "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", - context = TAG + "findActivityByPaymentId error (paymentHashOrTxId:'$paymentHashOrTxId' type:'$type' txType:'$txType')", + context = TAG, ) - Result.failure(e) } } @@ -333,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 " + @@ -346,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) } } @@ -379,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( @@ -390,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) } } @@ -405,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) { @@ -485,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) @@ -493,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) } } @@ -533,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(), @@ -554,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) } } @@ -569,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) @@ -582,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) } } @@ -597,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 @@ -611,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) } } @@ -626,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) } } @@ -647,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) @@ -670,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) } } @@ -696,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) @@ -705,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/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 59fa2ebc0..6cc9804a2 100644 --- a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt @@ -41,7 +41,7 @@ import to.bitkit.env.Env import to.bitkit.ext.calculateRemoteBalance import to.bitkit.ext.nowTimestamp import to.bitkit.models.BlocktankBackupV1 -import to.bitkit.models.EUR_CURRENCY +import to.bitkit.models.EUR import to.bitkit.services.CoreService import to.bitkit.services.LightningService import to.bitkit.utils.Logger @@ -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, @@ -88,7 +88,7 @@ class BlocktankRepo @Inject constructor( flow { while (currentCoroutineContext().isActive) { emit(Unit) - delay(Env.blocktankOrderRefreshInterval) + delay(Env.lspOrdersRefreshInterval) } }.flowOn(bgDispatcher) .onEach { refreshOrders() } @@ -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,9 +197,9 @@ class BlocktankRepo @Inject constructor( amountSats: ULong, description: String = Env.DEFAULT_INVOICE_MESSAGE, ): Result = withContext(bgDispatcher) { - try { - if (coreService.isGeoBlocked()) throw ServiceError.GeoBlocked - val nodeId = lightningService.nodeId ?: throw ServiceError.NodeNotStarted + runCatching { + if (coreService.isGeoBlocked()) throw ServiceError.GeoBlocked() + val nodeId = lightningService.nodeId ?: throw ServiceError.NodeNotStarted() val lspBalance = getDefaultLspBalance(clientBalance = amountSats) val channelSizeSat = amountSats + lspBalance @@ -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 { - if (coreService.isGeoBlocked()) throw ServiceError.GeoBlocked + 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) } } @@ -323,7 +319,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 +346,7 @@ class BlocktankRepo @Inject constructor( } val satsPerEur = getSatsPerEur() - ?: throw ServiceError.CurrencyRateUnavailable + ?: throw ServiceError.CurrencyRateUnavailable() val params = DefaultLspBalanceParams( clientBalanceSat = clientBalance, @@ -363,10 +359,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) @@ -382,7 +378,7 @@ class BlocktankRepo @Inject constructor( } private fun getSatsPerEur(): ULong? { - return currencyRepo.convertFiatToSats(BigDecimal(1), EUR_CURRENCY).getOrNull() + return currencyRepo.convertFiatToSats(BigDecimal(1), EUR).getOrNull() } private fun totalBtChannelsValueSats(info: IBtInfo?): ULong { @@ -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) } } @@ -466,7 +462,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/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 791204e64..fa6b3229e 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -63,7 +63,6 @@ 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 java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject @@ -74,7 +73,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, @@ -118,7 +117,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()) { @@ -128,14 +127,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) @@ -144,7 +141,7 @@ 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) } @@ -152,16 +149,13 @@ class LightningRepo @Inject constructor( 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) } private suspend fun setup( @@ -170,24 +164,28 @@ class LightningRepo @Inject constructor( 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 { + 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") @@ -201,7 +199,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) } @@ -212,7 +210,7 @@ class LightningRepo @Inject constructor( return@withContext Result.success(Unit) } - try { + runCatching { _lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Starting) } // Setup if needed @@ -222,7 +220,7 @@ class LightningRepo @Inject constructor( _lightningState.update { it.copy( nodeLifecycleState = NodeLifecycleState.ErrorStarting( - setupResult.exceptionOrNull() ?: Exception("Unknown setup error") + setupResult.exceptionOrNull() ?: NodeSetupError() ) ) } @@ -247,20 +245,20 @@ class LightningRepo @Inject constructor( updateGeoBlockState() refreshChannelCache() - // Post-startup tasks - connectToTrustedPeers().onFailure { e -> - Logger.error("Failed to connect to trusted peers", e) + // Post-startup tasks (non-blocking) + connectToTrustedPeers().onFailure { + Logger.error("Failed to connect to trusted peers", it, context = TAG) } - sync() - registerForNotifications() - - Result.success(Unit) - } catch (e: Throwable) { + sync().getOrThrow().also { + scope.launch { registerForNotifications() } + } + }.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, @@ -270,24 +268,22 @@ class LightningRepo @Inject constructor( channelMigration = channelMigration, ) } else { - Logger.error("Node start error", e, context = TAG) _lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.ErrorStarting(e)) } - Result.failure(e) } } } private suspend fun onEvent(event: Event) { handleLdkEvent(event) - _eventHandlers.toList().forEach { it.invoke(event) } + _eventHandlers.toList().forEach { + runCatching { it.invoke(event) } + } _nodeEvents.emit(event) } - fun setRecoveryMode(enabled: Boolean) { - _isRecoveryMode.value = enabled - } + fun setRecoveryMode(enabled: Boolean) = _isRecoveryMode.update { enabled } suspend fun updateGeoBlockState() = withContext(bgDispatcher) { _lightningState.update { @@ -304,28 +300,13 @@ class LightningRepo @Inject constructor( 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") { @@ -343,7 +324,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) } @@ -354,60 +335,46 @@ 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) } } /** 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 - 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, - ) - } - - else -> Unit // Other events don't need special handling + is Event.ChannelPending, is Event.ChannelReady -> scope.launch { refreshChannelCache() } + is Event.ChannelClosed -> scope.launch { registerClosedChannel(event.channelId, event.reason) } + else -> Unit } } 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) + 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", - context = TAG + "Channel has no funding transaction, cannot persist closed channel: channelId='$channelId'", + context = TAG, ) return@withContext } 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() @@ -433,114 +400,104 @@ 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, context = TAG) } } 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) + val error = NodeStopTimeoutError() + Logger.warn(error.message, context = TAG) return@withContext Result.failure(error) } } @@ -548,27 +505,23 @@ 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") { - 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") { - val address = lightningService.newAddress() - Result.success(address) + runCatching { lightningService.newAddress() } } suspend fun createInvoice( @@ -577,10 +530,10 @@ 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") suspend fun fetchLnurlInvoice( callbackUrl: String, amountSats: ULong, @@ -592,7 +545,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, + ) } } @@ -601,8 +558,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) } @@ -628,32 +585,30 @@ 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( + lnurlAuth( k1 = k1, callback = callback, domain = domain, network = Env.network.toCoreNetwork(), bip32Mnemonic = mnemonic, bip39Passphrase = passphrase, - ) - - Logger.debug("LNURL auth result: '$result'") - - 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) + Logger.error("requestLnurlAuth error, k1: $k1, callback: $callback, domain: $domain", it, context = TAG) } suspend fun payInvoice( 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") @@ -671,23 +626,14 @@ 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 - val finalUtxosToSpend = utxosToSpend ?: determineUtxosToSpend( - sats = sats, - satsPerVByte = satsPerVByte, - ) + // use passed utxos if specified, otherwise run auto coin select if enabled + 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, @@ -697,7 +643,7 @@ class LightningRepo @Inject constructor( txId = txId, address = address, isReceive = false, - feeRate = satsPerVByte.toULong(), + feeRate = satsPerVByte, isTransfer = isTransfer, channelId = channelId ?: "", ) @@ -709,13 +655,12 @@ class LightningRepo @Inject constructor( suspend fun determineUtxosToSpend( sats: ULong, - satsPerVByte: UInt, + satsPerVByte: ULong, ): List? = withContext(bgDispatcher) { return@withContext runCatching { val settings = settingsStore.data.first() if (settings.coinSelectAuto) { val coinSelectionPreference = settings.coinSelectPreference - val allSpendableUtxos = lightningService.listSpendableOutputs().getOrThrow() if (coinSelectionPreference == CoinSelectionPreference.Consolidate) { @@ -743,8 +688,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) } @@ -765,10 +709,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().toUInt() - + val satsPerVByte = getFeeRateForSpeed(transactionSpeed, feeRates).getOrThrow() val addressOrDefault = address ?: cacheStore.data.first().onchainAddress val fee = lightningService.calculateTotalFee( @@ -777,13 +720,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'", e = it, context = TAG) + return@recoverCatching fallbackFee } } @@ -791,13 +733,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) } } } @@ -805,7 +747,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( @@ -814,9 +756,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( @@ -824,13 +766,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() { @@ -881,27 +819,23 @@ class LightningRepo @Inject constructor( return isRunning && lightningService.canReceive() } - fun separateTrustedChannels( - channels: List, - ): Pair, List> = lightningService.separateTrustedChannels(channels) + fun separateTrustedChannels(channels: List) = lightningService.separateTrustedChannels(channels) 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) 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) - Result.success(Unit) - } catch (e: Throwable) { - Logger.error("Register for notifications error", e) - Result.failure(e) + }.onFailure { + Logger.error("registerForNotifications error", it, context = TAG) } } @@ -909,108 +843,85 @@ 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" - ) - ) - } - - 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, satsPerVByte = satsPerVByte, ) Logger.debug( - "bumpFeeByRbf success, replacementTxId: $replacementTxId originalTxId: $originalTxId, satsPerVByte: $satsPerVByte" + "bumpFeeByRbf success, " + + "replacementTxId: $replacementTxId " + + "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 + it, + context = TAG, ) - Result.failure(e) } } suspend fun accelerateByCpfp( originalTxId: Txid, - satsPerVByte: UInt, + 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, satsPerVByte = satsPerVByte, - destinationAddress = destinationAddress, + 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) { + return@runCatching newDestinationTxId + }.onFailure { Logger.error( - "accelerateByCpfp error originalTxId: $originalTxId, satsPerVByte: $satsPerVByte destinationAddress: $destinationAddress", - e, - context = TAG + "accelerateByCpfp error: " + + "originalTxId: $originalTxId, " + + "satsPerVByte: $satsPerVByte, " + + "destinationAddress: $destinationAddress", + 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", 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") - lightningService.estimateRoutingFeesForAmount(bolt11, amountSats) - .onSuccess { - Logger.info("Routing fees estimated: $it") - } - .onFailure { - Logger.error("Routing fees estimation failed", it) - } + 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("estimateRoutingFeesForAmount error", it, context = TAG) + } } // region debug @@ -1020,9 +931,10 @@ class LightningRepo @Inject constructor( executeWhenNodeRunning("exportNetworkGraphToFile") { lightningService.exportNetworkGraphToFile(outputDir) } + // endregion suspend fun restartNode(): Result = withContext(bgDispatcher) { - Logger.info("Restarting LDK node", context = TAG) + Logger.info("Restarting node", context = TAG) stop().onFailure { Logger.error("Failed to stop node during restart", it, context = TAG) return@withContext Result.failure(it) @@ -1030,23 +942,23 @@ class LightningRepo @Inject constructor( start(shouldRetry = false).onFailure { Logger.error("Failed to start node during restart", it, context = TAG) return@withContext Result.failure(it) + }.onSuccess { + Logger.info("Node restarted successfully", context = TAG) } - Logger.info("LDK node restarted successfully", context = TAG) - Result.success(Unit) } - // endregion 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 } } -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/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/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/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index 2873d7696..2f1050912 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -35,13 +35,12 @@ 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.measured 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 +74,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 +96,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 +174,8 @@ class WalletRepo @Inject constructor( syncBalances() lightningRepo.sync().onSuccess { syncBalances() - }.onFailure { e -> - if (e is TimeoutCancellationException) { + }.onFailure { + if (it is TimeoutCancellationException) { syncBalances() } } @@ -195,9 +191,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", e = it, context = TAG) } } } @@ -285,37 +281,33 @@ 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, context = TAG) } } suspend fun wipeWallet(walletIndex: Int = 0): Result = withContext(bgDispatcher) { - return@withContext wipeWalletUseCase( + wipeWalletUseCase.invoke( walletIndex = walletIndex, resetWalletState = ::resetState, onSuccess = ::setWalletExistsState, @@ -327,7 +319,6 @@ class WalletRepo @Inject constructor( _balanceState.update { BalanceState() } } - // Blockchain address management fun getOnchainAddress(): String = _walletState.value.onchainAddress suspend fun setOnchainAddress(address: String) { @@ -336,9 +327,9 @@ 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) } + .onFailure { error -> Logger.error("Error generating new address", error, context = TAG) } } suspend fun getAddresses( @@ -346,8 +337,9 @@ class WalletRepo @Inject constructor( isChange: Boolean = false, count: Int = 20, ): Result> = withContext(bgDispatcher) { - return@withContext try { - val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) ?: throw ServiceError.MnemonicNotFound + runCatching { + val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) + ?: throw ServiceError.MnemonicNotFound() val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) @@ -374,14 +366,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, context = TAG) } } - // Bolt11 management fun getBolt11(): String = _walletState.value.bolt11 suspend fun setBolt11(bolt11: String) { @@ -389,7 +379,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) } @@ -405,11 +394,10 @@ class WalletRepo @Inject constructor( bitcoinAddress = bitcoinAddress, amountSats = amountSats, message = message, - lightningInvoice = lightningInvoice + lightningInvoice = lightningInvoice, ) } - // BIP21 state management fun setBip21AmountSats(amount: ULong?) = _walletState.update { it.copy(bip21AmountSats = amount) } fun setBip21Description(description: String) = _walletState.update { it.copy(bip21Description = description) } @@ -425,17 +413,17 @@ class WalletRepo @Inject constructor( } } - // Payment ID management 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 } - }.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() } @@ -443,50 +431,47 @@ 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 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 { e -> - Logger.error("Failed to add tag to pre-activity metadata", e, 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( 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) } } @@ -496,35 +481,17 @@ 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) + }.onFailure { + Logger.error("Failed to reset tags for pre-activity metadata", it, context = TAG) } } - suspend fun loadTagsForCurrentInvoice() { - val paymentId = paymentId() - if (paymentId == null || paymentId.isEmpty()) { - _walletState.update { it.copy(selectedTags = emptyList()) } - return - } - - 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 suspend fun updateBip21Invoice( 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 @@ -554,24 +521,23 @@ 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) } } 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/repositories/WidgetsRepo.kt b/app/src/main/java/to/bitkit/repositories/WidgetsRepo.kt index 9d9829127..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, @@ -60,6 +61,7 @@ class WidgetsRepo @Inject constructor( private val _refreshStates = MutableStateFlow( WidgetType.entries.associateWith { false } ) + val refreshStates: StateFlow> = _refreshStates.asStateFlow() init { 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..1424a20ee 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -117,56 +117,57 @@ class CoreService @Inject constructor( // Block queue until the init completes forcing any additional calls to wait for it ServiceQueue.CORE.blocking { - try { - 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) + runCatching { + val result = initDb(Env.bitkitCoreStoragePath(walletIndex)) + Logger.info("bitkit-core database init: '$result'", context = TAG) + }.onFailure { + Logger.error("bitkit-core database init failed", it, context = TAG) } - 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) + Logger.info("Blocktank URL updated to: '$blocktankUrl'", context = TAG) + }.onFailure { + 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 { @@ -196,7 +195,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 +224,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 +266,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 +288,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 +371,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 +394,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 +499,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 +688,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 +1254,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 +1282,7 @@ class BlocktankService( return Result.success(fees) } + @Suppress("LongParameterList") suspend fun createCjit( channelSizeSat: ULong, invoiceSat: ULong, @@ -1387,7 +1354,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() @@ -1471,6 +1438,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 463db9bd6..5ea9cfc81 100644 --- a/app/src/main/java/to/bitkit/services/CurrencyService.kt +++ b/app/src/main/java/to/bitkit/services/CurrencyService.kt @@ -16,15 +16,15 @@ 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) { - 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 @@ -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 e4bfce649..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 @@ -58,7 +60,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, @@ -73,7 +75,9 @@ 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 + + private var listenerJob: Job? = null suspend fun setup( walletIndex: Int, @@ -82,7 +86,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 +97,7 @@ class LightningService @Inject constructor( channelMigration, ) - Logger.info("LDK node setup") + Logger.info("LDK node setup", context = TAG) } private fun config( @@ -102,11 +106,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( @@ -121,6 +124,7 @@ class LightningService @Inject constructor( ) } + @Suppress("ForbiddenComment") private suspend fun build( walletIndex: Int, customServerUrl: String?, @@ -141,8 +145,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), ) } @@ -152,7 +158,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 +176,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( @@ -193,9 +200,9 @@ 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…") + Logger.debug("Starting node…", context = TAG) ServiceQueue.LDK.background { try { @@ -208,28 +215,30 @@ class LightningService @Inject constructor( // start event listener after node started onEvent?.let { eventHandler -> shouldListenForEvents = true - launch { - try { - Logger.debug("LDK event listener started") + 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) + }.onFailure { + Logger.error("LDK event listener error", it, context = TAG) } } } - Logger.info("Node started") + Logger.info("Node started", context = TAG) } suspend fun stop() { shouldListenForEvents = false - val node = this.node ?: throw ServiceError.NodeNotStarted + listenerJob?.cancelAndJoin() + listenerJob = null + val node = this.node ?: throw ServiceError.NodeNotStarted() - Logger.debug("Stopping node…") + Logger.debug("Stopping node…", context = TAG) ServiceQueue.LDK.background { try { node.stop() @@ -239,51 +248,32 @@ 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…") + if (node != null) throw ServiceError.NodeStillRunning() + 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 + 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 - 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) @@ -291,7 +281,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() @@ -300,15 +290,15 @@ 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) { 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 +311,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 { @@ -336,50 +326,43 @@ 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 { - 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) } } } - suspend fun disconnectPeer(peer: PeerDetails) { - val node = this.node ?: throw ServiceError.NodeNotSetup + suspend fun disconnectPeer(peer: PeerDetails): Result { + val node = this.node ?: throw ServiceError.NodeNotSetup() val uri = peer.uri - Logger.debug("Disconnecting peer: $uri") - 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") - } catch (e: NodeException) { - Logger.warn("Peer disconnect error: $uri", LdkError(e)) } } - 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 } @@ -397,12 +380,12 @@ 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 { 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, @@ -420,12 +403,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) } } @@ -437,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 @@ -469,12 +452,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 } @@ -482,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 } @@ -509,14 +492,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 } @@ -526,26 +509,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 + 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, ) } @@ -553,12 +539,12 @@ 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") + 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 { @@ -573,36 +559,32 @@ 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 { + 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) } } } 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 { + 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) } } } @@ -610,57 +592,53 @@ 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 { + 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) } } } suspend fun selectUtxosWithAlgorithm( targetAmountSats: ULong, - satsPerVByte: UInt, + satsPerVByte: ULong, 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 { + runCatching { val result = node.onchainPayment().selectUtxosWithAlgorithm( targetAmountSats = targetAmountSats, - feeRate = convertVByteToKwu(satsPerVByte), + feeRate = FeeRate.fromSatPerVbUnchecked(satsPerVByte), algorithm = algorithm, 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) } } } // endregion // region boost - suspend fun bumpFeeByRbf(txid: Txid, satsPerVByte: UInt): Txid { - val node = this.node ?: throw ServiceError.NodeNotSetup + 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) @@ -670,19 +648,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 + 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) @@ -693,9 +671,9 @@ 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.info("Calculating CPFP fee for parentTxid $parentTxid") + Logger.debug("Calculating CPFP fee for parentTxid $parentTxid", context = TAG) return ServiceQueue.LDK.background { return@background try { @@ -712,13 +690,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 + 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 { @@ -726,10 +705,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) @@ -744,16 +726,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) } @@ -761,19 +743,16 @@ 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 { + 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() @@ -791,18 +770,19 @@ 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") // 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 @@ -850,7 +830,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}") } @@ -893,15 +873,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") @@ -925,13 +905,13 @@ 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() } 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 { @@ -951,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) } } } @@ -960,8 +940,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 } } @@ -975,16 +953,3 @@ data class NetworkGraphInfo( class TrustedPeerForceCloseException : Exception( "Cannot force close channel with trusted peer. Force close is disabled for Blocktank LSP channels." ) - -// 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/LnurlService.kt b/app/src/main/java/to/bitkit/services/LnurlService.kt index 4cd8317cf..ae7e2e325 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,48 @@ 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}") - } - + when (parsedResponse.status == "ERROR") { + true -> throw HttpError("requestLnurlChannel error: '${parsedResponse.reason}'", 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/LspNotificationsService.kt b/app/src/main/java/to/bitkit/services/LspNotificationsService.kt index d29e8b95b..955b775d9 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 @@ -24,12 +24,12 @@ 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…") 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/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index dc3408277..96188dbf9 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 @@ -13,7 +15,6 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -37,6 +38,7 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import androidx.navigation.toRoute +import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.rememberHazeState import kotlinx.coroutines.delay @@ -207,6 +209,7 @@ fun ContentView( transferViewModel: TransferViewModel, settingsViewModel: SettingsViewModel, backupsViewModel: BackupsViewModel, + hazeState: HazeState, modifier: Modifier = Modifier, ) { val navController = rememberNavController() @@ -281,7 +284,6 @@ fun ContentView( val restoreState by walletViewModel.restoreState.collectAsStateWithLifecycle() val isRestoringFromRNRemoteBackup by walletViewModel.isRestoringFromRNRemoteBackup.collectAsStateWithLifecycle() - var restoreRetryCount by remember { mutableIntStateOf(0) } // React to nodeLifecycleState changes LaunchedEffect(nodeLifecycleState, restoreState, isRestoringFromRNRemoteBackup) { @@ -307,21 +309,15 @@ fun ContentView( } if (walletIsInitializing) { - // TODO ADAPT THIS LOGIC TO WORK WITH LightningNodeService if (nodeLifecycleState is NodeLifecycleState.ErrorStarting) { WalletRestoreErrorView( - retryCount = restoreRetryCount, - onRetry = { - restoreRetryCount++ - walletViewModel.setInitNodeLifecycleState() - walletViewModel.start() - }, + retryCount = restoreState.retryCount(), + hazeState = hazeState, + onRetry = walletViewModel::onRestoreRetry, onProceedWithoutRestore = { - walletViewModel.proceedWithoutRestore( - onDone = { - walletIsInitializing = false - } - ) + walletViewModel.onProceedWithoutRestore { + walletIsInitializing = false + } }, ) } else { @@ -392,9 +388,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() @@ -415,6 +411,7 @@ fun ContentView( }, onCancel = { appViewModel.hideSheet() }, ) + is Sheet.Gift -> GiftSheet(sheet, appViewModel) is Sheet.TimedSheet -> { when (sheet.type) { @@ -770,7 +767,7 @@ private fun RootNavHost( } // region destinations -@Suppress("LongParameterList") +@Suppress("LongMethod", "LongParameterList") private fun NavGraphBuilder.home( walletViewModel: WalletViewModel, appViewModel: AppViewModel, @@ -780,7 +777,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() @@ -796,7 +793,7 @@ private fun NavGraphBuilder.home( .hazeSource(hazeState) ) { HomeScreen( - mainUiState = uiState, + isRefreshing = isRefreshing, drawerState = drawerState, rootNavController = navController, walletNavController = navController, @@ -836,11 +833,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) }, @@ -880,6 +877,7 @@ private fun NavGraphBuilder.settings( composableWithDefaultTransitions { SettingsScreen(navController) } + @Suppress("ForbiddenComment") // TODO: display as sheet composableWithDefaultTransitions { QuickPayIntroScreen( @@ -1371,6 +1369,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 f9fea9f7b..db4b23dee 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) @@ -136,7 +137,8 @@ class MainActivity : FragmentActivity() { transferViewModel = transferViewModel, settingsViewModel = settingsViewModel, backupsViewModel = backupsViewModel, - modifier = Modifier.hazeSource(hazeState, zIndex = 0f) + hazeState = hazeState, + modifier = Modifier.hazeSource(hazeState, zIndex = 0f), ) AnimatedVisibility( diff --git a/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt b/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt index 795426274..5e81df27d 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, ) @@ -458,7 +458,7 @@ private fun Preview() { AppThemeSurface { Content( isDevModeEnabled = false, - uiState = MainUiState( + lightningState = LightningState( nodeId = "0348a2b7c2d3f4e5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9", ), ) @@ -473,7 +473,7 @@ private fun PreviewDevMode() { val syncTime = now().epochSeconds.toULong() Content( isDevModeEnabled = true, - uiState = MainUiState( + lightningState = LightningState( nodeLifecycleState = NodeLifecycleState.Running, nodeStatus = NodeStatus( isRunning = true, @@ -490,7 +490,7 @@ private fun PreviewDevMode() { latestPathfindingScoresSyncTimestamp = null, ), nodeId = "0348a2b7c2d3f4e5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9", - peers = listOf(Peers.staging), + peers = listOf(Peers.stag), channels = listOf( createChannelDetails().copy( channelId = "abc123def456789012345678901234567890123456789012345678901234567890", @@ -520,40 +520,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/Notifications.kt b/app/src/main/java/to/bitkit/ui/Notifications.kt index 58b2ccbda..cda3cefa0 100644 --- a/app/src/main/java/to/bitkit/ui/Notifications.kt +++ b/app/src/main/java/to/bitkit/ui/Notifications.kt @@ -1,6 +1,6 @@ package to.bitkit.ui -import android.Manifest +import android.Manifest.permission import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent @@ -9,10 +9,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 @@ -44,7 +48,7 @@ internal fun Context.notificationBuilder( extra?.let { putExtras(it) } } val flags = FLAG_IMMUTABLE or FLAG_ONE_SHOT - // TODO: review if needed: + val pendingIntent = PendingIntent.getActivity(this, 0, intent, flags) return NotificationCompat.Builder(this, channelId) @@ -66,7 +70,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 +91,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/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 909a0555a..936134e01 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/onboarding/WalletRestoreErrorView.kt b/app/src/main/java/to/bitkit/ui/onboarding/WalletRestoreErrorView.kt index 9a3902918..be3350675 100644 --- a/app/src/main/java/to/bitkit/ui/onboarding/WalletRestoreErrorView.kt +++ b/app/src/main/java/to/bitkit/ui/onboarding/WalletRestoreErrorView.kt @@ -16,6 +16,9 @@ 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 dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.rememberHazeState import to.bitkit.R import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.Display @@ -34,12 +37,15 @@ fun WalletRestoreErrorView( retryCount: Int, onRetry: () -> Unit, onProceedWithoutRestore: () -> Unit, + hazeState: HazeState = rememberHazeState(), modifier: Modifier = Modifier, ) { var showDialog by remember { mutableStateOf(false) } ScreenColumn( - modifier = modifier.padding(horizontal = 32.dp) + modifier = modifier + .hazeSource(hazeState) + .padding(horizontal = 32.dp) ) { VerticalSpacer(24.dp) 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..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 @@ -29,30 +29,28 @@ import to.bitkit.ui.LocalDrawerState import to.bitkit.ui.components.Title import to.bitkit.ui.theme.AppThemeSurface -@Composable @OptIn(ExperimentalMaterial3Api::class) +@Composable fun AppTopBar( titleText: String?, onBackClick: (() -> Unit)?, modifier: Modifier = Modifier, - icon: Painter? = null, + @DrawableRes icon: Int? = null, actions: @Composable (RowScope.() -> Unit) = {}, ) { 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, + painter = painterResource(icon), contentDescription = null, tint = Color.Unspecified, modifier = Modifier @@ -60,12 +58,12 @@ fun AppTopBar( .size(32.dp) ) } - Title(text = titleText, maxLines = 1) + Title(text = text, maxLines = 1) } } }, actions = actions, - colors = TopAppBarDefaults.centerAlignedTopAppBarColors( + colors = TopAppBarDefaults.topAppBarColors( containerColor = Color.Transparent, scrolledContainerColor = Color.Transparent, ), @@ -147,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/scanner/QrScanningScreen.kt b/app/src/main/java/to/bitkit/ui/screens/scanner/QrScanningScreen.kt index ee78f6339..84d7c76fd 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 @@ -83,6 +83,8 @@ import java.util.concurrent.Executors const val SCAN_REQUEST_KEY = "SCAN_REQUEST" const val SCAN_RESULT_KEY = "SCAN_RESULT" +private const val TAG = "QrScanningScreen" + @OptIn(ExperimentalPermissionsApi::class) @Composable fun QrScanningScreen( @@ -374,9 +376,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 +401,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, context = TAG) + onError(it) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/settings/DevSettingsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/settings/DevSettingsScreen.kt index 8e0fd9659..ff033c2ce 100644 --- a/app/src/main/java/to/bitkit/ui/screens/settings/DevSettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/settings/DevSettingsScreen.kt @@ -61,6 +61,10 @@ fun DevSettingsScreen( viewModel.zipLogsForSharing { uri -> context.shareZipFile(uri) } } ) + SettingsTextButtonRow( + title = "Wipe Logs", + onClick = viewModel::wipeLogs + ) if (Env.network == Network.REGTEST) { SectionHeader("REGTEST") 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..9d14a6eb2 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, context = "WebView") } } @@ -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/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/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..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 @@ -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 @@ -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..eacc494f9 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,8 @@ import to.bitkit.ui.utils.withAccentBoldBright import to.bitkit.utils.Logger import to.bitkit.viewmodels.TransferViewModel +private const val TAG = "SettingUpScreen" + @Composable fun SettingUpScreen( viewModel: TransferViewModel, @@ -57,12 +59,12 @@ fun SettingUpScreen( if (Env.network == Network.REGTEST) { delay(5000) - try { - Logger.debug("Auto-mining a block", context = "SettingUpScreen") + runCatching { + Logger.debug("Auto-mining a block", context = TAG) regtestMine(1u) - Logger.debug("Successfully mined a block", context = "SettingUpScreen") - } catch (e: Throwable) { - Logger.error("Failed to mine block: $e", context = "SettingUpScreen") + Logger.debug("Successfully mined a block", context = TAG) + }.onFailure { + Logger.error("Failed to mine block", it, context = TAG) } } } 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 7a5912fea..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 @@ -53,13 +53,13 @@ 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.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 @@ -103,11 +103,12 @@ fun SpendingConfirmScreen( onTransferToSpendingConfirm = viewModel::onTransferToSpendingConfirm, order = order, hasNotificationPermission = notificationsGranted, - onSwitchClick = { NotificationUtils.openNotificationSettings(context) }, + onSwitchClick = { context.openNotificationSettings() }, isAdvanced = isAdvanced, ) } +@Suppress("MagicNumber") @Composable private fun Content( onBackClick: () -> Unit, 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..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.from 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 @@ -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..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 @@ -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()) } @@ -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/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/HomeScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt index a0d043ca2..5367f742f 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 @@ -111,13 +111,13 @@ 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 +@Suppress("CyclomaticComplexMethod") @Composable fun HomeScreen( - mainUiState: MainUiState, + isRefreshing: Boolean, drawerState: DrawerState, rootNavController: NavController, walletNavController: NavHostController, @@ -153,7 +153,7 @@ fun HomeScreen( } Content( - mainUiState = mainUiState, + isRefreshing = isRefreshing, homeUiState = homeUiState, rootNavController = rootNavController, walletNavController = walletNavController, @@ -261,10 +261,11 @@ fun HomeScreen( ) } +@Suppress("MagicNumber") @OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class) @Composable private fun Content( - mainUiState: MainUiState, + isRefreshing: Boolean, homeUiState: HomeUiState, rootNavController: NavController, walletNavController: NavController, @@ -296,11 +297,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) @@ -642,7 +643,7 @@ private fun TopBar( ) } }, - colors = TopAppBarDefaults.largeTopAppBarColors(Color.Transparent), + colors = TopAppBarDefaults.topAppBarColors(Color.Transparent), modifier = Modifier.fillMaxWidth() ) } @@ -672,7 +673,7 @@ private fun Preview() { AppThemeSurface { Box { Content( - mainUiState = MainUiState(), + isRefreshing = false, homeUiState = HomeUiState( showWidgets = true, ), @@ -696,7 +697,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/SavingsWalletScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/SavingsWalletScreen.kt index 6f02b7dd2..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 @@ -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 71be8ee9f..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 @@ -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) } @@ -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() @@ -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/activity/ActivityDetailScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt index 63886cd13..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 @@ -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 @@ -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, @@ -739,7 +740,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/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 81962392a..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 @@ -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,9 +38,10 @@ 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.repositories.CurrencyState import to.bitkit.ui.LocalCurrencies import to.bitkit.ui.activityListViewModel import to.bitkit.ui.blocktankViewModel @@ -58,6 +60,7 @@ import java.time.Instant import java.time.LocalDate import java.time.ZoneId +@Suppress("CyclomaticComplexMethod") @Composable fun ActivityRow( item: Activity, @@ -117,6 +120,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 +132,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 +140,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 +148,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) } @@ -208,6 +212,7 @@ private fun TransactionStatusText( private fun AmountView( item: Activity, prefix: String, + currencies: CurrencyState = LocalCurrencies.current, ) { val amount = item.totalValue() @@ -225,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/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/ReceiveConfirmScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveConfirmScreen.kt index 8b3465186..d1a93def0 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,27 +36,16 @@ 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.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 -// TODO replace with direct use of the now serializable IcJitEntry -@Serializable -data class CjitEntryDetails( - val networkFeeSat: Long, - val serviceFeeSat: Long, - val channelSizeSat: Long, - val feeSat: Long, - val receiveAmountSats: Long, - val invoice: String, -) - @Composable fun ReceiveConfirmScreen( settingsViewModel: SettingsViewModel = hiltViewModel(), @@ -107,9 +96,7 @@ fun ReceiveConfirmScreen( receiveAmountFormatted = receiveAmountFormatted, onLearnMoreClick = onLearnMore, isAdditional = isAdditional, - onSystemSettingsClick = { - NotificationUtils.openNotificationSettings(context) - }, + onSystemSettingsClick = { context.openNotificationSettings() }, hasNotificationPermission = notificationsGranted, onContinueClick = { onContinue(entry.invoice) }, onBackClick = onBack, @@ -197,6 +184,17 @@ private fun Content( } } +// TODO replace with direct use of the now serializable IcJitEntry +@Serializable +data class CjitEntryDetails( + val networkFeeSat: Long, + val serviceFeeSat: Long, + val channelSizeSat: Long, + val feeSat: Long, + val receiveAmountSats: Long, + val invoice: String, +) + @Preview(showSystemUi = true) @Composable private fun Preview() { 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/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..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 @@ -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.openNotificationSettings 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) @@ -130,9 +131,7 @@ fun ReceiveSheet( onContinue = { navController.popBackStack() }, onBack = { navController.popBackStack() }, hasNotificationPermission = notificationsGranted, - onSwitchClick = { - NotificationUtils.openNotificationSettings(context) - }, + onSwitchClick = { context.openNotificationSettings() }, ) } } @@ -147,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/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/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/screens/wallets/send/SendCoinSelectionViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionViewModel.kt index 33c03430c..851027e26 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 @@ -26,6 +26,9 @@ class SendCoinSelectionViewModel @Inject constructor( private val lightningRepo: LightningRepo, private val activityRepo: ActivityRepo, ) : ViewModel() { + companion object { + private const val TAG = "SendCoinSelectionViewModel" + } private val _uiState = MutableStateFlow(CoinSelectionUiState()) val uiState = _uiState.asStateFlow() @@ -39,34 +42,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 } + 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 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, context = TAG) + ToastEventBus.send(Exception("Failed to load UTXOs: ${it.message}")) } } @@ -84,8 +85,8 @@ class SendCoinSelectionViewModel @Inject constructor( _tagsByTxId.update { currentMap -> currentMap + (txId to tags) } } } - .onFailure { e -> - Logger.error("Failed to load tags for utxo $txId", e) + .onFailure { + Logger.error("Failed to load tags for utxo $txId", it, context = TAG) } } } @@ -133,8 +134,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/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/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/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 47778d650..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, @@ -50,7 +51,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 +66,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("") } @@ -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/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/ui/settings/backgroundPayments/BackgroundPaymentsSettings.kt b/app/src/main/java/to/bitkit/ui/settings/backgroundPayments/BackgroundPaymentsSettings.kt index 573312612..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 @@ -25,12 +25,12 @@ 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.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/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..dedea0a92 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 @@ -38,13 +38,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 +130,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 +152,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..f8e05fd9d 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 @@ -32,10 +32,7 @@ fun SuccessScreen( onBack: () -> Unit, ) { SuccessContent( - onContinue = { - // TODO: verify backup - onContinue() - }, + onContinue = onContinue, onBack = onBack, ) } 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 3081689de..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 @@ -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) }, ) } @@ -499,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/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/shared/util/ShareSheet.kt b/app/src/main/java/to/bitkit/ui/shared/util/ShareSheet.kt index c608f3bda..21cc7a715 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 @@ -44,10 +45,10 @@ fun shareQrCode(context: Context, bitmap: Bitmap, text: String) { addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } - val chooser = Intent.createChooser(intent, "Share Qr code via") + val chooser = Intent.createChooser(intent, context.getString(R.string.other__share_qr)) 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 22d219d20..e814bb10f 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 @@ -67,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 @@ -123,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) } } } @@ -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, ) } } @@ -167,20 +176,20 @@ 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) } } } 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 +203,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( @@ -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/main/java/to/bitkit/ui/sheets/SendSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt index 5c4f49e59..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, @@ -56,7 +57,7 @@ fun SendSheet( // always reset state on new user-initiated send if (startDestination == SendRoute.Recipient) { appViewModel.resetSendState() - appViewModel.resetQuickPayData() + appViewModel.resetQuickPay() } } Column( @@ -111,10 +112,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 +168,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/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/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) 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/usecases/WipeWalletUseCase.kt b/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt index 381365c44..a8ca69af3 100644 --- a/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt +++ b/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt @@ -6,6 +6,7 @@ import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore import to.bitkit.data.WidgetsStore import to.bitkit.data.keychain.Keychain +import to.bitkit.env.Env import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.BackupRepo import to.bitkit.repositories.BlocktankRepo @@ -32,13 +33,12 @@ class WipeWalletUseCase @Inject constructor( private val firebaseMessaging: FirebaseMessaging, private val migrationService: MigrationService, ) { - @Suppress("TooGenericExceptionCaught") suspend operator fun invoke( walletIndex: Int = 0, resetWalletState: () -> Unit, onSuccess: () -> Unit, ): Result { - try { + return runCatching { backupRepo.setWiping(true) backupRepo.reset() @@ -58,20 +58,20 @@ class WipeWalletUseCase @Inject constructor( migrationService.markMigrationChecked() - return lightningRepo.wipeStorage(walletIndex) + lightningRepo.wipeStorage(walletIndex) .onSuccess { onSuccess() - Logger.reset() + if (Env.isDebug) Logger.reset() } - } catch (e: Throwable) { - Logger.error("Wipe wallet error", e, context = TAG) - return Result.failure(e) - } finally { + .getOrThrow() + }.onFailure { + Logger.error("Wipe wallet error", it, context = TAG) + }.also { backupRepo.setWiping(false) } } - companion object Companion { - const val TAG = "WipeWalletUseCase" + companion object { + private const val TAG = "WipeWalletUseCase" } } diff --git a/app/src/main/java/to/bitkit/utils/Crypto.kt b/app/src/main/java/to/bitkit/utils/Crypto.kt index dbce66c8e..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") @@ -61,7 +62,7 @@ class Crypto @Inject constructor() { } } } catch (e: Exception) { - throw CryptoError.SecurityProviderSetupFailed + throw CryptoError.SecurityProviderSetupFailed() } } @@ -80,7 +81,7 @@ class Crypto @Inject constructor() { publicKey = publicKey, ) } catch (e: Exception) { - throw CryptoError.KeypairGenerationFailed + throw CryptoError.KeypairGenerationFailed() } } @@ -111,7 +112,7 @@ class Crypto @Inject constructor() { return baseSecret } catch (e: Exception) { - throw CryptoError.SharedSecretGenerationFailed + throw CryptoError.SharedSecretGenerationFailed() } } @@ -139,7 +140,7 @@ class Crypto @Inject constructor() { return cipher.doFinal(encryptedPayload.cipher + encryptedPayload.tag) } catch (e: Exception) { - throw CryptoError.DecryptionFailed + throw CryptoError.DecryptionFailed() } } @@ -171,7 +172,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 +181,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 +215,7 @@ class Crypto @Inject constructor() { continue } } - throw CryptoError.SigningFailed + throw CryptoError.SigningFailed() } private fun formatSignature(recId: Int, r: BigInteger, s: BigInteger): String { @@ -231,10 +232,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..b831c3439 100644 --- a/app/src/main/java/to/bitkit/utils/Errors.kt +++ b/app/src/main/java/to/bitkit/utils/Errors.kt @@ -5,35 +5,26 @@ 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, + cause: Throwable? = null, +) : Exception(message, cause) { + constructor(cause: Throwable) : this(cause.message, cause) } 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") } +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)) @@ -129,6 +120,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 diff --git a/app/src/main/java/to/bitkit/utils/Logger.kt b/app/src/main/java/to/bitkit/utils/Logger.kt index 8a69aa448..1b0ddbdad 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 @@ -23,6 +22,7 @@ import java.io.File import java.io.FileOutputStream import java.util.Date import java.util.Locale +import kotlin.time.measureTime import org.lightningdevkit.ldknode.LogLevel as LdkLogLevel private const val APP = "APP" @@ -34,9 +34,11 @@ enum class LogLevel { PERF, VERBOSE, GOSSIP, TRACE, DEBUG, INFO, WARN, ERROR; } val Logger = AppLogger() -class AppLogger( - private val source: LogSource = LogSource.Bitkit, -) { +class AppLogger(private val source: LogSource = LogSource.Bitkit) { + companion object { + private const val TAG = "Logger" + } + private var delegate: LoggerImpl? = null init { @@ -49,7 +51,7 @@ class AppLogger( } fun reset() { - warn("Wiping entire logs directory...") + warn("Wiping entire logs directory…", context = TAG) runCatching { Env.logDir.deleteRecursively() } delegate = runCatching { createDelegate() }.getOrNull() } @@ -146,7 +148,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 +161,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 +357,17 @@ 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}']" + +internal inline fun measured( + label: String, + context: String, + block: () -> T, +): T { + var result: T + val elapsed = measureTime { + result = block() + } + Logger.perf("$label took $elapsed", context = context) + return result +} diff --git a/app/src/main/java/to/bitkit/utils/Perf.kt b/app/src/main/java/to/bitkit/utils/Perf.kt deleted file mode 100644 index b5ef217aa..000000000 --- a/app/src/main/java/to/bitkit/utils/Perf.kt +++ /dev/null @@ -1,27 +0,0 @@ -package to.bitkit.utils - -import kotlin.time.Duration -import kotlin.time.measureTime - -fun Duration.formatted(): String = toComponents { hours, minutes, seconds, nanoseconds -> - val ms = nanoseconds / 1_000_000 - buildString { - if (hours > 0) append("${hours}h ") - if (minutes > 0) append("${minutes}m ") - if (seconds > 0) append("${seconds}s ") - if (ms > 0 || isEmpty()) append("${ms}ms") - }.trim() -} - -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()}", context = context) - return result -} diff --git a/app/src/main/java/to/bitkit/utils/Utils.kt b/app/src/main/java/to/bitkit/utils/Utils.kt deleted file mode 100644 index 8db35aa84..000000000 --- a/app/src/main/java/to/bitkit/utils/Utils.kt +++ /dev/null @@ -1,3 +0,0 @@ -package to.bitkit.utils - -fun nameof(t: Any): String = "${t::class.simpleName}" 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 873e2a2ff..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 @@ -17,23 +17,19 @@ class AppUpdateTimedSheet @Inject constructor( override val type = TimedSheetType.APP_UPDATE override val priority = 5 - @Suppress("TooGenericExceptionCaught") 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..fb3fc95bc 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 { activities -> + _filteredActivities.update { activities } + } } private suspend fun refreshActivityState() { @@ -113,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 } @@ -130,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) @@ -178,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/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index afab19013..e82e89cb8 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.Defaults import to.bitkit.env.Env -import to.bitkit.env.TransactionDefaults import to.bitkit.ext.WatchResult import to.bitkit.ext.amountOnClose import to.bitkit.ext.getClipboardText @@ -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, @@ -552,7 +552,7 @@ class AppViewModel @Inject constructor( private suspend fun notifyPaymentReceived(event: Event) { val command = NotifyPaymentReceived.Command.from(event) ?: return - val result = notifyPaymentReceivedHandler(command).getOrNull() + val result = notifyPaymentReceivedHandler.invoke(command).getOrNull() if (result !is NotifyPaymentReceived.Result.ShowSheet) return showTransactionSheet(result.sheet) } @@ -616,6 +616,7 @@ class AppViewModel @Inject constructor( // region send + @Suppress("CyclomaticComplexMethod") private fun observeSendEvents() { viewModelScope.launch { sendEvents.collect { @@ -820,7 +821,7 @@ class AppViewModel @Inject constructor( } } - SendMethod.ONCHAIN -> amount > TransactionDefaults.dustLimit.toULong() + SendMethod.ONCHAIN -> amount > Defaults.dustLimit.toULong() } } @@ -853,7 +854,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) } @@ -1301,6 +1302,7 @@ class AppViewModel @Inject constructor( } } + @Suppress("LongMethod") private suspend fun proceedWithPayment() { delay(SCREEN_TRANSITION_DELAY_MS) // wait for screen transitions when applicable @@ -1566,7 +1568,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() { @@ -1586,13 +1588,11 @@ class AppViewModel @Inject constructor( .mapCatching { satsPerVByte -> lightningRepo.determineUtxosToSpend( sats = currentState.amount, - satsPerVByte = satsPerVByte.toUInt(), + satsPerVByte = satsPerVByte, ) } .onSuccess { utxos -> - _sendUiState.update { - it.copy(selectedUtxos = utxos) - } + _sendUiState.update { it.copy(selectedUtxos = utxos) } } } refreshFeeEstimates() diff --git a/app/src/main/java/to/bitkit/viewmodels/DevSettingsViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/DevSettingsViewModel.kt index e8d10c8f6..30a5bd849 100644 --- a/app/src/main/java/to/bitkit/viewmodels/DevSettingsViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/DevSettingsViewModel.kt @@ -8,14 +8,12 @@ import com.google.firebase.messaging.FirebaseMessaging import com.synonym.bitkitcore.testNotification import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.launch import kotlinx.coroutines.tasks.await import to.bitkit.R import to.bitkit.data.AppDb import to.bitkit.data.CacheStore import to.bitkit.data.WidgetsStore -import to.bitkit.di.BgDispatcher import to.bitkit.env.Env import to.bitkit.models.NewTransactionSheetDetails import to.bitkit.models.NewTransactionSheetDirection @@ -27,13 +25,13 @@ import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LogsRepo import to.bitkit.repositories.WalletRepo import to.bitkit.ui.shared.toast.ToastEventBus +import to.bitkit.utils.Logger import javax.inject.Inject -@Suppress("LongParameterList") +@Suppress("TooManyFunctions", "LongParameterList") @HiltViewModel class DevSettingsViewModel @Inject constructor( @ApplicationContext private val context: Context, - @BgDispatcher private val bgDispatcher: CoroutineDispatcher, private val firebaseMessaging: FirebaseMessaging, private val lightningRepo: LightningRepo, private val walletRepo: WalletRepo, @@ -45,78 +43,66 @@ class DevSettingsViewModel @Inject constructor( private val appDb: AppDb, ) : ViewModel() { - fun openChannel() { - viewModelScope.launch(bgDispatcher) { - val peer = lightningRepo.getPeers()?.firstOrNull() + fun openChannel() = viewModelScope.launch { + val peer = lightningRepo.getPeers()?.firstOrNull() - if (peer == null) { - ToastEventBus.send(type = Toast.ToastType.WARNING, title = "No peer connected") - return@launch - } - - lightningRepo.openChannel(peer, 50_000u, 25_000u) - .onSuccess { - ToastEventBus.send(type = Toast.ToastType.INFO, title = "Channel pending") - } - .onFailure { ToastEventBus.send(it) } + if (peer == null) { + ToastEventBus.send(type = Toast.ToastType.WARNING, title = "No peer connected") + return@launch } - } - fun registerForNotifications() { - viewModelScope.launch { - lightningRepo.registerForNotifications() - .onSuccess { - ToastEventBus.send(type = Toast.ToastType.INFO, title = "Registered for notifications") - } - .onFailure { ToastEventBus.send(it) } - } + lightningRepo.openChannel(peer, 50_000u, 25_000u) + .onSuccess { + ToastEventBus.send(type = Toast.ToastType.INFO, title = "Channel pending") + } + .onFailure { ToastEventBus.send(it) } } - fun testLspNotification() { - viewModelScope.launch(bgDispatcher) { - runCatching { - testNotification( - deviceToken = firebaseMessaging.token.await(), - secretMessage = "hello", - notificationType = "incomingHtlc", - customUrl = Env.blocktankNotificationApiUrl, - ) - ToastEventBus.send(type = Toast.ToastType.INFO, title = "LSP notification sent to this device") - }.onFailure { - ToastEventBus.send(type = Toast.ToastType.WARNING, title = "Error testing LSP notification") + fun registerForNotifications() = viewModelScope.launch { + lightningRepo.registerForNotifications() + .onSuccess { + ToastEventBus.send(type = Toast.ToastType.INFO, title = "Registered for notifications") } - } + .onFailure { ToastEventBus.send(it) } } - fun fakeBgReceive() { - viewModelScope.launch { - cacheStore.setBackgroundReceive( - NewTransactionSheetDetails( - type = NewTransactionSheetType.LIGHTNING, - direction = NewTransactionSheetDirection.RECEIVED, - sats = 21_000_000, - ) + fun testLspNotification() = viewModelScope.launch { + runCatching { + testNotification( + deviceToken = firebaseMessaging.token.await(), + secretMessage = "hello", + notificationType = "incomingHtlc", + customUrl = Env.blocktankNotificationApiUrl, ) + ToastEventBus.send(type = Toast.ToastType.INFO, title = "LSP notification sent to this device") + }.onFailure { + ToastEventBus.send(type = Toast.ToastType.WARNING, title = "Error testing LSP notification") } } - fun resetWidgetsState() { - viewModelScope.launch { - widgetsStore.reset() - } + fun fakeBgReceive() = viewModelScope.launch { + cacheStore.setBackgroundReceive( + NewTransactionSheetDetails( + type = NewTransactionSheetType.LIGHTNING, + direction = NewTransactionSheetDirection.RECEIVED, + sats = 21_000_000, + ) + ) } - fun refreshCurrencyRates() { - viewModelScope.launch { - currencyRepo.triggerRefresh() - } + fun resetWidgetsState() = viewModelScope.launch { + widgetsStore.reset() + } + + fun refreshCurrencyRates() = viewModelScope.launch { + currencyRepo.triggerRefresh() } fun zipLogsForSharing(onReady: (Uri) -> Unit) { viewModelScope.launch { logsRepo.zipLogsForSharing() .onSuccess { uri -> onReady(uri) } - .onFailure { err -> + .onFailure { ToastEventBus.send( type = Toast.ToastType.WARNING, title = context.getString(R.string.lightning__error_logs), @@ -126,31 +112,25 @@ class DevSettingsViewModel @Inject constructor( } } - fun resetBackupState() { - viewModelScope.launch { - cacheStore.update { it.copy(backupStatuses = mapOf()) } - } + fun resetBackupState() = viewModelScope.launch { + cacheStore.update { it.copy(backupStatuses = mapOf()) } } - fun wipeWallet() { - viewModelScope.launch { - walletRepo.wipeWallet() - } + fun wipeWallet() = viewModelScope.launch { + walletRepo.wipeWallet() } - fun resetCacheStore() { - viewModelScope.launch { - cacheStore.reset() - } + fun resetCacheStore() = viewModelScope.launch { + cacheStore.reset() } fun resetDatabase() = viewModelScope.launch { appDb.clearAllTables() } - fun resetBlocktankState() { - viewModelScope.launch { - blocktankRepo.resetState() - } + fun resetBlocktankState() = viewModelScope.launch { + blocktankRepo.resetState() } + + fun wipeLogs() = Logger.reset() } 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/main/java/to/bitkit/viewmodels/LogsViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/LogsViewModel.kt index 17f07bad6..5e2587446 100644 --- a/app/src/main/java/to/bitkit/viewmodels/LogsViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/LogsViewModel.kt @@ -25,6 +25,10 @@ class LogsViewModel @Inject constructor( private val application: Application, private val logsRepo: LogsRepo, ) : AndroidViewModel(application) { + companion object { + private const val TAG = "LogsViewModel" + } + private val _logs = MutableStateFlow>(emptyList()) val logs: StateFlow> = _logs.asStateFlow() @@ -46,14 +50,14 @@ class LogsViewModel @Inject constructor( } .onFailure { e -> _selectedLogContent.update { listOf("Log file not found") } - Logger.error("Failed to load log content", e) + Logger.error("Failed to load log content", e, context = TAG) } } } 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 +75,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, context = TAG) } } } 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 5893416fe..471fce9d5 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -22,18 +22,15 @@ 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.R 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 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 @@ -46,7 +43,7 @@ import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds -@Suppress("LongParameterList") +@Suppress("TooManyFunctions", "LongParameterList") @HiltViewModel class WalletViewModel @Inject constructor( @ApplicationContext private val context: Context, @@ -60,7 +57,7 @@ class WalletViewModel @Inject constructor( ) : ViewModel() { companion object { private const val TAG = "WalletViewModel" - private val RESTORE_WAIT_TIMEOUT = 30.seconds + private val TIMEOUT_RESTORE_WAIT = 30.seconds } val lightningState = lightningRepo.lightningState @@ -70,24 +67,19 @@ 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() - 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 @@ -96,52 +88,49 @@ class WalletViewModel @Inject constructor( collectStates() } - @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, context = TAG) + migrationService.markMigrationChecked() + migrationService.setShowingMigrationLoading(false) + ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = "Migration Failed", + description = "Please restore your wallet manually using your recovery phrase" + ) } } @@ -151,37 +140,11 @@ class WalletViewModel @Inject constructor( } } - private fun collectStates() { - 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 fun collectStates() = viewModelScope.launch { + walletState.collect { + walletExists = it.walletExists + if (it.walletExists && _restoreState.value == RestoreState.InProgress.Wallet) { + restoreFromBackup() } } } @@ -190,8 +153,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 } } @@ -216,28 +179,29 @@ 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 onRestoreRetry() = viewModelScope.launch(bgDispatcher) { + _restoreState.update { it.countRetry() } + setInitNodeLifecycleState() + lightningRepo.restartNode() } - 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 onProceedWithoutRestore(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() @@ -260,18 +224,18 @@ class WalletViewModel @Inject constructor( private suspend fun waitForRestoreIfNeeded() { if (!_restoreState.value.isOngoing()) return - withTimeoutOrNull(RESTORE_WAIT_TIMEOUT) { + withTimeoutOrNull(TIMEOUT_RESTORE_WAIT) { _restoreState.first { !it.isOngoing() } - } ?: Logger.warn("Restore wait timed out, proceeding anyway", context = TAG) + } ?: Logger.warn("waitForRestoreIfNeeded timeout, proceeding anyway", context = TAG) } - private fun buildChannelMigrationIfAvailable(): ChannelDataMigration? { - val migration = migrationService.peekPendingChannelMigration() ?: return null - return ChannelDataMigration( - channelManager = migration.channelManager.map { it.toUByte() }, - channelMonitors = migration.channelMonitors.map { monitor -> monitor.map { it.toUByte() } }, - ) - } + private fun buildChannelMigrationIfAvailable(): ChannelDataMigration? = + migrationService.peekPendingChannelMigration()?.let { migration -> + ChannelDataMigration( + channelManager = migration.channelManager.map { it.toUByte() }, + channelMonitors = migration.channelMonitors.map { monitor -> monitor.map { it.toUByte() } }, + ) + } private suspend fun startNode( walletIndex: Int = 0, @@ -288,10 +252,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 RecoveryModeError) { + ToastEventBus.send(it) } } } @@ -301,19 +265,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) } } @@ -324,11 +288,11 @@ class WalletViewModel @Inject constructor( lightningRepo.clearPendingSync() syncJob = viewModelScope.launch { - _uiState.update { it.copy(isRefreshing = true) } + _isRefreshing.update { true } try { walletRepo.syncNodeAndWallet(source = SyncSource.MANUAL) } finally { - _uiState.update { it.copy(isRefreshing = false) } + _isRefreshing.update { false } } } } @@ -340,30 +304,26 @@ class WalletViewModel @Inject constructor( ToastEventBus.send( type = Toast.ToastType.INFO, title = context.getString(R.string.common__success), - description = context.getString(R.string.wallet__peer_disconnected), + description = context.getString(R.string.wallet__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_body) + description = it.message ?: context.getString(R.string.common__error_body) ) } } } - 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_body) - ) - } + 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_body) + ) } } @@ -373,11 +333,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) } } @@ -387,8 +345,8 @@ class WalletViewModel @Inject constructor( .onSuccess { backupRepo.scheduleFullBackup() } - .onFailure { error -> - ToastEventBus.send(error) + .onFailure { + ToastEventBus.send(it) } } @@ -399,22 +357,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) } } @@ -424,7 +382,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) } @@ -437,32 +395,20 @@ 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 { object Wallet : InProgress object Metadata : InProgress } + data class Retry(val count: Int) : RestoreState data object Completed : RestoreState data object Settled : RestoreState + fun retryCount() = (this as? Retry)?.count ?: 0 + fun countRetry(): RestoreState = if (this is Retry) Retry(count + 1) else Retry(1) fun isOngoing() = this is InProgress fun isIdle() = this is Initial || this is Settled } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3448384ca..54f5d0b3c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -484,6 +484,7 @@ Decoding Error Unable To Interpret Provided Data This QR code does not appear to contain payment data. + Share QR Code Via Go borderless ESims Shop with Bitcoin 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..2cd88e6c5 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,8 +358,8 @@ class LightningRepoTest : BaseUnitTest() { @Test fun `disconnectPeer should succeed when node is running`() = test { startNodeForTesting() - val testPeer = PeerDetails.from("nodeId", "host", "9735") - whenever(lightningService.disconnectPeer(any())).thenReturn(Unit) + val testPeer = PeerDetails.of("nodeId", "host", "9735") + 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/ui/WalletViewModelTest.kt b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt index 27928f2a9..e80b3f74b 100644 --- a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt @@ -16,7 +16,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 @@ -97,7 +97,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)) @@ -193,14 +193,14 @@ class WalletViewModelTest : BaseUnitTest() { } @Test - fun `proceedWithoutRestore should exit restore flow`() = test { + fun `onProceedWithoutRestore should exit restore flow`() = test { val testError = Exception("Test error") whenever(backupRepo.performFullRestoreFromLatestBackup()).thenReturn(Result.failure(testError)) sut.restoreWallet("mnemonic", "passphrase") walletState.value = walletState.value.copy(walletExists = true) assertEquals(RestoreState.Completed, sut.restoreState.value) - sut.proceedWithoutRestore(onDone = {}) + sut.onProceedWithoutRestore(onDone = {}) advanceUntilIdle() assertEquals(RestoreState.Settled, sut.restoreState.value) } 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 } diff --git a/app/src/test/java/to/bitkit/utils/CryptoTest.kt b/app/src/test/java/to/bitkit/utils/CryptoTest.kt index eb38f327a..482588db7 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() @@ -84,9 +84,12 @@ class CryptoTest { val serverPublicKey = "031e9923e689a181a803486b7d8c0d4a5aad360edb70c8bb413a98458d91652213" val derivationName = "bitkit-notifications" - val ciphertext = "l2fInfyw64gO12odo8iipISloQJ45Rc4WjFmpe95brdaAMDq+T/L9ZChcmMCXnR0J6BXd8sSIJe/0bmby8uSZZJuVCzwF76XHfY5oq0Y1/hKzyZTn8nG3dqfiLHnAPy1tZFQfm5ALgjwWnViYJLXoGFpXs7kLMA=".fromBase64() + @Suppress("MaxLineLength") + val ciphertext = ("l2fInfyw64gO12odo8iipISloQJ45Rc4WjFmpe95brdaAMDq+T/L9ZChcmMCXnR0J6BXd8sSIJe/0bmby8uSZZJuVCzwF76XHfY5oq0Y1/hKzyZTn8nG3dqfiLHnAPy1tZFQfm5ALgjwWnViYJLXoGFpXs7kLMA=").fromBase64() val iv = "2b8ed77fd2198e3ed88cfaa794a246e8" val tag = "caddd13746d6a6aed16176734964d3a3" + + @Suppress("MaxLineLength") val decryptedPayload = """{"source":"blocktank","type":"incomingHtlc","payload":{"secretMessage":"hello"},"createdAt":"2024-09-18T13:33:52.555Z"}""" // Without derivationName 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/config/detekt/detekt.yml b/config/detekt/detekt.yml index cfe81e1e3..84c4ea9ae 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,43 @@ 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' + - '300' + - '500' + - '512' + - '1000' + - '1024' + - '2048' + - '2500' + - '4096' + - '5000' + - '10000' + - '25000' + - '50000' + - '100000' ignoreHashCodeFunction: true ignorePropertyDeclaration: true ignoreLocalVariableDeclaration: true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 877e9e053..a2e665541 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" @@ -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.01" } 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" }