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/AGENTS.md b/AGENTS.md index ed2897729..99291cca2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -195,7 +195,7 @@ suspend fun getData(): Result = withContext(Dispatchers.IO) { - ALWAYS be mindful of thread safety when working with mutable lists & state - ALWAYS split screen composables into parent accepting viewmodel + inner private child accepting state and callbacks `Content()` - ALWAYS name lambda parameters in a composable function using present tense, NEVER use past tense -- ALWAYS list 3 suggested commit messages after implementation work for the entire set of uncommitted changes +- ALWAYS list 3 suggested commit messages after implementation work for ALL uncommitted changes - NEVER use `wheneverBlocking` in unit test expression body functions wrapped in a `= test {}` lambda - ALWAYS wrap unit tests `setUp` methods mocking suspending calls with `runBlocking`, e.g `setUp() = runBlocking { }` - ALWAYS add business logic to Repository layer via methods returning `Result` and use it in ViewModels @@ -207,6 +207,7 @@ suspend fun getData(): Result = withContext(Dispatchers.IO) { - ALWAYS wrap `ULong` numbers with `USat` in arithmetic operations, to guard against overflows - PREFER to use one-liners with `run { }` when applicable, e.g. `override fun someCall(value: String) = run { this.value = value }` - ALWAYS add imports instead of inline fully-qualified names +- ALWAYS place `@Suppress(...)` annotations at the narrowest possible scope (statement, property, or function level); NEVER use `@file:Suppress(...)` when a more targeted suppression is possible. ### Architecture Guidelines diff --git a/README.md b/README.md index e33fad3a6..089b1e651 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. @@ -57,9 +57,16 @@ See also: 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 +119,32 @@ 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 assembleMainnetDebug # mainnet debug build +# ./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 +175,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 +228,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 ad77c1f50..2bfe8257f 100644 --- a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt +++ b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt @@ -100,7 +100,7 @@ class LightningNodeService : Service() { } private fun createNotification( - contentText: String = getString(R.string.notification_running_in_background), + contentText: String = getString(R.string.notification__service__body), ): Notification { val notificationIntent = Intent(this, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP @@ -120,7 +120,7 @@ class LightningNodeService : Service() { .setContentIntent(pendingIntent) .addAction( R.drawable.ic_x, - getString(R.string.notification_stop_app), + getString(R.string.notification__service__stop), stopPendingIntent ) .build() diff --git a/app/src/main/java/to/bitkit/async/ServiceQueue.kt b/app/src/main/java/to/bitkit/async/ServiceQueue.kt index 5d3cf2e55..05cbd07d9 100644 --- a/app/src/main/java/to/bitkit/async/ServiceQueue.kt +++ b/app/src/main/java/to/bitkit/async/ServiceQueue.kt @@ -19,6 +19,7 @@ enum class ServiceQueue { private val scope by lazy { CoroutineScope(newSingleThreadDispatcher(name) + SupervisorJob()) } + @Suppress("TooGenericExceptionCaught") fun blocking( coroutineContext: CoroutineContext = scope.coroutineContext, functionName: String = Thread.currentThread().callerName, @@ -26,7 +27,7 @@ enum class ServiceQueue { ): T { return runBlocking(coroutineContext) { try { - measured(functionName) { + measured(label = functionName, context = TAG) { block() } } catch (e: Exception) { @@ -36,6 +37,7 @@ enum class ServiceQueue { } } + @Suppress("TooGenericExceptionCaught") suspend fun background( coroutineContext: CoroutineContext = scope.coroutineContext, functionName: String = Thread.currentThread().callerName, @@ -43,7 +45,7 @@ enum class ServiceQueue { ): T { return withContext(coroutineContext) { try { - measured(functionName) { + measured(label = functionName, context = TAG) { block() } } catch (e: Exception) { @@ -52,6 +54,10 @@ enum class ServiceQueue { } } } + + companion object { + private const val TAG = "ServiceQueue" + } } fun newSingleThreadDispatcher(id: String): ExecutorCoroutineDispatcher { diff --git a/app/src/main/java/to/bitkit/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..53f66a222 100644 --- a/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt +++ b/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt @@ -30,7 +30,7 @@ class VssBackupClient @Inject constructor( private var isSetup = CompletableDeferred() suspend fun setup(walletIndex: Int = 0) = withContext(ioDispatcher) { - try { + runCatching { withTimeout(30.seconds) { Logger.debug("VSS client setting up…", context = TAG) val vssUrl = Env.vssServerUrl @@ -40,7 +40,7 @@ class VssBackupClient @Inject constructor( Logger.verbose("Building VSS client with lnurlAuthServerUrl: '$lnurlAuthServerUrl'") if (lnurlAuthServerUrl.isNotEmpty()) { val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) - ?: throw ServiceError.MnemonicNotFound + ?: throw ServiceError.MnemonicNotFound() val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) vssNewClientWithLnurlAuth( @@ -59,9 +59,9 @@ class VssBackupClient @Inject constructor( isSetup.complete(Unit) Logger.info("VSS client setup with server: '$vssUrl'", context = TAG) } - } catch (e: Throwable) { - isSetup.completeExceptionally(e) - Logger.error("VSS client setup error", e = e, context = TAG) + }.onFailure { + isSetup.completeExceptionally(it) + Logger.error("VSS client setup error", e = it, context = TAG) } } diff --git a/app/src/main/java/to/bitkit/data/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..d3e516b51 100644 --- a/app/src/main/java/to/bitkit/data/widgets/WeatherService.kt +++ b/app/src/main/java/to/bitkit/data/widgets/WeatherService.kt @@ -10,6 +10,7 @@ import to.bitkit.data.dto.FeeCondition import to.bitkit.data.dto.FeeEstimates import to.bitkit.data.dto.WeatherDTO import to.bitkit.env.Env +import to.bitkit.ext.nowMs import to.bitkit.models.WidgetType import to.bitkit.repositories.CurrencyRepo import to.bitkit.utils.AppError @@ -18,26 +19,42 @@ import java.math.BigDecimal import javax.inject.Inject import javax.inject.Singleton import kotlin.math.floor +import kotlin.time.Clock +import kotlin.time.Duration import kotlin.time.Duration.Companion.minutes +import kotlin.time.ExperimentalTime +@OptIn(ExperimentalTime::class) @Singleton class WeatherService @Inject constructor( private val client: HttpClient, private val currencyRepo: CurrencyRepo, + private val clock: Clock, ) : WidgetService { override val widgetType = WidgetType.WEATHER override val refreshInterval = 8.minutes - private companion object { + private var cachedFeeEstimates: FeeEstimates? = null + private var feeEstimatesTimestamp: Long = 0L + private var cachedHistoricalData: List? = null + private var historicalDataTimestamp: Long = 0L + + companion object { private const val TAG = "WeatherService" + + @Suppress("SpellCheckingInspection") private const val AVERAGE_SEGWIT_VBYTES_SIZE = 140 private const val USD_GOOD_THRESHOLD = 1.0 // $1 USD threshold for good condition private const val PERCENTILE_LOW = 0.33 private const val PERCENTILE_HIGH = 0.66 private const val USD_CURRENCY = "USD" + private val TTL_FEE_ESTIMATES = 2.minutes + private val TTL_HISTORICAL_DATA = 30.minutes } + private fun isCacheValid(timestamp: Long, ttl: Duration) = clock.nowMs() - timestamp < ttl.inWholeMilliseconds + override suspend fun fetchData(): Result = runCatching { // Fetch fee estimates and historical data in parallel val feeEstimates = getFeeEstimates() @@ -56,34 +73,42 @@ class WeatherService @Inject constructor( nextBlockFee = feeEstimates.fast ) }.onFailure { - Logger.warn(e = it, msg = "Failed to fetch weather data", context = TAG) + Logger.warn("Failed to fetch weather data", e = it, context = TAG) } - private suspend fun getFeeEstimates(): FeeEstimates { // TODO CACHE + private suspend fun getFeeEstimates(): FeeEstimates { + cachedFeeEstimates?.takeIf { isCacheValid(feeEstimatesTimestamp, TTL_FEE_ESTIMATES) }?.let { return it } val response: HttpResponse = client.get("${Env.mempoolBaseUrl}/v1/fees/recommended") return when (response.status.isSuccess()) { - true -> response.body() + true -> response.body().also { + cachedFeeEstimates = it + feeEstimatesTimestamp = clock.nowMs() + } + else -> throw WeatherError.InvalidResponse("Failed to fetch fee estimates: ${response.status.description}") } } - private suspend fun getHistoricalFeeData(): List { // TODO CACHE + private suspend fun getHistoricalFeeData(): List { + cachedHistoricalData?.takeIf { isCacheValid(historicalDataTimestamp, TTL_HISTORICAL_DATA) }?.let { return it } val response: HttpResponse = client.get("${Env.mempoolBaseUrl}/v1/mining/blocks/fee-rates/3m") return when (response.status.isSuccess()) { - true -> response.body>() + true -> response.body>().also { + cachedHistoricalData = it + historicalDataTimestamp = clock.nowMs() + } + else -> throw WeatherError.InvalidResponse( "Failed to fetch historical fee data: ${response.status.description}" ) } } - private suspend fun calculateCondition( + private fun calculateCondition( currentFeeRate: Double, - history: List + history: List, ): FeeCondition { - if (history.isEmpty()) { - return FeeCondition.AVERAGE - } + if (history.isEmpty()) return FeeCondition.AVERAGE // Extract median fees from historical data and sort val historicalFees = history.map { it.avgFee50 }.sorted() @@ -94,11 +119,10 @@ class WeatherService @Inject constructor( // Check USD threshold first val avgFeeSats = currentFeeRate * AVERAGE_SEGWIT_VBYTES_SIZE - val avgFeeUsd = currencyRepo.convertSatsToFiat(avgFeeSats.toLong(), currency = USD_CURRENCY).getOrNull() ?: return FeeCondition.AVERAGE + val avgFeeUsd = currencyRepo.convertSatsToFiat(avgFeeSats.toLong(), currency = USD_CURRENCY).getOrNull() + ?: return FeeCondition.AVERAGE - if (avgFeeUsd.value <= BigDecimal(USD_GOOD_THRESHOLD)) { - return FeeCondition.GOOD - } + if (avgFeeUsd.value <= BigDecimal(USD_GOOD_THRESHOLD)) return FeeCondition.GOOD // Determine condition based on percentiles return when { @@ -108,16 +132,12 @@ class WeatherService @Inject constructor( } } - private suspend fun formatFeeForDisplay(satoshis: Int): String { + private fun formatFeeForDisplay(satoshis: Int): String { val usdValue = currencyRepo.convertSatsToFiat(satoshis.toLong(), currency = USD_CURRENCY).getOrNull() return usdValue?.formatted.orEmpty() } } -/** - * Weather-specific error types - */ sealed class WeatherError(message: String) : AppError(message) { - data class InvalidResponse(override val message: String) : WeatherError(message) - data class ConversionError(override val message: String) : WeatherError(message) + class InvalidResponse(override val message: String) : WeatherError(message) } diff --git a/app/src/main/java/to/bitkit/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/domain/commands/NotifyPaymentReceivedHandler.kt b/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt index d3058a9dd..fb17d1adf 100644 --- a/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt +++ b/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt @@ -96,11 +96,11 @@ class NotifyPaymentReceivedHandler @Inject constructor( private suspend fun buildNotificationContent(sats: Long): NotificationDetails { val settings = settingsStore.data.first() - val title = context.getString(R.string.notification_received_title) + val title = context.getString(R.string.notification__received__title) val body = if (settings.showNotificationDetails) { formatNotificationAmount(sats, settings) } else { - context.getString(R.string.notification_received_body_hidden) + context.getString(R.string.notification__received__body_hidden) } return NotificationDetails(title, body) } @@ -117,7 +117,7 @@ class NotifyPaymentReceivedHandler @Inject constructor( } } ?: "$BITCOIN_SYMBOL ${sats.formatToModernDisplay()}" - return context.getString(R.string.notification_received_body_amount, amountText) + return context.getString(R.string.notification__received__body_amount, amountText) } companion object { diff --git a/app/src/main/java/to/bitkit/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 bea254c2b..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 { @@ -60,11 +61,11 @@ fun Long.toDateUTC(): String { return dateTime.format(DateTimeFormatter.ofPattern("dd/MM/yyyy")) } -fun Long.toLocalizedTimestamp(): String { - val uLocale = ULocale.forLocale(Locale.US) - val formatter = DateFormat.getDateTimeInstance(DateFormat.LONG, DateFormat.SHORT, uLocale) - ?: return SimpleDateFormat("MMMM d, yyyy 'at' h:mm a", Locale.US).format(Date(this)) - return formatter.format(Date(this)) +fun Long.toLocalizedTimestamp(locale: Locale = Locale.US): String { + val date = Date(this) + val formatter = DateFormat.getDateTimeInstance(DateFormat.LONG, DateFormat.SHORT, ULocale.forLocale(locale)) + ?: return SimpleDateFormat("MMMM d, yyyy 'at' h:mm a", locale).format(date) + return formatter.format(date) } @Suppress("LongMethod") @@ -72,6 +73,8 @@ fun Long.toLocalizedTimestamp(): String { fun Long.toRelativeTimeString( locale: Locale = Locale.getDefault(), clock: Clock = Clock.System, + style: RelativeDateTimeFormatter.Style = RelativeDateTimeFormatter.Style.LONG, + capitalizationContext: DisplayContext = DisplayContext.CAPITALIZATION_FOR_MIDDLE_OF_SENTENCE, ): String { val now = nowMillis(clock) val diffMillis = now - this @@ -82,9 +85,9 @@ fun Long.toRelativeTimeString( val formatter = RelativeDateTimeFormatter.getInstance( uLocale, numberFormat, - RelativeDateTimeFormatter.Style.LONG, - DisplayContext.CAPITALIZATION_FOR_MIDDLE_OF_SENTENCE, - ) ?: return toLocalizedTimestamp() + style, + capitalizationContext, + ) ?: return toLocalizedTimestamp(locale) val seconds = diffMillis / Factor.MILLIS_TO_SECONDS val minutes = seconds / Factor.SECONDS_TO_MINUTES @@ -108,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/fcm/WakeNodeWorker.kt b/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt index 77aedd670..a3a12243b 100644 --- a/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt +++ b/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt @@ -76,8 +76,8 @@ class WakeNodeWorker @AssistedInject constructor( Logger.warn("Notification type is null, proceeding with node wake", context = TAG) } - try { - measured(TAG) { + return runCatching { + measured(label = "doWork", context = TAG) { lightningRepo.start( walletIndex = 0, timeout = timeout, @@ -92,31 +92,35 @@ class WakeNodeWorker @AssistedInject constructor( Logger.error("Missing orderId", context = TAG) } else { Logger.info("Open channel request for order $orderId", context = TAG) - blocktankRepo.openChannel(orderId).onFailure { e -> - Logger.error("Failed to open channel", e, context = TAG) + blocktankRepo.openChannel(orderId).onFailure { + Logger.error("Failed to open channel", it, context = TAG) bestAttemptContent = NotificationDetails( - title = appContext.getString(R.string.notification_channel_open_failed_title), - body = e.message ?: appContext.getString(R.string.notification_unknown_error), + title = appContext.getString(R.string.notification__channel_open_failed_title), + body = it.message ?: appContext.getString(R.string.common__error_body), ) deliver() } } } } - withTimeout(timeout) { deliverSignal.await() } // Stops node on timeout & avoids notification replay by OS - return Result.success() - } catch (e: Exception) { - val reason = e.message ?: appContext.getString(R.string.notification_unknown_error) + // Stops node on timeout & avoids notification replay by OS + withTimeout(timeout) { deliverSignal.await() } + } + .fold( + onSuccess = { Result.success() }, + onFailure = { e -> + val reason = e.message ?: appContext.getString(R.string.common__error_body) - bestAttemptContent = NotificationDetails( - title = appContext.getString(R.string.notification_lightning_error_title), - body = reason, - ) - Logger.error("Lightning error", e, context = TAG) - deliver() + bestAttemptContent = NotificationDetails( + title = appContext.getString(R.string.notification__lightning_error_title), + body = reason, + ) + Logger.error("Lightning error", e, context = TAG) + deliver() - return Result.failure(workDataOf("Reason" to reason)) - } + Result.failure(workDataOf("Reason" to reason)) + } + ) } /** @@ -125,14 +129,14 @@ class WakeNodeWorker @AssistedInject constructor( */ private suspend fun handleLdkEvent(event: Event) { val showDetails = settingsStore.data.first().showNotificationDetails - val hiddenBody = appContext.getString(R.string.notification_received_body_hidden) + val hiddenBody = appContext.getString(R.string.notification__received__body_hidden) when (event) { is Event.PaymentReceived -> onPaymentReceived(event, showDetails, hiddenBody) is Event.ChannelPending -> { bestAttemptContent = NotificationDetails( - title = appContext.getString(R.string.notification_channel_opened_title), - body = appContext.getString(R.string.notification_channel_pending_body), + title = appContext.getString(R.string.notification__channel_opened_title), + body = appContext.getString(R.string.notification__channel_pending_body), ) // Don't deliver, give a chance for channelReady event to update the content if it's a turbo channel } @@ -142,7 +146,7 @@ class WakeNodeWorker @AssistedInject constructor( is Event.PaymentFailed -> { bestAttemptContent = NotificationDetails( - title = appContext.getString(R.string.notification_payment_failed_title), + title = appContext.getString(R.string.notification__payment_failed_title), body = "⚡ ${event.reason}", ) @@ -158,18 +162,18 @@ class WakeNodeWorker @AssistedInject constructor( private suspend fun onChannelClosed(event: Event.ChannelClosed) { bestAttemptContent = when (notificationType) { mutualClose -> NotificationDetails( - title = appContext.getString(R.string.notification_channel_closed_title), - body = appContext.getString(R.string.notification_channel_closed_mutual_body), + title = appContext.getString(R.string.notification__channel_closed__title), + body = appContext.getString(R.string.notification__channel_closed__mutual_body), ) orderPaymentConfirmed -> NotificationDetails( - title = appContext.getString(R.string.notification_channel_open_bg_failed_title), - body = appContext.getString(R.string.notification_please_try_again_body), + title = appContext.getString(R.string.notification__channel_open_bg_failed_title), + body = appContext.getString(R.string.notification__please_try_again_body), ) else -> NotificationDetails( - title = appContext.getString(R.string.notification_channel_closed_title), - body = appContext.getString(R.string.notification_channel_closed_reason_body, event.reason), + title = appContext.getString(R.string.notification__channel_closed__title), + body = appContext.getString(R.string.notification__channel_closed__reason_body, event.reason), ) } @@ -193,7 +197,7 @@ class WakeNodeWorker @AssistedInject constructor( ) val content = if (showDetails) "$BITCOIN_SYMBOL $sats" else hiddenBody bestAttemptContent = NotificationDetails( - title = appContext.getString(R.string.notification_received_title), + title = appContext.getString(R.string.notification__received__title), body = content, ) if (notificationType == incomingHtlc) { @@ -206,10 +210,10 @@ class WakeNodeWorker @AssistedInject constructor( showDetails: Boolean, hiddenBody: String, ) { - val viaNewChannel = appContext.getString(R.string.notification_via_new_channel_body) + val viaNewChannel = appContext.getString(R.string.notification__received__body_channel) if (notificationType == cjitPaymentArrived) { bestAttemptContent = NotificationDetails( - title = appContext.getString(R.string.notification_received_title), + title = appContext.getString(R.string.notification__received__title), body = viaNewChannel, ) @@ -235,8 +239,8 @@ class WakeNodeWorker @AssistedInject constructor( } } else if (notificationType == orderPaymentConfirmed) { bestAttemptContent = NotificationDetails( - title = appContext.getString(R.string.notification_channel_opened_title), - body = appContext.getString(R.string.notification_channel_ready_body), + title = appContext.getString(R.string.notification__channel_opened_title), + body = appContext.getString(R.string.notification__channel_ready_body), ) } deliver() diff --git a/app/src/main/java/to/bitkit/models/ActivityBannerType.kt b/app/src/main/java/to/bitkit/models/ActivityBannerType.kt index d13d6df9c..cd366c294 100644 --- a/app/src/main/java/to/bitkit/models/ActivityBannerType.kt +++ b/app/src/main/java/to/bitkit/models/ActivityBannerType.kt @@ -14,11 +14,11 @@ enum class ActivityBannerType( SPENDING( color = Colors.Purple, icon = R.drawable.ic_transfer, - title = R.string.activity_banner__transfer_in_progress + title = R.string.lightning__transfer_in_progress ), SAVINGS( color = Colors.Brand, icon = R.drawable.ic_transfer, - title = R.string.activity_banner__transfer_in_progress + title = R.string.lightning__transfer_in_progress ) } diff --git a/app/src/main/java/to/bitkit/models/AddressType.kt b/app/src/main/java/to/bitkit/models/AddressType.kt index 25540b315..3759011bb 100644 --- a/app/src/main/java/to/bitkit/models/AddressType.kt +++ b/app/src/main/java/to/bitkit/models/AddressType.kt @@ -1,3 +1,5 @@ +@file:Suppress("MatchingDeclarationName") + package to.bitkit.models import com.synonym.bitkitcore.AddressType diff --git a/app/src/main/java/to/bitkit/models/BlocktankNotificationType.kt b/app/src/main/java/to/bitkit/models/BlocktankNotificationType.kt index 162645d53..abc02cb53 100644 --- a/app/src/main/java/to/bitkit/models/BlocktankNotificationType.kt +++ b/app/src/main/java/to/bitkit/models/BlocktankNotificationType.kt @@ -2,7 +2,7 @@ package to.bitkit.models import kotlinx.serialization.Serializable -@Suppress("EnumEntryName") +@Suppress("EnumEntryNameCase", "EnumNaming") @Serializable enum class BlocktankNotificationType { incomingHtlc, diff --git a/app/src/main/java/to/bitkit/models/ElectrumServer.kt b/app/src/main/java/to/bitkit/models/ElectrumServer.kt index cebfc7b64..873c2f4e0 100644 --- a/app/src/main/java/to/bitkit/models/ElectrumServer.kt +++ b/app/src/main/java/to/bitkit/models/ElectrumServer.kt @@ -4,6 +4,8 @@ import kotlinx.serialization.Serializable import org.lightningdevkit.ldknode.Network import to.bitkit.env.Env +const val MAX_VALID_PORT = 65535 + @Serializable data class ElectrumServer( val host: String, @@ -24,8 +26,6 @@ data class ElectrumServer( } companion object { - const val MAX_VALID_PORT = 65535 - fun parse(url: String): ElectrumServer { val url = url.trim() require(url.isNotBlank()) { "URL cannot be blank" } @@ -91,6 +91,7 @@ data class ElectrumServerPeer( val protocol: ElectrumProtocol, ) +@Suppress("MagicNumber") fun ElectrumProtocol.getDefaultPort(): Int { val network = Env.network diff --git a/app/src/main/java/to/bitkit/models/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/NodeLifecycleState.kt b/app/src/main/java/to/bitkit/models/NodeLifecycleState.kt index ab8cf7694..1c5e92a16 100644 --- a/app/src/main/java/to/bitkit/models/NodeLifecycleState.kt +++ b/app/src/main/java/to/bitkit/models/NodeLifecycleState.kt @@ -1,5 +1,8 @@ package to.bitkit.models +import android.content.Context +import to.bitkit.R + sealed class NodeLifecycleState { data object Stopped : NodeLifecycleState() data object Starting : NodeLifecycleState() @@ -14,16 +17,14 @@ sealed class NodeLifecycleState { fun isRunning() = this is Running fun canRun() = this.isRunningOrStarting() || this is Initializing - // TODO add missing localized texts - val uiText: String - get() = when (this) { - is Stopped -> "Stopped" - is Starting -> "Starting" - is Running -> "Running" - is Stopping -> "Stopping" - is ErrorStarting -> "Error starting: ${cause.message}" - is Initializing -> "Setting up wallet..." - } + fun uiText(context: Context): String = when (this) { + is Stopped -> context.getString(R.string.other__node_stopped) + is Starting -> context.getString(R.string.other__node_starting) + is Running -> context.getString(R.string.other__node_running) + is Stopping -> context.getString(R.string.other__node_stopping) + is ErrorStarting -> context.getString(R.string.other__node_error_starting, cause.message ?: "") + is Initializing -> context.getString(R.string.other__node_initializing) + } fun asHealth() = when (this) { Running -> HealthState.READY diff --git a/app/src/main/java/to/bitkit/models/widget/ArticleModel.kt b/app/src/main/java/to/bitkit/models/widget/ArticleModel.kt index 16dd06cc1..fc41ddccb 100644 --- a/app/src/main/java/to/bitkit/models/widget/ArticleModel.kt +++ b/app/src/main/java/to/bitkit/models/widget/ArticleModel.kt @@ -2,71 +2,54 @@ package to.bitkit.models.widget import kotlinx.serialization.Serializable import to.bitkit.data.dto.ArticleDTO +import to.bitkit.ext.toRelativeTimeString import to.bitkit.utils.Logger +import to.bitkit.utils.errorLogOf import java.time.OffsetDateTime import java.time.format.DateTimeFormatter import java.time.format.DateTimeParseException -import java.time.temporal.ChronoUnit import java.util.Locale +import kotlin.time.ExperimentalTime @Serializable data class ArticleModel( val title: String, val timeAgo: String, val link: String, - val publisher: String + val publisher: String, ) fun ArticleDTO.toArticleModel() = ArticleModel( title = this.title, timeAgo = timeAgo(this.publishedDate), link = this.link, - publisher = this.publisher.title + publisher = this.publisher.title, ) -/** - * Converts a date string to a human-readable time ago format - * @param dateString Date string in format "EEE, dd MMM yyyy HH:mm:ss Z" - * @return Human-readable time difference (e.g. "5 hours ago") - */ +private const val TAG = "ArticleModel" + +@OptIn(ExperimentalTime::class) private fun timeAgo(dateString: String): String { - return try { + return runCatching { val formatters = listOf( - DateTimeFormatter.RFC_1123_DATE_TIME, // Handles "EEE, dd MMM yyyy HH:mm:ss zzz" (like GMT) - DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss Z", Locale.ENGLISH) // Handles "+0000" + DateTimeFormatter.RFC_1123_DATE_TIME, + DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss Z", Locale.ENGLISH) ) var parsedDateTime: OffsetDateTime? = null for (formatter in formatters) { try { parsedDateTime = OffsetDateTime.parse(dateString, formatter) - break // Successfully parsed, stop trying other formatters - } catch (e: DateTimeParseException) { - // Continue to the next formatter if this one fails + break + } catch (_: DateTimeParseException) { + // Continue to the next formatter } } - if (parsedDateTime == null) { - Logger.debug("Failed to parse date: Unparseable date: $dateString") - return "" - } - - val now = OffsetDateTime.now() + requireNotNull(parsedDateTime) { "Unparseable date: '$dateString'" } - val diffMinutes = ChronoUnit.MINUTES.between(parsedDateTime, now) - val diffHours = ChronoUnit.HOURS.between(parsedDateTime, now) - val diffDays = ChronoUnit.DAYS.between(parsedDateTime, now) - val diffMonths = ChronoUnit.MONTHS.between(parsedDateTime, now) - - return when { - diffMinutes < 1 -> "just now" - diffMinutes < 60 -> "$diffMinutes minutes ago" - diffHours < 24 -> "$diffHours hours ago" - diffDays < 30 -> "$diffDays days ago" // Approximate for months - else -> "$diffMonths months ago" - } - } catch (e: Exception) { - Logger.warn("An unexpected error occurred while parsing date: ${e.message}") - "" - } + parsedDateTime.toInstant().toEpochMilli().toRelativeTimeString() + }.onFailure { + Logger.warn("Failed to parse date: ${errorLogOf(it)}", it, context = TAG) + }.getOrDefault("") } diff --git a/app/src/main/java/to/bitkit/repositories/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..9174d01db 100644 --- a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt @@ -55,7 +55,7 @@ import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds @Singleton -@Suppress("LongParameterList") +@Suppress("LongParameterList", "TooManyFunctions") class BlocktankRepo @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, private val coreService: CoreService, @@ -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) @@ -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..0b265526d 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -63,7 +63,7 @@ import to.bitkit.services.NodeEventHandler import to.bitkit.utils.AppError import to.bitkit.utils.Logger import to.bitkit.utils.ServiceError -import to.bitkit.utils.errLogOf +import to.bitkit.utils.errorLogOf import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject @@ -74,7 +74,7 @@ import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds @Singleton -@Suppress("LongParameterList") +@Suppress("LongParameterList", "TooManyFunctions", "LargeClass") class LightningRepo @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, private val lightningService: LightningService, @@ -118,7 +118,7 @@ class LightningRepo @Inject constructor( waitTimeout: Duration = 1.minutes, operation: suspend () -> Result, ): Result = withContext(bgDispatcher) { - Logger.verbose("Operation called: $operationName", context = TAG) + Logger.verbose("Operation called: '$operationName'", context = TAG) val nodeLifecycleState = _lightningState.value.nodeLifecycleState if (nodeLifecycleState.isRunning()) { @@ -128,14 +128,12 @@ class LightningRepo @Inject constructor( // If node is not in a state that can become running, fail fast if (!nodeLifecycleState.canRun()) { return@withContext Result.failure( - Exception("Cannot execute '$operationName': node is '$nodeLifecycleState' and not starting") + AppError("Cannot execute '$operationName': node is '$nodeLifecycleState' and not starting") ) } val nodeRunning = withTimeoutOrNull(waitTimeout) { - if (nodeLifecycleState.isRunning()) { - return@withTimeoutOrNull true - } + if (nodeLifecycleState.isRunning()) return@withTimeoutOrNull true // Otherwise, wait for it to transition to running state Logger.verbose("Waiting for node to run before executing '$operationName'", context = TAG) @@ -144,7 +142,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 +150,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 +165,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 +200,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 +211,7 @@ class LightningRepo @Inject constructor( return@withContext Result.success(Unit) } - try { + runCatching { _lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Starting) } // Setup if needed @@ -222,7 +221,7 @@ class LightningRepo @Inject constructor( _lightningState.update { it.copy( nodeLifecycleState = NodeLifecycleState.ErrorStarting( - setupResult.exceptionOrNull() ?: Exception("Unknown setup error") + setupResult.exceptionOrNull() ?: NodeSetupError() ) ) } @@ -247,20 +246,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, @@ -274,20 +273,19 @@ class LightningRepo @Inject constructor( _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 +302,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 +326,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 +337,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 +402,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: ${errorLogOf(it)}", 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 +507,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 +532,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 +547,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 +560,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 +587,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 +628,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 +645,7 @@ class LightningRepo @Inject constructor( txId = txId, address = address, isReceive = false, - feeRate = satsPerVByte.toULong(), + feeRate = satsPerVByte, isTransfer = isTransfer, channelId = channelId ?: "", ) @@ -709,13 +657,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 +690,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 +711,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 +722,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', ${errorLogOf(it)}", context = TAG) + return@recoverCatching fallbackFee } } @@ -791,13 +735,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 +749,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 +758,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 +768,9 @@ class LightningRepo @Inject constructor( force: Boolean = false, forceCloseReason: String? = null, ): Result = executeWhenNodeRunning("closeChannel") { - lightningService.closeChannel( - channel, - force, - forceCloseReason, - ) - syncState() - Result.success(Unit) + runCatching { lightningService.closeChannel(channel, force, forceCloseReason) }.also { + syncState() + } } fun syncState() { @@ -886,22 +826,20 @@ class LightningRepo @Inject constructor( ): Pair, 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 +847,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 @@ -1038,15 +953,16 @@ class LightningRepo @Inject constructor( companion object { private const val TAG = "LightningRepo" - private const val CHANNEL_ID_PREVIEW_LENGTH = 10 - private const val SYNC_LOOP_DEBOUNCE_MS = 500L + private const val LENGTH_CHANNEL_ID_PREVIEW = 10 + private const val MS_SYNC_LOOP_DEBOUNCE = 500L } } -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 b361e67e9..635a868a9 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -35,13 +35,13 @@ import to.bitkit.usecases.WipeWalletUseCase import to.bitkit.utils.Bip21Utils import to.bitkit.utils.Logger import to.bitkit.utils.ServiceError -import to.bitkit.utils.errLogOf +import to.bitkit.utils.errorLogOf import to.bitkit.utils.measured import javax.inject.Inject import javax.inject.Singleton import kotlin.coroutines.cancellation.CancellationException -@Suppress("LongParameterList") +@Suppress("LongParameterList", "TooManyFunctions") @Singleton class WalletRepo @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, @@ -75,11 +75,10 @@ class WalletRepo @Inject constructor( } fun loadFromCache() { - // TODO try keeping in sync with cache if performant and reliable repoScope.launch { val cacheData = cacheStore.data.first() - _walletState.update { currentState -> - currentState.copy( + _walletState.update { + it.copy( onchainAddress = cacheData.onchainAddress, bolt11 = cacheData.bolt11, bip21 = cacheData.bip21, @@ -98,12 +97,10 @@ class WalletRepo @Inject constructor( } suspend fun checkAddressUsage(address: String): Result = withContext(bgDispatcher) { - return@withContext try { - val result = coreService.isAddressUsed(address) - Result.success(result) - } catch (e: Exception) { - Logger.error("checkAddressUsage error", e, context = TAG) - Result.failure(e) + runCatching { + coreService.isAddressUsed(address) + }.onFailure { + Logger.error("checkAddressUsage error", it, context = TAG) } } @@ -174,12 +171,12 @@ class WalletRepo @Inject constructor( val startHeight = lightningRepo.lightningState.value.block()?.height Logger.debug("Sync $sourceLabel started at block height=$startHeight", context = TAG) - val result = measured("Sync $sourceLabel") { + val result = measured(label = "Sync $sourceLabel", context = TAG) { syncBalances() lightningRepo.sync().onSuccess { syncBalances() - }.onFailure { e -> - if (e is TimeoutCancellationException) { + }.onFailure { + if (it is TimeoutCancellationException) { syncBalances() } } @@ -195,9 +192,9 @@ class WalletRepo @Inject constructor( deriveBalanceStateUseCase().onSuccess { balanceState -> runCatching { cacheStore.cacheBalance(balanceState) } _balanceState.update { balanceState } - }.onFailure { e -> - if (e !is CancellationException) { - Logger.warn("Could not sync balances ${errLogOf(e)}", context = TAG) + }.onFailure { + if (it !is CancellationException) { + Logger.warn("Could not sync balances ${errorLogOf(it)}", context = TAG) } } } @@ -285,37 +282,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) } } suspend fun wipeWallet(walletIndex: Int = 0): Result = withContext(bgDispatcher) { - return@withContext wipeWalletUseCase( + wipeWalletUseCase( walletIndex = walletIndex, resetWalletState = ::resetState, onSuccess = ::setWalletExistsState, @@ -336,7 +329,7 @@ class WalletRepo @Inject constructor( } suspend fun newAddress(): Result = withContext(bgDispatcher) { - return@withContext lightningRepo.newAddress() + lightningRepo.newAddress() .onSuccess { address -> setOnchainAddress(address) } .onFailure { error -> Logger.error("Error generating new address", error) } } @@ -346,8 +339,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 +368,12 @@ class WalletRepo @Inject constructor( ) } - Result.success(addresses) - } catch (e: Exception) { - Logger.error("Error getting addresses", e) - Result.failure(e) + return@runCatching addresses + }.onFailure { + Logger.error("Error getting addresses", it) } } - // Bolt11 management fun getBolt11(): String = _walletState.value.bolt11 suspend fun setBolt11(bolt11: String) { @@ -389,7 +381,6 @@ class WalletRepo @Inject constructor( _walletState.update { it.copy(bolt11 = bolt11) } } - // BIP21 management suspend fun setBip21(bip21: String) { runCatching { cacheStore.setBip21(bip21) } _walletState.update { it.copy(bip21 = bip21) } @@ -405,11 +396,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 +415,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 +433,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 +483,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 +523,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..8458835e1 100644 --- a/app/src/main/java/to/bitkit/services/LnurlService.kt +++ b/app/src/main/java/to/bitkit/services/LnurlService.kt @@ -6,6 +6,8 @@ import io.ktor.client.request.get import io.ktor.client.statement.HttpResponse import io.ktor.http.isSuccess import kotlinx.serialization.Serializable +import to.bitkit.utils.AppError +import to.bitkit.utils.HttpError import to.bitkit.utils.Logger import javax.inject.Inject import javax.inject.Singleton @@ -15,26 +17,26 @@ class LnurlService @Inject constructor( private val client: HttpClient, ) { suspend fun requestLnurlWithdraw(callbackUrl: String): Result = runCatching { - Logger.debug("Requesting LNURL withdraw via: '$callbackUrl'") + Logger.debug("Requesting LNURL withdraw via: '$callbackUrl'", context = TAG) val response: HttpResponse = client.get(callbackUrl) - Logger.debug("Http call: $response") + Logger.debug("Http call: $response", context = TAG) if (!response.status.isSuccess()) { - throw Exception("HTTP error: ${response.status}") + throw HttpError("requestLnurlWithdraw error: '${response.status.description}'", response.status.value) } val withdrawResponse = response.body() when { withdrawResponse.status == "ERROR" -> { - throw Exception("LNURL error: ${withdrawResponse.reason}") + throw AppError("requestLnurlWithdraw error: ${withdrawResponse.reason}") } else -> withdrawResponse } }.onFailure { - Logger.warn("Failed to request LNURL withdraw", e = it, context = TAG) + Logger.warn("Failed to request LNURL withdraw", it, context = TAG) } suspend fun fetchLnurlInvoice( @@ -42,7 +44,7 @@ class LnurlService @Inject constructor( amountSats: ULong, comment: String? = null, ): Result = runCatching { - Logger.debug("Fetching LNURL pay invoice from: $callbackUrl") + Logger.debug("Fetching LNURL pay invoice from: $callbackUrl", context = TAG) val response = client.get(callbackUrl) { url { @@ -52,51 +54,50 @@ class LnurlService @Inject constructor( } } } - Logger.debug("Http call: $response") + Logger.debug("Http call: $response", context = TAG) if (!response.status.isSuccess()) { - throw Exception("HTTP error: ${response.status}") + throw HttpError("fetchLnurlInvoice error: '${response.status.description}'", response.status.value) } return@runCatching response.body() } suspend fun fetchLnurlChannelInfo(url: String): Result = runCatching { - Logger.debug("Fetching LNURL channel info from: $url") + Logger.debug("Fetching LNURL channel info from: $url", context = TAG) val response: HttpResponse = client.get(url) - Logger.debug("Http call: $response") + Logger.debug("Http call: $response", context = TAG) if (!response.status.isSuccess()) { - throw Exception("HTTP error: ${response.status}") + throw HttpError("fetchLnurlChannelInfo error: '${response.status.description}'", response.status.value) } return@runCatching response.body() }.onFailure { - Logger.warn("Failed to fetch channel info", e = it, context = TAG) + Logger.warn("Failed to fetch channel info", it, context = TAG) } - suspend fun requestLnurlChannel( - url: String, - ): Result = runCatching { - Logger.debug("Requesting LNURL channel request via: '$url'") + suspend fun requestLnurlChannel(url: String): Result = runCatching { + Logger.debug("Requesting LNURL channel request via: '$url'", context = TAG) val response: HttpResponse = client.get(url) - Logger.debug("Http call: $response") + Logger.debug("Http call: $response", context = TAG) - if (!response.status.isSuccess()) throw Exception("HTTP error: ${response.status}") + if (!response.status.isSuccess()) { + throw HttpError("requestLnurlChannel error: '${response.status.description}'", response.status.value) + } val parsedResponse = response.body() when { parsedResponse.status == "ERROR" -> { - throw Exception("LNURL channel error: ${parsedResponse.reason}") + throw HttpError("requestLnurlChannel error: '${response.status.description}'", response.status.value) } - else -> parsedResponse } }.onFailure { - Logger.warn("Failed to request LNURL channel", e = it, context = TAG) + Logger.warn("Failed to request LNURL channel", it, context = TAG) } companion object { diff --git a/app/src/main/java/to/bitkit/services/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/services/RNBackupClient.kt b/app/src/main/java/to/bitkit/services/RNBackupClient.kt index 61160c3ab..8e2a8462f 100644 --- a/app/src/main/java/to/bitkit/services/RNBackupClient.kt +++ b/app/src/main/java/to/bitkit/services/RNBackupClient.kt @@ -27,7 +27,6 @@ import to.bitkit.utils.AppError import to.bitkit.utils.Crypto import to.bitkit.utils.Logger import javax.crypto.Cipher -import javax.crypto.Mac import javax.crypto.spec.GCMParameterSpec import javax.crypto.spec.SecretKeySpec import javax.inject.Inject @@ -48,8 +47,6 @@ class RNBackupClient @Inject constructor( private const val SIGNED_MESSAGE_PREFIX = "react-native-ldk backup server auth:" private const val GCM_IV_LENGTH = 12 private const val GCM_TAG_LENGTH = 16 - private const val PBKDF2_ITERATIONS = 2048 - private const val PBKDF2_KEY_LENGTH_BITS = 512 } @Volatile diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index d7f26ff9c..96ec209f1 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -1,3 +1,5 @@ +@file:Suppress("TooManyFunctions") + package to.bitkit.ui import android.content.Intent @@ -391,9 +393,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() @@ -768,7 +770,7 @@ private fun RootNavHost( } // region destinations -@Suppress("LongParameterList") +@Suppress("LongMethod", "LongParameterList") private fun NavGraphBuilder.home( walletViewModel: WalletViewModel, appViewModel: AppViewModel, @@ -778,7 +780,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() @@ -794,7 +796,7 @@ private fun NavGraphBuilder.home( .hazeSource(hazeState) ) { HomeScreen( - mainUiState = uiState, + isRefreshing = isRefreshing, drawerState = drawerState, rootNavController = navController, walletNavController = navController, @@ -834,11 +836,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) }, @@ -878,6 +880,7 @@ private fun NavGraphBuilder.settings( composableWithDefaultTransitions { SettingsScreen(navController) } + @Suppress("ForbiddenComment") // TODO: display as sheet composableWithDefaultTransitions { QuickPayIntroScreen( @@ -1352,6 +1355,7 @@ private fun NavGraphBuilder.support( } } +@Suppress("LongMethod") private fun NavGraphBuilder.widgets( navController: NavHostController, settingsViewModel: SettingsViewModel, diff --git a/app/src/main/java/to/bitkit/ui/Locals.kt b/app/src/main/java/to/bitkit/ui/Locals.kt index 0d8e7397f..2843e38c4 100644 --- a/app/src/main/java/to/bitkit/ui/Locals.kt +++ b/app/src/main/java/to/bitkit/ui/Locals.kt @@ -1,5 +1,3 @@ -@file:Suppress("CompositionLocalAllowlist") - package to.bitkit.ui import androidx.compose.material3.DrawerState diff --git a/app/src/main/java/to/bitkit/ui/MainActivity.kt b/app/src/main/java/to/bitkit/ui/MainActivity.kt index 77723f287..445559a94 100644 --- a/app/src/main/java/to/bitkit/ui/MainActivity.kt +++ b/app/src/main/java/to/bitkit/ui/MainActivity.kt @@ -30,6 +30,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.serialization.Serializable +import to.bitkit.R import to.bitkit.androidServices.LightningNodeService import to.bitkit.androidServices.LightningNodeService.Companion.CHANNEL_ID_NODE import to.bitkit.models.NewTransactionSheetDetails @@ -71,15 +72,15 @@ class MainActivity : FragmentActivity() { private val settingsViewModel by viewModels() private val backupsViewModel by viewModels() + @Suppress("LongMethod") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) initNotificationChannel() initNotificationChannel( - // TODO Transifex id = CHANNEL_ID_NODE, - name = "Lightning node notification", - desc = "Channel for LightningNodeService", + name = getString(R.string.notification__channel_node__name), + desc = getString(R.string.notification__channel_node__body), importance = NotificationManager.IMPORTANCE_LOW ) appViewModel.handleDeeplinkIntent(intent) diff --git a/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt b/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt index 320165885..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, ) @@ -191,16 +191,17 @@ private fun NodeStateSection( nodeLifecycleState: NodeLifecycleState, nodeStatus: NodeStatus?, ) { + val context = LocalContext.current Column(modifier = Modifier.fillMaxWidth()) { SectionHeader("Node State") SettingsTextButtonRow( title = stringResource(R.string.lightning__status), - value = nodeLifecycleState.uiText, + value = nodeLifecycleState.uiText(context), ) nodeStatus?.let { status -> SettingsTextButtonRow( - title = stringResource(R.string.common_ready), + title = stringResource(R.string.common__ready), value = if (status.isRunning) "✅" else "⏳", ) SettingsTextButtonRow( @@ -344,7 +345,7 @@ private fun ChannelsSection( VerticalSpacer(8.dp) ChannelDetailRow( - title = stringResource(R.string.common_ready), + title = stringResource(R.string.common__ready), value = if (channel.isChannelReady) "✅" else "❌", ) ChannelDetailRow( @@ -376,7 +377,7 @@ private fun ChannelsSection( value = "₿ ${(channel.nextOutboundHtlcMinimumMsat / 1000u).formatToModernDisplay()}", ) ChannelDetailRow( - title = stringResource(R.string.common_confirmations), + title = stringResource(R.string.common__confirmations), value = "${channel.confirmations ?: 0}/${channel.confirmationsRequired ?: 0}", ) @@ -457,7 +458,7 @@ private fun Preview() { AppThemeSurface { Content( isDevModeEnabled = false, - uiState = MainUiState( + lightningState = LightningState( nodeId = "0348a2b7c2d3f4e5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9", ), ) @@ -472,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, @@ -489,7 +490,7 @@ private fun PreviewDevMode() { latestPathfindingScoresSyncTimestamp = null, ), nodeId = "0348a2b7c2d3f4e5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9", - peers = listOf(Peers.staging), + peers = listOf(Peers.stag), channels = listOf( createChannelDetails().copy( channelId = "abc123def456789012345678901234567890123456789012345678901234567890", @@ -519,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..d5af1260b 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,8 @@ internal fun Context.notificationBuilder( extra?.let { putExtras(it) } } val flags = FLAG_IMMUTABLE or FLAG_ONE_SHOT - // TODO: review if needed: + + @Suppress("ForbiddenComment") // TODO: review if needed: val pendingIntent = PendingIntent.getActivity(this, 0, intent, flags) return NotificationCompat.Builder(this, channelId) @@ -66,7 +71,7 @@ internal fun Context.pushNotification( // Only check permission if running on Android 13+ (SDK 33+) val needsPermissionGrant = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && - requiresPermission(Manifest.permission.POST_NOTIFICATIONS) + requiresPermission(permission.POST_NOTIFICATIONS) if (!needsPermissionGrant) { val builder = notificationBuilder(extras) @@ -87,4 +92,20 @@ internal fun Context.pushNotification( } } +fun Context.openNotificationSettings() { + val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { + putExtra(Settings.EXTRA_APP_PACKAGE, packageName) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + runCatching { startActivity(intent) } + .onFailure { Logger.error("Failed to open notification settings", e = it, context = TAG) } +} + +fun Context.areNotificationsEnabled(): Boolean = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + ContextCompat.checkSelfPermission(this, permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED + } else { + NotificationManagerCompat.from(this).areNotificationsEnabled() + } + private const val TAG = "Notifications" diff --git a/app/src/main/java/to/bitkit/ui/components/ActivityBanner.kt b/app/src/main/java/to/bitkit/ui/components/ActivityBanner.kt index 00d35fbee..5d03ad156 100644 --- a/app/src/main/java/to/bitkit/ui/components/ActivityBanner.kt +++ b/app/src/main/java/to/bitkit/ui/components/ActivityBanner.kt @@ -183,7 +183,7 @@ private fun Preview() { items(items = ActivityBannerType.entries) { item -> ActivityBanner( gradientColor = item.color, - title = stringResource(R.string.activity_banner__transfer_in_progress), + title = stringResource(R.string.lightning__transfer_in_progress), icon = item.icon, onClick = {}, modifier = Modifier.fillMaxWidth() diff --git a/app/src/main/java/to/bitkit/ui/components/AppStatus.kt b/app/src/main/java/to/bitkit/ui/components/AppStatus.kt index 9e5ada2d5..9f73780f5 100644 --- a/app/src/main/java/to/bitkit/ui/components/AppStatus.kt +++ b/app/src/main/java/to/bitkit/ui/components/AppStatus.kt @@ -120,6 +120,7 @@ fun rememberHealthState(): HealthState { return healthState.app } +@Suppress("MagicNumber") @Composable private fun rememberRotationEasing(): Easing { val bezierEasing = remember { CubicBezierEasing(0.4f, 0f, 0.2f, 1f) } diff --git a/app/src/main/java/to/bitkit/ui/components/AuthCheckView.kt b/app/src/main/java/to/bitkit/ui/components/AuthCheckView.kt index 848ae94fa..849033aea 100644 --- a/app/src/main/java/to/bitkit/ui/components/AuthCheckView.kt +++ b/app/src/main/java/to/bitkit/ui/components/AuthCheckView.kt @@ -67,6 +67,7 @@ fun AuthCheckView( ) } +@Suppress("ComplexCondition") @Composable private fun AuthCheckViewContent( isBiometricsEnabled: Boolean, diff --git a/app/src/main/java/to/bitkit/ui/components/BalanceHeaderView.kt b/app/src/main/java/to/bitkit/ui/components/BalanceHeaderView.kt index 0039fff56..565cbd41b 100644 --- a/app/src/main/java/to/bitkit/ui/components/BalanceHeaderView.kt +++ b/app/src/main/java/to/bitkit/ui/components/BalanceHeaderView.kt @@ -24,6 +24,7 @@ import to.bitkit.models.BITCOIN_SYMBOL import to.bitkit.models.ConvertedAmount import to.bitkit.models.PrimaryDisplay import to.bitkit.models.formatToModernDisplay +import to.bitkit.repositories.CurrencyState import to.bitkit.ui.LocalCurrencies import to.bitkit.ui.currencyViewModel import to.bitkit.ui.settingsViewModel @@ -39,6 +40,7 @@ fun BalanceHeaderView( sats: Long, modifier: Modifier = Modifier, onClick: (() -> Unit)? = null, + currencies: CurrencyState = LocalCurrencies.current, prefix: String? = null, showBitcoinSymbol: Boolean = true, useSwipeToHide: Boolean = true, @@ -68,7 +70,10 @@ fun BalanceHeaderView( val settings = settingsViewModel ?: return val currency = currencyViewModel ?: return - val (_, _, _, _, _, displayUnit, primaryDisplay) = LocalCurrencies.current + + val displayUnit = currencies.displayUnit + val primaryDisplay = currencies.primaryDisplay + val converted: ConvertedAmount? = currency.convert(sats = sats) val isSwipeToHideEnabled by settings.enableSwipeToHideBalance.collectAsStateWithLifecycle() diff --git a/app/src/main/java/to/bitkit/ui/components/Button.kt b/app/src/main/java/to/bitkit/ui/components/Button.kt index 6da684202..a939be36e 100644 --- a/app/src/main/java/to/bitkit/ui/components/Button.kt +++ b/app/src/main/java/to/bitkit/ui/components/Button.kt @@ -1,3 +1,5 @@ +@file:Suppress("MatchingDeclarationName") + package to.bitkit.ui.components import androidx.compose.foundation.BorderStroke diff --git a/app/src/main/java/to/bitkit/ui/components/DrawerMenu.kt b/app/src/main/java/to/bitkit/ui/components/DrawerMenu.kt index 0071996ac..5012ae8e3 100644 --- a/app/src/main/java/to/bitkit/ui/components/DrawerMenu.kt +++ b/app/src/main/java/to/bitkit/ui/components/DrawerMenu.kt @@ -52,8 +52,8 @@ import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.theme.InterFontFamily -private const val zIndexScrim = 10f -private const val zIndexMenu = 11f +private const val Z_INDEX_SCRIM = 10f +private const val Z_INDEX_MENU = 11f private val bgScrim = Colors.Black50 private val drawerBg = Colors.Brand private val drawerWidth = 200.dp @@ -77,7 +77,7 @@ fun DrawerMenu( }, modifier = Modifier .fillMaxSize() - .zIndex(zIndexScrim) + .zIndex(Z_INDEX_SCRIM) ) AnimatedVisibility( @@ -91,7 +91,7 @@ fun DrawerMenu( modifier = modifier.then( Modifier .fillMaxHeight() - .zIndex(zIndexMenu) + .zIndex(Z_INDEX_MENU) .blockPointerInputPassthrough() ) ) { diff --git a/app/src/main/java/to/bitkit/ui/components/LightningChannel.kt b/app/src/main/java/to/bitkit/ui/components/LightningChannel.kt index 80770af4e..3f44e409c 100644 --- a/app/src/main/java/to/bitkit/ui/components/LightningChannel.kt +++ b/app/src/main/java/to/bitkit/ui/components/LightningChannel.kt @@ -1,7 +1,19 @@ +@file:Suppress("MatchingDeclarationName") + package to.bitkit.ui.components import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowDownward diff --git a/app/src/main/java/to/bitkit/ui/components/NotificationPreview.kt b/app/src/main/java/to/bitkit/ui/components/NotificationPreview.kt index f36ac8bce..2169aa50b 100644 --- a/app/src/main/java/to/bitkit/ui/components/NotificationPreview.kt +++ b/app/src/main/java/to/bitkit/ui/components/NotificationPreview.kt @@ -15,6 +15,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import to.bitkit.R @@ -50,7 +51,10 @@ fun NotificationPreview( verticalArrangement = Arrangement.SpaceBetween ) { BodySSB(text = title, color = Colors.Black) - val textDescription = if (showDetails) description else "Open Bitkit to see details" // TODO Transifex + val textDescription = when (showDetails) { + true -> description + else -> stringResource(R.string.notification__received__body_hidden) + } AnimatedContent(targetState = textDescription) { text -> Footnote(text = text, color = Colors.Gray3) } diff --git a/app/src/main/java/to/bitkit/ui/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/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/profile/CreateProfileScreen.kt b/app/src/main/java/to/bitkit/ui/screens/profile/CreateProfileScreen.kt index 0c5071c03..8d8e11c83 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/CreateProfileScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/CreateProfileScreen.kt @@ -33,7 +33,7 @@ fun CreateProfileScreen( Spacer(Modifier.weight(1f)) Display( - text = "Comming soon", + text = stringResource(R.string.other__coming_soon), color = Colors.White ) Spacer(Modifier.weight(1f)) diff --git a/app/src/main/java/to/bitkit/ui/screens/recovery/RecoveryMnemonicViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/recovery/RecoveryMnemonicViewModel.kt index d32aba3c1..fd53d23a9 100644 --- a/app/src/main/java/to/bitkit/ui/screens/recovery/RecoveryMnemonicViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/recovery/RecoveryMnemonicViewModel.kt @@ -1,13 +1,16 @@ package to.bitkit.ui.screens.recovery +import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import to.bitkit.R import to.bitkit.data.keychain.Keychain import to.bitkit.models.Toast import to.bitkit.ui.shared.toast.ToastEventBus @@ -16,6 +19,7 @@ import javax.inject.Inject @HiltViewModel class RecoveryMnemonicViewModel @Inject constructor( + @ApplicationContext private val context: Context, private val keychain: Keychain, ) : ViewModel() { @@ -40,8 +44,8 @@ class RecoveryMnemonicViewModel @Inject constructor( } ToastEventBus.send( type = Toast.ToastType.ERROR, - title = "Failed to load mnemonic", - description = "Failed to load mnemonic", + title = context.getString(R.string.security__mnemonic_load_error), + description = context.getString(R.string.security__mnemonic_load_error), ) return@launch } diff --git a/app/src/main/java/to/bitkit/ui/screens/recovery/RecoveryViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/recovery/RecoveryViewModel.kt index 1b72b16d4..c0f19f378 100644 --- a/app/src/main/java/to/bitkit/ui/screens/recovery/RecoveryViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/recovery/RecoveryViewModel.kt @@ -75,8 +75,8 @@ class RecoveryViewModel @Inject constructor( } ToastEventBus.send( type = Toast.ToastType.ERROR, - title = "Error", - description = "Failed to create log zip file", + title = context.getString(R.string.common__error), + description = context.getString(R.string.other__logs_export_error), ) } ) @@ -100,8 +100,8 @@ class RecoveryViewModel @Inject constructor( viewModelScope.launch { ToastEventBus.send( type = Toast.ToastType.ERROR, - title = "Error", - description = "Failed to open support links", + title = context.getString(R.string.common__error), + description = context.getString(R.string.settings__support__link_error), ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/scanner/QrScanningScreen.kt b/app/src/main/java/to/bitkit/ui/screens/scanner/QrScanningScreen.kt index ee78f6339..2682f2fde 100644 --- a/app/src/main/java/to/bitkit/ui/screens/scanner/QrScanningScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/scanner/QrScanningScreen.kt @@ -374,9 +374,9 @@ private fun processImageFromGallery( context: Context, uri: Uri, onScanSuccess: (String) -> Unit, - onError: (Exception) -> Unit, + onError: (Throwable) -> Unit, ) { - try { + runCatching { val image = InputImage.fromFilePath(context, uri) val options = BarcodeScannerOptions.Builder() .setBarcodeFormats(Barcode.FORMAT_QR_CODE) @@ -399,8 +399,8 @@ private fun processImageFromGallery( Logger.error("Failed to scan QR code from gallery", e) onError(e) } - } catch (e: Exception) { - Logger.error("Failed to process image from gallery", e) - onError(e) + }.onFailure { + Logger.error("Failed to process image from gallery", it) + onError(it) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/shop/shopDiscover/MapWebViewClient.kt b/app/src/main/java/to/bitkit/ui/screens/shop/shopDiscover/MapWebViewClient.kt index d2c04f2c5..324c07e42 100644 --- a/app/src/main/java/to/bitkit/ui/screens/shop/shopDiscover/MapWebViewClient.kt +++ b/app/src/main/java/to/bitkit/ui/screens/shop/shopDiscover/MapWebViewClient.kt @@ -25,6 +25,7 @@ class MapWebViewClient( onLoadingStateChanged(false) } + @Suppress("ComplexCondition") override fun onReceivedError( view: WebView?, request: WebResourceRequest?, diff --git a/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewClient.kt b/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewClient.kt index a4ae3ecb3..b25cb3db6 100644 --- a/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewClient.kt +++ b/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewClient.kt @@ -49,6 +49,7 @@ class ShopWebViewClient( ) } + @Suppress("ComplexCondition") override fun onReceivedError( view: WebView?, request: WebResourceRequest?, diff --git a/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewInterface.kt b/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewInterface.kt index 99dc56c75..d4acdc27c 100644 --- a/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewInterface.kt +++ b/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewInterface.kt @@ -26,6 +26,7 @@ class ShopWebViewInterface( * * @param message JSON string containing the message data */ + @Suppress("NestedBlockDepth") @JavascriptInterface fun postMessage(message: String) { if (message.isBlank()) { @@ -33,7 +34,7 @@ class ShopWebViewInterface( return } - try { + runCatching { val data = json.decodeFromString(message) when (data.event) { "payment_intent" -> { @@ -51,8 +52,8 @@ class ShopWebViewInterface( Logger.debug("Unknown event type: ${data.event}", context = "WebView") } } - } catch (e: Exception) { - Logger.error("Error parsing message: $message", e) + }.onFailure { + Logger.error("Error parsing message: $message", it) } } @@ -61,6 +62,7 @@ class ShopWebViewInterface( * * @return true if the interface is initialized and ready */ + @Suppress("FunctionOnlyReturningConstant") @JavascriptInterface fun isReady(): Boolean { return true diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/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..d58d46e90 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/SettingUpScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SettingUpScreen.kt @@ -42,6 +42,7 @@ import to.bitkit.ui.utils.withAccentBoldBright import to.bitkit.utils.Logger import to.bitkit.viewmodels.TransferViewModel +@Suppress("TooGenericExceptionCaught") @Composable fun SettingUpScreen( viewModel: TransferViewModel, @@ -57,12 +58,12 @@ fun SettingUpScreen( if (Env.network == Network.REGTEST) { delay(5000) - try { + runCatching { Logger.debug("Auto-mining a block", context = "SettingUpScreen") regtestMine(1u) Logger.debug("Successfully mined a block", context = "SettingUpScreen") - } catch (e: Throwable) { - Logger.error("Failed to mine block: $e", context = "SettingUpScreen") + }.onFailure { + Logger.error("Failed to mine block: $it", context = "SettingUpScreen") } } } diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingConfirmScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingConfirmScreen.kt index dd2c4cc60..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 @@ -99,17 +99,16 @@ fun SpendingConfirmScreen( onLearnMoreClick = onLearnMoreClick, onAdvancedClick = onAdvancedClick, onConfirm = onConfirm, - onUseDefaultLspBalanceClick = { viewModel.onUseDefaultLspBalanceClick() }, - onTransferToSpendingConfirm = { order -> viewModel.onTransferToSpendingConfirm(order) }, + onUseDefaultLspBalanceClick = viewModel::onUseDefaultLspBalanceClick, + onTransferToSpendingConfirm = viewModel::onTransferToSpendingConfirm, order = order, hasNotificationPermission = notificationsGranted, - onSwitchClick = { - NotificationUtils.openNotificationSettings(context) - }, - isAdvanced = isAdvanced + onSwitchClick = { context.openNotificationSettings() }, + isAdvanced = isAdvanced, ) } +@Suppress("MagicNumber") @Composable private fun Content( onBackClick: () -> Unit, @@ -140,7 +139,7 @@ private fun Content( modifier = Modifier .fillMaxWidth() .padding(horizontal = 60.dp) - .align(alignment = Alignment.BottomCenter) + .align(Alignment.BottomCenter) .padding(bottom = 76.dp) ) } @@ -158,10 +157,7 @@ private fun Content( val lspBalance = order.lspBalanceSat VerticalSpacer(32.dp) - Display( - text = stringResource(R.string.lightning__transfer__confirm) - .withAccent(accentColor = Colors.Purple) - ) + Display(stringResource(R.string.lightning__transfer__confirm).withAccent(accentColor = Colors.Purple)) VerticalSpacer(8.dp) Row( @@ -206,7 +202,7 @@ private fun Content( } SettingsSwitchRow( - title = "Set up in background", + title = stringResource(R.string.settings__bg__setup), isChecked = hasNotificationPermission, colors = AppSwitchDefaults.colorsPurple, onClick = onSwitchClick, diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalConfirmScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalConfirmScreen.kt index fbaae5a19..9fd0426b5 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalConfirmScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalConfirmScreen.kt @@ -66,7 +66,7 @@ fun ExternalConfirmScreen( Content( uiState = uiState, - onConfirm = { viewModel.onConfirm() }, + onConfirm = viewModel::onConfirm, onNetworkFeeClick = onNetworkFeeClick, onBackClick = onBackClick, ) @@ -96,9 +96,7 @@ private fun Content( val totalFee = uiState.amount.sats + networkFee Spacer(modifier = Modifier.height(16.dp)) - Display( - text = stringResource(R.string.lightning__transfer__confirm).withAccent(accentColor = Colors.Purple) - ) + Display(stringResource(R.string.lightning__transfer__confirm).withAccent(accentColor = Colors.Purple)) Spacer(modifier = Modifier.height(8.dp)) Row( diff --git a/app/src/main/java/to/bitkit/ui/screens/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 5e8a6c64d..78ef4c388 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt @@ -115,13 +115,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, @@ -157,7 +157,7 @@ fun HomeScreen( } Content( - mainUiState = mainUiState, + isRefreshing = isRefreshing, homeUiState = homeUiState, rootNavController = rootNavController, walletNavController = walletNavController, @@ -276,10 +276,11 @@ fun HomeScreen( ) } +@Suppress("MagicNumber") @OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class) @Composable private fun Content( - mainUiState: MainUiState, + isRefreshing: Boolean, homeUiState: HomeUiState, rootNavController: NavController, walletNavController: NavController, @@ -313,11 +314,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) @@ -679,7 +680,7 @@ private fun TopBar( ) } }, - colors = TopAppBarDefaults.largeTopAppBarColors(Color.Transparent), + colors = TopAppBarDefaults.topAppBarColors(Color.Transparent), modifier = Modifier.fillMaxWidth() ) } @@ -709,7 +710,7 @@ private fun Preview() { AppThemeSurface { Box { Content( - mainUiState = MainUiState(), + isRefreshing = false, homeUiState = HomeUiState( showWidgets = true, ), @@ -733,7 +734,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 d071dfe6d..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() @@ -112,7 +112,7 @@ fun SavingsWalletScreen( if (canTransfer) { SecondaryButton( onClick = onTransferToSpendingClick, - text = "Transfer To Spending", // TODO add missing localized text + text = stringResource(R.string.wallet__transfer_to_spending), icon = { Icon( painter = painterResource(R.drawable.ic_transfer), diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/SpendingWalletScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/SpendingWalletScreen.kt index 82460dca1..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() @@ -114,7 +114,7 @@ fun SpendingWalletScreen( if (canTransfer) { SecondaryButton( onClick = onTransferToSavingsClick, - text = "Transfer To Savings", // TODO add missing localized text + text = stringResource(R.string.wallet__transfer_to_savings), icon = { Icon( painter = painterResource(R.drawable.ic_transfer), @@ -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 0e2a543f7..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 @@ -436,7 +436,7 @@ private fun ActivityDetailContent( text = when { isTransferToSpending -> stringResource(R.string.wallet__activity_transfer_to_spending) isTransferFromSpending -> stringResource(R.string.wallet__activity_transfer_to_savings) - isSelfSend -> "Sent to myself" // TODO add missing localized text + isSelfSend -> stringResource(R.string.wallet__activity_sent_self) else -> stringResource(R.string.wallet__activity_payment) }, color = Colors.White64, @@ -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,8 +740,8 @@ private fun StatusSection( var statusTestTag: String? = null if (item.v1.isTransfer) { - val duration = FeeRate.getFeeDescription(item.v1.feeRate, feeRates) - .removeEstimationSymbol() + 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" @@ -953,6 +954,3 @@ private fun isBoostCompleted( return activity.boostTxIds.any { boostTxDoesExist[it] == true } } } - -// TODO remove this method after transifex update -private fun String.removeEstimationSymbol() = this.replace("±", "") diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/DateRangeSelectorSheet.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/DateRangeSelectorSheet.kt index 6c47947fa..07580448a 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/DateRangeSelectorSheet.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/DateRangeSelectorSheet.kt @@ -255,7 +255,7 @@ private fun Content( ) { Icon( imageVector = Icons.AutoMirrored.Filled.KeyboardArrowLeft, - contentDescription = "Previous month", + contentDescription = stringResource(R.string.wallet__activity_previous_month), tint = Colors.Brand ) } @@ -268,7 +268,7 @@ private fun Content( ) { Icon( imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = "Next month", + contentDescription = stringResource(R.string.wallet__activity_next_month), tint = Colors.Brand ) } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/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 b7c9d7638..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,27 +132,25 @@ 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.removeEstimationSymbol()) + .replace("{duration}", duration) } 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.removeEstimationSymbol()) + .replace("{duration}", duration) } confirmed == true -> formattedTime(timestamp) else -> { - val feeDescription = FeeRate.getFeeDescription(item.v1.feeRate, feeRates) - stringResource(R.string.wallet__activity_confirms_in).replace( - "{feeRateDescription}", - feeDescription - ) + val feeDescription = context.getFeeShortDescription(item.v1.feeRate, feeRates) + stringResource(R.string.wallet__activity_confirms_in) + .replace("{feeRateDescription}", feeDescription) } } } @@ -210,6 +212,7 @@ private fun TransactionStatusText( private fun AmountView( item: Activity, prefix: String, + currencies: CurrencyState = LocalCurrencies.current, ) { val amount = item.totalValue() @@ -227,7 +230,9 @@ private fun AmountView( val settings = settingsViewModel ?: return val currency = currencyViewModel ?: return - val (_, _, _, _, _, displayUnit, primaryDisplay) = LocalCurrencies.current + + val primaryDisplay = currencies.primaryDisplay + val displayUnit = currencies.displayUnit val hideBalance by settings.hideBalance.collectAsStateWithLifecycle() @@ -323,9 +328,6 @@ private fun formattedTime(timestamp: ULong): String { } } -// TODO remove this method after transifex update -private fun String.removeEstimationSymbol() = this.replace("±", "") - private class ActivityItemsPreviewProvider : PreviewParameterProvider { override val values: Sequence get() = previewActivityItems.asSequence() } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/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 611224d24..2f07d3a90 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveConfirmScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveConfirmScreen.kt @@ -1,3 +1,5 @@ +@file:Suppress("MatchingDeclarationName") + package to.bitkit.ui.screens.wallets.receive import androidx.compose.foundation.layout.Arrangement @@ -36,13 +38,13 @@ 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 @@ -107,9 +109,7 @@ fun ReceiveConfirmScreen( receiveAmountFormatted = receiveAmountFormatted, onLearnMoreClick = onLearnMore, isAdditional = isAdditional, - onSystemSettingsClick = { - NotificationUtils.openNotificationSettings(context) - }, + onSystemSettingsClick = { context.openNotificationSettings() }, hasNotificationPermission = notificationsGranted, onContinueClick = { onContinue(entry.invoice) }, onBackClick = onBack, @@ -171,7 +171,7 @@ private fun Content( FillHeight() SettingsSwitchRow( - title = "Set up in background", + title = stringResource(R.string.settings__bg__setup), isChecked = hasNotificationPermission, colors = AppSwitchDefaults.colorsPurple, onClick = onSystemSettingsClick, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveLiquidityScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveLiquidityScreen.kt index 5f3b9c801..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)) @@ -131,14 +143,14 @@ private fun Content( FillHeight() BodyM( - text = "Enable background setup to safely exit Bitkit while your balance is being configured.", + text = stringResource(R.string.wallet__receive_liquidity__bg_setup_desc), color = Colors.White64 ) VerticalSpacer(15.dp) SettingsSwitchRow( - title = "Set up in background", + title = stringResource(R.string.wallet__receive_liquidity__bg_setup_switch), isChecked = hasNotificationPermission, colors = AppSwitchDefaults.colorsPurple, onClick = onSwitchClick, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/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..a9167e7f9 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionViewModel.kt @@ -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 @@ -39,34 +39,32 @@ class SendCoinSelectionViewModel @Inject constructor( this.onchainActivities = onchainActivities } - fun loadUtxos(requiredAmount: ULong, address: String) { - viewModelScope.launch { - try { - val sortedUtxos = lightningRepo.listSpendableOutputs().getOrThrow() - .sortedByDescending { it.valueSats } + 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) + ToastEventBus.send(Exception("Failed to load UTXOs: ${it.message}")) } } @@ -133,8 +131,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/wallets/send/SendRecipientScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendRecipientScreen.kt index 640e47aa8..f88ac3df9 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendRecipientScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendRecipientScreen.kt @@ -169,7 +169,7 @@ fun SendRecipientScreen( app?.toast( type = Toast.ToastType.ERROR, title = context.getString(R.string.other__qr_error_header), - description = "Failed to initialize camera: ${e.message}" + description = context.getString(R.string.other__camera_init_error, e.message.orEmpty()) ) isCameraInitialized = false } @@ -388,7 +388,7 @@ private fun CameraPreviewWithControls( } BodyMSB( - "Scan QR", + stringResource(R.string.other__camera_scan_qr), color = Colors.White, modifier = Modifier .padding(top = 31.dp) @@ -425,12 +425,16 @@ private fun PermissionDenied( .background(Colors.Black) .padding(32.dp) ) { - Display("SCAN\nQR CODE".withAccent(accentColor = Colors.Brand), color = Colors.White) + Display( + stringResource(R.string.other__camera_permission_title) + .withAccent(accentColor = Colors.Brand), + color = Colors.White + ) VerticalSpacer(8.dp) BodyM( - "Allow camera access to scan bitcoin invoices and pay more quickly.", + stringResource(R.string.other__camera_permission_description), color = Colors.White64, modifier = Modifier.fillMaxWidth() ) @@ -438,7 +442,7 @@ private fun PermissionDenied( VerticalSpacer(32.dp) PrimaryButton( - text = "Enable camera", + text = stringResource(R.string.other__camera_permission_button), icon = { Icon(painter = painterResource(R.drawable.ic_camera), contentDescription = null) }, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/withdraw/WithdrawErrorScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/withdraw/WithdrawErrorScreen.kt index bae5d34bf..f2f7b6f65 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/withdraw/WithdrawErrorScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/withdraw/WithdrawErrorScreen.kt @@ -56,8 +56,7 @@ fun WithdrawErrorScreen( VerticalSpacer(46.dp) BodyM( - // TODO add missing localized text - text = "Your withdrawal was unsuccessful. Please scan the QR code again or contact support.", + text = stringResource(R.string.wallet__withdraw_error), color = Colors.White64, ) diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/blocks/BlocksEditScreen.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/blocks/BlocksEditScreen.kt index 2c4a086d5..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 @@ -114,7 +114,7 @@ fun BlocksEditContent( // Block number toggle BlockEditOptionRow( - label = "Block", + label = stringResource(R.string.widgets__blocks__field__block), value = block.height, isEnabled = blocksPreferences.showBlock, onClick = onClickShowBlock, @@ -123,7 +123,7 @@ fun BlocksEditContent( // Time toggle BlockEditOptionRow( - label = "Time", + label = stringResource(R.string.widgets__blocks__field__time), value = block.time, isEnabled = blocksPreferences.showTime, onClick = onClickShowTime, @@ -132,7 +132,7 @@ fun BlocksEditContent( // Date toggle BlockEditOptionRow( - label = "Date", + label = stringResource(R.string.widgets__blocks__field__date), value = block.date, isEnabled = blocksPreferences.showDate, onClick = onClickShowDate, @@ -141,7 +141,7 @@ fun BlocksEditContent( // Transactions toggle BlockEditOptionRow( - label = "Transactions", + label = stringResource(R.string.widgets__blocks__field__transactions), value = block.transactionCount, isEnabled = blocksPreferences.showTransactions, onClick = onClickShowTransactions, @@ -150,7 +150,7 @@ fun BlocksEditContent( // Size toggle BlockEditOptionRow( - label = "Size", + label = stringResource(R.string.widgets__blocks__field__size), value = block.size, isEnabled = blocksPreferences.showSize, onClick = onClickShowSize, @@ -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/BackupSettingsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/BackupSettingsScreen.kt index 558d127f2..329dc6b1b 100644 --- a/app/src/main/java/to/bitkit/ui/settings/BackupSettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/BackupSettingsScreen.kt @@ -124,7 +124,7 @@ private fun BackupSettingsScreenContent( if (Env.isE2eTest && allSynced) { Icon( painter = painterResource(R.drawable.ic_check_circle), - contentDescription = "All Synced", + contentDescription = null, tint = Colors.Green, modifier = Modifier .padding(end = 4.dp) @@ -243,7 +243,7 @@ private fun BackupRetryButton(onClick: () -> Unit) { } } -@Preview +@Preview(showSystemUi = true) @Composable private fun Preview() { val categories = BackupCategory.entries diff --git a/app/src/main/java/to/bitkit/ui/settings/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/LanguageSettingsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/LanguageSettingsScreen.kt index 3b21f3a2d..9c0276207 100644 --- a/app/src/main/java/to/bitkit/ui/settings/LanguageSettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/LanguageSettingsScreen.kt @@ -8,10 +8,12 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import to.bitkit.R import to.bitkit.models.Language import to.bitkit.ui.components.Text13Up import to.bitkit.ui.components.settings.SettingsButtonRow @@ -53,7 +55,7 @@ private fun Content( modifier = modifier.screen() ) { AppTopBar( - titleText = "Language", // TODO Transifex + titleText = stringResource(R.string.settings__language_title), onBackClick = onBackClick, actions = { DrawerNavIcon() } ) diff --git a/app/src/main/java/to/bitkit/ui/settings/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 9555246b5..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, @@ -143,8 +132,8 @@ private fun Content( // ) SettingsButtonRow( - title = "Branch and Bound", // TODO add missing localized text - description = "Finds exact amount matches to minimize change", // TODO add missing localized text + title = stringResource(R.string.settings__cs__bnb_title), + description = stringResource(R.string.settings__cs__bnb_desc), value = SettingsButtonValue.BooleanValue( uiState.coinSelectionPreference == CoinSelectionPreference.BranchAndBound ), @@ -153,8 +142,8 @@ private fun Content( ) SettingsButtonRow( - title = "Single Random Draw", // TODO add missing localized text - description = "Random selection for privacy", // TODO add missing localized text + title = stringResource(R.string.settings__cs__srd_title), + description = stringResource(R.string.settings__cs__srd_desc), value = SettingsButtonValue.BooleanValue( uiState.coinSelectionPreference == CoinSelectionPreference.SingleRandomDraw ), @@ -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/appStatus/AppStatusScreen.kt b/app/src/main/java/to/bitkit/ui/settings/appStatus/AppStatusScreen.kt index 9fe6a8799..3f1e8c174 100644 --- a/app/src/main/java/to/bitkit/ui/settings/appStatus/AppStatusScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/appStatus/AppStatusScreen.kt @@ -31,7 +31,6 @@ import to.bitkit.R import to.bitkit.ext.startActivityAppSettings import to.bitkit.ext.toLocalizedTimestamp import to.bitkit.models.HealthState -import to.bitkit.models.NodeLifecycleState import to.bitkit.repositories.AppHealthState import to.bitkit.ui.Routes import to.bitkit.ui.components.BodyMSB @@ -262,7 +261,7 @@ private fun Preview() { backups = HealthState.READY, ), backupSubtitle = now().minus(3.minutes).toEpochMilliseconds().toLocalizedTimestamp(), - nodeSubtitle = NodeLifecycleState.Running.uiText, + nodeSubtitle = "Running", ), ) } diff --git a/app/src/main/java/to/bitkit/ui/settings/appStatus/AppStatusViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/appStatus/AppStatusViewModel.kt index 44199deca..5c9ec0d3f 100644 --- a/app/src/main/java/to/bitkit/ui/settings/appStatus/AppStatusViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/appStatus/AppStatusViewModel.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import to.bitkit.R import to.bitkit.data.CacheStore @@ -36,45 +37,40 @@ class AppStatusViewModel @Inject constructor( collectState() } - private fun collectState() { - viewModelScope.launch { - combine( - healthRepo.healthState, - cacheStore.backupStatuses, - lightningRepo.lightningState, - ) { healthState, backupStatuses, lightningState -> - AppStatusUiState( - health = healthState, - backupSubtitle = computeBackupSubtitle(healthState.backups, backupStatuses), - nodeSubtitle = when (healthState.node) { - HealthState.ERROR -> context.getString(R.string.settings__status__lightning_node__error) - else -> lightningState.nodeLifecycleState.uiText - }, - ) - }.collect { newState -> - _uiState.value = newState - } + private fun collectState() = viewModelScope.launch { + combine( + healthRepo.healthState, + cacheStore.backupStatuses, + lightningRepo.lightningState, + ) { healthState, backupStatuses, lightningState -> + AppStatusUiState( + health = healthState, + backupSubtitle = computeBackupSubtitle(healthState.backups, backupStatuses), + nodeSubtitle = when (healthState.node) { + HealthState.ERROR -> context.getString(R.string.settings__status__lightning_node__error) + else -> lightningState.nodeLifecycleState.uiText(context) + }, + ) + }.collect { newState -> + _uiState.update { newState } } } private fun computeBackupSubtitle( backupHealthState: HealthState, backupStatuses: Map, - ): String { - return when (backupHealthState) { - HealthState.ERROR -> context.getString(R.string.settings__status__backup__error) - else -> { - val syncTimes = BackupCategory.entries - .filter { it != BackupCategory.LIGHTNING_CONNECTIONS } - .map { category -> backupStatuses[category]?.synced ?: 0L } + ) = when (backupHealthState) { + HealthState.ERROR -> context.getString(R.string.settings__status__backup__error) + else -> { + val syncTimes = BackupCategory.entries + .filter { it != BackupCategory.LIGHTNING_CONNECTIONS } + .map { category -> backupStatuses[category]?.synced ?: 0L } - when (val maxSyncTime = syncTimes.max()) { - 0L -> context.getString(R.string.settings__status__backup__ready) - else -> runCatching { maxSyncTime.toLocalizedTimestamp() } - .getOrDefault( - context.getString(R.string.settings__status__backup__ready) - ) - } + when (val maxSyncTime = syncTimes.max()) { + 0L -> context.getString(R.string.settings__status__backup__ready) + else -> runCatching { maxSyncTime.toLocalizedTimestamp() }.getOrDefault( + context.getString(R.string.settings__status__backup__ready) + ) } } } diff --git a/app/src/main/java/to/bitkit/ui/settings/backgroundPayments/BackgroundPaymentsIntroScreen.kt b/app/src/main/java/to/bitkit/ui/settings/backgroundPayments/BackgroundPaymentsIntroScreen.kt index 08204ea77..73c8a8457 100644 --- a/app/src/main/java/to/bitkit/ui/settings/backgroundPayments/BackgroundPaymentsIntroScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/backgroundPayments/BackgroundPaymentsIntroScreen.kt @@ -36,7 +36,7 @@ fun BackgroundPaymentsIntroScreen( modifier = modifier.screen() ) { AppTopBar( - titleText = "Background Payments", // Todo Transifex + titleText = stringResource(R.string.settings__bg__title), onBackClick = onBack, actions = { DrawerNavIcon() }, ) @@ -66,11 +66,11 @@ fun BackgroundPaymentsIntroContent( ) Display( - text = "GET PAID\nPASSIVELY".withAccent(accentColor = Colors.Blue), - color = Colors.White + text = stringResource(R.string.settings__bg__intro_title).withAccent(accentColor = Colors.Blue), + color = Colors.White, ) VerticalSpacer(8.dp) - BodyM(text = "Turn on notifications to get paid, even when your Bitkit app is closed.", color = Colors.White64) + BodyM(text = stringResource(R.string.settings__bg__intro_desc), color = Colors.White64) VerticalSpacer(32.dp) PrimaryButton( text = stringResource(R.string.common__continue), diff --git a/app/src/main/java/to/bitkit/ui/settings/backgroundPayments/BackgroundPaymentsSettings.kt b/app/src/main/java/to/bitkit/ui/settings/backgroundPayments/BackgroundPaymentsSettings.kt index 562bbfe62..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 @@ -10,6 +10,7 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -24,55 +25,51 @@ 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 @Composable fun BackgroundPaymentsSettings( - onBack: () -> Unit, settingsViewModel: SettingsViewModel = hiltViewModel(), + onBack: () -> Unit, ) { val context = LocalContext.current val notificationsGranted by settingsViewModel.notificationsGranted.collectAsStateWithLifecycle() val showNotificationDetails by settingsViewModel.showNotificationDetails.collectAsStateWithLifecycle() RequestNotificationPermissions( - onPermissionChange = { granted -> - settingsViewModel.setNotificationPreference(granted) - }, - showPermissionDialog = false + onPermissionChange = settingsViewModel::setNotificationPreference, + showPermissionDialog = false, ) Content( - onBack = onBack, - onSystemSettingsClick = { - NotificationUtils.openNotificationSettings(context) - }, hasPermission = notificationsGranted, showDetails = showNotificationDetails, + onBack = onBack, + onSystemSettingsClick = context::openNotificationSettings, toggleNotificationDetails = settingsViewModel::toggleNotificationDetails, ) } @Composable private fun Content( + hasPermission: Boolean, + showDetails: Boolean, onBack: () -> Unit, onSystemSettingsClick: () -> Unit, toggleNotificationDetails: () -> Unit, - hasPermission: Boolean, - showDetails: Boolean, ) { Column( modifier = Modifier.screen() ) { AppTopBar( - titleText = "Background Payments", + titleText = stringResource(R.string.settings__bg__title), onBackClick = onBack, actions = { DrawerNavIcon() }, ) @@ -85,15 +82,14 @@ private fun Content( VerticalSpacer(16.dp) SettingsSwitchRow( - title = "Get paid when Bitkit is closed", + title = stringResource(R.string.settings__bg__switch_title), isChecked = hasPermission, - onClick = onSystemSettingsClick + onClick = onSystemSettingsClick, ) if (hasPermission) { - @Suppress("MaxLineLength") // TODO transifex BodyM( - text = "Background payments are enabled. You can receive funds even when the app is closed (if your device is connected to the internet).", + text = stringResource(R.string.settings__bg__enabled), color = Colors.White64, modifier = Modifier.padding(vertical = 16.dp), ) @@ -104,14 +100,14 @@ private fun Content( modifier = Modifier.padding(vertical = 16.dp) ) { BodyMB( - text = "Background payments are disabled, because you have denied notifications.", + text = stringResource(R.string.settings__bg__disabled), color = Colors.Red, ) } NotificationPreview( enabled = hasPermission, - title = "Payment Received", + title = stringResource(R.string.notification__received__title), description = "₿ 21 000", showDetails = showDetails, modifier = Modifier.fillMaxWidth() @@ -120,12 +116,12 @@ private fun Content( VerticalSpacer(32.dp) Text13Up( - text = "Privacy", + text = stringResource(R.string.settings__bg__privacy_header), color = Colors.White64 ) SettingsButtonRow( - "Include amount in notifications", + stringResource(R.string.settings__bg__include_amount), value = SettingsButtonValue.BooleanValue(showDetails), onClick = toggleNotificationDetails, ) @@ -133,18 +129,16 @@ private fun Content( VerticalSpacer(32.dp) Text13Up( - text = "Notifications", + text = stringResource(R.string.settings__bg__notifications_header), color = Colors.White64 ) VerticalSpacer(16.dp) SecondaryButton( - "Customize in Android Bitkit Settings", - icon = { - Image(painter = painterResource(R.drawable.ic_bell), contentDescription = null) - }, - onClick = onSystemSettingsClick + stringResource(R.string.settings__bg__customize), + icon = { Image(painter = painterResource(R.drawable.ic_bell), contentDescription = null) }, + onClick = onSystemSettingsClick, ) } } @@ -155,11 +149,11 @@ private fun Content( private fun Preview1() { AppThemeSurface { Content( + hasPermission = true, + showDetails = true, onBack = {}, onSystemSettingsClick = {}, toggleNotificationDetails = {}, - hasPermission = true, - showDetails = true, ) } } @@ -169,11 +163,11 @@ private fun Preview1() { private fun Preview2() { AppThemeSurface { Content( + hasPermission = false, + showDetails = false, onBack = {}, onSystemSettingsClick = {}, toggleNotificationDetails = {}, - hasPermission = false, - showDetails = false, ) } } diff --git a/app/src/main/java/to/bitkit/ui/settings/backups/BackupNavSheetViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/backups/BackupNavSheetViewModel.kt index 48a498c87..3c41f83b5 100644 --- a/app/src/main/java/to/bitkit/ui/settings/backups/BackupNavSheetViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/backups/BackupNavSheetViewModel.kt @@ -27,6 +27,7 @@ import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.Logger import javax.inject.Inject +@Suppress("TooManyFunctions") @HiltViewModel class BackupNavSheetViewModel @Inject constructor( @ApplicationContext private val context: Context, @@ -72,29 +73,28 @@ class BackupNavSheetViewModel @Inject constructor( } } - fun loadMnemonicData() { - viewModelScope.launch { - try { - val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)!! // NPE handled with UI toast - val bip39Passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) ?: "" + fun loadMnemonicData() = viewModelScope.launch { + runCatching { + val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)!! // NPE handled with UI toast + val bip39Passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) ?: "" - _uiState.update { - it.copy( - bip39Mnemonic = mnemonic, - bip39Passphrase = bip39Passphrase, - ) - } - } catch (e: Throwable) { - Logger.error("Error loading mnemonic", e, context = TAG) - ToastEventBus.send( - type = Toast.ToastType.WARNING, - title = context.getString(R.string.security__mnemonic_error), - description = context.getString(R.string.security__mnemonic_error_description), + _uiState.update { + it.copy( + bip39Mnemonic = mnemonic, + bip39Passphrase = bip39Passphrase, ) } + }.onFailure { + Logger.error("Error loading mnemonic", it, context = TAG) + ToastEventBus.send( + type = Toast.ToastType.WARNING, + title = context.getString(R.string.security__mnemonic_error), + description = context.getString(R.string.security__mnemonic_error_description), + ) } } + @Suppress("MagicNumber") fun onRevealMnemonic() { viewModelScope.launch { delay(200) // Small delay for better UX diff --git a/app/src/main/java/to/bitkit/ui/settings/backups/ConfirmMnemonicScreen.kt b/app/src/main/java/to/bitkit/ui/settings/backups/ConfirmMnemonicScreen.kt index 4cf5980ec..3a5f4da7d 100644 --- a/app/src/main/java/to/bitkit/ui/settings/backups/ConfirmMnemonicScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/backups/ConfirmMnemonicScreen.kt @@ -39,6 +39,7 @@ import to.bitkit.ui.shared.util.gradientBackground import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors +@Suppress("CyclomaticComplexMethod") @Composable fun ConfirmMnemonicScreen( uiState: BackupContract.UiState, diff --git a/app/src/main/java/to/bitkit/ui/settings/backups/ResetAndRestoreScreen.kt b/app/src/main/java/to/bitkit/ui/settings/backups/ResetAndRestoreScreen.kt index e9b83d1fc..3b002f311 100644 --- a/app/src/main/java/to/bitkit/ui/settings/backups/ResetAndRestoreScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/backups/ResetAndRestoreScreen.kt @@ -1,3 +1,5 @@ +@file:Suppress("MatchingDeclarationName") + package to.bitkit.ui.settings.backups import androidx.compose.foundation.Image @@ -38,13 +40,6 @@ import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.walletViewModel -object ResetAndRestoreTestTags { - const val SCREEN = "restore_screen" - const val BACKUP_BUTTON = "restore_backup_button" - const val RESET_BUTTON = "restore_reset_button" - const val RESET_DIALOG = "restore_reset_button" -} - @Composable fun ResetAndRestoreScreen( navController: NavController, @@ -137,7 +132,14 @@ private fun Content( } } -@Preview +object ResetAndRestoreTestTags { + const val SCREEN = "restore_screen" + const val BACKUP_BUTTON = "restore_backup_button" + const val RESET_BUTTON = "restore_reset_button" + const val RESET_DIALOG = "restore_reset_button" +} + +@Preview(showSystemUi = true) @Composable private fun Preview() { AppThemeSurface { @@ -152,9 +154,9 @@ private fun Preview() { } } -@Preview +@Preview(showSystemUi = true) @Composable -private fun Preview2() { +private fun PreviewDialog() { AppThemeSurface { Content( showConfirmDialog = true, diff --git a/app/src/main/java/to/bitkit/ui/settings/backups/ShowMnemonicScreen.kt b/app/src/main/java/to/bitkit/ui/settings/backups/ShowMnemonicScreen.kt index b58ded966..0468834aa 100644 --- a/app/src/main/java/to/bitkit/ui/settings/backups/ShowMnemonicScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/backups/ShowMnemonicScreen.kt @@ -246,6 +246,7 @@ private fun PreviewShown() { } } +@Suppress("MagicNumber") @Preview(showSystemUi = true) @Composable private fun Preview24Words() { diff --git a/app/src/main/java/to/bitkit/ui/settings/backups/SuccessScreen.kt b/app/src/main/java/to/bitkit/ui/settings/backups/SuccessScreen.kt index 7d6948412..df40e0d0e 100644 --- a/app/src/main/java/to/bitkit/ui/settings/backups/SuccessScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/backups/SuccessScreen.kt @@ -33,6 +33,7 @@ fun SuccessScreen( ) { SuccessContent( onContinue = { + @Suppress("ForbiddenComment") // TODO: verify backup onContinue() }, diff --git a/app/src/main/java/to/bitkit/ui/settings/general/DefaultUnitSettingsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/general/DefaultUnitSettingsScreen.kt index 623984f03..95893d2f7 100644 --- a/app/src/main/java/to/bitkit/ui/settings/general/DefaultUnitSettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/general/DefaultUnitSettingsScreen.kt @@ -16,6 +16,7 @@ import androidx.navigation.NavController import to.bitkit.R import to.bitkit.models.BitcoinDisplayUnit import to.bitkit.models.PrimaryDisplay +import to.bitkit.repositories.CurrencyState import to.bitkit.ui.LocalCurrencies import to.bitkit.ui.components.settings.SectionFooter import to.bitkit.ui.components.settings.SectionHeader @@ -31,8 +32,11 @@ import to.bitkit.viewmodels.CurrencyViewModel fun DefaultUnitSettingsScreen( currencyViewModel: CurrencyViewModel, navController: NavController, + currencies: CurrencyState = LocalCurrencies.current, ) { - val (_, _, _, selectedCurrency, _, displayUnit, primaryDisplay) = LocalCurrencies.current + val selectedCurrency = currencies.selectedCurrency + val displayUnit = currencies.displayUnit + val primaryDisplay = currencies.primaryDisplay DefaultUnitSettingsScreenContent( selectedCurrency = selectedCurrency, diff --git a/app/src/main/java/to/bitkit/ui/settings/general/GeneralSettingsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/general/GeneralSettingsScreen.kt index cc63bfc56..1e188b51c 100644 --- a/app/src/main/java/to/bitkit/ui/settings/general/GeneralSettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/general/GeneralSettingsScreen.kt @@ -109,7 +109,7 @@ private fun GeneralSettingsContent( .verticalScroll(rememberScrollState()) ) { SettingsButtonRow( - title = "Language", + title = stringResource(R.string.settings__language_title), value = SettingsButtonValue.StringValue(selectedLanguage), onClick = onLanguageSettingsClick, modifier = Modifier.testTag("LanguageSettings") @@ -155,9 +155,11 @@ private fun GeneralSettingsContent( modifier = Modifier.testTag("QuickpaySettings") ) SettingsButtonRow( - title = "Background Payments", // TODO Transifex + title = stringResource(R.string.settings__bg__title), onClick = onBgPaymentsClick, - value = SettingsButtonValue.StringValue(if (notificationsGranted) "On" else "Off"), + value = SettingsButtonValue.StringValue( + stringResource(if (notificationsGranted) R.string.settings__bg__on else R.string.settings__bg__off) + ), modifier = Modifier.testTag("BackgroundPaymentSettings") ) } diff --git a/app/src/main/java/to/bitkit/ui/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..74c7ed5a6 100644 --- a/app/src/main/java/to/bitkit/ui/shared/util/ShareSheet.kt +++ b/app/src/main/java/to/bitkit/ui/shared/util/ShareSheet.kt @@ -8,6 +8,7 @@ import androidx.core.content.FileProvider import kotlinx.io.IOException import to.bitkit.R import to.bitkit.env.Env +import to.bitkit.utils.Logger import java.io.File import java.io.FileOutputStream @@ -47,7 +48,7 @@ fun shareQrCode(context: Context, bitmap: Bitmap, text: String) { val chooser = Intent.createChooser(intent, "Share Qr code via") context.startActivity(chooser) } catch (e: IOException) { - e.printStackTrace() + Logger.error("Failed to share QR code", e, context = "ShareSheet") // Fallback to text-only sharing shareText(context, text) } diff --git a/app/src/main/java/to/bitkit/ui/sheets/BackgroundPaymentsIntroSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/BackgroundPaymentsIntroSheet.kt index feae729ea..7daaf880d 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/BackgroundPaymentsIntroSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/BackgroundPaymentsIntroSheet.kt @@ -6,7 +6,9 @@ import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview +import to.bitkit.R import to.bitkit.ui.components.BottomSheetPreview import to.bitkit.ui.scaffold.SheetTopBar import to.bitkit.ui.settings.backgroundPayments.BackgroundPaymentsIntroContent @@ -27,9 +29,7 @@ fun BackgroundPaymentsIntroSheet( .navigationBarsPadding() .testTag("background_payments_intro_sheet") ) { - SheetTopBar( - titleText = "Background Payments", // Todo Transifex - ) + SheetTopBar(titleText = stringResource(R.string.settings__bg__title)) BackgroundPaymentsIntroContent(onContinue = onContinue) } } diff --git a/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionSheet.kt index f9d38456a..2ba8313cb 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionSheet.kt @@ -302,7 +302,7 @@ private fun CustomModeContent( backgroundColor = Colors.Red16, enabled = uiState.decreaseEnabled, onClick = { onChangeAmount(false) }, - contentDescription = "Reduce fee", + contentDescription = stringResource(R.string.wallet__boost_decrease_fee), modifier = Modifier.testTag(BoostTransactionTestTags.DECREASE_FEE_BUTTON) ) @@ -349,7 +349,7 @@ private fun CustomModeContent( backgroundColor = Colors.Green16, enabled = uiState.increaseEnabled, onClick = { onChangeAmount(true) }, - contentDescription = "Increase fee", + contentDescription = stringResource(R.string.wallet__boost_increase_fee), modifier = Modifier.testTag(BoostTransactionTestTags.INCREASE_FEE_BUTTON) ) } diff --git a/app/src/main/java/to/bitkit/ui/sheets/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/LnurlAuthSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/LnurlAuthSheet.kt index 36c951e96..711b13ebd 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/LnurlAuthSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/LnurlAuthSheet.kt @@ -62,13 +62,11 @@ private fun Content( .navigationBarsPadding() .padding(horizontal = 16.dp) ) { - // TODO add missing localized text - SheetTopBar(titleText = "Log In") + SheetTopBar(titleText = stringResource(R.string.other__lnurl_auth_login_title)) VerticalSpacer(16.dp) BodyM( - // TODO add missing localized text - text = "Log in to {domain}?".replace("{domain}", domain), + text = stringResource(R.string.other__lnurl_auth_login_prompt).replace("{domain}", domain), color = Colors.White64, ) @@ -93,9 +91,8 @@ private fun Content( .weight(1f) .testTag("LnurlAuthCancel") ) - // TODO add missing localized text PrimaryButton( - text = "Log In", + text = stringResource(R.string.other__lnurl_auth_login_button), onClick = onContinue, fullWidth = false, modifier = Modifier diff --git a/app/src/main/java/to/bitkit/ui/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 61445ae23..000000000 --- a/app/src/main/java/to/bitkit/ui/utils/NotificationUtils.kt +++ /dev/null @@ -1,49 +0,0 @@ -package to.bitkit.ui.utils - -import android.Manifest -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 { e -> - Logger.error("Failed to open notification settings", e = e, 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, - Manifest.permission.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/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..c7c650f12 100644 --- a/app/src/main/java/to/bitkit/utils/Logger.kt +++ b/app/src/main/java/to/bitkit/utils/Logger.kt @@ -8,7 +8,6 @@ import kotlinx.coroutines.launch import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.encodeToJsonElement import kotlinx.serialization.json.put import kotlinx.serialization.serializer import org.lightningdevkit.ldknode.LogRecord @@ -146,7 +145,7 @@ class LoggerImpl( path: String = getCallerPath(), line: Int = getCallerLine(), ) { - val errMsg = e?.let { errLogOf(it) }.orEmpty() + val errMsg = e?.let { errorLogOf(it) }.orEmpty() val message = formatLog(LogLevel.WARN, "$msg $errMsg", context, path, line) if (compact) Log.w(tag, message) else Log.w(tag, message, e) saver.save(message) @@ -159,7 +158,7 @@ class LoggerImpl( path: String = getCallerPath(), line: Int = getCallerLine(), ) { - val errMsg = e?.let { errLogOf(it) }.orEmpty() + val errMsg = e?.let { errorLogOf(it) }.orEmpty() val message = formatLog(LogLevel.ERROR, "$msg $errMsg", context, path, line) if (compact) Log.e(tag, message) else Log.e(tag, message, e) saver.save(message) @@ -355,4 +354,4 @@ inline fun jsonLogOf(value: T): String { }.toString() } -fun errLogOf(e: Throwable): String = "[${e::class.simpleName}='${e.message}']" +fun errorLogOf(e: Throwable): String = "[${e::class.simpleName}='${e.message}']" diff --git a/app/src/main/java/to/bitkit/utils/Perf.kt b/app/src/main/java/to/bitkit/utils/Perf.kt index d1a120f37..b5ef217aa 100644 --- a/app/src/main/java/to/bitkit/utils/Perf.kt +++ b/app/src/main/java/to/bitkit/utils/Perf.kt @@ -15,12 +15,13 @@ fun Duration.formatted(): String = toComponents { hours, minutes, seconds, nanos internal inline fun measured( label: String, + context: String, block: () -> T, ): T { var result: T val elapsed = measureTime { result = block() } - Logger.perf("$label took ${elapsed.formatted()}") + Logger.perf("$label took ${elapsed.formatted()}", context = context) return result } diff --git a/app/src/main/java/to/bitkit/utils/timedsheets/sheets/AppUpdateTimedSheet.kt b/app/src/main/java/to/bitkit/utils/timedsheets/sheets/AppUpdateTimedSheet.kt index 919dbcb50..0ba35366b 100644 --- a/app/src/main/java/to/bitkit/utils/timedsheets/sheets/AppUpdateTimedSheet.kt +++ b/app/src/main/java/to/bitkit/utils/timedsheets/sheets/AppUpdateTimedSheet.kt @@ -18,21 +18,18 @@ class AppUpdateTimedSheet @Inject constructor( override val priority = 5 override suspend fun shouldShow(): Boolean = withContext(bgDispatcher) { - try { + runCatching { val androidReleaseInfo = appUpdaterService.getReleaseInfo().platforms.android val currentBuildNumber = BuildConfig.VERSION_CODE if (androidReleaseInfo.buildNumber <= currentBuildNumber) return@withContext false - - if (androidReleaseInfo.isCritical) { - return@withContext false - } - - return@withContext true - } catch (e: Exception) { - Logger.warn("Failure fetching new releases", e = e, context = TAG) - return@withContext false - } + if (androidReleaseInfo.isCritical) return@withContext false + }.onFailure { + Logger.warn("Failure fetching new releases", e = it, context = TAG) + }.fold( + onSuccess = { true }, + onFailure = { false }, + ) } override suspend fun onShown() { diff --git a/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt index 3c949489b..ed6db7e07 100644 --- a/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt @@ -200,7 +200,7 @@ class ActivityDetailViewModel @Inject constructor( channelId: String?, txId: String?, ): IBtOrder? = withContext(bgDispatcher) { - try { + runCatching { val orders = blocktankRepo.blocktankState.value.orders if (channelId != null) { @@ -214,10 +214,9 @@ class ActivityDetailViewModel @Inject constructor( } null - } catch (e: Exception) { - Logger.warn("Failed to find order for transfer: channelId=$channelId, txId=$txId", e, context = TAG) - null - } + }.onFailure { + Logger.warn("Failed to find order for transfer: channelId='$channelId', txId='$txId'", it, context = TAG) + }.getOrNull() } private companion object { diff --git a/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt index 97c6fe5d0..878eff4b9 100644 --- a/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt @@ -88,7 +88,9 @@ class ActivityListViewModel @Inject constructor( activityRepo.activitiesChanged, ) { debouncedSearch, filtersWithoutSearch, _ -> fetchFilteredActivities(filtersWithoutSearch.copy(searchText = debouncedSearch)) - }.collect { _filteredActivities.value = it } + }.collect { + _filteredActivities.update { it } + } } 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 31186901d..71ac4dcb1 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, @@ -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) } @@ -881,6 +882,7 @@ class AppViewModel @Inject constructor( } } + @Suppress("LongMethod") private suspend fun onScanOnchain(invoice: OnChainInvoice, scanResult: String) { val lnInvoice: LightningInvoice? = invoice.params?.get("lightning")?.let { bolt11 -> runCatching { decode(bolt11) }.getOrNull() @@ -971,8 +973,8 @@ class AppViewModel @Inject constructor( if (!lightningRepo.canSend(invoice.amountSatoshis)) { toast( type = Toast.ToastType.ERROR, - title = "Insufficient Funds", - description = "You do not have enough funds to send this payment." + title = context.getString(R.string.wallet__error_insufficient_funds_title), + description = context.getString(R.string.wallet__error_insufficient_funds_msg) ) return } @@ -1231,6 +1233,7 @@ class AppViewModel @Inject constructor( } } + @Suppress("LongMethod", "CyclomaticComplexMethod", "ReturnCount") private suspend fun handleSanityChecks(amountSats: ULong) { if (_sendUiState.value.showSanityWarningDialog != null) return @@ -1299,6 +1302,7 @@ class AppViewModel @Inject constructor( } } + @Suppress("LongMethod") private suspend fun proceedWithPayment() { delay(SCREEN_TRANSITION_DELAY_MS) // wait for screen transitions when applicable @@ -1317,7 +1321,7 @@ class AppViewModel @Inject constructor( it.copy(decodedInvoice = invoice) } }.onFailure { - toast(Exception("Error fetching lnurl invoice")) + toast(Exception(context.getString(R.string.wallet__error_lnurl_invoice_fetch))) hideSheet() return } @@ -1330,7 +1334,7 @@ class AppViewModel @Inject constructor( val validatedAddress = runCatching { validateBitcoinAddress(address) } .getOrElse { e -> Logger.error("Invalid bitcoin send address: '$address'", e, context = TAG) - toast(Exception("Invalid bitcoin send address")) + toast(Exception(context.getString(R.string.wallet__error_invalid_bitcoin_address))) hideSheet() return } @@ -1354,8 +1358,8 @@ class AppViewModel @Inject constructor( Logger.error(msg = "Error sending onchain payment", e = e, context = TAG) toast( type = Toast.ToastType.ERROR, - title = "Error Sending", - description = e.message ?: "Unknown error" + title = context.getString(R.string.wallet__error_sending_title), + description = e.message ?: context.getString(R.string.common__error_body) ) hideSheet() } @@ -1564,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() { @@ -1584,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() @@ -1810,7 +1812,11 @@ class AppViewModel @Inject constructor( } fun toast(error: Throwable) { - toast(type = Toast.ToastType.ERROR, title = "Error", description = error.message ?: "Unknown error") + toast( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.common__error), + description = error.message ?: context.getString(R.string.common__error_body) + ) } fun toast(toast: Toast) { diff --git a/app/src/main/java/to/bitkit/viewmodels/DevSettingsViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/DevSettingsViewModel.kt index e8d10c8f6..fbd06bccc 100644 --- a/app/src/main/java/to/bitkit/viewmodels/DevSettingsViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/DevSettingsViewModel.kt @@ -29,7 +29,7 @@ import to.bitkit.repositories.WalletRepo import to.bitkit.ui.shared.toast.ToastEventBus import javax.inject.Inject -@Suppress("LongParameterList") +@Suppress("TooManyFunctions", "LongParameterList") @HiltViewModel class DevSettingsViewModel @Inject constructor( @ApplicationContext private val context: Context, diff --git a/app/src/main/java/to/bitkit/viewmodels/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..472c99b5c 100644 --- a/app/src/main/java/to/bitkit/viewmodels/LogsViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/LogsViewModel.kt @@ -51,9 +51,10 @@ class LogsViewModel @Inject constructor( } } + @Suppress("TooGenericExceptionCaught") fun prepareLogForSharing(logFile: LogFile, onReady: (Uri) -> Unit) { viewModelScope.launch { - try { + runCatching { withContext(Dispatchers.IO) { val tempDir = application.externalCacheDir?.resolve("logs")?.apply { mkdirs() } ?: error("External cache dir is not available") @@ -71,8 +72,8 @@ class LogsViewModel @Inject constructor( onReady(contentUri) } } - } catch (e: Exception) { - Logger.error("Error preparing file for sharing", e) + }.onFailure { + Logger.error("Error preparing file for sharing", it) } } } diff --git a/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt index 925e4516d..ba3e42286 100644 --- a/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt @@ -13,6 +13,7 @@ import to.bitkit.data.SettingsStore import to.bitkit.models.TransactionSpeed import javax.inject.Inject +@Suppress("TooManyFunctions") @HiltViewModel class SettingsViewModel @Inject constructor( private val settingsStore: SettingsStore, diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index fe71fcdd7..9a83e0472 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -50,7 +50,7 @@ import kotlin.time.ExperimentalTime const val RETRY_INTERVAL_MS = 1 * 60 * 1000L // 1 minutes in ms const val GIVE_UP_MS = 30 * 60 * 1000L // 30 minutes in ms -@Suppress("LongParameterList") +@Suppress("TooManyFunctions", "LongParameterList") @OptIn(ExperimentalTime::class) @HiltViewModel class TransferViewModel @Inject constructor( @@ -218,50 +218,48 @@ class TransferViewModel @Inject constructor( } } - private suspend fun watchOrder(orderId: String): Result { + private suspend fun watchOrder(orderId: String): Result = runCatching { Logger.debug("Started watching order: '$orderId'", context = TAG) - try { - // Step 0: Starting - settingsStore.update { it.copy(lightningSetupStep = LN_SETUP_STEP_0) } - Logger.debug("LN setup step: $LN_SETUP_STEP_0", context = TAG) - delay(MIN_STEP_DELAY_MS) - - // Poll until payment is confirmed (order state becomes PAID or EXECUTED) - val paidOrder = pollUntil(orderId) { order -> - order.state2 == BtOrderState2.PAID || order.state2 == BtOrderState2.EXECUTED - } ?: return Result.failure(Exception("Order not found or expired")) - - // Step 1: Payment confirmed - settingsStore.update { it.copy(lightningSetupStep = LN_SETUP_STEP_1) } - Logger.debug("LN setup step: $LN_SETUP_STEP_1", context = TAG) - delay(MIN_STEP_DELAY_MS) - - // Try to open channel (idempotent - safe to call multiple times) - blocktankRepo.openChannel(paidOrder.id) - - // Step 2: Channel opening requested - settingsStore.update { it.copy(lightningSetupStep = LN_SETUP_STEP_2) } - Logger.debug("LN setup step: $LN_SETUP_STEP_2", context = TAG) - delay(MIN_STEP_DELAY_MS) - - // Poll until channel is ready (EXECUTED state or channel has state) - pollUntil(orderId) { order -> - order.state2 == BtOrderState2.EXECUTED || order.channel?.state != null - } ?: return Result.failure(Exception("Order not found or expired")) - - // Step 3: Complete - transferRepo.syncTransferStates() - settingsStore.update { it.copy(lightningSetupStep = LN_SETUP_STEP_3) } - Logger.debug("LN setup step: $LN_SETUP_STEP_3", context = TAG) - - Logger.debug("Order settled: '$orderId'", context = TAG) - return Result.success(true) - } catch (e: Throwable) { - Logger.error("Failed to watch order: '$orderId'", e, context = TAG) - return Result.failure(e) - } finally { - Logger.debug("Stopped watching order: '$orderId'", context = TAG) - } + + // Step 0: Starting + settingsStore.update { it.copy(lightningSetupStep = LN_SETUP_STEP_0) } + Logger.debug("LN setup step: $LN_SETUP_STEP_0", context = TAG) + delay(MIN_STEP_DELAY_MS) + + // Poll until payment is confirmed (order state becomes PAID or EXECUTED) + val paidOrder = pollUntil(orderId) { order -> + order.state2 == BtOrderState2.PAID || order.state2 == BtOrderState2.EXECUTED + } ?: return Result.failure(Exception("Order not found or expired")) + + // Step 1: Payment confirmed + settingsStore.update { it.copy(lightningSetupStep = LN_SETUP_STEP_1) } + Logger.debug("LN setup step: $LN_SETUP_STEP_1", context = TAG) + delay(MIN_STEP_DELAY_MS) + + // Try to open channel (idempotent - safe to call multiple times) + blocktankRepo.openChannel(paidOrder.id) + + // Step 2: Channel opening requested + settingsStore.update { it.copy(lightningSetupStep = LN_SETUP_STEP_2) } + Logger.debug("LN setup step: $LN_SETUP_STEP_2", context = TAG) + delay(MIN_STEP_DELAY_MS) + + // Poll until channel is ready (EXECUTED state or channel has state) + pollUntil(orderId) { order -> + order.state2 == BtOrderState2.EXECUTED || order.channel?.state != null + } ?: return Result.failure(Exception("Order not found or expired")) + + // Step 3: Complete + transferRepo.syncTransferStates() + settingsStore.update { it.copy(lightningSetupStep = LN_SETUP_STEP_3) } + Logger.debug("LN setup step: $LN_SETUP_STEP_3", context = TAG) + + Logger.debug("Order settled: '$orderId'", context = TAG) + return@runCatching true + }.onFailure { + Logger.error("Failed to watch order: '$orderId'", it, context = TAG) + }.also { + Logger.debug("Stopped watching order: '$orderId'", context = TAG) } private suspend fun pollUntil(orderId: String, condition: (IBtOrder) -> Boolean): IBtOrder? { diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index 7895faf4b..2dc53c7be 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -1,11 +1,13 @@ package to.bitkit.viewmodels +import android.content.Context import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Job import kotlinx.coroutines.async @@ -20,17 +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 @@ -43,8 +43,10 @@ import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds +@Suppress("TooManyFunctions", "LongParameterList") @HiltViewModel class WalletViewModel @Inject constructor( + @ApplicationContext private val context: Context, @BgDispatcher private val bgDispatcher: CoroutineDispatcher, private val walletRepo: WalletRepo, private val lightningRepo: LightningRepo, @@ -65,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 @@ -92,51 +89,49 @@ class WalletViewModel @Inject constructor( } @Suppress("TooGenericExceptionCaught") - private fun checkAndPerformRNMigration() { - viewModelScope.launch(bgDispatcher) { - val isChecked = migrationService.isMigrationChecked() - if (isChecked) { - loadCacheIfWalletExists() - return@launch - } + private fun checkAndPerformRNMigration() = viewModelScope.launch(bgDispatcher) { + val isChecked = migrationService.isMigrationChecked() + if (isChecked) { + loadCacheIfWalletExists() + return@launch + } - val hasNative = migrationService.hasNativeWalletData() - if (hasNative) { - migrationService.markMigrationChecked() - loadCacheIfWalletExists() - return@launch - } + val hasNative = migrationService.hasNativeWalletData() + if (hasNative) { + migrationService.markMigrationChecked() + loadCacheIfWalletExists() + return@launch + } - val hasRN = migrationService.hasRNWalletData() - if (!hasRN) { - migrationService.markMigrationChecked() - loadCacheIfWalletExists() - return@launch - } + val hasRN = migrationService.hasRNWalletData() + if (!hasRN) { + migrationService.markMigrationChecked() + loadCacheIfWalletExists() + return@launch + } - migrationService.setShowingMigrationLoading(true) + migrationService.setShowingMigrationLoading(true) - runCatching { - migrationService.migrateFromReactNative() - walletRepo.setWalletExistsState() - walletExists = walletRepo.walletExists() - loadCacheIfWalletExists() - if (walletExists) { - val channelMigration = buildChannelMigrationIfAvailable() - startNode(0, channelMigration) - } else { - migrationService.setShowingMigrationLoading(false) - } - }.onFailure { e -> - Logger.error("RN migration failed: $e", e, context = "WalletViewModel") - migrationService.markMigrationChecked() + runCatching { + migrationService.migrateFromReactNative() + walletRepo.setWalletExistsState() + walletExists = walletRepo.walletExists() + loadCacheIfWalletExists() + if (walletExists) { + val channelMigration = buildChannelMigrationIfAvailable() + startNode(0, channelMigration) + } else { migrationService.setShowingMigrationLoading(false) - ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = "Migration Failed", - description = "Please restore your wallet manually using your recovery phrase" - ) } + }.onFailure { + Logger.error("RN migration failed: $it", it, context = "WalletViewModel") + migrationService.markMigrationChecked() + migrationService.setShowingMigrationLoading(false) + ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = "Migration Failed", + description = "Please restore your wallet manually using your recovery phrase" + ) } } @@ -146,37 +141,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() } } } @@ -185,8 +154,8 @@ class WalletViewModel @Inject constructor( _restoreState.update { RestoreState.InProgress.Metadata } runCatching { restoreFromMostRecentBackup() - }.onFailure { e -> - Logger.error("Restore from backup failed", e, context = TAG) + }.onFailure { + Logger.error("Restore from backup failed", it, context = TAG) } _restoreState.update { RestoreState.Completed } } @@ -211,28 +180,23 @@ class WalletViewModel @Inject constructor( } } - private suspend fun restoreFromRNRemoteBackup() { - runCatching { - migrationService.restoreFromRNRemoteBackup() - walletRepo.loadFromCache() - }.onFailure { e -> - Logger.warn("RN remote backup restore failed, falling back to VSS", e, context = TAG) - backupRepo.performFullRestoreFromLatestBackup(onCacheRestored = walletRepo::loadFromCache) - } + private suspend fun restoreFromRNRemoteBackup() = runCatching { + migrationService.restoreFromRNRemoteBackup() + walletRepo.loadFromCache() + }.onFailure { + Logger.warn("RN remote backup restore failed, falling back to VSS", it, context = TAG) + backupRepo.performFullRestoreFromLatestBackup(onCacheRestored = walletRepo::loadFromCache) } - fun onRestoreContinue() { - _restoreState.update { RestoreState.Settled } - } + fun onRestoreContinue() = _restoreState.update { RestoreState.Settled } - fun proceedWithoutRestore(onDone: () -> Unit) { - viewModelScope.launch { - // TODO start LDK without trying to restore backup state from VSS if possible - lightningRepo.stop() - delay(LOADING_MS.milliseconds) - _restoreState.update { RestoreState.Settled } - onDone() - } + @Suppress("ForbiddenComment") + fun proceedWithoutRestore(onDone: () -> Unit) = viewModelScope.launch { + // TODO start LDK without trying to restore backup state from VSS if possible + lightningRepo.stop() + delay(LOADING_MS.milliseconds) + _restoreState.update { RestoreState.Settled } + onDone() } fun setInitNodeLifecycleState() = lightningRepo.setInitNodeLifecycleState() @@ -283,10 +247,10 @@ class WalletViewModel @Inject constructor( walletRepo.refreshBip21() } } - .onFailure { error -> - Logger.error("Node startup error", error, context = TAG) - if (error !is RecoveryModeException) { - ToastEventBus.send(error) + .onFailure { + Logger.error("Node startup error", it, context = TAG) + if (it !is RecoveryModeError) { + ToastEventBus.send(it) } } } @@ -296,19 +260,19 @@ class WalletViewModel @Inject constructor( viewModelScope.launch(bgDispatcher) { lightningRepo.stop() - .onFailure { error -> - Logger.error("Node stop error", error) - ToastEventBus.send(error) + .onFailure { + Logger.error("Node stop error", it) + ToastEventBus.send(it) } } } fun refreshState() = viewModelScope.launch { walletRepo.syncNodeAndWallet() - .onFailure { error -> - Logger.error("Failed to refresh state: ${error.message}", error) - if (error is CancellationException || error.isTxSyncTimeout()) return@onFailure - ToastEventBus.send(error) + .onFailure { + Logger.error("Failed to refresh state: ${it.message}", it) + if (it is CancellationException || it.isTxSyncTimeout()) return@onFailure + ToastEventBus.send(it) } } @@ -319,11 +283,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 } } } } @@ -334,31 +298,27 @@ class WalletViewModel @Inject constructor( .onSuccess { ToastEventBus.send( type = Toast.ToastType.INFO, - title = "Success", + title = context.getString(R.string.common__success), description = "Peer disconnected." ) } - .onFailure { error -> + .onFailure { ToastEventBus.send( type = Toast.ToastType.ERROR, - title = "Error", - description = error.message ?: "Unknown error" + title = context.getString(R.string.common__error), + 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 = "Error updating invoice", - description = error.message ?: "Unknown error" - ) - } + 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) + ) } } @@ -368,11 +328,9 @@ class WalletViewModel @Inject constructor( walletRepo.refreshBip21() } - fun wipeWallet() { - viewModelScope.launch(bgDispatcher) { - walletRepo.wipeWallet().onFailure { error -> - ToastEventBus.send(error) - } + fun wipeWallet() = viewModelScope.launch(bgDispatcher) { + walletRepo.wipeWallet().onFailure { + ToastEventBus.send(it) } } @@ -382,8 +340,8 @@ class WalletViewModel @Inject constructor( .onSuccess { backupRepo.scheduleFullBackup() } - .onFailure { error -> - ToastEventBus.send(error) + .onFailure { + ToastEventBus.send(it) } } @@ -394,22 +352,22 @@ class WalletViewModel @Inject constructor( walletRepo.restoreWallet( mnemonic = mnemonic, bip39Passphrase = bip39Passphrase, - ).onFailure { error -> - ToastEventBus.send(error) + ).onFailure { + ToastEventBus.send(it) } } // region debug methods fun addTagToSelected(newTag: String) = viewModelScope.launch { - walletRepo.addTagToSelected(newTag).onFailure { e -> - ToastEventBus.send(e) + walletRepo.addTagToSelected(newTag).onFailure { + ToastEventBus.send(it) } } fun removeTag(tag: String) = viewModelScope.launch { - walletRepo.removeTag(tag).onFailure { e -> - ToastEventBus.send(e) + walletRepo.removeTag(tag).onFailure { + ToastEventBus.send(it) } } @@ -419,7 +377,7 @@ class WalletViewModel @Inject constructor( fun updateBip21Description(newText: String) { if (newText.isEmpty()) { - Logger.warn("Empty") + Logger.warn(context.getString(R.string.common__empty)) } walletRepo.setBip21Description(newText) } @@ -432,22 +390,6 @@ class WalletViewModel @Inject constructor( } } -// TODO rename to walletUiState -data class MainUiState( - val nodeId: String = "", - val onchainAddress: String = "", - val bolt11: String = "", - val bip21: String = "", - val nodeStatus: NodeStatus? = null, - val nodeLifecycleState: NodeLifecycleState = NodeLifecycleState.Stopped, - val peers: List = emptyList(), - val channels: List = emptyList(), - val isRefreshing: Boolean = false, - val bip21AmountSats: ULong? = null, - val bip21Description: String = "", - val selectedTags: List = listOf(), -) - sealed interface RestoreState { data object Initial : RestoreState sealed interface InProgress : RestoreState { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bd0d18aeb..ea8614b1f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,1235 +1,1296 @@ - Back up Store your bitcoin - Discount + Back up + Please try again + Failed + Buy some bitcoin + Buy Spending Balance - QuickPay - Scan and pay - Invite + Discount Share Bitkit - Spend - Instant payments - Transfer - In progress... - Connecting + Invite Ready in ±10m - Ready + Connecting Connected! - Transfer - Ready in ±{duration} - Initiating - Keep app open - Secure + Ready + In progress... + Transfer + Instant payments + Spend + When Bitkit is closed + Get paid Set up a PIN code - Shop + Secure + Scan and pay + QuickPay Shop with Bitcoin - Profile + Shop Add your details - Support - Get assistance - Buy - Buy some bitcoin - Failed - Please try again - Get paid - When Bitkit is closed + Profile Suggestions - TRANSFER IN PROGRESS + Get assistance + Support + Keep app open + Initiating + Ready in ±{duration} + Transfer Advanced - Continue + Announced + Are You Sure? + Back Cancel Close - Are You Sure? - Yes, Proceed - Try Again - No, Cancel - ₿ / vbyte - ₿/vbyte - Edit + Confirmations + Connect + Continue + Copied To Clipboard Copy - Share - Search - Discard - Save - Done + Default Delete Yes, Delete + No, Cancel + Discard + Done + Edit + Empty + Error + Unknown error + Later + Learn More + Max + Min + Never + No OK Awesome!\nNice!\nCool!\nGreat!\nFantastic!\nSweet!\nExcellent!\nTerrific! + Preview + Ready Reset Retry - Later + ₿ / vbyte + ₿/vbyte + Save + Search + Share Skip - Copied To Clipboard - Yes - No - Back - Learn More - Never + Success + Try Again Understood - Connect - Min - Max - Default - Preview - Instant - ±2-10 seconds - 2-10s - ±2s - Fast + Usable + Yes + Yes, Proceed + Depends on the fee + Depends on the fee + Depends on the fee + Custom ±10-20 minutes - 10-20m ±10m - Normal + 10-20m + Fast + ±2-10 seconds + ±2s + 2-10s + Instant + +2 hours + +2h + +2h + Minimum ±20-60 minutes - 20-60m ±20m - Slow + 20-60m + Normal ±1-2 hours - 1-2h ±1h - Minimum - +2 hours - +2h - +2h - Custom - Depends on the fee - Depends on the fee - Depends on the fee - Spending\n<accent>Balance</accent> - Fund your spending balance to enjoy instant and cheap transactions with friends, family, and merchants. - Get Started + 1-2h + Slow + Funds transfer to savings is usually instant, but settlement may take up to <accent>14 days</accent> under certain network conditions. + Funds\n<accent>availability</accent> + Balance + Spending base fee + Block Height + Channel ID + Peer ID + You can now pay anyone, anywhere, instantly. + Spending Balance Ready + Channel point + Close + Close Connection + Transfer Failed + Unable to transfer your funds back to savings. Please try again. + Your funds are being transferred back to your savings. + Transfer Initiated + The fee to close this Lightning Connection and transfer your funds back to your savings depends on network conditions.\n\nFunds transfer to savings is usually instant, but settlement may take up to <accent>14 days</accent> under certain network conditions. + Closed on + Closure reason + Add Connection + Export Logs + Closed connections + Hide Closed & Failed + Show Closed & Failed + Failed connections + Open connections + Pending connections + Connection + The funds on your spending balance have been transferred to your savings. + Connection Closed + Lightning Connections + Created on + Bitkit could not add the Lightning peer. + Unable To Add Lightning Peer + Bitkit cannot add Tor nodes. + The URI appears to be invalid. + Instant Payments Setup Failed + Receiving amount needs to be greater than ${usdValue} + An error occurred when setting up your instant balance. {raw} + Unable To Decode Invoice + Failed to Create Invoice + Log Export Failed + Bitkit was not able to export the LDK logs. + Unable to save lightning peer + Unable To Save Lightning Peer + Export Lightning Logs + Lightning Connection + Spending\n<accent>balance</accent> + Host + Node ID + Paste Node URI + Port + Scan QR + You can use an external node to manually open a Lightning connection. Enter the node details to continue. + <accent>Manual setup</accent> + Lightning connection initiated. You will be able to use your spending balance in <accent>±30 minutes</accent> (depends on node configuration). + Connection\n<accent>initiated</accent> + Fee rate + Fee Rate Cache Update Time + Fees + Force Transfer + Some connections could not be closed. + Unable to transfer your funds back to savings. Please try again. + Force Transfer Failed + Your funds will be accessible in ±14 days. + Force Transfer Initiated + Force Transfer + Could not initiate transfer. Do you want to force this transfer? You won’t be able to use these funds for ±14 days (!) + Force\n<accent>Transfer</accent> + Transfer from Savings + Use Other Wallet + Advanced Spending Balance - Fund your <accent>spending balance</accent> You can use your Bitkit savings or send bitcoin from a different wallet. Bitkit does not currently provide Lightning services in your country, but you can still connect to other nodes directly. Bitkit does not currently provide Lightning services in your country, but you can still connect to other nodes directly. - Transfer from Savings - Use Other Wallet - Advanced - Spending Balance - Advanced <accent>setup</accent> - Scan a QR to claim your LNURL Channel from another LSP, or choose manual setup. + Fund your <accent>spending balance</accent> LNURL Channel QR Manual Setup - No Available Funds + Spending Balance + Scan a QR to claim your LNURL Channel from another LSP, or choose manual setup. + Advanced <accent>setup</accent> + Inbound Capacity + Inbound HTLC Max + Inbound HTLC Min + Copied Invoice to Clipboard + Is Usable + Lightning Balances + Spending Balance Liquidity + Your Spending Balance uses the Lightning Network to make your payments cheaper, faster, and more private.\n\nThis works like internet access, but you pay for liquidity & routing instead of bandwidth.\n\nThis setup includes some one-time costs. + Liquidity\n<accent>& routing</accent> + Channel Monitor Archival Height + Next Outbound HTLC Limit + Next Outbound HTLC Min Before you can transfer funds from your savings balance, you need to send bitcoin to your Bitkit wallet. Fund wallet - Transfer Funds - Please\n<accent>confirm</accent> - Custom <accent>fee</accent> - Swipe To Transfer - Transfer\n<accent>to spending</accent> - Transfer funds to your spending balance to enjoy instant and cheap transactions with friends, family, and merchants. - Get Started - Transfer Failure - Transfer\n<accent>failed</accent> - Sorry, Bitkit could not set up your spending balance or complete your transfer. Please try again. - Inspect Error - Try Again - Transfer\n<accent>to spending</accent> - 25% - Savings Balance Minimum - A minimum of ₿ {amount} is needed to set up your spending balance. - Spending Balance Maximum - The amount you can transfer to your spending balance is currently limited to ₿ {amount}. - Your transfer to the spending balance is limited due to liquidity policy. For details, visit the Help Center. - Network fees - Service fees - To spending - Total - Use Defaults - Receiving\n<accent>capacity</accent> - Liquidity fee - Liquidity\n<accent>& routing</accent> - Your Spending Balance uses the Lightning Network to make your payments cheaper, faster, and more private.\n\nThis works like internet access, but you pay for liquidity & routing instead of bandwidth.\n\nThis setup includes some one-time costs. - Spending Balance Liquidity - Transfer\n<accent>to savings</accent> - Transfer your spending balance to your savings to store your Bitcoin long-term and secure your future. - Get Started - Funds\n<accent>availability</accent> - Funds transfer to savings is usually instant, but settlement may take up to <accent>14 days</accent> under certain network conditions. - Transfer to savings - Transfer all - Select funds\n<accent>to transfer</accent> + No Available Funds + disconnected + Bitkit failed to initialize the Lightning node. + LDK Node ID + Lightning Node + Lightning Wallet Sync Time + Onchain Wallet Sync Time + Opened on + Order ID + Order Details + The setup process expired. Please try again. + Instant Payments Setup Failed + Order Expiry + Order fee + The setup process expired. Please try again. + Instant Payments Setup Failed + Processing payment + Connection closed + Connection closing + Order expired + Given up + Connection inactive + Connection open + Opening connection + Payment successful + Payment canceled + Queued for opening + Refund available + Payment refunded + Other + The Lightning peer was successfully added and saved. + Receiving capacity + Reserve balance (not spendable) + Rapid Gossip Sync Snapshot Time + Savings You can transfer part of your spending balance to savings, because you have multiple active Lightning Connections. + Select funds\n<accent>to transfer</accent> Total selected - Funds\n<accent>in transfer</accent> - Please wait, your funds transfer is in progress. This should take <accent>±10 seconds.</accent> + Transfer to savings + Transfer all Transfer\n<accent>interrupted</accent> - Keep Bitkit\n<accent>open</accent> Funds were not transferred yet. Bitkit will try to initiate the transfer during the next <accent>30 minutes</accent>. Please keep your app open. - Transfer Successful - Funds moved\n<accent>to spending</accent> - Your funds have been transferred and your spending balance is ready to use. - Funds moved\n<accent>to savings</accent> - Your funds have been transferred. You are able to use these funds immediately. - Lightning Connection - <accent>Manual setup</accent> - You can use an external node to manually open a Lightning connection. Enter the node details to continue. - Node ID - Host - Port - Paste Node URI - Scan QR - Spending\n<accent>balance</accent> - Connection\n<accent>initiated</accent> - Lightning connection initiated. You will be able to use your spending balance in <accent>±30 minutes</accent> (depends on node configuration). - Instant Payments Setup Failed - An error occurred when setting up your instant balance. {raw} - Receiving amount needs to be greater than ${usdValue} - Spending - Savings - Spending balance - Receiving capacity + Keep Bitkit\n<accent>open</accent> + Get Started + Transfer your spending balance to your savings to store your Bitcoin long-term and secure your future. + Transfer\n<accent>to savings</accent> + Please wait, your funds transfer is in progress. This should take <accent>±10 seconds.</accent> + Funds\n<accent>in transfer</accent> + Pathfinding Scores Sync Time + Continue Using Bitkit In transfer - Please wait, your funds transfer is in progress. This should take <accent>±10 minutes.</accent> Processing Payment Payment Successful Queued For Opening Opening Connection - Continue Using Bitkit - Lightning Node - LDK Node ID - disconnected - Bitkit failed to initialize the Lightning node. - Lightning Connections - Pending connections - Open connections - Closed connections - Failed connections - Show Closed & Failed - Hide Closed & Failed - Export Logs - Add Connection - Export Lightning Logs - Log Export Failed - Bitkit was not able to export the LDK logs. - Failed to Create Invoice - Unable To Add Lightning Peer - Bitkit could not add the Lightning peer. - The URI appears to be invalid. - Bitkit cannot add Tor nodes. - Unable To Save Lightning Peer - Unable to save lightning peer - Unable To Decode Invoice - The Lightning peer was successfully added and saved. - Copied Invoice to Clipboard - Connection + Please wait, your funds transfer is in progress. This should take <accent>±10 minutes.</accent> + Spendable Onchain + Spending + Liquidity fee + Receiving\n<accent>capacity</accent> + The amount you can transfer to your spending balance is currently limited to ₿ {amount}. + Your transfer to the spending balance is limited due to liquidity policy. For details, visit the Help Center. + Spending Balance Maximum + A minimum of ₿ {amount} is needed to set up your spending balance. + Savings Balance Minimum + 25% + Transfer\n<accent>to spending</accent> + To spending + Use Defaults + Service fees + Network fees + Total + Get Started + Transfer funds to your spending balance to enjoy instant and cheap transactions with friends, family, and merchants. + Transfer\n<accent>to spending</accent> + Spending balance Status - Order Details - Order ID - Created on - Order Expiry - Transaction - Order fee - Balance - Reserve balance (not spendable) - Total channel size - Fees - Spending base fee - Fee rate - Other - Is Usable - Opened on - Closed on - Peer ID - Channel ID - Channel point - Closure reason Support - Connection Closed - The funds on your spending balance have been transferred to your savings. - Close Connection - Transfer Failed - Unable to transfer your funds back to savings. Please try again. - Transfer Initiated - Your funds are being transferred back to your savings. - The fee to close this Lightning Connection and transfer your funds back to your savings depends on network conditions.\n\nFunds transfer to savings is usually instant, but settlement may take up to <accent>14 days</accent> under certain network conditions. - Close - Force Transfer - Force\n<accent>Transfer</accent> - Could not initiate transfer. Do you want to force this transfer? You won’t be able to use these funds for ±14 days (!) - Force Transfer - Force Transfer Initiated - Your funds will be accessible in ±14 days. - Some connections could not be closed. - Force Transfer Failed - Unable to transfer your funds back to savings. Please try again. - Spending Balance Ready - You can now pay anyone, anywhere, instantly. - Instant Payments Setup Failed - The setup process expired. Please try again. - Instant Payments Setup Failed - The setup process expired. Please try again. - Processing payment - Payment canceled - Payment successful - Refund available - Payment refunded - Queued for opening - Opening connection - Connection closing - Given up - Order expired - Connection closed - Connection open - Connection inactive - Please wait for Bitkit to connect to the payment network (±10 seconds). + Total Anchor Channels Reserve + Total Lightning + Total Onchain + Total channel size + Transaction + Please\n<accent>confirm</accent> + Custom <accent>fee</accent> + Transfer Funds + Swipe To Transfer + Inspect Error + Try Again + Transfer Failure + Sorry, Bitkit could not set up your spending balance or complete your transfer. Please try again. + Transfer\n<accent>failed</accent> + TRANSFER IN PROGRESS + Get Started + Fund your spending balance to enjoy instant and cheap transactions with friends, family, and merchants. + Spending\n<accent>Balance</accent> + Transfer Successful + Your funds have been transferred. You are able to use these funds immediately. + Your funds have been transferred and your spending balance is ready to use. + Funds moved\n<accent>to savings</accent> + Funds moved\n<accent>to spending</accent> Connecting & Syncing... - Bitkit\n<accent>terms of use</accent> - Terms of use - I declare that I have read and accept the terms of use. - Privacy Policy - I declare that I have read and accept the <accent>privacy policy.</accent> - You can ₿ \n<accent>the change</accent> - Use Bitkit to pay anyone, anywhere, any time, and spend your bitcoin on the things you value in life. - Get Started - Skip Intro - Skip - Freedom in\n<accent>your pocket</accent> - Bitkit hands you the keys to manage your money. Spend now or save for later. The choice is yours. - Instant\n<accent>payments</accent> - Spend bitcoin faster than ever. Enjoy instant and cheap payments with friends, family, and merchants. - *Bitkit does not currently provide Lightning services in your country, but you can still connect to other nodes. - Bitcoiners,\n<accent>borderless</accent> - Take charge of your digital life with portable profiles and payable contacts. - Privacy is\n<accent>not a crime</accent> - Swipe to hide your balance, enjoy more private payments, and protect your wallet by enabling security features. - Your keys,\n<accent>your coins</accent> - Let’s create your wallet. Please be aware that Bitkit is mobile software. <accent>Don’t store all your money in Bitkit.</accent> - New Wallet - Restore - Restore Wallet - Advanced Setup + Please wait for Bitkit to connect to the payment network (±10 seconds). + Wallet Balances + Please wait while your old wallet data migrates to this new Bitkit version... + Wallet Migration + MIGRATING\n<accent>WALLET</accent> + Balance moved from spending to savings + Reason: %s + Channel closed + Notification channel for Lightning node foreground service + Lightning node notification + Channel failed to open in the background + Channel open failed + Channel opened + Pending + Ready to send + Lightning error + Payment failed + Please try again + Received %s + Via new channel + Open Bitkit to see details + Payment Received + Bitkit is running in background so you can receive Lightning payments + Stop App Advanced - Secure with <accent>Passphrase</accent> - You can add a secret passphrase to the 12-word recovery phrase. If you do, make sure you don’t forget. - Passphrase + Advanced Setup Create New Wallet - <accent>Restore</accent>\nyour wallet - Please type in your recovery phrase from any (paper) backup. - If a word is shown in <accent>red</accent>, it means that it was not found in the recovery phrase dictionary. Check for spelling errors. - The checksum for the recovery phrase appears to be incorrect. Please double check your recovery phrase. - SUGGESTIONS - Passphrase* - *Optional, enter only if you’ve set up one. - Wallet Restore Failed - Bitkit could not restore your wallet from backup or recovery phrase. + To get\nstarted\n<accent>send\nBitcoin</accent>\nto your\nwallet + Wallet Creation Failed + Get Started Setting up\n<accent>your wallet</accent> - Wallet <accent>restored</accent> - You have successfully restored your wallet from backup. Enjoy Bitkit! + <accent>Caution:</accent>\nmultiple devices + Don\'t install your Bitkit recovery phrase into multiple phones simultaneously, as this can corrupt your data. + New Wallet + Passphrase + Secure with <accent>Passphrase</accent> + You can add a secret passphrase to the 12-word recovery phrase. If you do, make sure you don’t forget. + Privacy Policy + I declare that I have read and accept the <accent>privacy policy.</accent> + Restore + Bitkit could not restore your wallet from backup or recovery phrase. + Wallet Restore Failed Spending balance <accent>error</accent> Bitkit restored your savings, but failed to restore your current spending balance (Lightning state) and wallet data. + <accent>Restore</accent>\nyour wallet + The checksum for the recovery phrase appears to be incorrect. Please double check your recovery phrase. Proceed Without Backup If you previously had a lightning backup it will be overwritten and lost. This could result in a loss of funds. - To get\nstarted\n<accent>send\nBitcoin</accent>\nto your\nwallet - Wallet Creation Failed - <accent>Caution:</accent>\nmultiple devices - Don\'t install your Bitkit recovery phrase into multiple phones simultaneously, as this can corrupt your data. - Scan QR Code - Unable To Read QR - Bitkit is not able to read this QR code. - Unable to Read Data - Bitkit could not read the provided data. - Incorrect Network - Bitkit is currently set to {selectedNetwork} but data is for {dataNetwork}. - Paste QR Code + *Optional, enter only if you’ve set up one. + Passphrase* + Please type in your recovery phrase from any (paper) backup. + If a word is shown in <accent>red</accent>, it means that it was not found in the recovery phrase dictionary. Check for spelling errors. + Wallet <accent>restored</accent> + You have successfully restored your wallet from backup. Enjoy Bitkit! + SUGGESTIONS + Restore Wallet + Skip + Skip Intro + Freedom in\n<accent>your pocket</accent> + Bitkit hands you the keys to manage your money. Spend now or save for later. The choice is yours. + Instant\n<accent>payments</accent> + *Bitkit does not currently provide Lightning services in your country, but you can still connect to other nodes. + Spend bitcoin faster than ever. Enjoy instant and cheap payments with friends, family, and merchants. + Bitcoiners,\n<accent>borderless</accent> + Take charge of your digital life with portable profiles and payable contacts. + Privacy is\n<accent>not a crime</accent> + Swipe to hide your balance, enjoy more private payments, and protect your wallet by enabling security features. + Your keys,\n<accent>your coins</accent> + Let’s create your wallet. Please be aware that Bitkit is mobile software. <accent>Don’t store all your money in Bitkit.</accent> + Terms of use + I declare that I have read and accept the terms of use. + Bitkit\n<accent>terms of use</accent> + Use Bitkit to pay anyone, anywhere, any time, and spend your bitcoin on the things you value in life. + You can ₿ \n<accent>the change</accent> + You need {delta} more to complete this transaction. + Unable to add LSP node as a peer at this time. + Transfer Failed + An error occurred when moving funds. {raw} + Choose Exchange Buy some\n<accent>Bitcoin</accent> Don’t have any Bitcoin or need more? - Choose Exchange - Data Connection Issue - Could not load primary key from keychain. - Electrum Connection Restored - Bitkit successfully reconnected to Electrum. + Bitkit needs permission to use your camera + Permission to use camera + Failed to initialize camera: {message} + <bold>It appears Bitkit does not have permission to access your camera.</bold>\n\nTo utilize this feature in the future you will need to enable camera permissions for this app from your phone\'s settings. + Enable camera + Allow camera access to scan bitcoin invoices and pay more quickly. + SCAN\n<accent>QR CODE</accent> + Scan QR + Do you want to be redirected to the relevant screen? + Clipboard Data Detected + Coming soon + Bitkit successfully reconnected to the Internet. + Internet Connection Restored Internet Connectivity Issues It appears you’re disconnected, trying to reconnect... - Reconnecting To Electrum Lost connection to Electrum, trying to reconnect... - Internet Connection Restored - Bitkit successfully reconnected to the Internet. + Reconnecting To Electrum + Bitkit successfully reconnected to Electrum. + Electrum Connection Restored + EARLIER + Data Connection Issue + Could not load primary key from keychain. + Claiming your Bitkit gift code... + Claiming Gift + OK + Bitkit couldn\'t claim the funds. Please try again later or contact us. + Gift Code Error + OK + This Bitkit gift code has already been used, and the funds have been paid out. + Used Code + Sorry, you\'re too late! All gifts for this code have already been claimed. + Out of Gifts + Learn More + Understood High Balance - High\n<accent>Balance</accent> <accent>Your wallet balance exceeds $500.</accent>\nFor your security, consider moving some of your savings to an offline wallet. - Understood - Learn More - Critical Update - Update\n<accent>Bitkit now</accent> - There is a critical update for Bitkit. You must update to continue using Bitkit. - Update Bitkit - Permission to use camera - Bitkit needs permission to use your camera - <bold>It appears Bitkit does not have permission to access your camera.</bold>\n\nTo utilize this feature in the future you will need to enable camera permissions for this app from your phone\'s settings. - Clipboard Data Detected - Do you want to be redirected to the relevant screen? - Insufficient Savings - Insufficient Spending Balance - More ₿ needed to pay this Bitcoin invoice. - ₿ {amount} more needed to pay this Bitcoin invoice. - ₿ {amount} more needed to pay this Lightning invoice. - Swipe To Confirm - Decoding Error - Unable To Interpret Provided Data - This QR code does not appear to contain payment data. - Bitcoin Price Update Failed - Bitkit could not update the current Bitcoin exchange rate. Using price from {date} - Bitkit could not update the current Bitcoin exchange rate. Please try again later. - Unable To Pay (LNURL) - Could not start local Lightning node. Please try again or restart Bitkit. - Not enough outbound/sending capacity to complete lnurl-pay request. + High\n<accent>Balance</accent> + Sign In Failed (LNURL) + An error occurred when you attempted to sign in. {raw} + Log In + Log in to {domain}? + Log In + You successfully signed in to {domain}. + You successfully signed in. + Signed In + Bitkit could not connect to Blocktank. Unable To Open Channel (LNURL) An error occured when you tried paying: {raw} Lightning Connection - New\nlightning\n<accent>connection</accent> - Do you want open a Lightning connection with this Lightning Service Provider? + Host Lightning service provider + Do you want open a Lightning connection with this Lightning Service Provider? Node ID - Host Port - Bitkit could not connect to Blocktank. - Channel Requested - Successfully requested channel from: {peer} Successfully requested channel. - Sign In Failed (LNURL) - An error occurred when you attempted to sign in. {raw} - Signed In - You successfully signed in to {domain}. - You successfully signed in. + Successfully requested channel from: {peer} + Channel Requested + New\nlightning\n<accent>connection</accent> + Could not start local Lightning node. Please try again or restart Bitkit. + Unable To Pay (LNURL) + Not enough outbound/sending capacity to complete lnurl-pay request. Withdraw Failed (LNURL) - Sorry, an error occurred. - Not enough receiving capacity to complete withdraw request. Could not create invoice for withdraw. + Sorry, an error occurred. Incorrect LNURL withdraw params, min/max not set correctly. - Withdraw Requested + Not enough receiving capacity to complete withdraw request. Your withdraw was successfully requested. Waiting for payment. + Withdraw Requested + Failed to create log zip file + Error starting: %1$s + Setting up wallet… + Running + Starting + Stopped + Stopping + Insufficient Savings + ₿ {amount} more needed to pay this Bitcoin invoice. + More ₿ needed to pay this Bitcoin invoice. + Insufficient Spending Balance + ₿ {amount} more needed to pay this Lightning invoice. Open Phone Settings - Transfer Failed - Unable to add LSP node as a peer at this time. - An error occurred when moving funds. {raw} - You need {delta} more to complete this transaction. - EARLIER - Update Available - Update\n<accent>Bitkit</accent> - Please update Bitkit to the latest version for new features and bug fixes! - Update - Please try again. - Important: Bitkit Transfer - Open Bitkit to complete your transfer - Bitkit is unable to read the provided data. + Unable To Read QR + Incorrect Network + Bitkit is currently set to {selectedNetwork} but data is for {dataNetwork}. + Unable to Read Data + Bitkit could not read the provided data. + Bitkit is not able to read this QR code. + Paste QR Code + Scan QR Code + Bitkit could not update the current Bitcoin exchange rate. Using price from {date} + Bitkit could not update the current Bitcoin exchange rate. Please try again later. + Bitcoin Price Update Failed This Lightning invoice has expired. - Claiming Gift - Claiming your Bitkit gift code... - Gift Code Error - Bitkit couldn\'t claim the funds. Please try again later or contact us. - OK - Used Code - This Bitkit gift code has already been used, and the funds have been paid out. - OK - Out of Gifts - Sorry, you\'re too late! All gifts for this code have already been claimed. - Shop - Get your life on the Bitcoin standard. Spend your Bitcoin on digital gift cards, eSIMs, phone refills, and more. - Get Started - Shop - Shop - Map - Gift card categories - Gift Cards - Shop with Bitcoin - ESims + Bitkit is unable to read the provided data. + Decoding Error + Unable To Interpret Provided Data + This QR code does not appear to contain payment data. Go borderless - Phone Refill + ESims + Shop with Bitcoin + Gift Cards + Gift card categories + Shop Top up your phone - Travel + Phone Refill + Map + Shop Book your ₿ holiday + Travel + Get Started + Get your life on the Bitcoin standard. Spend your Bitcoin on digital gift cards, eSIMs, phone refills, and more. + Shop Shop - Wallet Backup - <accent>Safely store</accent> your Bitcoin + Swipe To Confirm + Open Bitkit to complete your transfer + Important: Bitkit Transfer + Please try again. + Update + Update Bitkit + Critical Update + There is a critical update for Bitkit. You must update to continue using Bitkit. + Update\n<accent>Bitkit now</accent> + Update Available + Please update Bitkit to the latest version for new features and bug fixes! + Update\n<accent>Bitkit</accent> + Authorize + Authorizing... + This service claims to be + Deny + Make sure you trust this service before granting permission to manage your data. + Unable to auth with Pubky service + Pubky Auth Error + Unable to retrieve Pubky key + Pubky Error + Requested Permissions + Success + Authorization + Back Up Now that you have some funds in your wallet, it is time to back up your money! There are no funds in your wallet yet, but you can create a backup if you wish. - Back Up - passphrase - Your Passphrase - You added a passphrase to your recovery phrase during wallet setup. - <accent>Never share</accent> your passphrase with anyone as this may result in the loss of funds. - Confirm Passphrase - Enter the passphrase you added while setting up and creating your wallet. - <accent>Passphrase:</accent> {passphrase} - Mnemonic Phrase - Your Recovery Phrase - Wallet Failure - Bitkit could not read your recovery phrase. - Write down these {length} words in the right order and store them in a safe place. - Use the 12 words below to recover your money at a later date. - Tap To Reveal - <accent>Never share</accent> your recovery phrase with anyone as this may result in the loss of funds. + <accent>Safely store</accent> your Bitcoin + Wallet Backup + Biometrics + PIN code set. Would you like to use {biometricsName} instead of your PIN code? + Authenticate with {biometricsName} + Confirm {biometricsName} + Bitkit could not set up {type} for your device. + Biometrics Setup Failed + Face ID + Loading... + It appears that your device does not support Biometric security. + Looks like you haven’t set up biometric security yet (or it is not supported). Try to enable it in your phone settings. + Phone Settings + Touch ID + Use {biometricsName} + Close Bitkit + Contact Support + You have successfully changed your PIN to a new 4-digit combination. + PIN changed + Please retype your 4-digit PIN to complete the setup process. + Retype New PIN + Please use a PIN you will remember. If you forget your PIN you can reset it, but that will require restoring your wallet. + Set New PIN + You can change your PIN code to a new\n4-digit combination. Please enter your current PIN code first. + Change PIN + Try again, this is not the same PIN. + Show Seed Phrase + Export + Bitkit could not create the backup file. + Bitkit could not export the backup file to your phone. + Backup Failed + Password + Share backup file + Bitkit successfully exported the backup file to your phone. + Backup Exported + You can export a copy of your wallet data as a .ZIP file. This file is encrypted with the password you set below. + Export To Phone Confirm Recovery Phrase Tap the 12 words in the correct order. Mnemonic copied to clipboard - Successful - Make sure you store your recovery phrase in a <accent>secure place</accent>, as this is the <accent>only way to recover</accent> your money! - Keep It Safe - Remember, <accent>never share your recovery phrase</accent> with anyone! If you do, they can steal your money, profile and other data. Wallet Data Your profile, contacts, accounts, tags, and activity will be backed up automatically to our free cloud service. + Wallet Failure + Bitkit could not read your recovery phrase. + Keep It Safe + Remember, <accent>never share your recovery phrase</accent> with anyone! If you do, they can steal your money, profile and other data. + <bold>Latest data backup:</bold> {time} + Failed to load mnemonic Multiple Devices Don\'t use your Bitkit recovery phrase on multiple phones simultaneously, as this can corrupt your data. - <bold>Latest data backup:</bold> {time} - Increase Security - <accent>Protect</accent>\nyour bitcoin - To increase wallet security, you can set up a PIN code and Face ID. - Secure Wallet + <accent>Never share</accent> your recovery phrase with anyone as this may result in the loss of funds. + Mnemonic Phrase + Successful + Make sure you store your recovery phrase in a <accent>secure place</accent>, as this is the <accent>only way to recover</accent> your money! + Tap To Reveal + Use the 12 words below to recover your money at a later date. + Write down these {length} words in the right order and store them in a safe place. + Your Recovery Phrase + passphrase + Confirm Passphrase + Enter the passphrase you added while setting up and creating your wallet. + <accent>Never share</accent> your passphrase with anyone as this may result in the loss of funds. + <accent>Passphrase:</accent> {passphrase} + You added a passphrase to your recovery phrase during wallet setup. + Your Passphrase + {attemptsRemaining} attempts remaining. Forgot your PIN? Choose 4-Digit PIN Please use a PIN you will remember. If you forget your PIN you can reset it, but that will require restoring your wallet. - Retype 4-Digit PIN - Please retype your 4-digit PIN to complete the setup process. - Try again, this is not the same PIN. - Disable PIN - PIN code is currently enabled. If you want to disable your PIN, you need to enter your current PIN code first. Disable PIN + PIN code is currently enabled. If you want to disable your PIN, you need to enter your current PIN code first. + Disable PIN Please enter your PIN code - Last attempt. Entering the wrong PIN again will reset your wallet. - {attemptsRemaining} attempts remaining. Forgot your PIN? - Forgot PIN? - Forgot your PIN? Reset and recover your Bitkit wallet with your recovery phrase. Set a new PIN after completing recovery. Reset (Requires Recovery Phrase) + Forgot your PIN? Reset and recover your Bitkit wallet with your recovery phrase. Set a new PIN after completing recovery. + Forgot PIN? + Last attempt. Entering the wrong PIN again will reset your wallet. + Try again, this is not the same PIN. + Retype 4-Digit PIN + Please retype your 4-digit PIN to complete the setup process. + Secure Wallet + Increase Security + To increase wallet security, you can set up a PIN code and Face ID. + <accent>Protect</accent>\nyour bitcoin Please enter your PIN code to confirm and send out this payment. Enter PIN Code Use {biometricsName} - Biometrics - Authenticate with {biometricsName} - It appears that your device does not support Biometric security. - Face ID - Touch ID - Confirm {biometricsName} - Biometrics Setup Failed - Bitkit could not set up {type} for your device. - Loading... - Looks like you haven’t set up biometric security yet (or it is not supported). Try to enable it in your phone settings. - PIN code set. Would you like to use {biometricsName} instead of your PIN code? - Use {biometricsName} - Phone Settings - Wallet Secured - You have successfully set up a PIN code and {biometricsName} to improve wallet security. - You have successfully set up a PIN code to improve your wallet security. - Also require for payments - Reset And Restore - Back up your wallet first to avoid loss of your funds and wallet data. Resetting will overwrite your current Bitkit setup. + Recovery + You\'ve entered Bitkit\'s recovery mode. Here are some actions to perform when running into issues that prevent the app from fully functioning. Restart the app for a normal startup. Back Up First Reset Wallet - Reset Bitkit? - Are you sure you want to reset your Bitkit Wallet? Do you have a backup of your recovery phrase and wallet data? Yes, Reset - Recovery - You\'ve entered Bitkit\'s recovery mode. Here are some actions to perform when running into issues that prevent the app from fully functioning. Restart the app for a normal startup. - Show Seed Phrase - Contact Support - Wipe App - Close Bitkit - Export To Phone - You can export a copy of your wallet data as a .ZIP file. This file is encrypted with the password you set below. - Password - Export - Share backup file - Backup Exported - Bitkit successfully exported the backup file to your phone. - Backup Failed - Bitkit could not export the backup file to your phone. - Bitkit could not create the backup file. - Change PIN - You can change your PIN code to a new\n4-digit combination. Please enter your current PIN code first. - Retype New PIN - Please retype your 4-digit PIN to complete the setup process. - Set New PIN - Please use a PIN you will remember. If you forget your PIN you can reset it, but that will require restoring your wallet. - Try again, this is not the same PIN. - PIN changed - You have successfully changed your PIN to a new 4-digit combination. + Are you sure you want to reset your Bitkit Wallet? Do you have a backup of your recovery phrase and wallet data? + Reset Bitkit? + Back up your wallet first to avoid loss of your funds and wallet data. Resetting will overwrite your current Bitkit setup. + Reset And Restore + You have successfully set up a PIN code and {biometricsName} to improve wallet security. + You have successfully set up a PIN code to improve your wallet security. + Also require for payments + Wallet Secured Use PIN code - Wallet Data Deleted + Wipe App Bitkit has been reset and all wallet data has been deleted. - Authorization - Make sure you trust this service before granting permission to manage your data. - Authorize - Deny - Authorizing... - Success - This service claims to be - Requested Permissions - Pubky Error - Unable to retrieve Pubky key - Pubky Auth Error - Unable to auth with Pubky service - Settings - Dev Options Enabled - Developer options are now enabled throughout the app. - Dev Options Disabled - Developer options are now disabled throughout the app. - General - Security and Privacy - Back up or Restore - Advanced - About - Support - About Bitkit - Thank you for being a responsible Bitcoiner.\nChange your wallet, change the world.\n\nBitkit hands you the keys to your money, profile, contacts, and web accounts.\n\nBitkit was crafted by Synonym Software Ltd. + Wallet Data Deleted Legal Share - Version Change your wallet, change the world. Download Bitkit for iPhone {appStoreUrl} or Android {playStoreUrl} - Dev Settings - Local currency - Local Currency - Prices powered by Bitfinex & CoinGecko. - Most Used - Other Currencies - Default unit - Default Unit - Display amounts in - Bitcoin - Tip: Quickly toggle between Bitcoin and {currency} by tapping on your wallet balance. - Bitcoin denomination - Modern (₿ 10 000) - Classic (₿ 0.00010000) - Transaction speed - Transaction Speed - Default Transaction Speed - Set Custom Fee - ₿ {feeSats} for the average transaction - ₿ {feeSats} for the average transaction ({fiatSymbol}{fiatFormatted}) - Tags - Previously used tags - Widgets - Widgets - Show Widget Titles - QuickPay - <accent>Frictionless</accent>\npayments - Bitkit QuickPay makes checking out faster by automatically paying QR codes when scanned. - Enable QuickPay - If enabled, scanned invoices below ${amount} will be paid automatically without requiring your confirmation or PIN*. - Quickpay threshold - * Bitkit QuickPay exclusively supports payments from your Spending Balance. - Security And Privacy - Swipe balance to hide - Hide balance on open - Read clipboard for ease of use - Warn when sending over $100 - PIN Code - Change PIN Code - Require PIN on launch - Require PIN when idle - Require PIN for payments - Enabled - Disabled - Use {biometryTypeName} instead - When enabled, you can use {biometryTypeName} instead of your PIN code to unlock your wallet or send payments. - Back Up Or Restore - Back up your wallet - Export wallet data to phone - Reset and restore wallet - Data Backup Failure - Bitkit failed to back up wallet data. Retrying in {interval, plural, one {# minute} other {# minutes}}. - latest data backups - Running - Failed Backup: {time} - Latest Backup: {time} - Connections - Connection Receipts - Transaction Log - Boosts & Transfers - Settings - Widgets - Tags - Profile - Contacts - Support - Need help? Report your issue from within Bitkit, visit the help center, check the status, or reach out to us on our social channels. - Report Issue - Help Center - App Status - Please describe the issue you are experiencing or ask a general question. - Email address - Issue or question - satoshi@satoshi.com - Describe the issue or ask a question - Send - Sent Successfully - Thank you for contacting us! We will try to get back to you as soon as possible. - OK - Failed To Send - Something went wrong while trying to send your issue or question. Please try again. - Try Again - App Status - Internet - Connected - Reconnecting... - Disconnected - Bitcoin Node - Connected - Connecting... - Could not connect to Electrum - Lightning Node - Synced - Syncing... - Could not initiate - Lightning Connection - Open - Opening... - No open connections - Latest Full Data Backup - Backed up - Backing up... - Failed to complete a full backup - Payments - Networks - Other + Thank you for being a responsible Bitcoiner.\nChange your wallet, change the world.\n\nBitkit hands you the keys to your money, profile, contacts, and web accounts.\n\nBitkit was crafted by Synonym Software Ltd. + About Bitkit + Version + About + Change Addresses + Receiving Addresses + Check Balances + Copied to clipboard + Generate 20 More + Index: {index} + Loading Addresses... + No Addresses To Display + No addresses found when searching for \"{searchTxt}\" + No addresses with funds found when searching for \"{searchTxt}\" + No funds found under the {addressType} address type, change addresses up to index {index}. + No funds found under the {addressType} address type, receiving addresses up to index {index}. + Path: {path} + Hide Private Key + Private Key: {privateKey} + View Private Key + Rescan Failed + Bitkit was not able to check the address balances. + ₿ {totalBalance} found + {count, plural, one {Spend All Funds From # address} other {Spend All Funds From # addresses}} + {count, plural, one {Spend ₿ {fundsToSpend} From # address} other {Spend ₿ {fundsToSpend} From # addresses}} Bitcoin Address Type - Monitored Address Types - Monitored Address Types Updated - Changes will take full effect after app restarts. - Address Gap Limit + Address Viewer + Bitcoin Network Coin Selection - Coin Selection Method - Manual Autopilot Autopilot Mode - Smallest First - Sort by and use smallest UTXO first. Potentially higher fee, but hides your largest UTXOs. - Largest First - Sort by and use largest UTXO first. Potentially lower fee, but reveals your largest UTXOs. Consolidate Use all available UTXOs regardless of the amount being sent. Use this method when fees are low in order to reduce fees in future transactions. First-In First-Out Use older UTXOs first (by block height). Last-In Last-Out Use newer UTXOs first (by block height). + Manual + Smallest First + Sort by and use smallest UTXO first. Potentially higher fee, but hides your largest UTXOs. + Coin Selection Method + Largest First + Sort by and use largest UTXO first. Potentially lower fee, but reveals your largest UTXOs. + Electrum Server + Address Gap Limit + Lightning Connections + Lightning Node + Monitored Address Types + Changes will take full effect after app restarts. + Monitored Address Types Updated Payment Preference - Choose how you prefer to receive money when users send funds to your profile key. - * This requires sharing payment data. - Payment preference (drag to reorder) Pay to/from contacts Enable payments with contacts* - Address Viewer + Payment preference (drag to reorder) + * This requires sharing payment data. + Choose how you prefer to receive money when users send funds to your profile key. Rescan Addresses - Reset Suggestions - Reset Suggestions? - Are you sure you want to reset the suggestions? They will reappear in case you have removed them from your Bitkit wallet overview. Yes, Reset - Lightning Connections - Lightning Node - Electrum Server + Are you sure you want to reset the suggestions? They will reappear in case you have removed them from your Bitkit wallet overview. + Reset Suggestions? Rapid-Gossip-Sync + Networks + Other + Payments + Reset Suggestions Slashtags Web Relay - Bitcoin Network - Fast (more expensive) - Fast - ± 10-20 minutes - Normal - Normal - ± 20-60 minutes - Slow (cheaper) - Slow - ± 1-2 hours - Custom - Custom - Depends on fee - No Addresses To Display - Loading Addresses... - No funds found under the {addressType} address type, receiving addresses up to index {index}. - No funds found under the {addressType} address type, change addresses up to index {index}. - No addresses with funds found when searching for \"{searchTxt}\" - No addresses found when searching for \"{searchTxt}\" - Rescan Failed - Bitkit was not able to check the address balances. - {count, plural, one {Spend ₿ {fundsToSpend} From # address} other {Spend ₿ {fundsToSpend} From # addresses}} - {count, plural, one {Spend All Funds From # address} other {Spend All Funds From # addresses}} - Index: {index} - Path: {path} - Hide Private Key - View Private Key - Private Key: {privateKey} - Change Addresses - Receiving Addresses - ₿ {totalBalance} found - Generate 20 More - Check Balances - Copied to clipboard - Please specify a host and port to connect to. + Advanced + Connection Receipts + Connections + Contacts + Profile + Settings + Tags + Transaction Log + Boosts & Transfers + Widgets + Export wallet data to phone + Bitkit failed to back up wallet data. Retrying in {interval, plural, one {# minute} other {# minutes}}. + Data Backup Failure + latest data backups + Reset and restore wallet + Failed Backup: {time} + Running + Latest Backup: {time} + Back Up Or Restore + Back up your wallet + Back up or Restore + Customize in Android Bitkit Settings + Background payments are disabled, because you have denied notifications. + Background payments are enabled. You can receive funds even when the app is closed (if your device is connected to the internet). + Include amount in notifications + Turn on notifications to get paid, even when your Bitkit app is closed. + GET PAID\n<accent>PASSIVELY</accent> + Notifications + Off + On + Privacy + Set up in background + Get paid when Bitkit is closed + Background Payments + Finds exact amount matches to minimize change + Branch and Bound + Random selection for privacy + Single Random Draw + Developer options are now disabled throughout the app. + Dev Options Disabled + Developer options are now enabled throughout the app. + Dev Options Enabled + Dev Settings + Connect To Host + Reset To Default + Currently connected to + disconnected Please specify a host to connect to. - Please specify a port to connect to. - Please specify a valid port. + Please specify a host and port to connect to. Please specify a valid url. Electrum Error - Electrum Server Updated - Successfully connected to {host}:{port} - Electrum Connection Failed - Bitkit could not establish a connection to Electrum. - Currently connected to - disconnected + Please specify a port to connect to. + Please specify a valid port. Host Port Protocol - Reset To Default - Connect To Host - Save - Reset - Address Gap Limit Updated + Electrum Connection Failed + Bitkit could not establish a connection to Electrum. + Successfully connected to {host}:{port} + Electrum Server Updated + Depends on fee + Custom + Custom + ± 10-20 minutes + Fast (more expensive) + Fast + ± 20-60 minutes + Normal + Normal + ± 1-2 hours + Slow (cheaper) + Slow Changes will take full effect after app restarts. - Look Behind + Address Gap Limit Updated Look Ahead - Look Behind Change Look Ahead Change - Rapid-Gossip-Sync Server URL + Look Behind + Look Behind Change + Reset + Save + Prices powered by Bitfinex & CoinGecko. + Local currency + Local Currency + Most Used + Other Currencies + Classic (₿ 0.00010000) + Bitcoin denomination + Modern (₿ 10 000) + Transaction speed + Default Transaction Speed + Set Custom Fee + ₿ {feeSats} for the average transaction + ₿ {feeSats} for the average transaction ({fiatSymbol}{fiatFormatted}) + Transaction Speed + Tags + Previously used tags + Default unit + Bitcoin + Display amounts in + Tip: Quickly toggle between Bitcoin and {currency} by tapping on your wallet balance. + Default Unit + General + Language + Bitkit QuickPay makes checking out faster by automatically paying QR codes when scanned. + <accent>Frictionless</accent>\npayments + QuickPay + Quickpay threshold + * Bitkit QuickPay exclusively supports payments from your Spending Balance. + If enabled, scanned invoices below ${amount} will be paid automatically without requiring your confirmation or PIN*. + Enable QuickPay Connect - Rapid-Gossip-Sync Server Updated + Rapid-Gossip-Sync Server URL You may need to restart the app once or twice for this change to take effect. - Web Relay Error - Please specify a URL to connect to. - Not a valid HTTPS url. - Healthcheck Failed - Web Relay Updated - Successfully connected to {url} - Your name - Your Name - Contact Name - Contact - Contacts - Add contact + Rapid-Gossip-Sync Server Updated + Read clipboard for ease of use + When enabled, you can use {biometryTypeName} instead of your PIN code to unlock your wallet or send payments. + Hide balance on open + PIN Code + Change PIN Code + Disabled + Enabled + Require PIN when idle + Require PIN on launch + Require PIN for payments + Swipe balance to hide + Security And Privacy + Use {biometryTypeName} instead + Warn when sending over $100 + Security and Privacy + Settings + Failed to complete a full backup + Backing up... + Backed up + Latest Full Data Backup + Could not connect to Electrum + Connecting... + Connected + Bitcoin Node + Disconnected + Reconnecting... + Connected + Internet + No open connections + Opening... + Open + Lightning Connection + Could not initiate + Syncing... + Synced + Lightning Node + App Status + Help Center + Email address + Issue or question + Failed to open support links + satoshi@satoshi.com + Describe the issue or ask a question + Report Issue + Please describe the issue you are experiencing or ask a general question. + App Status + Need help? Report your issue from within Bitkit, visit the help center, check the status, or reach out to us on our social channels. + Send + Thank you for contacting us! We will try to get back to you as soon as possible. + OK + Something went wrong while trying to send your issue or question. Please try again. + Try Again + Support + Sent Successfully + Failed To Send + Support + Widgets + Show Widget Titles + Widgets + Healthcheck Failed + Not a valid HTTPS url. + Please specify a URL to connect to. + Web Relay Error + Successfully connected to {url} + Web Relay Updated + Contact + Add contact + Add Add Contact Add a new contact by scanning their QR or by pasting their key below. - Add - Paste a key - You cannot add yourself as a contact. - This is not a valid key. + Assign Contact + Profile Key Copied To Clipboard + Are you sure you want to delete {name} from your contacts? + Delete {name}? + Yes, Delete Edit Contact - Contact\'s name - Your public\nprofile name - Retrieving\ncontact info... + This is not a valid key. + You cannot add yourself as a contact. + Paste a key + Lightning is not ready yet + Contact Name No links added yet... Unable To Pay Contact - Lightning is not ready yet + Retrieving\ncontact info... + Select Contact Share Profile Key this contact - Profile Key Copied To Clipboard - Delete {name}? - Are you sure you want to delete {name} from your contacts? - Yes, Delete - Select Contact - Assign Contact + Your public\nprofile name + Contacts + Contact\'s name No Contacts found Slashtags disabled - Dynamic\n<accent>contacts</accent> - Get automatic updates from your Bitkit contacts, pay them, and follow their public profiles. + Unable To Delete Profile + The contact you’re trying to send to hasn’t enabled payments. + Unable To Pay Contact + Unable To Save Contact + Unable To Save Profile + My profile + Enable payments with contacts* + * This requires sharing payment data. Add First Contact + Dynamic\n<accent>contacts</accent> Own your\n<accent>profile</accent> Set up your public profile and links, so your Bitkit contacts can reach you or pay you anytime, anywhere. Pay <accent>Bitkit\ncontacts</accent> You and your contacts can use Bitkit to send payments directly, without banks, anytime, anywhere. - My profile + Get automatic updates from your Bitkit contacts, pay them, and follow their public profiles. Profile - Save - Pay Your Contacts - Create Profile - Please note that all your profile information will be publicly available and visible. - Short bio. Say a bit about yourself. Add Link + Short bio. Say a bit about yourself. + Create Profile + Delete + Are you sure you want to delete all of your Bitkit profile information? + Delete Profile Information? + Yes, Delete + Your Bitkit profile information has been deleted. + Profile Deleted + Edit Profile Label For example \'Website\' - Link or text Note: Any link you add will be publicly visible. Suggestions Suggestions To Add + Link or text + Pay Your Contacts + Please note that all your profile information will be publicly available and visible. + Save Scan to add {name} - Edit Profile - Delete - Delete Profile Information? - Are you sure you want to delete all of your Bitkit profile information? - Yes, Delete - Profile Deleted - Your Bitkit profile information has been deleted. - Enable payments with contacts* - * This requires sharing payment data. - Unable To Save Contact - Unable To Save Profile - Unable To Delete Profile - Unable To Pay Contact - The contact you’re trying to send to hasn’t enabled payments. - Deprecated - Slashauth is deprecated. Please use Bitkit Beta. - Wallet - Activity - Contacts - Profile - Widgets - Shop - Settings - App Status - Send - Receive + Your name + Your Name + %1$s sats + Please wait while Bitkit looks for funds in unsupported addresses (Legacy, Nested SegWit, and Taproot). + LOOKING FOR FUNDS... + Looking For Funds + BROADCASTING… + Confirm Sweep + PREPARING… + Retry + Swipe to confirm + To Address + Custom Fee + for this transaction + Error + Network Fee + Bitkit found funds in unsupported addresses (Legacy, Nested SegWit, and Taproot). + FUNDS FOUND + Found Funds + Legacy (P2PKH) + Sweep Funds + Bitkit checked unsupported address types and found no funds to sweep. + No Funds To Sweep + Bitkit found funds on unsupported Bitcoin address types. Sweep to move the funds to your new wallet balance. + Sweep + SWEEP OLD\n<accent>BITKIT FUNDS</accent> + SegWit (P2SH) + Your funds have been swept and will be added to your wallet balance. + Sweep Complete + Wallet Overview + Taproot (P2TR) + Sweep To Wallet + Total + %1$s, %2$d UTXO + %1$s, %2$d UTXOs + Activity + Address + All Activity + Assign + Received Bitcoin + Sent Bitcoin + Boost + Boost Fee + Boosted incoming transaction + Already Boosted + BOOSTED TRANSACTION {num} (CPFP) + BOOSTED TRANSACTION {num} (RBF) + Boosting + Confirmed + Confirming + Confirms in {feeRateDescription} + Boosting. Confirms in {feeRateDescription} + Contact + Date + Detach + Transaction Retrieval Failed + Bitkit was not able to fetch the transaction data. + Failed to load activity + Activity not found + The transaction was not found. + Explore + Open Block Explorer + Failed + Fee + Fee (prepaid) + {count, plural, one {INPUT} other {INPUTS (#)}} + Invoice + Comment + Invoice note + Fee potentially too low + Next month + No activity yet + Receive some funds to get started + {count, plural, one {OUTPUT} other {OUTPUTS (#)}} + Payment + Payment hash + Pending + Preimage + Previous month + Received + Removed from Mempool + Please check your activity list. The {count} impacted transaction(s) will be highlighted in red. + Transactions Removed From Mempool + Sent + Sent to myself + Show All Activity + Status + Successful + All + Other + Received + Sent + Tag + Time + Transfer + Transfer ({duration}) + From Spending + From Spending ({duration}) + From Savings + From Savings ({duration}) + To Savings + To Spending + Transaction ID + Swipe your wallet balance to reveal it again. + Wallet Balance Hidden <text> (</text><pending/><text> pending)</text> - Please reopen the app and try again. - Wallet \"{walletName}\" already exists. - Invalid recovery phrase. - Please double-check if your recovery phrase is accurate. - Send Bitcoin - To + Tap your wallet balance to switch it back to {unit}. + Switched to {unit} + Boost + Reduce fee + Bitkit was unable to boost the transaction. + Unable to increase the fee any further. Otherwise, it will exceed half the current input balance + Boost Failed + Your transaction may settle faster if you include an additional network fee. Set your custom fee below. + Your transaction may settle faster if you include an additional network fee. Here is a recommendation: + Increase fee + Use Suggested Fee + The transaction was successfully boosted. + Boosted! + Swipe To Boost + Boost Transaction + Please reopen the app and try again. + Wallet \"{walletName}\" already exists. + Invalid recovery phrase. + Please double-check if your recovery phrase is accurate. + Savings + Spending + Incoming Transfer: + Activity + Contacts + Profile + Settings + Shop + App Status + Wallet + Widgets + Transaction Broadcast Failed + Please check your connection and try again.\n{message} + An error occurred when broadcasting your transaction. {raw} + Transaction Creation Failed + An error occurred. Please try again {raw} + You do not have enough funds to send this payment. + Insufficient Funds + Invalid bitcoin send address + Error updating invoice + Error fetching lnurl invoice + Please increase your fee and try again. + Minimum relay fee not met + No lightning invoice found. + Please check your transaction info and try again. + No transaction is available to broadcast. + Error Sending + Apply + Clear + Select Range + Received Instant Bitcoin + Lightning Startup Error + Lightning Sync Error + Maximum amount + Pay Bitcoin + The minimum amount for this invoice is ₿ {amount}. + Amount Too Low + Comment + Optional comment to receiver + Withdraw + AvailablE TO WITHDRAW + The funds you withdraw will be deposited into your Bitkit spending balance. + Withdraw Bitcoin + Fee Exceeds Maximum Limit + Lower the custom fee and try again. + Fee Below Minimum Limit + Increase the custom fee and try again. + MINIMUM + Note + Received Bitcoin + Peer disconnected. + Receive + Receive Lightning funds + Receive Bitcoin + Bitcoin invoice + Transaction Failed + Failed to send funds to your spending account. + To receive more instant Bitcoin, Bitkit has to increase your liquidity. A <accent>{networkFee}</accent> network fee and <accent>{serviceFee}</accent> service provider fee will be deducted from the amount you specified. + To set up your spending balance, a <accent>{networkFee}</accent> network fee and <accent>{serviceFee}</accent> service provider fee will be deducted. + Invoice copied to clipboard + Payments to your spending balance might fail if you switch between apps. + Keep Bitkit In Foreground + Bitkit does not provide Lightning services in your country, but you can still connect to other nodes. + Instant Payments Unavailable + Insufficient receiving capacity to receive this amount over Lightning. + Insufficient receiving balance. + Spending Balance Initializing... + Lightning invoice + Enable background setup to safely exit Bitkit while your balance is being configured. + Set up in background + Spending Balance Liquidity + Additional Spending Balance Liquidity + Spending Balance Setup + Increase Receiving Capacity + Your Spending Balance uses the Lightning Network to make your payments cheaper, faster, and more private.\n\nThis works like internet access, but you pay for liquidity & routing instead of bandwidth.\n\nThis setup includes some one-time costs. + Your Spending Balance uses the Lightning Network to make your payments cheaper, faster, and more private.\n\nThis works like internet access, but you pay for liquidity & routing instead of bandwidth.\n\nBitkit needs to increase the receiving capacity of your spending balance to process this payment. + Optional note to payer + Enjoy instant and cheap\ntransactions with friends, family,\nand merchants. + Receive on <accent>spending balance</accent> + Generating QR ... + Share receiving address + Show Details + Show QR Code + Edit Invoice + Receive on Spending Balance + Auto + Savings + Spending + Want to receive <accent>Lightning</accent> funds? + You will receive Contact Paste Invoice Enter Manually Scan QR - Invoice + An error occurred and couldn\'t refresh the wallet. Please try again. + Electrum server connection is throttled. Please wait several minutes before trying again. + Connection Throttled + Refresh Failed + Blockchain Reorg Detected + {count, plural, one {# of your transactions is no longer confirmed.} other {# of your transactions are no longer confirmed.}} Please wait. + Please check your activity list for affected transactions. + sats/vbyte + <accent>Send\nbitcoin</accent>\nto your\nsavings balance + Savings + Auto + Coin Selection + Total required + Total selected + Send Enter an invoice, address, or profile key - Clipboard Empty - Please copy an address or an invoice. Bitcoin Amount - MAX - DONE + The amount is greater than your current balance. + The amount indicated does not allow a fee to be included. + Amount Invalid Available - Available (spending) Available (savings) - Reserve Balance - The maximum spendable amount is a bit lower due to a required reserve balance. - Review & Send + Available (spending) + Send Bitcoin + Please copy an address or an invoice. + Clipboard Empty + Please remove UTXOs or increase your send amount to proceed. Confirming in - Invoice expiration - Swipe To Pay - Yes, Send + Details It appears you are sending over $100. Do you want to continue? It appears you are sending over 50% of your total balance. Do you want to continue? The transaction fee appears to be over 50% of the amount you are sending. Do you want to continue? The transaction fee appears to be over $10. Do you want to continue? - Fee is potentially too low Current network conditions require that your fee should be greater than {minimumFee} ₿/vbyte. This transaction may fail, take a while to confirm, or get trimmed from the mempool. Do you wish to proceed? - Bitcoin Sent - Payment Pending - QuickPay - Paying\n<accent>invoice...</accent> - This payment is taking a bit longer than expected. You can continue using Bitkit. - Instant Payment Failed - Regular Payment + Fee is potentially too low + DONE + Unable to broadcast the transaction. Please try again. Unfortunately contact could not be paid instantly. You can try a regular payment (more expensive, slower). Transaction Failed - Unable to broadcast the transaction. Please try again. - Removing Tag Failed - Bitkit was unable to find the transaction data. - No lightning invoice found. - No transaction is available to broadcast. - Please check your transaction info and try again. - Minimum relay fee not met - Please increase your fee and try again. - Amount Invalid - The amount is greater than your current balance. - The amount indicated does not allow a fee to be included. - Details Speed and fee - Speed + Set Custom Fee Fee Invalid - Fee Exceeds Maximum Limit - Lower the custom fee and try again. - Fee Below Minimum Limit - Increase the custom fee and try again. - sats/vbyte - Send Amount Too Small - Please increase your send amount to proceed. - Please remove UTXOs or increase your send amount to proceed. - Unable to decrease the fee any further. Unable to increase the fee any further. Otherwise, it will exceed half the current input balance. - Set Custom Fee + Unable to decrease the fee any further. + Speed ₿ {feeSats} for this transaction ₿ {feeSats} for this transaction ({fiatSymbol}{fiatFormatted}) - Note - Comment - Optional comment to receiver + Instant Payment Failed + Invoice + Invoice expiration + MAX + The maximum spendable amount is a bit lower due to a required reserve balance. + Reserve Balance + Please increase your send amount to proceed. + Send Amount Too Small + Payment Pending + This payment is taking a bit longer than expected. You can continue using Bitkit. + QuickPay + Paying\n<accent>invoice...</accent> + Regular Payment + Review & Send + Bitcoin Sent + Swipe To Pay + To + Yes, Send + <accent>Send\nbitcoin</accent>\nto your\nspending balance + Spending + Bitkit was unable to find the transaction data. + Removing Tag Failed Tags Add Tag Add - Adding Tag Failed Bitkit was unable to find the transaction data. - New tag - Enter a new tag - Previously used tags + Adding Tag Failed Filter activity using tags Select Tag + New tag + Enter a new tag No tags available yet - Payment Sent - Your instant payment was sent successfully. - Payment Failed + Previously used tags Your instant payment failed. Please try again. - Received Transaction Replaced + Payment Failed + Your instant payment was sent successfully. + Payment Sent Your received transaction was replaced by a fee bump - Transaction Removed + Received Transaction Replaced Transaction was removed from mempool - Transaction Replaced + Transaction Removed Your transaction was replaced by a fee bump - Transaction Unconfirmed + Transaction Replaced Transaction became unconfirmed due to blockchain reorganization - Coin Selection - Auto - Total required - Total selected - Receive Bitcoin - Edit Invoice - Invoice copied to clipboard - Share receiving address - Bitcoin invoice - Lightning invoice - Optional note to payer - Receive Lightning funds - Show QR Code - Show Details - Savings - Auto - Spending - Want to receive <accent>Lightning</accent> funds? - Receive on Spending Balance - To set up your spending balance, a <accent>{networkFee}</accent> network fee and <accent>{serviceFee}</accent> service provider fee will be deducted. - To receive more instant Bitcoin, Bitkit has to increase your liquidity. A <accent>{networkFee}</accent> network fee and <accent>{serviceFee}</accent> service provider fee will be deducted from the amount you specified. - Spending Balance Setup - Increase Receiving Capacity - Your Spending Balance uses the Lightning Network to make your payments cheaper, faster, and more private.\n\nThis works like internet access, but you pay for liquidity & routing instead of bandwidth.\n\nThis setup includes some one-time costs. - Your Spending Balance uses the Lightning Network to make your payments cheaper, faster, and more private.\n\nThis works like internet access, but you pay for liquidity & routing instead of bandwidth.\n\nBitkit needs to increase the receiving capacity of your spending balance to process this payment. - Spending Balance Liquidity - Additional Spending Balance Liquidity - Transaction Failed - Failed to send funds to your spending account. - You will receive - Spending Balance Initializing... - Receive on <accent>spending balance</accent> - Enjoy instant and cheap\ntransactions with friends, family,\nand merchants. - Generating QR ... - Instant Payments Unavailable - Bitkit does not provide Lightning services in your country, but you can still connect to other nodes. - MINIMUM - Activity - Show All Activity - No activity yet - Receive some funds to get started - Sent - Received - Pending - Failed - Boost Fee - Boosted incoming transaction - Transfer - From Spending (±{duration}) - From Spending - From Savings (±{duration}) - From Savings - To Spending - To Savings - Transfer (±{duration}) - Confirms in {feeRateDescription} - Boosting. Confirms in {feeRateDescription} - Fee potentially too low - Sent Bitcoin - Received Bitcoin - Transaction Retrieval Failed - Bitkit was not able to fetch the transaction data. - The transaction was not found. - Activity not found - Failed to load activity - Confirming - Confirmed - Removed from Mempool - Transactions Removed From Mempool - Please check your activity list. The {count} impacted transaction(s) will be highlighted in red. - Boosting - Fee - Fee (prepaid) - Payment - Status - Date - Time - Contact - Assign - Detach - Tag - Boost - Already Boosted - Explore - Transaction ID - Preimage - Payment hash - Address - {count, plural, one {INPUT} other {INPUTS (#)}} - {count, plural, one {OUTPUT} other {OUTPUTS (#)}} - BOOSTED TRANSACTION {num} (CPFP) - BOOSTED TRANSACTION {num} (RBF) - Open Block Explorer - Successful - Invoice note - Comment - Invoice - All Activity - All - Sent - Received - Other - Savings - Spending - Savings - <accent>Send\nbitcoin</accent>\nto your\nsavings balance - Spending - <accent>Send\nbitcoin</accent>\nto your\nspending balance - Incoming Transfer: + Transaction Unconfirmed + Transfer To Savings + Transfer To Spending Transaction Invalid - Boost - Boost Transaction - Boosted! - The transaction was successfully boosted. - Boost Failed - Bitkit was unable to boost the transaction. - Unable to increase the fee any further. Otherwise, it will exceed half the current input balance - Your transaction may settle faster if you include an additional network fee. Set your custom fee below. - Your transaction may settle faster if you include an additional network fee. Here is a recommendation: - Use Suggested Fee - Swipe To Boost - Received Bitcoin - Received Instant Bitcoin - Transaction Creation Failed - An error occurred. Please try again {raw} - Transaction Broadcast Failed - An error occurred when broadcasting your transaction. {raw} - Please check your connection and try again.\n{message} - Select Range - Clear - Apply - Blockchain Reorg Detected - {count, plural, one {# of your transactions is no longer confirmed.} other {# of your transactions are no longer confirmed.}} Please wait. - Please check your activity list for affected transactions. - Withdraw Bitcoin - AvailablE TO WITHDRAW - The funds you withdraw will be deposited into your Bitkit spending balance. - Withdraw - Pay Bitcoin - Amount Too Low - The minimum amount for this invoice is ₿ {amount}. - Maximum amount - Wallet Balance Hidden - Swipe your wallet balance to reveal it again. - Switched to {unit} - Tap your wallet balance to switch it back to {unit}. - Refresh Failed - An error occurred and couldn\'t refresh the wallet. Please try again. - Connection Throttled - Electrum server connection is throttled. Please wait several minutes before trying again. - Lightning Sync Error - Lightning Startup Error - Insufficient receiving balance. - Insufficient receiving capacity to receive this amount over Lightning. - Keep Bitkit In Foreground - Payments to your spending balance might fail if you switch between apps. - Widgets - Hello,\n<accent>Widgets</accent> - Enjoy decentralized feeds from your favorite web services, by adding fun and useful widgets to your Bitkit wallet. - Widget - Widget Feed - Default - Custom - Please select which fields you want to display in the {name} widget. - Source + Your withdrawal was unsuccessful. Please scan the QR code again or contact support. Add Widget - Delete Widget? - Are you sure you want to delete ‘{name}‘ from your widgets? - Bitcoin Price - Check the latest Bitcoin exchange rates for a variety of fiat currencies. - Couldn\'t get price data - Bitcoin Headlines - Read the latest & greatest Bitcoin headlines from various news sites. - Couldn\'t get the latest news - Bitcoin Blocks Examine various statistics on newly mined Bitcoin Blocks. Couldn\'t get blocks data - Bitcoin Facts - Discover fun facts about Bitcoin, every time you open your wallet. - Bitcoin Calculator + Block + Date + Size + Time + Transactions + Bitcoin Blocks Convert ₿ amounts to {fiatSymbol} or vice versa. - Bitcoin Weather - Find out when it’s a good time to transact on the Bitcoin blockchain. - Favorable Conditions - All clear. Now would be a good time to transact on the blockchain. - Average Conditions + Bitcoin Calculator + Are you sure you want to delete ‘{name}‘ from your widgets? + Delete Widget? + Discover fun facts about Bitcoin, every time you open your wallet. + Bitcoin Facts + Read the latest & greatest Bitcoin headlines from various news sites. + Couldn\'t get the latest news + Bitcoin Headlines + Enjoy decentralized feeds from your favorite web services, by adding fun and useful widgets to your Bitkit wallet. + Hello,\n<accent>Widgets</accent> + Check the latest Bitcoin exchange rates for a variety of fiat currencies. + Couldn\'t get price data + Bitcoin Price The next block rate is close to the monthly averages. - Poor Conditions + Average Conditions + All clear. Now would be a good time to transact on the blockchain. + Favorable Conditions If you are not in a hurry to transact, it may be better to wait a bit. + Poor Conditions Current average fee - Next block inclusion + Find out when it’s a good time to transact on the Bitcoin blockchain. Couldn\'t get current fee weather - Balance moved from spending to savings - Reason: %s - Channel closed - Channel failed to open in the background - Channel open failed - Channel opened - Pending - Ready to send - Lightning error - Payment failed - Please try again - Received %s - Open Bitkit to see details - Payment Received - Bitkit is running in background so you can receive Lightning payments - Stop App - Unknown error - Via new channel - Lightning Wallet Sync Time - Ready - Block Height - Onchain Wallet Sync Time - Rapid Gossip Sync Snapshot Time - Fee Rate Cache Update Time - Channel Monitor Archival Height - Pathfinding Scores Sync Time - Wallet Balances - Total Onchain - Spendable Onchain - Total Anchor Channels Reserve - Total Lightning - Lightning Balances - Usable - Announced - Inbound Capacity - Inbound HTLC Max - Inbound HTLC Min - Next Outbound HTLC Limit - Next Outbound HTLC Min - Confirmations - Please wait while your old wallet data migrates to this new Bitkit version... - Wallet Migration - MIGRATING\n<accent>WALLET</accent> - %1$s sats - Please wait while Bitkit looks for funds in unsupported addresses (Legacy, Nested SegWit, and Taproot). - LOOKING FOR FUNDS... - Looking For Funds - BROADCASTING… - Confirm Sweep - PREPARING… - Retry - Swipe to confirm - To Address - Custom Fee - for this transaction - Error - Network Fee - Bitkit found funds in unsupported addresses (Legacy, Nested SegWit, and Taproot). - FUNDS FOUND - Found Funds - Legacy (P2PKH) - Sweep Funds - Bitkit checked unsupported address types and found no funds to sweep. - No Funds To Sweep - Bitkit found funds on unsupported Bitcoin address types. Sweep to move the funds to your new wallet balance. - Sweep - SWEEP OLD\n<accent>BITKIT FUNDS</accent> - SegWit (P2SH) - Your funds have been swept and will be added to your wallet balance. - Sweep Complete - Wallet Overview - Taproot (P2TR) - Sweep To Wallet - Total - %1$s, %2$d UTXO - %1$s, %2$d UTXOs + Bitcoin Weather + Next block inclusion + Widget Feed + Custom + Default + Please select which fields you want to display in the {name} widget. + Widget + Source + Widgets diff --git a/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt b/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt index 4e9e7fb58..200705b97 100644 --- a/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt +++ b/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt @@ -105,7 +105,7 @@ class LightningNodeServiceTest : BaseUnitTest() { sats = 100L, ) val notification = NotificationDetails( - title = context.getString(R.string.notification_received_title), + title = context.getString(R.string.notification__received__title), body = "Received ₿ 100 ($0.10)", ) whenever(notifyPaymentReceivedHandler.invoke(any())) @@ -147,7 +147,7 @@ class LightningNodeServiceTest : BaseUnitTest() { val shadows = Shadows.shadowOf(notificationManager) val paymentNotification = shadows.allNotifications.find { - it.extras.getString(Notification.EXTRA_TITLE) == context.getString(R.string.notification_received_title) + it.extras.getString(Notification.EXTRA_TITLE) == context.getString(R.string.notification__received__title) } assertNotNull("Payment notification should be present", paymentNotification) @@ -184,7 +184,7 @@ class LightningNodeServiceTest : BaseUnitTest() { val shadows = Shadows.shadowOf(notificationManager) val paymentNotification = shadows.allNotifications.find { - it.extras.getString(Notification.EXTRA_TITLE) == context.getString(R.string.notification_received_title) + it.extras.getString(Notification.EXTRA_TITLE) == context.getString(R.string.notification__received__title) } assertNull("Payment notification should NOT be present in foreground", paymentNotification) @@ -212,7 +212,7 @@ class LightningNodeServiceTest : BaseUnitTest() { val shadows = Shadows.shadowOf(notificationManager) val paymentNotification = shadows.allNotifications.find { - it.extras.getString(Notification.EXTRA_TITLE) == context.getString(R.string.notification_received_title) + it.extras.getString(Notification.EXTRA_TITLE) == context.getString(R.string.notification__received__title) } assertNotNull("Payment notification should be present", paymentNotification) diff --git a/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt b/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt index 3862b7f20..a1ed26584 100644 --- a/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt +++ b/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt @@ -39,7 +39,7 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { @Before fun setUp() { - whenever(context.getString(R.string.notification_received_title)).thenReturn("Payment Received") + whenever(context.getString(R.string.notification__received__title)).thenReturn("Payment Received") whenever(context.getString(any(), any())).thenReturn("Received amount") whenever(settingsStore.data).thenReturn(flowOf(SettingsData(showNotificationDetails = true))) whenever(currencyRepo.convertSatsToFiat(any(), anyOrNull())).thenReturn( diff --git a/app/src/test/java/to/bitkit/ext/DateTimeExtTest.kt b/app/src/test/java/to/bitkit/ext/DateTimeExtTest.kt index f748a3ee7..710d1e408 100644 --- a/app/src/test/java/to/bitkit/ext/DateTimeExtTest.kt +++ b/app/src/test/java/to/bitkit/ext/DateTimeExtTest.kt @@ -135,7 +135,7 @@ class DateTimeExtTest : BaseUnitTest() { fun `toRelativeTimeString preserves backward compatibility with default locale`() { val twoDaysAgo = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(2) val resultWithoutParam = twoDaysAgo.toRelativeTimeString() - val resultWithDefaultParam = twoDaysAgo.toRelativeTimeString(Locale.getDefault()) + val resultWithDefaultParam = twoDaysAgo.toRelativeTimeString() assertEquals(resultWithDefaultParam, resultWithoutParam) } 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 8f55f55a1..df2b089cd 100644 --- a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt @@ -1,5 +1,6 @@ package to.bitkit.ui +import android.content.Context import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.runBlocking @@ -15,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 @@ -33,6 +34,7 @@ import to.bitkit.viewmodels.WalletViewModel class WalletViewModelTest : BaseUnitTest() { private lateinit var sut: WalletViewModel + private val context = mock() private val walletRepo = mock() private val lightningRepo = mock() private val settingsStore = mock() @@ -47,11 +49,13 @@ class WalletViewModelTest : BaseUnitTest() { @Before fun setUp() = runBlocking { + whenever(context.getString(any())).thenReturn("") whenever(walletRepo.walletState).thenReturn(walletState) whenever(lightningRepo.lightningState).thenReturn(lightningState) whenever(migrationService.isMigrationChecked()).thenReturn(true) sut = WalletViewModel( + context = context, bgDispatcher = testDispatcher, walletRepo = walletRepo, lightningRepo = lightningRepo, @@ -93,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)) @@ -235,6 +239,7 @@ class WalletViewModelTest : BaseUnitTest() { .thenReturn(Result.success(Unit)) val testSut = WalletViewModel( + context = context, bgDispatcher = testDispatcher, walletRepo = testWalletRepo, lightningRepo = testLightningRepo, @@ -274,6 +279,7 @@ class WalletViewModelTest : BaseUnitTest() { .thenReturn(Result.success(Unit)) val testSut = WalletViewModel( + context = context, bgDispatcher = testDispatcher, walletRepo = testWalletRepo, lightningRepo = testLightningRepo, diff --git a/app/src/test/java/to/bitkit/ui/settings/appStatus/AppStatusViewModelTest.kt b/app/src/test/java/to/bitkit/ui/settings/appStatus/AppStatusViewModelTest.kt index 8cb5bf270..dc5ff93a1 100644 --- a/app/src/test/java/to/bitkit/ui/settings/appStatus/AppStatusViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/settings/appStatus/AppStatusViewModelTest.kt @@ -36,6 +36,11 @@ class AppStatusViewModelTest : BaseUnitTest() { fun setUp() { whenever(context.getString(R.string.settings__status__backup__error)).thenReturn(failedBackupSubtitle) whenever(context.getString(R.string.settings__status__backup__ready)).thenReturn(readyBackupSubtitle) + whenever(context.getString(R.string.other__node_stopped)).thenReturn("Stopped") + whenever(context.getString(R.string.other__node_starting)).thenReturn("Starting") + whenever(context.getString(R.string.other__node_running)).thenReturn("Running") + whenever(context.getString(R.string.other__node_stopping)).thenReturn("Stopping") + whenever(context.getString(R.string.other__node_initializing)).thenReturn("Setting up wallet…") whenever(healthRepo.healthState).thenReturn(MutableStateFlow(AppHealthState())) whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState())) whenever(cacheStore.backupStatuses).thenReturn(flowOf(emptyMap())) 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..e19b5d69c 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 @@ -11,6 +11,7 @@ import to.bitkit.fcm.EncryptedNotification import kotlin.test.assertContentEquals import kotlin.test.assertEquals +@Suppress("SpacingBetweenDeclarationsWithAnnotations", "Wrapping") class CryptoTest { private lateinit var sut: Crypto @@ -28,13 +29,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,10 +85,15 @@ class CryptoTest { val serverPublicKey = "031e9923e689a181a803486b7d8c0d4a5aad360edb70c8bb413a98458d91652213" val derivationName = "bitkit-notifications" - val ciphertext = "l2fInfyw64gO12odo8iipISloQJ45Rc4WjFmpe95brdaAMDq+T/L9ZChcmMCXnR0J6BXd8sSIJe/0bmby8uSZZJuVCzwF76XHfY5oq0Y1/hKzyZTn8nG3dqfiLHnAPy1tZFQfm5ALgjwWnViYJLXoGFpXs7kLMA=".fromBase64() + @Suppress("MaxLineLength") + val ciphertext = ("l2fInfyw64gO12odo8iipISloQJ45Rc4WjFmpe95brdaAMDq+T/L9ZChcmMCXnR0J6BXd8sSI" + + "Je/0bmby8uSZZJuVCzwF76XHfY5oq0Y1/hKzyZTn8nG3dqfiLHnAPy1tZFQfm5ALgjwWnViYJLXoGFpXs7kLMA=") + .fromBase64() val iv = "2b8ed77fd2198e3ed88cfaa794a246e8" val tag = "caddd13746d6a6aed16176734964d3a3" - val decryptedPayload = """{"source":"blocktank","type":"incomingHtlc","payload":{"secretMessage":"hello"},"createdAt":"2024-09-18T13:33:52.555Z"}""" + @Suppress("MaxLineLength") + val decryptedPayload = """{"source":"blocktank","type":"incomingHtlc","payload":""" + + """{"secretMessage":"hello"},"createdAt":"2024-09-18T13:33:52.555Z"}""" // Without derivationName val sharedSecret = sut.generateSharedSecret(clientPrivateKey.fromHex(), serverPublicKey) 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/docs/strings.md b/docs/strings.md new file mode 100644 index 000000000..f411b80f7 --- /dev/null +++ b/docs/strings.md @@ -0,0 +1,118 @@ +# Untranslated Strings Tracker + +This document tracks hardcoded strings in the codebase. Strings in dev-only screens do not need translation. + +## Dev-Only Strings (No Translation Needed) + +These screens are only accessible in development builds and contain hardcoded strings that don't need localization. + +### DevSettingsScreen.kt +| Line | String | +|------|--------| +| 52 | Fee Settings | +| 53 | Channel Orders | +| 54 | LDK Debug | +| 56 | LOGS | +| 57 | Logs | +| 59 | Export Logs | +| 66 | REGTEST | +| 68 | Blocktank Regtest | +| 71 | APP CACHE | +| 74 | Reset Settings State | +| 77 | Settings state reset | +| 81 | Reset All Activities | +| 84 | Activities removed | +| 88 | Reset Backup State | +| 91 | Backup state reset | +| 95 | Reset Widgets State | +| 98 | Widgets state reset | +| 102 | Refresh Currency Rates | +| 105 | Currency rates refreshed | +| 109 | Reset App Database | +| 112 | Database state reset | +| 116 | Reset Blocktank State | +| 119 | Blocktank state reset | +| 123 | Reset Cache Store | +| 126 | Cache store reset | +| 130 | Wipe App | +| 133 | Wallet wiped | +| 137 | DEBUG | +| 140 | Generate Test Activities | +| 144 | Generated $count test activities | +| 148 | Fake New BG Receive | +| 151 | Restart app to see the payment received sheet | +| 155 | Open Channel To Trusted Peer | +| 161 | NOTIFICATIONS | +| 164 | Register For LSP Notifications | +| 170 | Test LSP Notification | + +### LdkDebugScreen.kt +| Line | String | +|------|--------| +| 96 | LDK Debug | +| 105 | ADD PEER | +| 109 | pubkey@host:port | +| 120 | Add Peer | +| 127 | Paste & Add | +| 135 | NETWORK GRAPH | +| 137 | Log Graph Info | +| 149 | Export to File | +| 160 | VSS | +| 162 | List Keys | +| 164 | found | +| 191 | Delete key | +| 205 | Delete All | +| 211 | NODE | +| 213 | Restart | +| 225 | Delete All VSS Keys? | +| 226 | This will permanently delete all... | +| 227 | Delete All | + +### BlocktankRegtestScreen.kt +| Line | String | +|------|--------| +| 57 | Blocktank Regtest | +| 81 | These actions are executed on the staging Blocktank server node. | +| 84 | DEPOSIT | +| 97 | Amount (sats) | +| 104 | Depositing... / Make Deposit | +| 116 | Success | +| 117 | Deposit successful. TxID: ... | +| 123 | Failed to deposit | +| 136 | MINING | +| 146 | Block Count | +| 180 | Mining... / Mine Blocks | +| 162 | Success | +| 163 | Successfully mined $count blocks | +| 169 | Failed to mine | +| 185 | LIGHTNING PAYMENT | +| 189 | Invoice | +| 197 | Amount (optional, sats) | +| 204 | Pay Invoice | +| 215 | Success | +| 216 | Payment successful. ID: ... | +| 222 | Failed to pay invoice from LND | +| 232 | CHANNEL CLOSE | +| 236 | Funding TxID | +| 244 | Vout | +| 253 | Force Close After (seconds) | +| 260 | Close Channel | +| 279 | Success | +| 280 | Channel closed. Closing TxID: ... | + +### LogsScreen.kt +- All strings are technical log display (no localization needed) + +### ChannelOrdersScreen.kt +- All strings are technical channel order data (no localization needed) + +## Preview Functions + +Hardcoded strings in `@Preview` functions throughout the codebase do not need translation as they are only visible in Android Studio previews, not to end users. + +## Borderline Cases + +| File | Line | String | Notes | +|------|------|--------|-------| +| NotificationPreview.kt | 63 | 3m ago | Placeholder in notification mockup | +| BackgroundPaymentsSettings.kt | 111 | ₿ 21 000 | Example amount in preview | 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" }