From 7ddf6be736b7d0f65a5610b11fa473ecf97cb99d Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 26 Oct 2026 14:05:46 +0100 Subject: [PATCH 01/47] fix: point transfers to manual setup after blocktank sunset --- .../ui/screens/transfer/FundingScreenTest.kt | 12 ++++ app/src/main/java/to/bitkit/env/Env.kt | 1 + .../to/bitkit/repositories/LightningRepo.kt | 3 + app/src/main/java/to/bitkit/ui/ContentView.kt | 36 ++++++++-- .../java/to/bitkit/ui/components/SheetHost.kt | 1 + .../to/bitkit/ui/components/SheetIntro.kt | 3 +- .../ui/screens/transfer/FundingScreen.kt | 4 +- .../ui/screens/wallets/SavingsWalletScreen.kt | 28 +------- .../wallets/receive/LocationBlockScreen.kt | 58 +++------------ .../ui/sheets/TransferUnavailableSheet.kt | 70 +++++++++++++++++++ .../to/bitkit/viewmodels/TransferViewModel.kt | 16 ++++- app/src/main/res/values-ar/strings.xml | 5 +- app/src/main/res/values-b+es+419/strings.xml | 5 +- app/src/main/res/values-ca/strings.xml | 5 +- app/src/main/res/values-cs/strings.xml | 5 +- app/src/main/res/values-de/strings.xml | 5 +- app/src/main/res/values-el/strings.xml | 5 +- app/src/main/res/values-es-rES/strings.xml | 5 +- app/src/main/res/values-es/strings.xml | 5 +- app/src/main/res/values-fr/strings.xml | 5 +- app/src/main/res/values-it/strings.xml | 5 +- app/src/main/res/values-nl/strings.xml | 5 +- app/src/main/res/values-pl/strings.xml | 5 +- app/src/main/res/values-pt-rBR/strings.xml | 5 +- app/src/main/res/values-pt/strings.xml | 5 +- app/src/main/res/values-ru/strings.xml | 5 +- app/src/main/res/values/strings.xml | 7 +- .../bitkit/repositories/LightningRepoTest.kt | 14 ++++ .../viewmodels/TransferViewModelTest.kt | 45 ++++++++++++ 29 files changed, 255 insertions(+), 118 deletions(-) create mode 100644 app/src/main/java/to/bitkit/ui/sheets/TransferUnavailableSheet.kt diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/transfer/FundingScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/transfer/FundingScreenTest.kt index a062322016..dedb592839 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/transfer/FundingScreenTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/transfer/FundingScreenTest.kt @@ -120,6 +120,18 @@ class FundingScreenTest { assertEquals(0, manualClicks) } + @Test + fun whenGeoBlocked_transferStillInvokesOnTransferForTheUnavailableSheet() { + setContent(isGeoBlocked = true) + + clickableTransferOption().performClick() + composeTestRule.waitForIdle() + + assertEquals(1, transferClicks) + assertEquals(0, fundClicks) + assertEquals(0, manualClicks) + } + @Test fun whenFundableBalanceIsZero_transferShouldNotRouteToTheTransferFlow() { setContent(fundableBalance = 0uL) diff --git a/app/src/main/java/to/bitkit/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt index 09797720d0..402e5cbb2d 100644 --- a/app/src/main/java/to/bitkit/env/Env.kt +++ b/app/src/main/java/to/bitkit/env/Env.kt @@ -177,6 +177,7 @@ internal object Env { const val BITKIT_TELEGRAM = "https://t.me/bitkitchat" const val BITKIT_GITHUB = "https://github.com/synonymdev" const val BITKIT_HELP_CENTER = "https://help.bitkit.to" + const val BLOCKTANK_SUNSET_URL = "https://bitkit.to/" const val TERMS_OF_USE_URL = "https://bitkit.to/terms-of-use" const val PRIVACY_POLICY_URL = "https://bitkit.to/privacy-policy" const val STORING_BITCOINS_URL = "https://en.bitcoin.it/wiki/Storing_bitcoins" diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index d1752edce9..148cd8c46a 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -368,6 +368,9 @@ class LightningRepo @Inject constructor( if (shouldCancelPendingStop) cancelPendingStop() + // The geocheck needs no node, so screens gated on it are right before the node is up. + scope.launch { updateGeoBlockState() } + eventHandler?.let { _eventHandlers.add(it) } // Track retry state outside mutex to avoid deadlock (Mutex is non-reentrant) diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index fff4a35de1..535b4b356e 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -226,6 +226,7 @@ import to.bitkit.ui.sheets.PinSheet import to.bitkit.ui.sheets.QrScanningSheet import to.bitkit.ui.sheets.QuickPayIntroSheet import to.bitkit.ui.sheets.SendSheet +import to.bitkit.ui.sheets.TransferUnavailableSheet import to.bitkit.ui.sheets.UpdateSheet import to.bitkit.ui.sheets.UsdtIntroSheet import to.bitkit.ui.sheets.WidgetsSheet @@ -656,6 +657,12 @@ fun ContentView( Sheet.ConnectionClosed -> ConnectionClosedSheet( onDismiss = { appViewModel.hideSheet() }, ) + Sheet.TransferUnavailable -> TransferUnavailableSheet( + onContinue = { + navController.navigateTo(ExternalConnection()) + appViewModel.hideSheet() + }, + ) is Sheet.BTCPayConnection -> BTCPayConnectionSheet(sheet, appViewModel) is Sheet.Gift -> GiftSheet(sheet, appViewModel) @@ -884,6 +891,8 @@ private fun RootNavHost( transferViewModel.transferEffects.collect { effect -> if (effect is TransferEffect.OnFundingPending) { appViewModel.showPendingTransfer(effect.attempt) + } else if (effect is TransferEffect.OnTransferUnavailable) { + appViewModel.showSheet(Sheet.TransferUnavailable) } else { navController.navigateForTransferEffect(effect) } @@ -1141,7 +1150,11 @@ private fun RootNavHost( FundingScreen( onTransfer = { - navController.navigateToTransferSpendingStart(hasSeenSpendingIntro) + if (isGeoBlocked) { + appViewModel.showSheet(Sheet.TransferUnavailable) + } else { + navController.navigateToTransferSpendingStart(hasSeenSpendingIntro) + } }, onFund = { scope.launch { @@ -1298,13 +1311,16 @@ private fun NavGraphBuilder.home( val forceCloseRemainingDuration by appViewModel.forceCloseRemainingDuration.collectAsStateWithLifecycle() SavingsWalletScreen( - isGeoBlocked = isGeoBlocked, onchainActivities = onchainActivities ?: persistentListOf(), onAllActivityButtonClick = { navController.navigateToAllActivity(activityListViewModel::clearFilters) }, onActivityItemClick = { navController.navToActivityDetail(it) }, onEmptyActivityRowClick = { appViewModel.showSheet(Sheet.Receive()) }, onTransferToSpendingClick = { - navController.navigateToTransferSpendingStart(hasSeenSpendingIntro) + if (isGeoBlocked) { + appViewModel.showSheet(Sheet.TransferUnavailable) + } else { + navController.navigateToTransferSpendingStart(hasSeenSpendingIntro) + } }, onBackClick = { navController.popBackStack() }, forceCloseRemainingDuration = forceCloseRemainingDuration, @@ -1313,6 +1329,7 @@ private fun NavGraphBuilder.home( deepLinkableComposable { val hasSeenSavingsIntro by settingsViewModel.hasSeenSavingsIntro.collectAsStateWithLifecycle() val hasSeenSpendingIntro by settingsViewModel.hasSeenSpendingIntro.collectAsStateWithLifecycle() + val isGeoBlocked by appViewModel.isGeoBlocked.collectAsStateWithLifecycle() val lightningState by walletViewModel.lightningState.collectAsStateWithLifecycle() val lightningActivities by activityListViewModel.lightningActivities.collectAsStateWithLifecycle() @@ -1330,7 +1347,11 @@ private fun NavGraphBuilder.home( } }, onTransferFromSavingsClick = { - navController.navigateToTransferSpendingStart(hasSeenSpendingIntro) + if (isGeoBlocked) { + appViewModel.showSheet(Sheet.TransferUnavailable) + } else { + navController.navigateToTransferSpendingStart(hasSeenSpendingIntro) + } }, onBackClick = { navController.popBackStack() }, ) @@ -1338,11 +1359,16 @@ private fun NavGraphBuilder.home( deepLinkableComposable { val walletId = it.toRoute().walletId val hasSeenSpendingIntro by settingsViewModel.hasSeenSpendingIntro.collectAsStateWithLifecycle() + val isGeoBlocked by appViewModel.isGeoBlocked.collectAsStateWithLifecycle() HardwareWalletScreen( walletId = walletId, onActivityItemClick = { navController.navToActivityDetail(it) }, onTransferToSpendingClick = { selectedDeviceId -> - navController.navigateToTransferSpendingStart(hasSeenSpendingIntro, selectedDeviceId) + if (isGeoBlocked) { + appViewModel.showSheet(Sheet.TransferUnavailable) + } else { + navController.navigateToTransferSpendingStart(hasSeenSpendingIntro, selectedDeviceId) + } }, onBackClick = { navController.popBackStack() }, ) diff --git a/app/src/main/java/to/bitkit/ui/components/SheetHost.kt b/app/src/main/java/to/bitkit/ui/components/SheetHost.kt index ff1b102332..18c366380e 100644 --- a/app/src/main/java/to/bitkit/ui/components/SheetHost.kt +++ b/app/src/main/java/to/bitkit/ui/components/SheetHost.kt @@ -98,6 +98,7 @@ sealed interface Sheet { data object ForceTransfer : Sheet data class Gift(val code: String, val amount: ULong) : Sheet data object ConnectionClosed : Sheet + data object TransferUnavailable : Sheet data class BTCPayConnection( val setup: SamRockSetupRequest, val isConnecting: Boolean = false, diff --git a/app/src/main/java/to/bitkit/ui/components/SheetIntro.kt b/app/src/main/java/to/bitkit/ui/components/SheetIntro.kt index a4a029e3b9..22face51ba 100644 --- a/app/src/main/java/to/bitkit/ui/components/SheetIntro.kt +++ b/app/src/main/java/to/bitkit/ui/components/SheetIntro.kt @@ -38,6 +38,7 @@ fun SheetIntro( continueLoading: Boolean = false, cancelText: String? = null, onCancel: (() -> Unit)? = null, + onBack: (() -> Unit)? = null, testTag: String = "SheetIntro", cancelTestTag: String = "${testTag}Cancel", continueTestTag: String = "${testTag}Continue", @@ -49,7 +50,7 @@ fun SheetIntro( .navigationBarsPadding() .testTag(testTag) ) { - SheetTopBar(navTitle) + SheetTopBar(navTitle, onBack = onBack) Column( modifier = Modifier.padding(horizontal = 32.dp), 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 39f276e3b0..c88240ebd4 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 @@ -62,7 +62,7 @@ fun FundingScreen( Spacer(modifier = Modifier.height(8.dp)) val text = if (isGeoBlocked) { - stringResource(R.string.lightning__funding__text_blocked) + stringResource(R.string.lightning__transfer_unavailable__text) } else { stringResource(R.string.lightning__funding__text) } @@ -78,7 +78,7 @@ fun FundingScreen( label = stringResource(R.string.lightning__funding__button1), icon = R.drawable.ic_transfer, iconTint = Colors.Purple, - enabled = canTransfer && !isGeoBlocked, + enabled = canTransfer, onClick = onTransfer, modifier = Modifier.testTag("FundTransfer") ) 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 f9aa4c5da8..317b8d5370 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 @@ -58,7 +58,6 @@ import to.bitkit.ui.utils.withAccent @Composable fun SavingsWalletScreen( - isGeoBlocked: Boolean, onchainActivities: ImmutableList, onAllActivityButtonClick: () -> Unit, onEmptyActivityRowClick: () -> Unit, @@ -74,8 +73,7 @@ fun SavingsWalletScreen( mutableStateOf(!hasFunds && !hasActivity) } val canTransfer by remember(balances.totalOnchainSats) { - val hasFunds = balances.totalOnchainSats > 0uL - mutableStateOf(hasFunds && !isGeoBlocked) + mutableStateOf(balances.totalOnchainSats > 0uL) } val hazeState = rememberHazeState() @@ -198,7 +196,6 @@ private fun Preview() { AppThemeSurface { Box { SavingsWalletScreen( - isGeoBlocked = false, onchainActivities = previewOnchainActivityItems(), onAllActivityButtonClick = {}, onActivityItemClick = {}, @@ -218,7 +215,6 @@ private fun PreviewTransfer() { AppThemeSurface { Box { SavingsWalletScreen( - isGeoBlocked = false, onchainActivities = previewOnchainActivityItems(), onAllActivityButtonClick = {}, onActivityItemClick = {}, @@ -241,7 +237,6 @@ private fun PreviewNoActivity() { AppThemeSurface { Box { SavingsWalletScreen( - isGeoBlocked = false, onchainActivities = persistentListOf(), onAllActivityButtonClick = {}, onActivityItemClick = {}, @@ -255,33 +250,12 @@ private fun PreviewNoActivity() { } } -@Preview(showSystemUi = true) -@Composable -private fun PreviewGeoBlocked() { - AppThemeSurface { - Box { - SavingsWalletScreen( - isGeoBlocked = true, - onchainActivities = previewOnchainActivityItems(), - onAllActivityButtonClick = {}, - onActivityItemClick = {}, - onEmptyActivityRowClick = {}, - onTransferToSpendingClick = {}, - onBackClick = {}, - balances = BalanceState(totalOnchainSats = 50_000u), - ) - TabBar() - } - } -} - @Preview(showSystemUi = true) @Composable private fun PreviewEmpty() { AppThemeSurface { Box { SavingsWalletScreen( - isGeoBlocked = false, onchainActivities = persistentListOf(), onAllActivityButtonClick = {}, onActivityItemClick = {}, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/LocationBlockScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/LocationBlockScreen.kt index 5b00aa4f5a..ee5b93b634 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/LocationBlockScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/LocationBlockScreen.kt @@ -1,29 +1,14 @@ package to.bitkit.ui.screens.wallets.receive -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.ContentScale -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 -import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BottomSheetPreview -import to.bitkit.ui.components.PrimaryButton -import to.bitkit.ui.scaffold.SheetTopBar import to.bitkit.ui.shared.modifiers.sheetHeight -import to.bitkit.ui.shared.util.gradientBackground +import to.bitkit.ui.sheets.TransferUnavailableContent import to.bitkit.ui.theme.AppThemeSurface -import to.bitkit.ui.theme.Colors @Composable fun LocationBlockScreen( @@ -31,40 +16,13 @@ fun LocationBlockScreen( navigateAdvancedSetup: () -> Unit, modifier: Modifier = Modifier, ) { - Column( - modifier = modifier - .fillMaxSize() - .gradientBackground() - .navigationBarsPadding() - ) { - SheetTopBar(stringResource(R.string.wallet__receive_bitcoin), onBack = onBackPressed) - - Spacer(modifier = Modifier.height(16.dp)) - - Column( - modifier = Modifier - .padding(horizontal = 16.dp) - .weight(1f), - ) { - BodyM(text = stringResource(R.string.lightning__funding__text_blocked_cjit), color = Colors.White64) - - Spacer(modifier = Modifier.weight(1f)) - - Image( - painter = painterResource(R.drawable.globe), - contentScale = ContentScale.FillWidth, - contentDescription = null, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 60.dp) - ) - - Spacer(modifier = Modifier.weight(1f)) - - PrimaryButton(text = stringResource(R.string.onboarding__advanced_setup), onClick = navigateAdvancedSetup) - Spacer(modifier = Modifier.height(16.dp)) - } - } + TransferUnavailableContent( + navTitle = stringResource(R.string.wallet__receive_bitcoin), + description = stringResource(R.string.lightning__transfer_unavailable__text_receive), + onContinue = navigateAdvancedSetup, + onBack = onBackPressed, + modifier = modifier, + ) } @Preview(showSystemUi = true) diff --git a/app/src/main/java/to/bitkit/ui/sheets/TransferUnavailableSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/TransferUnavailableSheet.kt new file mode 100644 index 0000000000..8cf390cea8 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/sheets/TransferUnavailableSheet.kt @@ -0,0 +1,70 @@ +package to.bitkit.ui.sheets + +import android.content.Intent +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.tooling.preview.Preview +import androidx.core.net.toUri +import to.bitkit.R +import to.bitkit.env.Env +import to.bitkit.ui.components.BottomSheetPreview +import to.bitkit.ui.components.SheetIntro +import to.bitkit.ui.shared.modifiers.sheetHeight +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.withAccent + +/** Shown instead of the Blocktank transfer to spending, which is no longer available. */ +@Composable +fun TransferUnavailableSheet( + onContinue: () -> Unit, + modifier: Modifier = Modifier, +) { + TransferUnavailableContent( + navTitle = stringResource(R.string.lightning__transfer_unavailable__nav_title), + description = stringResource(R.string.lightning__transfer_unavailable__text), + onContinue = onContinue, + modifier = modifier.sheetHeight(isModal = true), + ) +} + +@Composable +fun TransferUnavailableContent( + navTitle: String, + description: String, + onContinue: () -> Unit, + modifier: Modifier = Modifier, + onBack: (() -> Unit)? = null, +) { + val context = LocalContext.current + SheetIntro( + navTitle = navTitle, + title = stringResource(R.string.lightning__transfer_unavailable__title).withAccent(accentColor = Colors.Purple), + description = AnnotatedString(description), + image = R.drawable.lightning, + continueText = stringResource(R.string.common__continue), + onContinue = onContinue, + cancelText = stringResource(R.string.common__read_more), + onCancel = { + runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, Env.BLOCKTANK_SUNSET_URL.toUri())) } + }, + onBack = onBack, + testTag = "TransferUnavailable", + cancelTestTag = "TransferUnavailableReadMore", + continueTestTag = "TransferUnavailableContinue", + modifier = modifier, + ) +} + +@Preview(showSystemUi = true) +@Composable +private fun Preview() { + AppThemeSurface { + BottomSheetPreview { + TransferUnavailableSheet(onContinue = {}) + } + } +} diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 61db2e66a3..e6b09da274 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -357,7 +357,9 @@ class TransferViewModel @Inject constructor( private suspend fun getOrCreateSpendingOrder(): IBtOrder? { val state = _spendingUiState.value - return state.order ?: blocktankRepo.createOrder( + state.order?.let { return it } + if (isTransferUnavailable()) return null + return blocktankRepo.createOrder( spendingBalanceSats = state.clientBalanceSat, receivingBalanceSats = state.lspBalanceSat, ).getOrElse { @@ -414,6 +416,7 @@ class TransferViewModel @Inject constructor( return false } if (cacheStore.data.first().paidOrders.containsKey(order.id)) return true + if (isTransferUnavailable()) return false val plan = resolveSpendingConfirmPlan(order, shown) ?: return false Logger.debug( @@ -477,6 +480,15 @@ class TransferViewModel @Inject constructor( ) } + /** Blocktank no longer sells channels, so neither a new order nor one held from before may be paid. */ + private suspend fun isTransferUnavailable(): Boolean { + lightningRepo.updateGeoBlockState() + if (!lightningRepo.lightningState.value.isGeoBlocked) return false + Logger.warn("Skipped paying spending order: transfers are unavailable", context = TAG) + transferEffects.emit(TransferEffect.OnTransferUnavailable) + return true + } + private suspend fun showRetainedFundingPending(): Boolean { val attempt = lightningRepo.currentOnchainSendAttempt() ?: return false if (!attempt.isTransfer || !attempt.blocksNextSend) return false @@ -1231,6 +1243,7 @@ class TransferViewModel @Inject constructor( _spendingUiState.update { it.copy(isHwPassphraseRequired = true) } return@launch } + if (!isBroadcastRetry && isTransferUnavailable()) return@launch val order = ensureSpendingOrder() ?: return@launch val address = order.fundingAddress if (address.isEmpty()) { @@ -2170,6 +2183,7 @@ sealed interface TransferEffect { data object OnSpendingFundingPaid : TransferEffect data class OnFundingPending(val attempt: to.bitkit.repositories.OnchainSendAttempt) : TransferEffect data object OnHwTxSigned : TransferEffect + data object OnTransferUnavailable : TransferEffect data class ToastException(val e: Throwable) : TransferEffect data class ToastError(val title: String, val description: String) : TransferEffect } diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 0894e7c3af..08275e941c 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -162,8 +162,6 @@ استخدم محفظة أخرى رصيد الإنفاق أسهل خيار هو استخدام مدخرات Bitkit الخاصة بك أو إرسال بيتكوين من محفظة مختلفة. - لا يوفر Bitkit حاليًا خدمات Lightning في بلدك، لكن يمكنك الاتصال بعقد أخرى مباشرة. - لا يوفر Bitkit حاليًا خدمات Lightning في بلدك، لكن يمكنك الاتصال بعقد أخرى مباشرة. موّل <accent>رصيد الإنفاق</accent> رمز QR لقناة LNURL إعداد يدوي @@ -1288,6 +1286,9 @@ وقّع باستخدام\n<accent>جهازك</accent> تم توقيع\n<accent>المعاملة</accent> التحويل جاهز خلال %s + التحويل غير متاح + لم تعد التحويلات التلقائية إلى رصيد الإنفاق متاحة. استخدم عقدتك الخاصة، أو أنشئ اتصالًا مع مزوّد آخر. + <accent>الإعداد اليدوي</accent>\nمطلوب يرجى التأكد من أن لديك اتصالاً مستقرًا بالإنترنت. قد تظهر البيانات بشكل غير صحيح أثناء محاولة الاتصال. تم اكتشاف مشاكل في الشبكة لا يحتوي رمز QR هذا على SeedQR صالح من 12 كلمة. diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml index b051b9e1e7..c1e8f46efe 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -169,8 +169,6 @@ Usar Otra Billetera Saldo de gastos La forma más fácil es usar tus ahorros de Bitkit o enviar bitcoin desde una billetera diferente. - Bitkit no tiene soporte Lightning en tu región, pero puedes usar nodos externos manualmente. - Bitkit no tiene soporte Lightning en tu región, pero puedes usar nodos externos manualmente. Fondee su <accent>saldo de gastos</accent> QR de canal LNURL Configuración Manual @@ -1318,6 +1316,9 @@ Firme con\n<accent>su dispositivo</accent> Transacción\n<accent>firmada</accent> TRANSFERENCIA LISTA EN %s + Transferencia no disponible + Las transferencias automáticas a tu saldo de gasto ya no están disponibles. Usa tu propio nodo o configura una conexión con otro proveedor. + <accent>Configuración manual</accent>\nnecesaria Asegúrese de tener una conexión a internet estable. Los datos podrían mostrarse incorrectamente mientras se intenta conectar. Problemas de red detectados Este código QR no contiene un SeedQR válido de 12 palabras. diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index f83790081a..e57642c255 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -166,8 +166,6 @@ Utilitza un altre moneder Balanç de despesa La forma més fàcil és utilitzar els teus estalvis de Bitkit o enviar bitcoin des d\'una altra cartera. - Bitkit no ofereix serveis Lightning al teu país, però encara pots connectar-te a altres nodes directament. - Bitkit no ofereix serveis Lightning al teu país, però encara pots connectar-te a altres nodes directament. Finança el teu <accent>saldo de despesa</accent> QR de canal LNURL Configuració manual @@ -1312,6 +1310,9 @@ Signa amb\n<accent>el teu dispositiu</accent> Transacció\n<accent>signada</accent> TRANSFERÈNCIA LLESTA EN %s + Transferència no disponible + Les transferències automàtiques al teu saldo de despesa ja no estan disponibles. Fes servir el teu propi node o configura una connexió amb un altre proveïdor. + <accent>Configuració manual</accent>\nnecessària Assegura\'t que tens una connexió a Internet estable. És possible que les dades es mostrin incorrectament mentre s\'intenta connectar. S\'han detectat problemes de xarxa Aquest codi QR no conté un SeedQR de 12 paraules vàlid. diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index bb7a644e4b..d187d44323 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -169,8 +169,6 @@ Použijte jinou peněženku Dostupný zůstatek Nejjednodušší možností je použít úspory z Bitkitu nebo poslat bitcoin z jiné peněženky. - Bitkit v současné době neposkytuje služby lightning ve vaší zemi, ale stále se můžete připojit k jiným uzlům přímo. - Bitkit v současné době neposkytuje služby lightning ve vaší zemi, ale stále se můžete připojit k jiným uzlům přímo. Navyšte svůj <accent>disponibilní zůstatek</accent> LNURL kanál QR Manuální nastavení @@ -1316,6 +1314,9 @@ Podepište\n<accent>svým zařízením</accent> Transakce\n<accent>podepsána</accent> PŘEVOD PŘIPRAVEN ZA %s + Převod není dostupný + Automatické převody na zůstatek útrat již nejsou k dispozici. Použijte vlastní uzel nebo si nastavte připojení u jiného poskytovatele. + <accent>Vyžadováno ruční</accent>\nnastavení Zajistěte prosím stabilní připojení k internetu. Během pokusu o připojení se data mohou zobrazovat nesprávně. Zjištěny problémy se sítí Tento QR kód neobsahuje platný 12slovný SeedQR. diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index efc30cca07..e1a4c354d1 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -107,8 +107,6 @@ Spending Balance Fund your <accent>spending balance</accent> Am einfachsten ist es, deine Bitkit-Ersparnisse zu verwenden oder Bitcoin von einer anderen Wallet zu senden. - Bitkit bietet derzeit keine Lightning-Dienste in deinem Land an, aber du kannst dich trotzdem direkt mit anderen Knotenpunkten verbinden. - Bitkit bietet derzeit keine Lightning-Dienste in deinem Land an, aber du kannst dich trotzdem direkt mit anderen Knotenpunkten verbinden. Von Sparkonto übertragen Anderes Wallet verwenden Ausgabenkonto @@ -1313,6 +1311,9 @@ Mit deinem\n<accent>Gerät signieren</accent> Transaktion\n<accent>signiert</accent> ÜBERTRAGUNG BEREIT IN %s + Übertragung nicht verfügbar + Automatische Übertragungen auf dein Ausgabenkonto sind nicht mehr verfügbar. Nutze deinen eigenen Node oder richte eine Verbindung mit einem anderen Anbieter ein. + <accent>Manuelle Einrichtung</accent>\nerforderlich Bitte stelle sicher, dass du eine stabile Internetverbindung hast. Während des Verbindungsversuchs werden Daten möglicherweise falsch angezeigt. Netzwerkprobleme erkannt Dieser QR-Code enthält keinen gültigen 12-Wörter-SeedQR. diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 3800135d9c..e41ec16fe9 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -162,8 +162,6 @@ Χρήση άλλου πορτοφολιού Υπόλοιπο δαπανών Ο ευκολότερος τρόπος είναι να χρησιμοποιήσεις τις αποταμιεύσεις Bitkit ή να στείλεις bitcoin από διαφορετικό πορτοφόλι. - Το Bitkit δεν παρέχει προς το παρόν υπηρεσίες Lightning στη χώρα σου, αλλά μπορείς να συνδεθείς απευθείας σε άλλους κόμβους. - Το Bitkit δεν παρέχει προς το παρόν υπηρεσίες Lightning στη χώρα σου, αλλά μπορείς να συνδεθείς απευθείας σε άλλους κόμβους. Χρηματοδότησε το <accent>υπόλοιπο δαπανών</accent> LNURL Channel QR Χειροκίνητη ρύθμιση @@ -1294,6 +1292,9 @@ Υπόγραψε με\n<accent>τη συσκευή σου</accent> Η συναλλαγή\n<accent>υπογράφηκε</accent> Η ΜΕΤΑΦΟΡΑ ΕΙΝΑΙ ΕΤΟΙΜΗ ΣΕ %s + Μεταφορά μη διαθέσιμη + Οι αυτόματες μεταφορές στο υπόλοιπο δαπανών δεν είναι πλέον διαθέσιμες. Χρησιμοποιήστε τον δικό σας κόμβο ή ρυθμίστε σύνδεση με άλλον πάροχο. + <accent>Απαιτείται χειροκίνητη</accent>\nρύθμιση Βεβαιώσου ότι έχεις σταθερή σύνδεση στο διαδίκτυο. Τα δεδομένα μπορεί να εμφανίζονται λανθασμένα κατά την προσπάθεια σύνδεσης. Εντοπίστηκαν προβλήματα δικτύου Αυτός ο κωδικός QR δεν περιέχει έγκυρο SeedQR 12 λέξεων. diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index fc09657b40..2dfc7c7057 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -168,8 +168,6 @@ Usar Otro Monedero Saldo de gastos La forma más fácil es usar sus ahorros en Bitkit o enviar bitcoin desde un monedero diferente. - Bitkit actualmente no ofrece servicios Lightning en tu país, pero aún puedes conectarte directamente a otros nodos. - Bitkit actualmente no ofrece servicios Lightning en tu país, pero aún puedes conectarte directamente a otros nodos. Rellene su <accent>saldo de gastos</accent> QR de canal LNURL Configuración manual @@ -1310,6 +1308,9 @@ Firma con\n<accent>tu dispositivo</accent> Transacción\n<accent>firmada</accent> TRANSFERENCIA LISTA EN %s + Transferencia no disponible + Las transferencias automáticas a tu saldo de gasto ya no están disponibles. Usa tu propio nodo o configura una conexión con otro proveedor. + <accent>Configuración manual</accent>\nnecesaria Asegúrate de tener una conexión a internet estable. Los datos pueden mostrarse de forma incorrecta mientras se intenta conectar. Problemas de red detectados Este código QR no contiene un SeedQR válido de 12 palabras. diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 5072785f9b..b1c79bcf91 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -134,8 +134,6 @@ Comisiones Algunas conexiones no pudieron cerrarse. No se pueden transferir los fondos de vuelta a ahorros. Por favor, inténtalo de nuevo. - Bitkit no ofrece actualmente servicios Lightning en tu país, pero puedes conectarte a otros nodos directamente. - Bitkit no ofrece actualmente servicios Lightning en tu país, pero puedes conectarte a otros nodos directamente. QR de Canal LNURL Configuración Manual Escanea un QR para reclamar tu canal LNURL de otro LSP, o elige la configuración manual. @@ -1309,6 +1307,9 @@ Firme con\n<accent>su dispositivo</accent> Transacción\n<accent>firmada</accent> TRANSFERENCIA LISTA EN %s + Transferencia no disponible + Las transferencias automáticas a tu saldo de gasto ya no están disponibles. Usa tu propio nodo o configura una conexión con otro proveedor. + <accent>Configuración manual</accent>\nnecesaria Asegúrese de tener una conexión a internet estable. Los datos pueden mostrarse de forma incorrecta mientras se intenta conectar. Problemas de red detectados Este código QR no contiene un SeedQR válido de 12 palabras. diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 6948ef3d1e..e6a4ab3d5e 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -106,8 +106,6 @@ Solde Dépenses Financez votre <accent>solde Dépenses</accent> Le plus simple est d\'utiliser vos économies Bitkit ou d\'envoyer des bitcoins depuis un autre portefeuille. - Bitkit ne fournit pas actuellement de services Lightning dans votre pays, mais vous pouvez toujours vous connecter directement à d\'autres nœuds. - Bitkit ne fournit pas actuellement de services Lightning dans votre pays, mais vous pouvez toujours vous connecter directement à d\'autres nœuds. Transfert depuis l\'épargne Utiliser un autre portefeuille Solde Dépenses @@ -1312,6 +1310,9 @@ Signez avec\n<accent>votre appareil</accent> Transaction\n<accent>signée</accent> TRANSFERT PRÊT DANS %s + Transfert indisponible + Les transferts automatiques vers votre solde de dépenses ne sont plus disponibles. Utilisez votre propre nœud ou configurez une connexion avec un autre fournisseur. + <accent>Configuration manuelle</accent>\nrequise Veuillez vous assurer que vous disposez d\'une connexion internet stable. Les données peuvent s\'afficher incorrectement pendant la tentative de connexion. Problèmes de réseau détectés Ce QR code ne contient pas de SeedQR valide de 12 mots. diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 1689758d4e..eb95cbb7be 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -167,8 +167,6 @@ Usa un altro portafoglio Conto di Spesa Il modo più semplice è usare i tuoi risparmi Bitkit o inviare bitcoin da un portafoglio diverso. - Bitkit attualmente non fornisce servizi Lightning nel tuo paese, ma puoi comunque connetterti direttamente ad altri nodi. - Bitkit attualmente non fornisce servizi Lightning nel tuo paese, ma puoi comunque connetterti direttamente ad altri nodi. Manda fondi al tuo <accent>conto di spesa</accent> QR Canale LNURL Configurazione Manuale @@ -1315,6 +1313,9 @@ Firma con\n<accent>il tuo dispositivo</accent> Transazione\n<accent>firmata</accent> TRASFERIMENTO PRONTO TRA %s + Trasferimento non disponibile + I trasferimenti automatici al saldo di spesa non sono più disponibili. Usa il tuo nodo o configura una connessione con un altro fornitore. + <accent>Configurazione manuale</accent>\nrichiesta Assicurati di avere una connessione a internet stabile. I dati potrebbero essere mostrati in modo errato durante il tentativo di connessione. Rilevati Problemi di Rete Questo codice QR non contiene un SeedQR valido da 12 parole. diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 2cb52c4df6..26d3158011 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -166,8 +166,6 @@ Andere wallet gebruiken Bestedingssaldo De makkelijkste optie is om je Bitkit-spaargeld te gebruiken of bitcoin te versturen vanuit een andere wallet. - Bitkit biedt momenteel geen Lightning-services in jouw land, maar je kunt nog steeds rechtstreeks verbinding maken met andere nodes. - Bitkit biedt momenteel geen Lightning-services in jouw land, maar je kunt nog steeds rechtstreeks verbinding maken met andere nodes. Vul je <accent>bestedingssaldo</accent> LNURL Kanaal QR Handmatige configuratie @@ -1311,6 +1309,9 @@ Onderteken met\n<accent>uw apparaat</accent> Transactie\n<accent>ondertekend</accent> OVERBOEKING KLAAR OVER %s + Overboeking niet beschikbaar + Automatische overboekingen naar je bestedingssaldo zijn niet meer beschikbaar. Gebruik je eigen node of stel een verbinding in met een andere aanbieder. + <accent>Handmatige instelling</accent>\nvereist Zorg voor een stabiele internetverbinding. Gegevens kunnen onjuist worden weergegeven terwijl er verbinding wordt gemaakt. Netwerkproblemen gedetecteerd Deze QR-code bevat geen geldige SeedQR van 12 woorden. diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 6b90799070..9d7b4988cb 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -108,8 +108,6 @@ Saldo wydatków Zasil swoje <accent>saldo wydatków</accent> Najłatwiejszą opcją jest użycie oszczędności Bitkit lub wysłanie bitcoinów z innego portfela. - Bitkit nie świadczy obecnie usług Lightning w Twoim kraju, ale nadal możesz łączyć się z innymi węzłami. - Bitkit nie świadczy obecnie usług Lightning w Twoim kraju, ale nadal możesz łączyć się bezpośrednio z innymi węzłami. Prześlij z Oszczędności Użyj innego portfela Saldo do wydawania @@ -1315,6 +1313,9 @@ Podpisz\n<accent>urządzeniem</accent> Transakcja\n<accent>podpisana</accent> TRANSFER GOTOWY ZA %s + Przesłanie niedostępne + Automatyczne przesyłanie środków na saldo wydatków nie jest już dostępne. Użyj własnego węzła lub skonfiguruj połączenie z innym dostawcą. + <accent>Wymagana ręczna</accent>\nkonfiguracja Upewnij się, że masz stabilne połączenie z internetem. Podczas łączenia dane mogą być wyświetlane nieprawidłowo. Wykryto problemy z siecią Ten kod QR nie zawiera prawidłowego 12-wyrazowego SeedQR. diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 53396ccd4e..cf2dccb71b 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -170,8 +170,6 @@ Transferir de Outra Carteira Saldo de Gastos A forma mais fácil é usar suas economias do Bitkit ou enviar bitcoin de uma carteira diferente. - Bitkit não fornece serviços de Lightning no seu paús atualmente, mas você ainda pode se conectar diretamente a outros nodes - Bitkit não fornece serviços de Lightning no seu paús atualmente, mas você ainda pode se conectar diretamente a outros nodes Transfira para seu <accent>saldo de gastos</accent> QR Canal LNURL Setup Manual @@ -1317,6 +1315,9 @@ Assine com\n<accent>seu dispositivo</accent> Transação\n<accent>assinada</accent> TRANSFERÊNCIA PRONTA EM %s + Transferência indisponível + As transferências automáticas para seu saldo de gastos não estão mais disponíveis. Use seu próprio nó ou configure uma conexão com outro provedor. + <accent>Configuração manual</accent>\nnecessária Verifique se você tem uma conexão estável com a internet. Os dados podem ser exibidos incorretamente enquanto tentamos conectar. Problemas de Rede Detectados Este código QR não contém um SeedQR válido de 12 palavras. diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 33a4fed7a7..6c78e62288 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -103,8 +103,6 @@ Saldo de Gastos Transfira para seu <accent>saldo de gastos</accent> A forma mais fácil é usar as suas poupanças Bitkit ou enviar bitcoin de uma carteira diferente. - Bitkit não fornece serviços de Lightning no seu paús atualmente, mas você ainda pode se conectar diretamente a outros nodes - Bitkit não fornece serviços de Lightning no seu paús atualmente, mas você ainda pode se conectar diretamente a outros nodes Transferir do Saldo de Poupança Transferir de Outra Carteira Saldo de Gastos @@ -1286,6 +1284,9 @@ Assine com\n<accent>o seu dispositivo</accent> Transação\n<accent>assinada</accent> TRANSFERÊNCIA PRONTA EM %s + Transferência indisponível + As transferências automáticas para o seu saldo de gastos já não estão disponíveis. Use o seu próprio nó ou configure uma ligação com outro fornecedor. + <accent>Configuração manual</accent>\nnecessária Confirme que tem uma ligação à internet estável. Os dados podem ser apresentados incorretamente enquanto tenta ligar. Problemas de rede detetados Este código QR não contém um SeedQR válido de 12 palavras. diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 1b60527452..4a9271de24 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -178,8 +178,6 @@ Использовать Другой Кошелёк Баланс Расходов Самый простой вариант — использовать ваши сбережения Bitkit или отправить биткоин с другого кошелька. - Bitkit в настоящее время не предоставляет услуги Lightning в вашей стране, но вы всё ещё можете подключаться к другим нодам напрямую. - Bitkit в настоящее время не предоставляет услуги Lightning в вашей стране, но вы всё ещё можете подключаться к другим нодам напрямую. Пополните свой <accent>Баланс Расходов</accent> QR LNURL Канала Ручная Настройка @@ -1346,6 +1344,9 @@ Bitkit должен увеличить приёмную ёмкость ваше Подпишите на\n<accent>устройстве</accent> Транзакция\n<accent>подписана</accent> ПЕРЕВОД БУДЕТ ГОТОВ ЧЕРЕЗ %s + Перевод недоступен + Автоматические переводы на баланс расходов больше недоступны. Используйте собственный узел или настройте подключение к другому провайдеру. + <accent>Требуется ручная</accent>\nнастройка Убедитесь, что у вас стабильное подключение к интернету. Во время подключения данные могут отображаться некорректно. Обнаружены Проблемы с Сетью Этот QR-код не содержит действительный SeedQR из 12 слов. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f3e6434bbe..5dec235187 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -83,6 +83,7 @@ OK Awesome!\nNice!\nCool!\nGreat!\nFantastic!\nSweet!\nExcellent!\nTerrific! Preview + Read More Ready Remove Remove %1$s tag @@ -315,8 +316,6 @@ Use Other Wallet Spending Balance The easiest option is to 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. Fund your <accent>spending balance</accent> LNURL Channel QR Manual Setup @@ -442,6 +441,10 @@ 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> + Transfer Unavailable + Automatic transfers to your spending balance are no longer available. Use your own node, or set up a connection with another provider. + Receiving to your spending balance through Bitkit is no longer available. Use your own node, or set up a connection with another provider. + <accent>Manual setup</accent>\nrequired 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. This usually takes less than a minute. diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index a06dcd724b..2555185782 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -625,6 +625,20 @@ class LightningRepoTest : BaseUnitTest() { verifyBlocking(lightningService, never()) { startEventListener(any()) } } + @Test + fun `start updates geo block state before the node is up`() = test { + stubNodeStatus { false } + whenever(lightningService.node).thenReturn(mock()) + whenever(lightningService.start(anyOrNull(), any())).doSuspendableAnswer { awaitCancellation() } + whenever(coreService.isGeoBlocked()).thenReturn(true) + + val job = launch { sut.start(shouldRetry = false) } + runCurrent() + + assertTrue(sut.lightningState.value.isGeoBlocked) + job.cancelAndJoin() + } + @Test fun `start restores the initial state when cancelled during node setup`() = test { stubNodeStatus { false } diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index 991be6cf8b..6770f92c62 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -1200,6 +1200,51 @@ class TransferViewModelTest : BaseUnitTest() { verify(blocktankRepo, times(1)).createOrder(eq(order.clientBalanceSat), eq(order.lspBalanceSat), any()) } + @Test + fun `onTransferToSpendingConfirm pays no held order when transfers are unavailable`() = test { + val order = spendingOrder(feeSat = 99_000uL) + stubSpendableBalances(spendable = 200_000u) + whenever { + lightningRepo.selectUtxosWithAlgorithm(any(), any(), any(), anyOrNull()) + }.thenReturn(Result.success(listOf(stubUtxo(200_000u)))) + whenever(lightningRepo.calculateTotalFee(any(), any(), any(), anyOrNull(), anyOrNull())) + .thenReturn(Result.success(500uL)) + stubSendOnChain() + val effects = mutableListOf() + backgroundScope.launch { sut.transferEffects.collect { effects += it } } + quoteOrder(order) + prepareConfirm() + val nodeStatus = mock() + whenever(nodeStatus.isRunning).thenReturn(true) + whenever(lightningRepo.lightningState) + .thenReturn(MutableStateFlow(LightningState(nodeStatus = nodeStatus, isGeoBlocked = true))) + + sut.onTransferToSpendingConfirm() + advanceUntilIdle() + + assertTrue(TransferEffect.OnTransferUnavailable in effects) + assertEquals(false, sut.spendingUiState.value.isConfirmPaying) + verify(lightningRepo, never()).sendOnChain( + address = any(), + sats = any(), + speed = any(), + utxosToSpend = anyOrNull(), + feeRates = anyOrNull(), + isTransfer = any(), + channelId = anyOrNull(), + isMaxAmount = any(), + tags = any(), + beforeSendAttempt = any(), + onBroadcast = any(), + requestId = anyOrNull(), + orderId = anyOrNull(), + transferContext = anyOrNull(), + payerIdentity = anyOrNull(), + paymentDeadlineAt = anyOrNull(), + contactPublicKey = anyOrNull(), + ) + } + @Test fun `onTransferToSpendingConfirm does not drain when normal fee leaves non-dust change`() = test { val order = spendingOrder(feeSat = 35_341uL) From 1e0ef74e667b4986f2295682f7999b7eca23ba4b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 26 Oct 2026 14:22:06 +0100 Subject: [PATCH 02/47] fix: show the sunset sheet in every language --- .../to/bitkit/viewmodels/TransferViewModel.kt | 1 - app/src/main/res/values-ar/strings.xml | 2 ++ app/src/main/res/values-b+es+419/strings.xml | 2 ++ app/src/main/res/values-ca/strings.xml | 2 ++ app/src/main/res/values-cs/strings.xml | 2 ++ app/src/main/res/values-de/strings.xml | 2 ++ app/src/main/res/values-el/strings.xml | 2 ++ app/src/main/res/values-es-rES/strings.xml | 2 ++ app/src/main/res/values-es/strings.xml | 2 ++ app/src/main/res/values-fr/strings.xml | 2 ++ app/src/main/res/values-it/strings.xml | 2 ++ app/src/main/res/values-nl/strings.xml | 2 ++ app/src/main/res/values-pl/strings.xml | 2 ++ app/src/main/res/values-pt-rBR/strings.xml | 2 ++ app/src/main/res/values-pt/strings.xml | 2 ++ app/src/main/res/values-ru/strings.xml | 2 ++ .../transfer/transfer-unavailable-sheet.xml | 26 +++++++++++++++++++ 17 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 journeys/transfer/transfer-unavailable-sheet.xml diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index e6b09da274..a59160ed1c 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -480,7 +480,6 @@ class TransferViewModel @Inject constructor( ) } - /** Blocktank no longer sells channels, so neither a new order nor one held from before may be paid. */ private suspend fun isTransferUnavailable(): Boolean { lightningRepo.updateGeoBlockState() if (!lightningRepo.lightningState.value.isGeoBlocked) return false diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 08275e941c..758723091d 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -1142,6 +1142,7 @@ ربط الجهاز محفظة أجهزة إخفاء التفاصيل + اقرأ المزيد إزالة إزالة العلامة %1$s مستلم من %1$s @@ -1288,6 +1289,7 @@ التحويل جاهز خلال %s التحويل غير متاح لم تعد التحويلات التلقائية إلى رصيد الإنفاق متاحة. استخدم عقدتك الخاصة، أو أنشئ اتصالًا مع مزوّد آخر. + لم يعد الاستلام إلى رصيد الإنفاق عبر Bitkit متاحًا. استخدم عقدتك الخاصة، أو أنشئ اتصالًا مع مزوّد آخر. <accent>الإعداد اليدوي</accent>\nمطلوب يرجى التأكد من أن لديك اتصالاً مستقرًا بالإنترنت. قد تظهر البيانات بشكل غير صحيح أثناء محاولة الاتصال. تم اكتشاف مشاكل في الشبكة diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml index c1e8f46efe..57ed095691 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -1180,6 +1180,7 @@ Conectar dispositivo Hardware Ocultar detalles + Leer más Quitar Quitar etiqueta %1$s Recibido de %1$s @@ -1318,6 +1319,7 @@ TRANSFERENCIA LISTA EN %s Transferencia no disponible Las transferencias automáticas a tu saldo de gasto ya no están disponibles. Usa tu propio nodo o configura una conexión con otro proveedor. + Recibir en tu saldo de gasto a través de Bitkit ya no está disponible. Usa tu propio nodo o configura una conexión con otro proveedor. <accent>Configuración manual</accent>\nnecesaria Asegúrese de tener una conexión a internet estable. Los datos podrían mostrarse incorrectamente mientras se intenta conectar. Problemas de red detectados diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index e57642c255..26775ac900 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -1170,6 +1170,7 @@ Connecta el dispositiu Maquinari Amagar detalls + Llegeix més Eliminar Eliminar l\'etiqueta %1$s Rebut de %1$s @@ -1312,6 +1313,7 @@ TRANSFERÈNCIA LLESTA EN %s Transferència no disponible Les transferències automàtiques al teu saldo de despesa ja no estan disponibles. Fes servir el teu propi node o configura una connexió amb un altre proveïdor. + Rebre al teu saldo de despesa a través de Bitkit ja no està disponible. Fes servir el teu propi node o configura una connexió amb un altre proveïdor. <accent>Configuració manual</accent>\nnecessària Assegura\'t que tens una connexió a Internet estable. És possible que les dades es mostrin incorrectament mentre s\'intenta connectar. S\'han detectat problemes de xarxa diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index d187d44323..5e8075bc00 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -1177,6 +1177,7 @@ Připojit zařízení Hardware Skrýt podrobnosti + Číst dále Odebrat Odebrat tag %1$s Přijato od %1$s @@ -1316,6 +1317,7 @@ PŘEVOD PŘIPRAVEN ZA %s Převod není dostupný Automatické převody na zůstatek útrat již nejsou k dispozici. Použijte vlastní uzel nebo si nastavte připojení u jiného poskytovatele. + Příjem na zůstatek útrat přes Bitkit již není k dispozici. Použijte vlastní uzel nebo si nastavte připojení u jiného poskytovatele. <accent>Vyžadováno ruční</accent>\nnastavení Zajistěte prosím stabilní připojení k internetu. Během pokusu o připojení se data mohou zobrazovat nesprávně. Zjištěny problémy se sítí diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index e1a4c354d1..5c23734c6d 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1174,6 +1174,7 @@ Gerät verbinden Hardware Details ausblenden + Mehr lesen Entfernen Tag %1$s entfernen Empfangen von %1$s @@ -1313,6 +1314,7 @@ ÜBERTRAGUNG BEREIT IN %s Übertragung nicht verfügbar Automatische Übertragungen auf dein Ausgabenkonto sind nicht mehr verfügbar. Nutze deinen eigenen Node oder richte eine Verbindung mit einem anderen Anbieter ein. + Empfangen auf dein Ausgabenkonto über Bitkit ist nicht mehr verfügbar. Nutze deinen eigenen Node oder richte eine Verbindung mit einem anderen Anbieter ein. <accent>Manuelle Einrichtung</accent>\nerforderlich Bitte stelle sicher, dass du eine stabile Internetverbindung hast. Während des Verbindungsversuchs werden Daten möglicherweise falsch angezeigt. Netzwerkprobleme erkannt diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index e41ec16fe9..87dbb2cd82 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -1149,6 +1149,7 @@ Σύνδεση συσκευής Πορτοφόλι υλικού Απόκρυψη λεπτομερειών + Διαβάστε περισσότερα Αφαίρεση Αφαίρεση ετικέτας %1$s Λήφθηκε από %1$s @@ -1294,6 +1295,7 @@ Η ΜΕΤΑΦΟΡΑ ΕΙΝΑΙ ΕΤΟΙΜΗ ΣΕ %s Μεταφορά μη διαθέσιμη Οι αυτόματες μεταφορές στο υπόλοιπο δαπανών δεν είναι πλέον διαθέσιμες. Χρησιμοποιήστε τον δικό σας κόμβο ή ρυθμίστε σύνδεση με άλλον πάροχο. + Η λήψη στο υπόλοιπο δαπανών μέσω του Bitkit δεν είναι πλέον διαθέσιμη. Χρησιμοποιήστε τον δικό σας κόμβο ή ρυθμίστε σύνδεση με άλλον πάροχο. <accent>Απαιτείται χειροκίνητη</accent>\nρύθμιση Βεβαιώσου ότι έχεις σταθερή σύνδεση στο διαδίκτυο. Τα δεδομένα μπορεί να εμφανίζονται λανθασμένα κατά την προσπάθεια σύνδεσης. Εντοπίστηκαν προβλήματα δικτύου diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index 2dfc7c7057..8013177deb 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -1171,6 +1171,7 @@ Conectar dispositivo Hardware Ocultar detalles + Leer más Eliminar Eliminar etiqueta %1$s Recibido de %1$s @@ -1310,6 +1311,7 @@ TRANSFERENCIA LISTA EN %s Transferencia no disponible Las transferencias automáticas a tu saldo de gasto ya no están disponibles. Usa tu propio nodo o configura una conexión con otro proveedor. + Recibir en tu saldo de gasto a través de Bitkit ya no está disponible. Usa tu propio nodo o configura una conexión con otro proveedor. <accent>Configuración manual</accent>\nnecesaria Asegúrate de tener una conexión a internet estable. Los datos pueden mostrarse de forma incorrecta mientras se intenta conectar. Problemas de red detectados diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index b1c79bcf91..f9cede8344 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1168,6 +1168,7 @@ Conectar dispositivo Hardware Ocultar detalles + Leer más Eliminar Eliminar etiqueta %1$s Recibido de %1$s @@ -1309,6 +1310,7 @@ TRANSFERENCIA LISTA EN %s Transferencia no disponible Las transferencias automáticas a tu saldo de gasto ya no están disponibles. Usa tu propio nodo o configura una conexión con otro proveedor. + Recibir en tu saldo de gasto a través de Bitkit ya no está disponible. Usa tu propio nodo o configura una conexión con otro proveedor. <accent>Configuración manual</accent>\nnecesaria Asegúrese de tener una conexión a internet estable. Los datos pueden mostrarse de forma incorrecta mientras se intenta conectar. Problemas de red detectados diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index e6a4ab3d5e..29ebfedb6b 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -1173,6 +1173,7 @@ Connecter un appareil Matériel Masquer les détails + Lire la suite Retirer Retirer l\'étiquette %1$s Reçu de %1$s @@ -1312,6 +1313,7 @@ TRANSFERT PRÊT DANS %s Transfert indisponible Les transferts automatiques vers votre solde de dépenses ne sont plus disponibles. Utilisez votre propre nœud ou configurez une connexion avec un autre fournisseur. + La réception sur votre solde de dépenses via Bitkit n’est plus disponible. Utilisez votre propre nœud ou configurez une connexion avec un autre fournisseur. <accent>Configuration manuelle</accent>\nrequise Veuillez vous assurer que vous disposez d\'une connexion internet stable. Les données peuvent s\'afficher incorrectement pendant la tentative de connexion. Problèmes de réseau détectés diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index eb95cbb7be..40332859de 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -1175,6 +1175,7 @@ Connetti dispositivo Hardware Nascondi Dettagli + Leggi di più Rimuovi Rimuovi tag %1$s Ricevuto da %1$s @@ -1315,6 +1316,7 @@ TRASFERIMENTO PRONTO TRA %s Trasferimento non disponibile I trasferimenti automatici al saldo di spesa non sono più disponibili. Usa il tuo nodo o configura una connessione con un altro fornitore. + La ricezione sul saldo di spesa tramite Bitkit non è più disponibile. Usa il tuo nodo o configura una connessione con un altro fornitore. <accent>Configurazione manuale</accent>\nrichiesta Assicurati di avere una connessione a internet stabile. I dati potrebbero essere mostrati in modo errato durante il tentativo di connessione. Rilevati Problemi di Rete diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 26d3158011..8346f4a77b 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -1170,6 +1170,7 @@ Apparaat verbinden Hardware Details verbergen + Lees meer Verwijderen Tag %1$s verwijderen Ontvangen van %1$s @@ -1311,6 +1312,7 @@ OVERBOEKING KLAAR OVER %s Overboeking niet beschikbaar Automatische overboekingen naar je bestedingssaldo zijn niet meer beschikbaar. Gebruik je eigen node of stel een verbinding in met een andere aanbieder. + Ontvangen op je bestedingssaldo via Bitkit is niet meer beschikbaar. Gebruik je eigen node of stel een verbinding in met een andere aanbieder. <accent>Handmatige instelling</accent>\nvereist Zorg voor een stabiele internetverbinding. Gegevens kunnen onjuist worden weergegeven terwijl er verbinding wordt gemaakt. Netwerkproblemen gedetecteerd diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 9d7b4988cb..2ec8cee93c 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -1178,6 +1178,7 @@ Połącz urządzenie Sprzętowy Ukryj szczegóły + Czytaj więcej Usuń Usuń tag %1$s Otrzymano od %1$s @@ -1315,6 +1316,7 @@ TRANSFER GOTOWY ZA %s Przesłanie niedostępne Automatyczne przesyłanie środków na saldo wydatków nie jest już dostępne. Użyj własnego węzła lub skonfiguruj połączenie z innym dostawcą. + Odbieranie środków na saldo wydatków przez Bitkit nie jest już dostępne. Użyj własnego węzła lub skonfiguruj połączenie z innym dostawcą. <accent>Wymagana ręczna</accent>\nkonfiguracja Upewnij się, że masz stabilne połączenie z internetem. Podczas łączenia dane mogą być wyświetlane nieprawidłowo. Wykryto problemy z siecią diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index cf2dccb71b..9653c70d82 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -1180,6 +1180,7 @@ Conectar dispositivo Hardware Ocultar Detalhes + Leia Mais Remover Remover tag %1$s Recebido de %1$s @@ -1317,6 +1318,7 @@ TRANSFERÊNCIA PRONTA EM %s Transferência indisponível As transferências automáticas para seu saldo de gastos não estão mais disponíveis. Use seu próprio nó ou configure uma conexão com outro provedor. + O recebimento no seu saldo de gastos pelo Bitkit não está mais disponível. Use seu próprio nó ou configure uma conexão com outro provedor. <accent>Configuração manual</accent>\nnecessária Verifique se você tem uma conexão estável com a internet. Os dados podem ser exibidos incorretamente enquanto tentamos conectar. Problemas de Rede Detectados diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 6c78e62288..e2db0bfd2f 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -1142,6 +1142,7 @@ Ligar dispositivo Hardware Ocultar detalhes + Ler Mais Remover Remover etiqueta %1$s Recebido de %1$s @@ -1286,6 +1287,7 @@ TRANSFERÊNCIA PRONTA EM %s Transferência indisponível As transferências automáticas para o seu saldo de gastos já não estão disponíveis. Use o seu próprio nó ou configure uma ligação com outro fornecedor. + A receção no seu saldo de gastos através da Bitkit já não está disponível. Use o seu próprio nó ou configure uma ligação com outro fornecedor. <accent>Configuração manual</accent>\nnecessária Confirme que tem uma ligação à internet estável. Os dados podem ser apresentados incorretamente enquanto tenta ligar. Problemas de rede detetados diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 4a9271de24..c984c03010 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1209,6 +1209,7 @@ Bitkit должен увеличить приёмную ёмкость ваше Подключить устройство Аппаратный Скрыть детали + Читать далее Удалить Удалить тег %1$s Получено от %1$s @@ -1346,6 +1347,7 @@ Bitkit должен увеличить приёмную ёмкость ваше ПЕРЕВОД БУДЕТ ГОТОВ ЧЕРЕЗ %s Перевод недоступен Автоматические переводы на баланс расходов больше недоступны. Используйте собственный узел или настройте подключение к другому провайдеру. + Получение на баланс расходов через Bitkit больше недоступно. Используйте собственный узел или настройте подключение к другому провайдеру. <accent>Требуется ручная</accent>\nнастройка Убедитесь, что у вас стабильное подключение к интернету. Во время подключения данные могут отображаться некорректно. Обнаружены Проблемы с Сетью diff --git a/journeys/transfer/transfer-unavailable-sheet.xml b/journeys/transfer/transfer-unavailable-sheet.xml new file mode 100644 index 0000000000..dabc663ad8 --- /dev/null +++ b/journeys/transfer/transfer-unavailable-sheet.xml @@ -0,0 +1,26 @@ + + + Verifies that Bitkit points users to Manual Setup now that Blocktank no longer sells Lightning channels. + Blocktank's geocheck answers 403, so the app is geo-blocked, and every Transfer To Spending entry point + opens the "Transfer Unavailable" sheet (testTag "TransferUnavailable") instead of the transfer flow. The + sheet shows "MANUAL SETUP REQUIRED" (testTag "TransferUnavailableTitle") and the buttons "Read More" + (testTag "TransferUnavailableReadMore"), which opens https://bitkit.to/ in the browser, and "Continue" + (testTag "TransferUnavailableContinue"), which opens the existing Manual Setup screen. Receive's Lightning + path shows the same layout with copy for receiving. + + Precondition: dev build with geoblocking on (the default; `GEO=false` turns it off), onboarded wallet with + a positive on-chain Savings balance and no channels. Start on the wallet home screen. + + + Tap the Savings balance card (testTag "ActivitySavings") and verify "Transfer To Spending" (testTag "TransferToSpending") is shown + Tap "Transfer To Spending" and verify the sheet titled "Transfer Unavailable" shows "MANUAL SETUP REQUIRED" and "Automatic transfers to your spending balance are no longer available. Use your own node, or set up a connection with another provider." + Tap "Read More" and verify the browser opens bitkit.to, then return to Bitkit + Tap "Continue" and verify the sheet closes and the "MANUAL SETUP" screen shows the node id (testTag "NodeIdInput"), host (testTag "HostInput") and port (testTag "PortInput") fields + Go back to the wallet home, tap the Spending balance card (testTag "ActivitySpending"), tap the transfer button (testTag "TransferFromSavings") and verify the same sheet opens; close it + Open Settings › Advanced › Lightning Connections, tap add connection (testTag "add_connection_button") and verify the screen text reads "Automatic transfers to your spending balance are no longer available. Use your own node, or set up a connection with another provider." + Tap "Transfer from Savings" (testTag "FundTransfer") and verify the same sheet opens; close it and go back to the wallet home + Tap "Receive" (testTag "Receive"), select the Spending tab (testTag "Tab-spending") and tap "Receive Lightning funds" + Verify the screen titled "Receive Bitcoin" shows "MANUAL SETUP REQUIRED" and "Receiving to your spending balance through Bitkit is no longer available. Use your own node, or set up a connection with another provider.", with a back button + Tap "Continue" and verify the sheet closes and the "MANUAL SETUP" screen opens + + From b8cdd9d2471a2888d426d3a693494a3da5f6f10f Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 26 Oct 2026 14:28:52 +0100 Subject: [PATCH 03/47] fix: keep geo state on stop and add changelog --- app/src/main/java/to/bitkit/repositories/LightningRepo.kt | 8 ++++++-- .../main/java/to/bitkit/viewmodels/TransferViewModel.kt | 2 +- changelog.d/next/1696.changed.md | 1 + 3 files changed, 8 insertions(+), 3 deletions(-) create mode 100644 changelog.d/next/1696.changed.md diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 148cd8c46a..6701476671 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -729,7 +729,9 @@ class LightningRepo @Inject constructor( _lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Stopping) } lightningService.stop() clearProbeOutcomes() - _lightningState.update { LightningState(nodeLifecycleState = NodeLifecycleState.Stopped) } + _lightningState.update { + LightningState(nodeLifecycleState = NodeLifecycleState.Stopped, isGeoBlocked = it.isGeoBlocked) + } } }.onFailure { Logger.error("Node stop error", it, context = TAG) @@ -740,7 +742,9 @@ class LightningRepo @Inject constructor( _lightningState.update { s -> s.copy(nodeLifecycleState = NodeLifecycleState.Running) } } else { // Node appears stopped, update state - _lightningState.update { LightningState(nodeLifecycleState = NodeLifecycleState.Stopped) } + _lightningState.update { + LightningState(nodeLifecycleState = NodeLifecycleState.Stopped, isGeoBlocked = it.isGeoBlocked) + } } } } diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index a59160ed1c..48ba72081f 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -1238,11 +1238,11 @@ class TransferViewModel @Inject constructor( val signedOrder = _spendingUiState.value.order val isBroadcastRetry = signedOrder != null && pendingHwFundingBroadcast?.matches(signedOrder, walletId, signedOrder.fundingAddress) == true + if (!isBroadcastRetry && isTransferUnavailable()) return@launch if (!isBroadcastRetry && hwWalletRepo.needsPassphrase(walletId)) { _spendingUiState.update { it.copy(isHwPassphraseRequired = true) } return@launch } - if (!isBroadcastRetry && isTransferUnavailable()) return@launch val order = ensureSpendingOrder() ?: return@launch val address = order.fundingAddress if (address.isEmpty()) { diff --git a/changelog.d/next/1696.changed.md b/changelog.d/next/1696.changed.md new file mode 100644 index 0000000000..d724a48892 --- /dev/null +++ b/changelog.d/next/1696.changed.md @@ -0,0 +1 @@ +Transfers to spending now point to Manual Setup, since Bitkit no longer opens Lightning connections automatically. From ff2d43ee88395224e2c66928fb31f397d27a837c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 26 Oct 2026 14:42:19 +0100 Subject: [PATCH 04/47] fix: stop hardware broadcast retries of unpaid orders --- app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 48ba72081f..5f254987d0 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -1238,7 +1238,7 @@ class TransferViewModel @Inject constructor( val signedOrder = _spendingUiState.value.order val isBroadcastRetry = signedOrder != null && pendingHwFundingBroadcast?.matches(signedOrder, walletId, signedOrder.fundingAddress) == true - if (!isBroadcastRetry && isTransferUnavailable()) return@launch + if (isTransferUnavailable()) return@launch if (!isBroadcastRetry && hwWalletRepo.needsPassphrase(walletId)) { _spendingUiState.update { it.copy(isHwPassphraseRequired = true) } return@launch From c395e783fed399d9a77e466df9b10b2166bb861f Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 26 Oct 2026 14:58:31 +0100 Subject: [PATCH 05/47] fix: match the sunset figma frames --- .../ui/screens/transfer/FundingScreenTest.kt | 8 ++- app/src/main/java/to/bitkit/ui/ContentView.kt | 6 +- .../ui/screens/transfer/FundingScreen.kt | 64 ++++++++++++++++--- .../wallets/receive/LocationBlockScreen.kt | 4 +- .../ui/sheets/TransferUnavailableSheet.kt | 4 +- .../res/drawable/ic_device_tablet_speaker.xml | 18 ++++++ app/src/main/res/values-ar/strings.xml | 3 +- app/src/main/res/values-b+es+419/strings.xml | 3 +- app/src/main/res/values-ca/strings.xml | 3 +- app/src/main/res/values-cs/strings.xml | 3 +- app/src/main/res/values-de/strings.xml | 3 +- app/src/main/res/values-el/strings.xml | 3 +- app/src/main/res/values-es-rES/strings.xml | 3 +- app/src/main/res/values-es/strings.xml | 3 +- app/src/main/res/values-fr/strings.xml | 3 +- app/src/main/res/values-it/strings.xml | 3 +- app/src/main/res/values-nl/strings.xml | 3 +- app/src/main/res/values-pl/strings.xml | 3 +- app/src/main/res/values-pt-rBR/strings.xml | 3 +- app/src/main/res/values-pt/strings.xml | 3 +- app/src/main/res/values-ru/strings.xml | 3 +- app/src/main/res/values/strings.xml | 3 +- .../transfer/transfer-unavailable-sheet.xml | 17 ++--- 23 files changed, 107 insertions(+), 62 deletions(-) create mode 100644 app/src/main/res/drawable/ic_device_tablet_speaker.xml diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/transfer/FundingScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/transfer/FundingScreenTest.kt index dedb592839..2397670d96 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/transfer/FundingScreenTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/transfer/FundingScreenTest.kt @@ -121,15 +121,17 @@ class FundingScreenTest { } @Test - fun whenGeoBlocked_transferStillInvokesOnTransferForTheUnavailableSheet() { + fun whenGeoBlocked_onlyManualSetupShouldInvoke() { setContent(isGeoBlocked = true) clickableTransferOption().performClick() + composeTestRule.onNodeWithTag("FundReceive").performClick() + composeTestRule.onNodeWithTag("FundManual").performClick() composeTestRule.waitForIdle() - assertEquals(1, transferClicks) + assertEquals(0, transferClicks) assertEquals(0, fundClicks) - assertEquals(0, manualClicks) + assertEquals(1, manualClicks) } @Test diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 535b4b356e..639629c5d1 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -1150,11 +1150,7 @@ private fun RootNavHost( FundingScreen( onTransfer = { - if (isGeoBlocked) { - appViewModel.showSheet(Sheet.TransferUnavailable) - } else { - navController.navigateToTransferSpendingStart(hasSeenSpendingIntro) - } + navController.navigateToTransferSpendingStart(hasSeenSpendingIntro) }, onFund = { scope.launch { 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 c88240ebd4..1ff681448e 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 @@ -1,5 +1,6 @@ package to.bitkit.ui.screens.transfer +import android.content.Intent import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -13,16 +14,22 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.core.net.toUri import to.bitkit.R import to.bitkit.env.Defaults +import to.bitkit.env.Env import to.bitkit.ui.LocalBalances import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.Display +import to.bitkit.ui.components.FillHeight import to.bitkit.ui.components.RectangleButton +import to.bitkit.ui.components.SecondaryButton +import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.scaffold.AppAlertDialog import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.DrawerNavIcon @@ -70,15 +77,26 @@ fun FundingScreen( Spacer(modifier = Modifier.height(32.dp)) + val manualButton = @Composable { + RectangleButton( + label = stringResource(R.string.lightning__funding_advanced__button2), + icon = R.drawable.ic_pencil_full, + iconTint = Colors.Purple, + iconSize = 13.37.dp, + onClick = onManual, + modifier = Modifier.testTag("FundManual") + ) + } Column( verticalArrangement = Arrangement.spacedBy(8.dp), ) { + if (isGeoBlocked) manualButton() Box { RectangleButton( label = stringResource(R.string.lightning__funding__button1), icon = R.drawable.ic_transfer, iconTint = Colors.Purple, - enabled = canTransfer, + enabled = canTransfer && !isGeoBlocked, onClick = onTransfer, modifier = Modifier.testTag("FundTransfer") ) @@ -87,12 +105,23 @@ fun FundingScreen( .matchParentSize() .clickableAlpha( pressedAlpha = 1f, - enabled = balances.channelFundableBalance == 0uL, + enabled = balances.channelFundableBalance == 0uL && !isGeoBlocked, onClick = { showNoFundsAlert = true }, ) .testTag("FundTransfer") ) } + if (isGeoBlocked) { + RectangleButton( + label = stringResource(R.string.lightning__funding__button_hw), + icon = R.drawable.ic_device_tablet_speaker, + iconTint = Colors.Purple, + iconSize = 16.25.dp, + enabled = false, + onClick = {}, + modifier = Modifier.testTag("FundHardware") + ) + } RectangleButton( label = stringResource(R.string.lightning__funding__button2), icon = R.drawable.ic_qr_purple, @@ -102,16 +131,23 @@ fun FundingScreen( onClick = onFund, modifier = Modifier.testTag("FundReceive") ) - RectangleButton( - label = stringResource(R.string.lightning__funding_advanced__button2), - icon = R.drawable.ic_pencil_full, - iconTint = Colors.Purple, - iconSize = 13.37.dp, - onClick = onManual, - modifier = Modifier.testTag("FundManual") - ) + if (!isGeoBlocked) manualButton() } } + if (isGeoBlocked) { + val context = LocalContext.current + FillHeight() + SecondaryButton( + text = stringResource(R.string.common__learn_more), + onClick = { + runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, Env.BLOCKTANK_SUNSET_URL.toUri())) } + }, + modifier = Modifier + .padding(horizontal = 16.dp) + .testTag("FundLearnMore") + ) + VerticalSpacer(16.dp) + } if (showNoFundsAlert) { AppAlertDialog( title = stringResource(R.string.lightning__no_funds__title), @@ -135,3 +171,11 @@ private fun FundingScreenPreview() { FundingScreen(isGeoBlocked = false) } } + +@Preview(showSystemUi = true) +@Composable +private fun FundingScreenGeoBlockedPreview() { + AppThemeSurface { + FundingScreen(isGeoBlocked = true) + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/LocationBlockScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/LocationBlockScreen.kt index ee5b93b634..f2a15691f5 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/LocationBlockScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/LocationBlockScreen.kt @@ -17,8 +17,8 @@ fun LocationBlockScreen( modifier: Modifier = Modifier, ) { TransferUnavailableContent( - navTitle = stringResource(R.string.wallet__receive_bitcoin), - description = stringResource(R.string.lightning__transfer_unavailable__text_receive), + navTitle = stringResource(R.string.lightning__transfer_unavailable__nav_title), + description = stringResource(R.string.lightning__transfer_unavailable__text), onContinue = navigateAdvancedSetup, onBack = onBackPressed, modifier = modifier, diff --git a/app/src/main/java/to/bitkit/ui/sheets/TransferUnavailableSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/TransferUnavailableSheet.kt index 8cf390cea8..761f478988 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/TransferUnavailableSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/TransferUnavailableSheet.kt @@ -47,13 +47,13 @@ fun TransferUnavailableContent( image = R.drawable.lightning, continueText = stringResource(R.string.common__continue), onContinue = onContinue, - cancelText = stringResource(R.string.common__read_more), + cancelText = stringResource(R.string.common__learn_more), onCancel = { runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, Env.BLOCKTANK_SUNSET_URL.toUri())) } }, onBack = onBack, testTag = "TransferUnavailable", - cancelTestTag = "TransferUnavailableReadMore", + cancelTestTag = "TransferUnavailableLearnMore", continueTestTag = "TransferUnavailableContinue", modifier = modifier, ) diff --git a/app/src/main/res/drawable/ic_device_tablet_speaker.xml b/app/src/main/res/drawable/ic_device_tablet_speaker.xml new file mode 100644 index 0000000000..4ac0ee6adb --- /dev/null +++ b/app/src/main/res/drawable/ic_device_tablet_speaker.xml @@ -0,0 +1,18 @@ + + + + + diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 758723091d..5ce33fff83 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -1142,7 +1142,6 @@ ربط الجهاز محفظة أجهزة إخفاء التفاصيل - اقرأ المزيد إزالة إزالة العلامة %1$s مستلم من %1$s @@ -1263,6 +1262,7 @@ لم يتم العثور على القناة تم تحويل الأموال في رصيد إنفاقك إلى مدخراتك. تم إغلاق الاتصال + تحويل من محفظة الأجهزة إلى المدخرات المبلغ منخفض جدًا للتحويل إلى المدخرات. إغلاق القناة بدلاً من ذلك @@ -1289,7 +1289,6 @@ التحويل جاهز خلال %s التحويل غير متاح لم تعد التحويلات التلقائية إلى رصيد الإنفاق متاحة. استخدم عقدتك الخاصة، أو أنشئ اتصالًا مع مزوّد آخر. - لم يعد الاستلام إلى رصيد الإنفاق عبر Bitkit متاحًا. استخدم عقدتك الخاصة، أو أنشئ اتصالًا مع مزوّد آخر. <accent>الإعداد اليدوي</accent>\nمطلوب يرجى التأكد من أن لديك اتصالاً مستقرًا بالإنترنت. قد تظهر البيانات بشكل غير صحيح أثناء محاولة الاتصال. تم اكتشاف مشاكل في الشبكة diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml index 57ed095691..dac24de7f3 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -1180,7 +1180,6 @@ Conectar dispositivo Hardware Ocultar detalles - Leer más Quitar Quitar etiqueta %1$s Recibido de %1$s @@ -1294,6 +1293,7 @@ Falló la verificación de la dirección. Revise la dirección en su dispositivo y vuelva a intentarlo. Este dispositivo contiene una billetera distinta a la que vinculó. Conecte la billetera vinculada o añada esta como una nueva billetera de hardware. Canal no encontrado + Transferir desde billetera HW A ahorros El monto es demasiado bajo para transferirlo a ahorros. Cerrar el canal en su lugar @@ -1319,7 +1319,6 @@ TRANSFERENCIA LISTA EN %s Transferencia no disponible Las transferencias automáticas a tu saldo de gasto ya no están disponibles. Usa tu propio nodo o configura una conexión con otro proveedor. - Recibir en tu saldo de gasto a través de Bitkit ya no está disponible. Usa tu propio nodo o configura una conexión con otro proveedor. <accent>Configuración manual</accent>\nnecesaria Asegúrese de tener una conexión a internet estable. Los datos podrían mostrarse incorrectamente mientras se intenta conectar. Problemas de red detectados diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 26775ac900..de71725e65 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -1170,7 +1170,6 @@ Connecta el dispositiu Maquinari Amagar detalls - Llegeix més Eliminar Eliminar l\'etiqueta %1$s Rebut de %1$s @@ -1287,6 +1286,7 @@ Aquest dispositiu conté una cartera diferent de la que has vinculat. Connecta la cartera vinculada o afegeix aquesta com a nova cartera de maquinari. Canal no trobat Els fons del teu saldo de despesa s\'han transferit als teus estalvis. + Transferir des del moneder HW A estalvis L\'import és massa baix per transferir-lo a estalvis. Tancar el canal @@ -1313,7 +1313,6 @@ TRANSFERÈNCIA LLESTA EN %s Transferència no disponible Les transferències automàtiques al teu saldo de despesa ja no estan disponibles. Fes servir el teu propi node o configura una connexió amb un altre proveïdor. - Rebre al teu saldo de despesa a través de Bitkit ja no està disponible. Fes servir el teu propi node o configura una connexió amb un altre proveïdor. <accent>Configuració manual</accent>\nnecessària Assegura\'t que tens una connexió a Internet estable. És possible que les dades es mostrin incorrectament mentre s\'intenta connectar. S\'han detectat problemes de xarxa diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 5e8075bc00..20fef8d846 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -1177,7 +1177,6 @@ Připojit zařízení Hardware Skrýt podrobnosti - Číst dále Odebrat Odebrat tag %1$s Přijato od %1$s @@ -1291,6 +1290,7 @@ Ověření adresy se nezdařilo. Zkontrolujte adresu na zařízení a zkuste to znovu. Toto zařízení obsahuje jinou peněženku než tu, kterou jste spárovali. Připojte spárovanou peněženku, nebo tuto přidejte jako novou hardwarovou peněženku. Kanál nenalezen + Převod z HW peněženky Do úspor Částka je příliš nízká pro převod do úspor. Místo toho uzavřít kanál @@ -1317,7 +1317,6 @@ PŘEVOD PŘIPRAVEN ZA %s Převod není dostupný Automatické převody na zůstatek útrat již nejsou k dispozici. Použijte vlastní uzel nebo si nastavte připojení u jiného poskytovatele. - Příjem na zůstatek útrat přes Bitkit již není k dispozici. Použijte vlastní uzel nebo si nastavte připojení u jiného poskytovatele. <accent>Vyžadováno ruční</accent>\nnastavení Zajistěte prosím stabilní připojení k internetu. Během pokusu o připojení se data mohou zobrazovat nesprávně. Zjištěny problémy se sítí diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 5c23734c6d..5e6ba5821f 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1174,7 +1174,6 @@ Gerät verbinden Hardware Details ausblenden - Mehr lesen Entfernen Tag %1$s entfernen Empfangen von %1$s @@ -1288,6 +1287,7 @@ Adressüberprüfung fehlgeschlagen. Überprüfe die Adresse auf deinem Gerät und versuche es erneut. Auf diesem Gerät befindet sich ein anderes Wallet als das gekoppelte. Verbinde das gekoppelte Wallet oder füge dieses als neues Hardware-Wallet hinzu. Kanal nicht gefunden + Von HW-Wallet übertragen Zum Sparkonto Der Betrag ist zu niedrig für eine Übertragung auf das Sparkonto. Stattdessen Kanal schließen @@ -1314,7 +1314,6 @@ ÜBERTRAGUNG BEREIT IN %s Übertragung nicht verfügbar Automatische Übertragungen auf dein Ausgabenkonto sind nicht mehr verfügbar. Nutze deinen eigenen Node oder richte eine Verbindung mit einem anderen Anbieter ein. - Empfangen auf dein Ausgabenkonto über Bitkit ist nicht mehr verfügbar. Nutze deinen eigenen Node oder richte eine Verbindung mit einem anderen Anbieter ein. <accent>Manuelle Einrichtung</accent>\nerforderlich Bitte stelle sicher, dass du eine stabile Internetverbindung hast. Während des Verbindungsversuchs werden Daten möglicherweise falsch angezeigt. Netzwerkprobleme erkannt diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 87dbb2cd82..ae396e2e75 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -1149,7 +1149,6 @@ Σύνδεση συσκευής Πορτοφόλι υλικού Απόκρυψη λεπτομερειών - Διαβάστε περισσότερα Αφαίρεση Αφαίρεση ετικέτας %1$s Λήφθηκε από %1$s @@ -1270,6 +1269,7 @@ Το κανάλι δεν βρέθηκε Τα κεφάλαια στο υπόλοιπο δαπανών σας έχουν μεταφερθεί στις αποταμιεύσεις σας. Η Σύνδεση Έκλεισε + Μεταφορά από πορτοφόλι HW Προς αποταμιεύσεις Το ποσό είναι πολύ χαμηλό για μεταφορά στις αποταμιεύσεις. Κλείσιμο καναλιού αντί αυτού @@ -1295,7 +1295,6 @@ Η ΜΕΤΑΦΟΡΑ ΕΙΝΑΙ ΕΤΟΙΜΗ ΣΕ %s Μεταφορά μη διαθέσιμη Οι αυτόματες μεταφορές στο υπόλοιπο δαπανών δεν είναι πλέον διαθέσιμες. Χρησιμοποιήστε τον δικό σας κόμβο ή ρυθμίστε σύνδεση με άλλον πάροχο. - Η λήψη στο υπόλοιπο δαπανών μέσω του Bitkit δεν είναι πλέον διαθέσιμη. Χρησιμοποιήστε τον δικό σας κόμβο ή ρυθμίστε σύνδεση με άλλον πάροχο. <accent>Απαιτείται χειροκίνητη</accent>\nρύθμιση Βεβαιώσου ότι έχεις σταθερή σύνδεση στο διαδίκτυο. Τα δεδομένα μπορεί να εμφανίζονται λανθασμένα κατά την προσπάθεια σύνδεσης. Εντοπίστηκαν προβλήματα δικτύου diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index 8013177deb..534c95d8f2 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -1171,7 +1171,6 @@ Conectar dispositivo Hardware Ocultar detalles - Leer más Eliminar Eliminar etiqueta %1$s Recibido de %1$s @@ -1286,6 +1285,7 @@ Este dispositivo contiene un monedero distinto del que vinculaste. Conecta el monedero vinculado o añade este como un nuevo monedero de hardware. Canal no encontrado Los fondos de tu saldo de gasto se han transferido a tus ahorros. + Transferir desde billetera HW A ahorros La cantidad es demasiado baja para transferirla a ahorros. Cerrar el canal en su lugar @@ -1311,7 +1311,6 @@ TRANSFERENCIA LISTA EN %s Transferencia no disponible Las transferencias automáticas a tu saldo de gasto ya no están disponibles. Usa tu propio nodo o configura una conexión con otro proveedor. - Recibir en tu saldo de gasto a través de Bitkit ya no está disponible. Usa tu propio nodo o configura una conexión con otro proveedor. <accent>Configuración manual</accent>\nnecesaria Asegúrate de tener una conexión a internet estable. Los datos pueden mostrarse de forma incorrecta mientras se intenta conectar. Problemas de red detectados diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index f9cede8344..1452b5b3aa 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1168,7 +1168,6 @@ Conectar dispositivo Hardware Ocultar detalles - Leer más Eliminar Eliminar etiqueta %1$s Recibido de %1$s @@ -1285,6 +1284,7 @@ Canal no encontrado Los fondos de su saldo de gasto han sido transferidos a sus ahorros. Conexión Cerrada + Transferir desde billetera HW A ahorros La cantidad es demasiado baja para transferirla a ahorros. Cerrar el canal en su lugar @@ -1310,7 +1310,6 @@ TRANSFERENCIA LISTA EN %s Transferencia no disponible Las transferencias automáticas a tu saldo de gasto ya no están disponibles. Usa tu propio nodo o configura una conexión con otro proveedor. - Recibir en tu saldo de gasto a través de Bitkit ya no está disponible. Usa tu propio nodo o configura una conexión con otro proveedor. <accent>Configuración manual</accent>\nnecesaria Asegúrese de tener una conexión a internet estable. Los datos pueden mostrarse de forma incorrecta mientras se intenta conectar. Problemas de red detectados diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 29ebfedb6b..fe18474473 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -1173,7 +1173,6 @@ Connecter un appareil Matériel Masquer les détails - Lire la suite Retirer Retirer l\'étiquette %1$s Reçu de %1$s @@ -1288,6 +1287,7 @@ Échec de la vérification de l\'adresse. Vérifiez l\'adresse sur votre appareil et réessayez. Cet appareil contient un autre portefeuille que celui que vous avez associé. Connectez le portefeuille associé ou ajoutez celui-ci comme nouveau portefeuille matériel. Canal introuvable + Transfert depuis le portefeuille HW Vers l\'épargne Le montant est trop faible pour être transféré vers l\'épargne. Fermer plutôt le canal @@ -1313,7 +1313,6 @@ TRANSFERT PRÊT DANS %s Transfert indisponible Les transferts automatiques vers votre solde de dépenses ne sont plus disponibles. Utilisez votre propre nœud ou configurez une connexion avec un autre fournisseur. - La réception sur votre solde de dépenses via Bitkit n’est plus disponible. Utilisez votre propre nœud ou configurez une connexion avec un autre fournisseur. <accent>Configuration manuelle</accent>\nrequise Veuillez vous assurer que vous disposez d\'une connexion internet stable. Les données peuvent s\'afficher incorrectement pendant la tentative de connexion. Problèmes de réseau détectés diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 40332859de..26b4e0eda3 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -1175,7 +1175,6 @@ Connetti dispositivo Hardware Nascondi Dettagli - Leggi di più Rimuovi Rimuovi tag %1$s Ricevuto da %1$s @@ -1291,6 +1290,7 @@ Questo dispositivo contiene un wallet diverso da quello associato. Connetti il wallet associato o aggiungi questo come nuovo hardware wallet. Canale non trovato I fondi sul tuo saldo di spesa sono stati trasferiti ai tuoi risparmi. + Trasferimento da wallet HW Ai risparmi L\'importo è troppo basso per essere trasferito ai risparmi. Chiudi invece il canale @@ -1316,7 +1316,6 @@ TRASFERIMENTO PRONTO TRA %s Trasferimento non disponibile I trasferimenti automatici al saldo di spesa non sono più disponibili. Usa il tuo nodo o configura una connessione con un altro fornitore. - La ricezione sul saldo di spesa tramite Bitkit non è più disponibile. Usa il tuo nodo o configura una connessione con un altro fornitore. <accent>Configurazione manuale</accent>\nrichiesta Assicurati di avere una connessione a internet stabile. I dati potrebbero essere mostrati in modo errato durante il tentativo di connessione. Rilevati Problemi di Rete diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 8346f4a77b..886256ef87 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -1170,7 +1170,6 @@ Apparaat verbinden Hardware Details verbergen - Lees meer Verwijderen Tag %1$s verwijderen Ontvangen van %1$s @@ -1287,6 +1286,7 @@ Kanaal niet gevonden Het geld op uw bestedingssaldo is overgemaakt naar uw spaargeld. Verbinding Gesloten + Overboeken van HW-wallet Naar spaargeld Bedrag is te laag om naar spaargeld over te boeken. In plaats daarvan kanaal sluiten @@ -1312,7 +1312,6 @@ OVERBOEKING KLAAR OVER %s Overboeking niet beschikbaar Automatische overboekingen naar je bestedingssaldo zijn niet meer beschikbaar. Gebruik je eigen node of stel een verbinding in met een andere aanbieder. - Ontvangen op je bestedingssaldo via Bitkit is niet meer beschikbaar. Gebruik je eigen node of stel een verbinding in met een andere aanbieder. <accent>Handmatige instelling</accent>\nvereist Zorg voor een stabiele internetverbinding. Gegevens kunnen onjuist worden weergegeven terwijl er verbinding wordt gemaakt. Netwerkproblemen gedetecteerd diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 2ec8cee93c..7bf22f59e5 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -1178,7 +1178,6 @@ Połącz urządzenie Sprzętowy Ukryj szczegóły - Czytaj więcej Usuń Usuń tag %1$s Otrzymano od %1$s @@ -1291,6 +1290,7 @@ Weryfikacja adresu nie powiodła się. Sprawdź adres na urządzeniu i spróbuj ponownie. To urządzenie zawiera inny portfel niż sparowany. Połącz sparowany portfel lub dodaj ten jako nowy portfel sprzętowy. Nie znaleziono kanału + Prześlij z portfela HW Na oszczędności Kwota jest zbyt niska, aby przesłać ją na oszczędności. Zamiast tego zamknij kanał @@ -1316,7 +1316,6 @@ TRANSFER GOTOWY ZA %s Przesłanie niedostępne Automatyczne przesyłanie środków na saldo wydatków nie jest już dostępne. Użyj własnego węzła lub skonfiguruj połączenie z innym dostawcą. - Odbieranie środków na saldo wydatków przez Bitkit nie jest już dostępne. Użyj własnego węzła lub skonfiguruj połączenie z innym dostawcą. <accent>Wymagana ręczna</accent>\nkonfiguracja Upewnij się, że masz stabilne połączenie z internetem. Podczas łączenia dane mogą być wyświetlane nieprawidłowo. Wykryto problemy z siecią diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 9653c70d82..427b4993cb 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -1180,7 +1180,6 @@ Conectar dispositivo Hardware Ocultar Detalhes - Leia Mais Remover Remover tag %1$s Recebido de %1$s @@ -1293,6 +1292,7 @@ Falha na verificação do endereço. Confira o endereço no seu dispositivo e tente novamente. Este dispositivo contém uma carteira diferente da que você pareou. Conecte a carteira pareada ou adicione esta como uma nova carteira de hardware. Canal não encontrado + Transferir da carteira HW Para poupança O valor é baixo demais para transferir para a poupança. Fechar o canal em vez disso @@ -1318,7 +1318,6 @@ TRANSFERÊNCIA PRONTA EM %s Transferência indisponível As transferências automáticas para seu saldo de gastos não estão mais disponíveis. Use seu próprio nó ou configure uma conexão com outro provedor. - O recebimento no seu saldo de gastos pelo Bitkit não está mais disponível. Use seu próprio nó ou configure uma conexão com outro provedor. <accent>Configuração manual</accent>\nnecessária Verifique se você tem uma conexão estável com a internet. Os dados podem ser exibidos incorretamente enquanto tentamos conectar. Problemas de Rede Detectados diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index e2db0bfd2f..bd5a9e81aa 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -1142,7 +1142,6 @@ Ligar dispositivo Hardware Ocultar detalhes - Ler Mais Remover Remover etiqueta %1$s Recebido de %1$s @@ -1262,6 +1261,7 @@ Canal não encontrado Os fundos do seu saldo de gastos foram transferidos para a sua poupança. Ligação fechada + Transferir da carteira HW Para a poupança O montante é demasiado baixo para transferir para a poupança. Fechar o canal @@ -1287,7 +1287,6 @@ TRANSFERÊNCIA PRONTA EM %s Transferência indisponível As transferências automáticas para o seu saldo de gastos já não estão disponíveis. Use o seu próprio nó ou configure uma ligação com outro fornecedor. - A receção no seu saldo de gastos através da Bitkit já não está disponível. Use o seu próprio nó ou configure uma ligação com outro fornecedor. <accent>Configuração manual</accent>\nnecessária Confirme que tem uma ligação à internet estável. Os dados podem ser apresentados incorretamente enquanto tenta ligar. Problemas de rede detetados diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index c984c03010..eb50149477 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1209,7 +1209,6 @@ Bitkit должен увеличить приёмную ёмкость ваше Подключить устройство Аппаратный Скрыть детали - Читать далее Удалить Удалить тег %1$s Получено от %1$s @@ -1322,6 +1321,7 @@ Bitkit должен увеличить приёмную ёмкость ваше Проверка адреса не удалась. Сверьте адрес на устройстве и попробуйте снова. На этом устройстве другой кошелёк, а не тот, который вы сопрягли. Подключите сопряжённый кошелёк или добавьте этот как новый аппаратный кошелёк. Канал не найден + Перевести с аппаратного кошелька В сбережения Сумма слишком мала для перевода в сбережения. Вместо этого закрыть канал @@ -1347,7 +1347,6 @@ Bitkit должен увеличить приёмную ёмкость ваше ПЕРЕВОД БУДЕТ ГОТОВ ЧЕРЕЗ %s Перевод недоступен Автоматические переводы на баланс расходов больше недоступны. Используйте собственный узел или настройте подключение к другому провайдеру. - Получение на баланс расходов через Bitkit больше недоступно. Используйте собственный узел или настройте подключение к другому провайдеру. <accent>Требуется ручная</accent>\nнастройка Убедитесь, что у вас стабильное подключение к интернету. Во время подключения данные могут отображаться некорректно. Обнаружены Проблемы с Сетью diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5dec235187..c75646852b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -83,7 +83,6 @@ OK Awesome!\nNice!\nCool!\nGreat!\nFantastic!\nSweet!\nExcellent!\nTerrific! Preview - Read More Ready Remove Remove %1$s tag @@ -314,6 +313,7 @@ Force\n<accent>Transfer</accent> Transfer from Savings Use Other Wallet + Transfer from HW wallet Spending Balance The easiest option is to use your Bitkit savings or send bitcoin from a different wallet. Fund your <accent>spending balance</accent> @@ -443,7 +443,6 @@ Funds moved\n<accent>to spending</accent> Transfer Unavailable Automatic transfers to your spending balance are no longer available. Use your own node, or set up a connection with another provider. - Receiving to your spending balance through Bitkit is no longer available. Use your own node, or set up a connection with another provider. <accent>Manual setup</accent>\nrequired Please wait for Bitkit to connect to the payment network (±10 seconds). Wallet Balances diff --git a/journeys/transfer/transfer-unavailable-sheet.xml b/journeys/transfer/transfer-unavailable-sheet.xml index dabc663ad8..22d55afc5a 100644 --- a/journeys/transfer/transfer-unavailable-sheet.xml +++ b/journeys/transfer/transfer-unavailable-sheet.xml @@ -1,12 +1,12 @@ Verifies that Bitkit points users to Manual Setup now that Blocktank no longer sells Lightning channels. - Blocktank's geocheck answers 403, so the app is geo-blocked, and every Transfer To Spending entry point - opens the "Transfer Unavailable" sheet (testTag "TransferUnavailable") instead of the transfer flow. The - sheet shows "MANUAL SETUP REQUIRED" (testTag "TransferUnavailableTitle") and the buttons "Read More" - (testTag "TransferUnavailableReadMore"), which opens https://bitkit.to/ in the browser, and "Continue" + Blocktank's geocheck answers 403, so the app is geo-blocked, and the Transfer To Spending buttons on the + wallet screens open the "Transfer Unavailable" sheet (testTag "TransferUnavailable") instead of the transfer flow. The + sheet shows "MANUAL SETUP REQUIRED" (testTag "TransferUnavailableTitle") and the buttons "Learn More" + (testTag "TransferUnavailableLearnMore"), which opens https://bitkit.to/ in the browser, and "Continue" (testTag "TransferUnavailableContinue"), which opens the existing Manual Setup screen. Receive's Lightning - path shows the same layout with copy for receiving. + path shows the same sheet content. Precondition: dev build with geoblocking on (the default; `GEO=false` turns it off), onboarded wallet with a positive on-chain Savings balance and no channels. Start on the wallet home screen. @@ -14,13 +14,14 @@ Tap the Savings balance card (testTag "ActivitySavings") and verify "Transfer To Spending" (testTag "TransferToSpending") is shown Tap "Transfer To Spending" and verify the sheet titled "Transfer Unavailable" shows "MANUAL SETUP REQUIRED" and "Automatic transfers to your spending balance are no longer available. Use your own node, or set up a connection with another provider." - Tap "Read More" and verify the browser opens bitkit.to, then return to Bitkit + Tap "Learn More" and verify the browser opens bitkit.to, then return to Bitkit Tap "Continue" and verify the sheet closes and the "MANUAL SETUP" screen shows the node id (testTag "NodeIdInput"), host (testTag "HostInput") and port (testTag "PortInput") fields Go back to the wallet home, tap the Spending balance card (testTag "ActivitySpending"), tap the transfer button (testTag "TransferFromSavings") and verify the same sheet opens; close it Open Settings › Advanced › Lightning Connections, tap add connection (testTag "add_connection_button") and verify the screen text reads "Automatic transfers to your spending balance are no longer available. Use your own node, or set up a connection with another provider." - Tap "Transfer from Savings" (testTag "FundTransfer") and verify the same sheet opens; close it and go back to the wallet home + Verify "Manual Setup" (testTag "FundManual") is the first option and enabled, and "Transfer from Savings" (testTag "FundTransfer"), "Transfer from HW wallet" (testTag "FundHardware") and "Use Other Wallet" (testTag "FundReceive") below it are disabled; tap "Transfer from Savings" and verify nothing opens + Tap "Learn More" (testTag "FundLearnMore") at the bottom and verify the browser opens bitkit.to, then return to Bitkit and go back to the wallet home Tap "Receive" (testTag "Receive"), select the Spending tab (testTag "Tab-spending") and tap "Receive Lightning funds" - Verify the screen titled "Receive Bitcoin" shows "MANUAL SETUP REQUIRED" and "Receiving to your spending balance through Bitkit is no longer available. Use your own node, or set up a connection with another provider.", with a back button + Verify the screen titled "Transfer Unavailable" shows "MANUAL SETUP REQUIRED" and "Automatic transfers to your spending balance are no longer available. Use your own node, or set up a connection with another provider.", with a back button Tap "Continue" and verify the sheet closes and the "MANUAL SETUP" screen opens From c3cbdf779fb58d549a8b9510ffd7371e3cff1db7 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 26 Oct 2026 17:25:47 +0100 Subject: [PATCH 06/47] fix: open the bitkit blog from learn more --- app/src/main/java/to/bitkit/env/Env.kt | 2 +- journeys/transfer/transfer-unavailable-sheet.xml | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/to/bitkit/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt index 402e5cbb2d..370b7e17da 100644 --- a/app/src/main/java/to/bitkit/env/Env.kt +++ b/app/src/main/java/to/bitkit/env/Env.kt @@ -177,7 +177,7 @@ internal object Env { const val BITKIT_TELEGRAM = "https://t.me/bitkitchat" const val BITKIT_GITHUB = "https://github.com/synonymdev" const val BITKIT_HELP_CENTER = "https://help.bitkit.to" - const val BLOCKTANK_SUNSET_URL = "https://bitkit.to/" + const val BLOCKTANK_SUNSET_URL = "https://blog.bitkit.to/" const val TERMS_OF_USE_URL = "https://bitkit.to/terms-of-use" const val PRIVACY_POLICY_URL = "https://bitkit.to/privacy-policy" const val STORING_BITCOINS_URL = "https://en.bitcoin.it/wiki/Storing_bitcoins" diff --git a/journeys/transfer/transfer-unavailable-sheet.xml b/journeys/transfer/transfer-unavailable-sheet.xml index 22d55afc5a..1e9b604ed9 100644 --- a/journeys/transfer/transfer-unavailable-sheet.xml +++ b/journeys/transfer/transfer-unavailable-sheet.xml @@ -4,7 +4,7 @@ Blocktank's geocheck answers 403, so the app is geo-blocked, and the Transfer To Spending buttons on the wallet screens open the "Transfer Unavailable" sheet (testTag "TransferUnavailable") instead of the transfer flow. The sheet shows "MANUAL SETUP REQUIRED" (testTag "TransferUnavailableTitle") and the buttons "Learn More" - (testTag "TransferUnavailableLearnMore"), which opens https://bitkit.to/ in the browser, and "Continue" + (testTag "TransferUnavailableLearnMore"), which opens https://blog.bitkit.to/ in the browser, and "Continue" (testTag "TransferUnavailableContinue"), which opens the existing Manual Setup screen. Receive's Lightning path shows the same sheet content. @@ -14,12 +14,12 @@ Tap the Savings balance card (testTag "ActivitySavings") and verify "Transfer To Spending" (testTag "TransferToSpending") is shown Tap "Transfer To Spending" and verify the sheet titled "Transfer Unavailable" shows "MANUAL SETUP REQUIRED" and "Automatic transfers to your spending balance are no longer available. Use your own node, or set up a connection with another provider." - Tap "Learn More" and verify the browser opens bitkit.to, then return to Bitkit + Tap "Learn More" and verify the browser opens blog.bitkit.to, then return to Bitkit Tap "Continue" and verify the sheet closes and the "MANUAL SETUP" screen shows the node id (testTag "NodeIdInput"), host (testTag "HostInput") and port (testTag "PortInput") fields Go back to the wallet home, tap the Spending balance card (testTag "ActivitySpending"), tap the transfer button (testTag "TransferFromSavings") and verify the same sheet opens; close it Open Settings › Advanced › Lightning Connections, tap add connection (testTag "add_connection_button") and verify the screen text reads "Automatic transfers to your spending balance are no longer available. Use your own node, or set up a connection with another provider." Verify "Manual Setup" (testTag "FundManual") is the first option and enabled, and "Transfer from Savings" (testTag "FundTransfer"), "Transfer from HW wallet" (testTag "FundHardware") and "Use Other Wallet" (testTag "FundReceive") below it are disabled; tap "Transfer from Savings" and verify nothing opens - Tap "Learn More" (testTag "FundLearnMore") at the bottom and verify the browser opens bitkit.to, then return to Bitkit and go back to the wallet home + Tap "Learn More" (testTag "FundLearnMore") at the bottom and verify the browser opens blog.bitkit.to, then return to Bitkit and go back to the wallet home Tap "Receive" (testTag "Receive"), select the Spending tab (testTag "Tab-spending") and tap "Receive Lightning funds" Verify the screen titled "Transfer Unavailable" shows "MANUAL SETUP REQUIRED" and "Automatic transfers to your spending balance are no longer available. Use your own node, or set up a connection with another provider.", with a back button Tap "Continue" and verify the sheet closes and the "MANUAL SETUP" screen opens From a5b4a6a55fc345f38d61ab11361d2ff1ce67cc17 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 26 Oct 2026 18:05:19 +0100 Subject: [PATCH 07/47] fix: drop background payments that relied on blocktank --- app/src/main/java/to/bitkit/fcm/FcmService.kt | 5 - .../main/java/to/bitkit/models/Suggestion.kt | 7 - .../to/bitkit/repositories/LightningRepo.kt | 32 ---- .../to/bitkit/repositories/SuggestionsRepo.kt | 1 - .../services/LspNotificationsService.kt | 67 ------- app/src/main/java/to/bitkit/ui/ContentView.kt | 54 ------ .../java/to/bitkit/ui/components/SheetHost.kt | 1 - .../ui/screens/settings/DevSettingsScreen.kt | 15 -- .../bitkit/ui/screens/wallets/HomeScreen.kt | 9 - .../to/bitkit/ui/settings/SettingsScreen.kt | 24 --- .../BackgroundPaymentsIntroScreen.kt | 98 ---------- .../BackgroundPaymentsSettings.kt | 179 ------------------ .../ui/sheets/BackgroundPaymentsIntroSheet.kt | 50 ----- .../sheets/NotificationsTimedSheet.kt | 49 ----- .../java/to/bitkit/viewmodels/AppViewModel.kt | 3 - .../bitkit/viewmodels/DevSettingsViewModel.kt | 28 --- .../to/bitkit/viewmodels/SettingsViewModel.kt | 8 - app/src/main/res/drawable-hdpi/bell.webp | Bin 4634 -> 0 bytes app/src/main/res/drawable-mdpi/bell.webp | Bin 2982 -> 0 bytes .../main/res/drawable-nodpi/bell_figure.png | Bin 139700 -> 0 bytes app/src/main/res/drawable-xhdpi/bell.webp | Bin 6440 -> 0 bytes app/src/main/res/drawable-xxhdpi/bell.webp | Bin 10840 -> 0 bytes app/src/main/res/drawable-xxxhdpi/bell.webp | Bin 16892 -> 0 bytes app/src/main/res/values-ar/strings.xml | 13 -- app/src/main/res/values-b+es+419/strings.xml | 13 -- app/src/main/res/values-ca/strings.xml | 13 -- app/src/main/res/values-cs/strings.xml | 13 -- app/src/main/res/values-de/strings.xml | 13 -- app/src/main/res/values-el/strings.xml | 13 -- app/src/main/res/values-es-rES/strings.xml | 13 -- app/src/main/res/values-es/strings.xml | 13 -- app/src/main/res/values-fr/strings.xml | 13 -- app/src/main/res/values-it/strings.xml | 13 -- app/src/main/res/values-nl/strings.xml | 13 -- app/src/main/res/values-pl/strings.xml | 13 -- app/src/main/res/values-pt-rBR/strings.xml | 13 -- app/src/main/res/values-pt/strings.xml | 13 -- app/src/main/res/values-ru/strings.xml | 14 -- app/src/main/res/values/strings.xml | 13 -- .../bitkit/repositories/LightningRepoTest.kt | 28 +-- .../sheets/NotificationsTimedSheetTest.kt | 123 ------------ .../viewmodels/AppViewModelSendFlowTest.kt | 1 - docs/screens-map.md | 6 - docs/strings.md | 1 - docs/timed-sheets.md | 15 +- journeys/README.md | 3 +- journeys/onchain-receive/README.md | 1 - ...confirmed-only-background-notification.xml | 22 --- 48 files changed, 9 insertions(+), 1030 deletions(-) delete mode 100644 app/src/main/java/to/bitkit/services/LspNotificationsService.kt delete mode 100644 app/src/main/java/to/bitkit/ui/settings/backgroundPayments/BackgroundPaymentsIntroScreen.kt delete mode 100644 app/src/main/java/to/bitkit/ui/settings/backgroundPayments/BackgroundPaymentsSettings.kt delete mode 100644 app/src/main/java/to/bitkit/ui/sheets/BackgroundPaymentsIntroSheet.kt delete mode 100644 app/src/main/java/to/bitkit/utils/timedsheets/sheets/NotificationsTimedSheet.kt delete mode 100644 app/src/main/res/drawable-hdpi/bell.webp delete mode 100644 app/src/main/res/drawable-mdpi/bell.webp delete mode 100644 app/src/main/res/drawable-nodpi/bell_figure.png delete mode 100644 app/src/main/res/drawable-xhdpi/bell.webp delete mode 100644 app/src/main/res/drawable-xxhdpi/bell.webp delete mode 100644 app/src/main/res/drawable-xxxhdpi/bell.webp delete mode 100644 app/src/test/java/to/bitkit/utils/timedsheets/sheets/NotificationsTimedSheetTest.kt delete mode 100644 journeys/onchain-receive/confirmed-only-background-notification.xml diff --git a/app/src/main/java/to/bitkit/fcm/FcmService.kt b/app/src/main/java/to/bitkit/fcm/FcmService.kt index b37a7e655a..5456a24d89 100644 --- a/app/src/main/java/to/bitkit/fcm/FcmService.kt +++ b/app/src/main/java/to/bitkit/fcm/FcmService.kt @@ -18,7 +18,6 @@ import to.bitkit.env.Env.derivationName import to.bitkit.ext.fromBase64 import to.bitkit.ext.fromHex import to.bitkit.models.BlocktankNotificationType -import to.bitkit.repositories.LightningRepo import to.bitkit.ui.pushNotification import to.bitkit.utils.Crypto import to.bitkit.utils.Logger @@ -40,9 +39,6 @@ class FcmService : FirebaseMessagingService() { @Inject lateinit var keychain: Keychain - @Inject - lateinit var lightningRepo: LightningRepo - /** * Act on received messages. [Debug](https://goo.gl/39bRNJ) */ @@ -154,7 +150,6 @@ class FcmService : FirebaseMessagingService() { override fun onNewToken(token: String) { Logger.debug("Got new FCM token via FirebaseMessagingService.onNewToken: '$token'", context = TAG) - lightningRepo.registerForNotificationsAsync(token) } } diff --git a/app/src/main/java/to/bitkit/models/Suggestion.kt b/app/src/main/java/to/bitkit/models/Suggestion.kt index c66ee2ffb5..6aecb0aa04 100644 --- a/app/src/main/java/to/bitkit/models/Suggestion.kt +++ b/app/src/main/java/to/bitkit/models/Suggestion.kt @@ -73,13 +73,6 @@ enum class Suggestion( color = Colors.Green24, icon = R.drawable.fast_forward ), - NOTIFICATIONS( - title = R.string.cards__notifications__title, - description = R.string.cards__notifications__description, - color = Colors.Purple24, - icon = R.drawable.bell, - dismissible = true, - ), } fun String.toSuggestionOrNull() = Suggestion.entries.firstOrNull { it.name == this } diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 6701476671..a7e3671d16 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -1,7 +1,6 @@ package to.bitkit.repositories import androidx.compose.runtime.Stable -import com.google.firebase.messaging.FirebaseMessaging import com.synonym.bitkitcore.AddressType import com.synonym.bitkitcore.ClosedChannelDetails import com.synonym.bitkitcore.FeeRates @@ -37,7 +36,6 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.tasks.await import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import kotlinx.serialization.json.JsonPrimitive @@ -96,7 +94,6 @@ import to.bitkit.services.LightningService import to.bitkit.services.LnurlChannelResponse import to.bitkit.services.LnurlService import to.bitkit.services.LnurlWithdrawResponse -import to.bitkit.services.LspNotificationsService import to.bitkit.services.NetworkGraphInfo import to.bitkit.services.NodeEventHandler import to.bitkit.utils.AppError @@ -123,8 +120,6 @@ class LightningRepo @Inject constructor( private val lightningService: LightningService, private val settingsStore: SettingsStore, private val coreService: CoreService, - private val lspNotificationsService: LspNotificationsService, - private val firebaseMessaging: FirebaseMessaging, private val keychain: Keychain, private val lnurlService: LnurlService, private val cacheStore: CacheStore, @@ -447,7 +442,6 @@ class LightningRepo @Inject constructor( sync().onFailure { e -> Logger.warn("Initial sync failed, event-driven sync will retry", e, context = TAG) } - scope.launch { registerForNotifications() } Result.success(Unit) }.getOrElse { e -> if (e is CancellationException) { @@ -2256,32 +2250,6 @@ class LightningRepo @Inject constructor( fun separateTrustedChannels(channels: List) = lightningService.separateTrustedChannels(channels) - @Suppress("KotlinConstantConditions") - suspend fun registerForNotifications(token: String? = null) = executeWhenNodeRunning("registerForNotifications") { - if (Env.isE2eTest) { - Logger.info("Skipped push registration in E2E build", context = TAG) - return@executeWhenNodeRunning Result.success(Unit) - } - - 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("registerForNotifications skipped, device token already registered") - return@executeWhenNodeRunning Result.success(Unit) - } - - lspNotificationsService.registerDevice(token) - }.onFailure { - Logger.error("registerForNotifications error", it, context = TAG) - } - } - - fun registerForNotificationsAsync(token: String) = scope.launch { registerForNotifications(token) } - suspend fun bumpFeeByRbf( originalTxId: Txid, satsPerVByte: ULong, diff --git a/app/src/main/java/to/bitkit/repositories/SuggestionsRepo.kt b/app/src/main/java/to/bitkit/repositories/SuggestionsRepo.kt index 6d88fe02f9..acea815e3b 100644 --- a/app/src/main/java/to/bitkit/repositories/SuggestionsRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/SuggestionsRepo.kt @@ -61,7 +61,6 @@ class SuggestionsRepo @Inject constructor( hasHardwareWallet: Boolean, ) = listOfNotNull( Suggestion.QUICK_PAY.takeIf { !settings.isQuickPayEnabled }, - Suggestion.NOTIFICATIONS.takeIf { !settings.notificationsGranted }, Suggestion.HARDWARE.takeIf { !hasHardwareWallet }, Suggestion.SHOP, Suggestion.PROFILE.takeIf { !profileAuthenticated }, diff --git a/app/src/main/java/to/bitkit/services/LspNotificationsService.kt b/app/src/main/java/to/bitkit/services/LspNotificationsService.kt deleted file mode 100644 index 955b775d92..0000000000 --- a/app/src/main/java/to/bitkit/services/LspNotificationsService.kt +++ /dev/null @@ -1,67 +0,0 @@ -package to.bitkit.services - -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.withContext -import to.bitkit.async.ServiceQueue -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.derivationName -import to.bitkit.ext.nowTimestamp -import to.bitkit.ext.toHex -import to.bitkit.utils.Crypto -import to.bitkit.utils.Logger -import to.bitkit.utils.ServiceError -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class LspNotificationsService @Inject constructor( - @BgDispatcher private val bgDispatcher: CoroutineDispatcher, - private val lightningService: LightningService, - private val keychain: Keychain, - private val crypto: Crypto, -) { - suspend fun registerDevice(deviceToken: String) = withContext(bgDispatcher) { - val nodeId = lightningService.nodeId ?: throw ServiceError.NodeNotStarted() - - Logger.debug("Registering device for notifications…") - - val timestamp = nowTimestamp() - val messageToSign = "$derivationName$deviceToken$timestamp" - - val signature = lightningService.sign(messageToSign) - - val keypair = crypto.generateKeyPair() - val publicKey = keypair.publicKey.toHex() - Logger.debug("Notification encryption public key: $publicKey") - - // New keypair for each token registration - if (keychain.exists(Key.PUSH_NOTIFICATION_PRIVATE_KEY.name)) { - keychain.delete(Key.PUSH_NOTIFICATION_PRIVATE_KEY.name) - } - keychain.save(Key.PUSH_NOTIFICATION_PRIVATE_KEY.name, keypair.privateKey) - - ServiceQueue.CORE.background { - com.synonym.bitkitcore.registerDevice( - deviceToken = deviceToken, - publicKey = publicKey, - features = Env.pushNotificationFeatures.map { it.toString() }, - nodeId = nodeId, - isoTimestamp = "$timestamp", - signature = signature, - customUrl = Env.blocktankNotificationApiUrl, - isProduction = !Env.isDebug, - ) - } - - // Cache token so we can avoid re-registering - if (keychain.exists(Key.PUSH_NOTIFICATION_TOKEN.name)) { - keychain.delete(Key.PUSH_NOTIFICATION_TOKEN.name) - } - keychain.saveString(Key.PUSH_NOTIFICATION_TOKEN.name, deviceToken) - - Logger.info("Device registered for notifications") - } -} diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 639629c5d1..d6f39abf9b 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -1,11 +1,7 @@ package to.bitkit.ui -import android.Manifest import android.content.Intent -import android.os.Build -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.DrawerState @@ -191,8 +187,6 @@ import to.bitkit.ui.settings.advanced.ElectrumConfigScreen import to.bitkit.ui.settings.advanced.RgsServerScreen import to.bitkit.ui.settings.advanced.WatchOnlyAccountsScreen import to.bitkit.ui.settings.appStatus.AppStatusScreen -import to.bitkit.ui.settings.backgroundPayments.BackgroundPaymentsIntroScreen -import to.bitkit.ui.settings.backgroundPayments.BackgroundPaymentsSettings import to.bitkit.ui.settings.backups.ResetAndRestoreScreen import to.bitkit.ui.settings.general.DefaultUnitSettingsScreen import to.bitkit.ui.settings.general.HardwareWalletsSettingsScreen @@ -212,7 +206,6 @@ import to.bitkit.ui.settings.support.SupportScreen import to.bitkit.ui.settings.transactionSpeed.CustomFeeSettingsScreen import to.bitkit.ui.settings.transactionSpeed.TransactionSpeedSettingsScreen import to.bitkit.ui.sheets.BTCPayConnectionSheet -import to.bitkit.ui.sheets.BackgroundPaymentsIntroSheet import to.bitkit.ui.sheets.BackupRoute import to.bitkit.ui.sheets.BackupSheet import to.bitkit.ui.sheets.ChangePinSheet @@ -240,7 +233,6 @@ import to.bitkit.ui.utils.composableWithDefaultTransitions import to.bitkit.ui.utils.deepLinkableComposable import to.bitkit.ui.utils.navigationWithDefaultTransitions import to.bitkit.ui.utils.rememberIs24HourFormat -import to.bitkit.ui.utils.rememberRequestNotificationPermission import to.bitkit.utils.Logger import to.bitkit.viewmodels.ActivityListViewModel import to.bitkit.viewmodels.AppViewModel @@ -296,11 +288,6 @@ fun ContentView( val keepActiveInBackground by settingsViewModel.keepBitkitActiveInBackground.collectAsStateWithLifecycle() val walletExists = walletUiState.walletExists - val requestNotificationPermission = rememberRequestNotificationPermission( - onPermissionResult = { granted -> settingsViewModel.setNotificationPreference(granted) }, - onPreTiramisu = { navController.navigateTo(Routes.BackgroundPaymentsSettings) }, - ) - // Effects on app entering fg (ON_START) / bg (ON_STOP) DisposableEffect(lifecycle) { val observer = LifecycleEventObserver { _, event -> @@ -697,20 +684,6 @@ fun ContentView( ) } - TimedSheetType.NOTIFICATIONS -> { - BackgroundPaymentsIntroSheet( - onLater = { - appViewModel.dismissTimedSheet() - settingsViewModel.setBgPaymentsIntroSeen(true) - }, - onEnable = { - appViewModel.dismissTimedSheet() - settingsViewModel.setBgPaymentsIntroSeen(true) - requestNotificationPermission() - }, - ) - } - TimedSheetType.QUICK_PAY -> { QuickPayIntroSheet( onLater = { @@ -1783,27 +1756,6 @@ private fun NavGraphBuilder.generalSettingsSubScreens( onClickAdd = { appViewModel.showSheet(Sheet.Hardware()) }, ) } - deepLinkableComposable { - BackgroundPaymentsSettings( - onBack = { navController.popBackStack() }, - ) - } - deepLinkableComposable { - val notificationPermissionLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.RequestPermission() - ) { granted -> - settingsViewModel.setNotificationPreference(granted) - } - BackgroundPaymentsIntroScreen( - onBack = { navController.popBackStack() }, - onEnable = { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) - } - navController.navigateTo(Routes.BackgroundPaymentsSettings) - }, - ) - } } private fun NavGraphBuilder.advancedSettingsSubScreens(navController: NavHostController) { @@ -2632,12 +2584,6 @@ sealed interface Routes { @Serializable data object RecoveryMnemonic : Routes.InternalOnly - @Serializable - data object BackgroundPaymentsIntro : Routes.DeepLinkable - - @Serializable - data object BackgroundPaymentsSettings : Routes.DeepLinkable - @Serializable data object AllActivity : Routes.DeepLinkable diff --git a/app/src/main/java/to/bitkit/ui/components/SheetHost.kt b/app/src/main/java/to/bitkit/ui/components/SheetHost.kt index 18c366380e..4773f5380a 100644 --- a/app/src/main/java/to/bitkit/ui/components/SheetHost.kt +++ b/app/src/main/java/to/bitkit/ui/components/SheetHost.kt @@ -118,7 +118,6 @@ enum class TimedSheetType(val priority: Int) { APP_UPDATE(priority = 6), USDT(priority = 5), BACKUP(priority = 4), - NOTIFICATIONS(priority = 3), QUICK_PAY(priority = 2), HIGH_BALANCE(priority = 1) } diff --git a/app/src/main/java/to/bitkit/ui/screens/settings/DevSettingsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/settings/DevSettingsScreen.kt index 657fec0fc4..68d5499331 100644 --- a/app/src/main/java/to/bitkit/ui/screens/settings/DevSettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/settings/DevSettingsScreen.kt @@ -241,21 +241,6 @@ fun DevSettingsScreen( viewModel.openChannel() } ) - - SectionHeader("NOTIFICATIONS") - - SettingsTextButtonRow( - title = "Register For LSP Notifications", - onClick = { - viewModel.registerForNotifications() - } - ) - SettingsTextButtonRow( - title = "Test LSP Notification", - onClick = { - viewModel.testLspNotification() - } - ) } } 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 5c75d59a64..406036ff5f 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 @@ -250,7 +250,6 @@ fun HomeScreen( val profileDisplayName by homeViewModel.profileDisplayName.collectAsStateWithLifecycle() val profileDisplayImageUri by homeViewModel.profileDisplayImageUri.collectAsStateWithLifecycle() val hasSeenWidgetsIntro: Boolean by settingsViewModel.hasSeenWidgetsIntro.collectAsStateWithLifecycle() - val bgPaymentsIntroSeen: Boolean by settingsViewModel.bgPaymentsIntroSeen.collectAsStateWithLifecycle() val quickPayIntroSeen by settingsViewModel.quickPayIntroSeen.collectAsStateWithLifecycle() val latestActivities by activityListViewModel.latestActivities.collectAsStateWithLifecycle() val hardwareIds by activityListViewModel.hardwareIds.collectAsStateWithLifecycle() @@ -366,14 +365,6 @@ fun HomeScreen( rootNavController.navigateTo(Routes.QuickPaySettings) } } - - Suggestion.NOTIFICATIONS -> { - if (bgPaymentsIntroSeen) { - rootNavController.navigateTo(Routes.BackgroundPaymentsSettings) - } else { - rootNavController.navigateTo(Routes.BackgroundPaymentsIntro) - } - } } }, onClickAddWidget = { 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 a2ec2e6cbd..b07c79bf9d 100644 --- a/app/src/main/java/to/bitkit/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/SettingsScreen.kt @@ -91,8 +91,6 @@ fun SettingsScreen( val showWidgets by settings.showWidgets.collectAsStateWithLifecycle() val isQuickPayEnabled by settings.isQuickpayEnabled.collectAsStateWithLifecycle() val quickPayIntroSeen by settings.quickPayIntroSeen.collectAsStateWithLifecycle() - val bgPaymentsIntroSeen by settings.bgPaymentsIntroSeen.collectAsStateWithLifecycle() - val notificationsGranted by settings.notificationsGranted.collectAsStateWithLifecycle() val isPubkyAuthenticated by settings.isPubkyAuthenticated.collectAsStateWithLifecycle() val isPaykitEnabled by settings.isPaykitEnabled.collectAsStateWithLifecycle() val contactPaymentsEnabled by settings.contactPaymentsEnabled.collectAsStateWithLifecycle() @@ -136,7 +134,6 @@ fun SettingsScreen( showWidgets = showWidgets, tagCount = lastUsedTags.size, isQuickPayEnabled = isQuickPayEnabled, - notificationsGranted = notificationsGranted, isPubkyAuthenticated = isPubkyAuthenticated, isPaykitEnabled = isPaykitEnabled, contactPaymentsEnabled = contactPaymentsEnabled, @@ -179,13 +176,6 @@ fun SettingsScreen( } SettingsEvent.UsdtClick -> settings.setUsdtEnabled(!isUsdtEnabled) SettingsEvent.QuickPayClick -> navController.navigateToQuickPaySettings(quickPayIntroSeen) - SettingsEvent.BgPaymentsClick -> { - if (bgPaymentsIntroSeen || notificationsGranted) { - navController.navigateTo(Routes.BackgroundPaymentsSettings) - } else { - navController.navigateTo(Routes.BackgroundPaymentsIntro) - } - } SettingsEvent.HardwareWalletsClick -> navController.navigateTo(Routes.HardwareWalletsSettings) SettingsEvent.BackupWalletClick -> app.showSheet(Sheet.Backup()) SettingsEvent.DataBackupsClick -> navController.navigateTo(Routes.BackupSettings) @@ -367,17 +357,6 @@ private fun GeneralTabContent( onClick = { onEvent(SettingsEvent.QuickPayClick) }, modifier = Modifier.testTag("QuickpaySettings") ) - SettingsButtonRow( - title = stringResource(R.string.settings__bg__title), - icon = { SettingsIcon(R.drawable.ic_bell) }, - value = SettingsButtonValue.StringValue( - stringResource( - if (state.notificationsGranted) R.string.settings__bg__on else R.string.settings__bg__off - ) - ), - onClick = { onEvent(SettingsEvent.BgPaymentsClick) }, - modifier = Modifier.testTag("BackgroundPaymentSettings") - ) SettingsButtonRow( title = stringResource(R.string.settings__hardware_wallets__nav_title), icon = { SettingsIcon(R.drawable.ic_device_mobile_speaker) }, @@ -654,7 +633,6 @@ private fun PreviewGeneral() { selectedLanguage = "System Settings", tagCount = 8, isQuickPayEnabled = true, - notificationsGranted = true, ), ) } @@ -703,7 +681,6 @@ sealed interface SettingsEvent { data object ContactPaymentsClick : SettingsEvent data object UsdtClick : SettingsEvent data object QuickPayClick : SettingsEvent - data object BgPaymentsClick : SettingsEvent data object HardwareWalletsClick : SettingsEvent // Security @@ -745,7 +722,6 @@ data class GeneralTabState( val showWidgets: Boolean = true, val tagCount: Int = 0, val isQuickPayEnabled: Boolean = false, - val notificationsGranted: Boolean = false, val isPubkyAuthenticated: Boolean = false, val isPaykitEnabled: Boolean = false, val contactPaymentsEnabled: Boolean = false, 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 deleted file mode 100644 index e94b99f3a7..0000000000 --- a/app/src/main/java/to/bitkit/ui/settings/backgroundPayments/BackgroundPaymentsIntroScreen.kt +++ /dev/null @@ -1,98 +0,0 @@ -package to.bitkit.ui.settings.backgroundPayments - -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.testTag -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 -import to.bitkit.R -import to.bitkit.ui.components.BodyM -import to.bitkit.ui.components.Display -import to.bitkit.ui.components.PrimaryButton -import to.bitkit.ui.components.VerticalSpacer -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.withAccent -import to.bitkit.viewmodels.SettingsViewModel - -@Composable -fun BackgroundPaymentsIntroScreen( - onBack: () -> Unit, - onEnable: () -> Unit, - modifier: Modifier = Modifier, - settingsViewModel: SettingsViewModel = hiltViewModel(), -) { - Column( - modifier = modifier.screen() - ) { - AppTopBar( - titleText = stringResource(R.string.settings__bg__title), - onBackClick = onBack, - actions = { DrawerNavIcon() }, - ) - BackgroundPaymentsIntroContent( - onEnable = { - settingsViewModel.setBgPaymentsIntroSeen(true) - onEnable() - }, - ) - } -} - -@Composable -fun BackgroundPaymentsIntroContent( - onEnable: () -> Unit, - modifier: Modifier = Modifier, -) { - Column( - modifier = modifier.padding(horizontal = 32.dp) - ) { - Image( - painter = painterResource(R.drawable.bell_figure), - contentDescription = null, - contentScale = ContentScale.Fit, - modifier = Modifier - .align(Alignment.CenterHorizontally) - .fillMaxWidth() - .weight(1f) - .heightIn(max = 320.dp) - ) - VerticalSpacer(32.dp) - Display( - text = stringResource(R.string.settings__bg__intro_title).withAccent(accentColor = Colors.Purple), - color = Colors.White, - ) - VerticalSpacer(8.dp) - BodyM(text = stringResource(R.string.settings__bg__intro_desc), color = Colors.White64) - VerticalSpacer(32.dp) - PrimaryButton( - text = stringResource(R.string.settings__bg__intro_button), - onClick = onEnable, - modifier = Modifier.testTag("BackgroundPaymentsIntro-enable") - ) - VerticalSpacer(16.dp) - } -} - -@Preview(showSystemUi = true) -@Composable -private fun Preview() { - AppThemeSurface { - BackgroundPaymentsIntroContent( - onEnable = {}, - ) - } -} 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 deleted file mode 100644 index db90c67454..0000000000 --- a/app/src/main/java/to/bitkit/ui/settings/backgroundPayments/BackgroundPaymentsSettings.kt +++ /dev/null @@ -1,179 +0,0 @@ -package to.bitkit.ui.settings.backgroundPayments - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.runtime.Composable -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 -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import to.bitkit.R -import to.bitkit.ui.components.BodyM -import to.bitkit.ui.components.BodyMB -import to.bitkit.ui.components.NotificationPreview -import to.bitkit.ui.components.SecondaryButton -import to.bitkit.ui.components.Text13Up -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.shared.util.screen -import to.bitkit.ui.theme.AppThemeSurface -import to.bitkit.ui.theme.Colors -import to.bitkit.ui.utils.RequestNotificationPermissions -import to.bitkit.viewmodels.SettingsViewModel - -@Composable -fun BackgroundPaymentsSettings( - settingsViewModel: SettingsViewModel = hiltViewModel(), - onBack: () -> Unit, -) { - val context = LocalContext.current - val notificationsGranted by settingsViewModel.notificationsGranted.collectAsStateWithLifecycle() - val keepActive by settingsViewModel.keepBitkitActiveInBackground.collectAsStateWithLifecycle() - - RequestNotificationPermissions( - onPermissionChange = settingsViewModel::setNotificationPreference, - showPermissionDialog = false, - ) - - Content( - hasPermission = notificationsGranted, - keepActive = keepActive, - onBack = onBack, - onSystemSettingsClick = context::openNotificationSettings, - onKeepActiveClick = { settingsViewModel.setKeepBitkitActiveInBackground(!keepActive) }, - ) -} - -@Composable -private fun Content( - hasPermission: Boolean, - keepActive: Boolean, - onBack: () -> Unit, - onSystemSettingsClick: () -> Unit, - onKeepActiveClick: () -> Unit, -) { - Column( - modifier = Modifier.screen() - ) { - AppTopBar( - titleText = stringResource(R.string.settings__bg__title), - onBackClick = onBack, - actions = { DrawerNavIcon() }, - ) - - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - ) { - VerticalSpacer(16.dp) - - SettingsSwitchRow( - title = stringResource(R.string.settings__bg__switch_title), - isChecked = hasPermission, - onClick = onSystemSettingsClick, - ) - - if (hasPermission) { - BodyM( - text = stringResource(R.string.settings__bg__enabled), - color = Colors.White64, - modifier = Modifier.padding(vertical = 16.dp), - ) - } - - AnimatedVisibility( - visible = !hasPermission, - modifier = Modifier.padding(vertical = 16.dp) - ) { - BodyMB( - text = stringResource(R.string.settings__bg__disabled), - color = Colors.Red, - ) - } - - SettingsSwitchRow( - title = stringResource(R.string.settings__bg__keep_active_title), - isChecked = keepActive, - onClick = onKeepActiveClick, - enabled = hasPermission, - ) - - BodyM( - text = stringResource(R.string.settings__bg__keep_active_desc), - color = Colors.White64, - modifier = Modifier.padding(vertical = 16.dp), - ) - - VerticalSpacer(32.dp) - - Text13Up( - text = stringResource(R.string.settings__bg__notifications_header), - color = Colors.White64 - ) - - VerticalSpacer(16.dp) - - SecondaryButton( - stringResource(R.string.settings__bg__customize), - icon = { Image(painter = painterResource(R.drawable.ic_bell), contentDescription = null) }, - onClick = onSystemSettingsClick, - ) - - VerticalSpacer(32.dp) - - Text13Up( - text = stringResource(R.string.common__preview), - color = Colors.White64 - ) - - VerticalSpacer(16.dp) - - NotificationPreview( - enabled = hasPermission, - title = stringResource(R.string.notification__received__title), - description = "₿ 21 000 ($21.00)", - modifier = Modifier.fillMaxWidth() - ) - } - } -} - -@Preview(showSystemUi = true) -@Composable -private fun Preview1() { - AppThemeSurface { - Content( - hasPermission = true, - keepActive = true, - onBack = {}, - onSystemSettingsClick = {}, - onKeepActiveClick = {}, - ) - } -} - -@Preview(showSystemUi = true) -@Composable -private fun Preview2() { - AppThemeSurface { - Content( - hasPermission = false, - keepActive = false, - onBack = {}, - onSystemSettingsClick = {}, - onKeepActiveClick = {}, - ) - } -} diff --git a/app/src/main/java/to/bitkit/ui/sheets/BackgroundPaymentsIntroSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/BackgroundPaymentsIntroSheet.kt deleted file mode 100644 index 0529fb4a64..0000000000 --- a/app/src/main/java/to/bitkit/ui/sheets/BackgroundPaymentsIntroSheet.kt +++ /dev/null @@ -1,50 +0,0 @@ -package to.bitkit.ui.sheets - -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.tooling.preview.Preview -import to.bitkit.R -import to.bitkit.ui.components.BottomSheetPreview -import to.bitkit.ui.components.SheetIntro -import to.bitkit.ui.shared.modifiers.sheetHeight -import to.bitkit.ui.theme.AppThemeSurface -import to.bitkit.ui.theme.Colors -import to.bitkit.ui.utils.withAccent - -@Composable -fun BackgroundPaymentsIntroSheet( - onLater: () -> Unit, - onEnable: () -> Unit, - modifier: Modifier = Modifier, -) { - SheetIntro( - navTitle = stringResource(R.string.settings__bg__title), - title = stringResource(R.string.settings__bg__intro_title).withAccent(accentColor = Colors.Purple), - description = AnnotatedString(stringResource(R.string.settings__bg__intro_desc)), - image = R.drawable.bell_figure, - continueText = stringResource(R.string.settings__bg__intro_button), - onContinue = onEnable, - cancelText = stringResource(R.string.common__later), - onCancel = onLater, - testTag = "BackgroundPaymentsIntro", - cancelTestTag = "BackgroundPaymentsIntro-later", - continueTestTag = "BackgroundPaymentsIntro-enable", - modifier = modifier - .sheetHeight(isModal = true) - ) -} - -@Preview(showSystemUi = true) -@Composable -private fun Preview() { - AppThemeSurface { - BottomSheetPreview { - BackgroundPaymentsIntroSheet( - onLater = {}, - onEnable = {}, - ) - } - } -} diff --git a/app/src/main/java/to/bitkit/utils/timedsheets/sheets/NotificationsTimedSheet.kt b/app/src/main/java/to/bitkit/utils/timedsheets/sheets/NotificationsTimedSheet.kt deleted file mode 100644 index 068dcf5561..0000000000 --- a/app/src/main/java/to/bitkit/utils/timedsheets/sheets/NotificationsTimedSheet.kt +++ /dev/null @@ -1,49 +0,0 @@ -package to.bitkit.utils.timedsheets.sheets - -import kotlinx.coroutines.flow.first -import to.bitkit.data.SettingsStore -import to.bitkit.ext.nowMillis -import to.bitkit.repositories.WalletRepo -import to.bitkit.ui.components.TimedSheetType -import to.bitkit.utils.Logger -import to.bitkit.utils.timedsheets.ONE_WEEK_ASK_INTERVAL_MILLIS -import to.bitkit.utils.timedsheets.TimedSheetItem -import to.bitkit.utils.timedsheets.checkTimeout -import javax.inject.Inject -import kotlin.time.ExperimentalTime - -class NotificationsTimedSheet @Inject constructor( - private val settingsStore: SettingsStore, - private val walletRepo: WalletRepo, -) : TimedSheetItem { - override val type = TimedSheetType.NOTIFICATIONS - override val priority = 3 - - override suspend fun shouldShow(): Boolean { - val settings = settingsStore.data.first() - if (settings.notificationsGranted) return false - if (walletRepo.balanceState.value.totalLightningSats == 0UL) return false - - return checkTimeout( - lastIgnoredMillis = settings.notificationsIgnoredMillis, - intervalMillis = ONE_WEEK_ASK_INTERVAL_MILLIS - ) - } - - override suspend fun onShown() { - Logger.debug("Notifications sheet shown", context = TAG) - } - - @OptIn(ExperimentalTime::class) - override suspend fun onDismissed() { - val currentTime = nowMillis() - settingsStore.update { - it.copy(notificationsIgnoredMillis = currentTime) - } - Logger.debug("Notifications sheet dismissed", context = TAG) - } - - companion object { - private const val TAG = "NotificationsTimedSheet" - } -} diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index c6cefa81e4..8f3f5e590e 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -255,7 +255,6 @@ import to.bitkit.utils.timedsheets.TimedSheetManager import to.bitkit.utils.timedsheets.sheets.AppUpdateTimedSheet import to.bitkit.utils.timedsheets.sheets.BackupTimedSheet import to.bitkit.utils.timedsheets.sheets.HighBalanceTimedSheet -import to.bitkit.utils.timedsheets.sheets.NotificationsTimedSheet import to.bitkit.utils.timedsheets.sheets.QuickPayTimedSheet import to.bitkit.utils.timedsheets.sheets.UsdtTimedSheet import java.math.BigDecimal @@ -315,7 +314,6 @@ class AppViewModel @Inject constructor( private val samRockRepo: SamRockRepo, private val appUpdateSheet: AppUpdateTimedSheet, private val backupSheet: BackupTimedSheet, - private val notificationsSheet: NotificationsTimedSheet, private val quickPaySheet: QuickPayTimedSheet, private val usdtSheet: UsdtTimedSheet, private val usdtPreferences: UsdtPreferencesRepo, @@ -548,7 +546,6 @@ class AppViewModel @Inject constructor( registerSheet(appUpdateSheet) registerSheet(usdtSheet) registerSheet(backupSheet) - registerSheet(notificationsSheet) registerSheet(quickPaySheet) registerSheet(highBalanceSheet) } diff --git a/app/src/main/java/to/bitkit/viewmodels/DevSettingsViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/DevSettingsViewModel.kt index 65d6c1e48f..2bd5e38ad1 100644 --- a/app/src/main/java/to/bitkit/viewmodels/DevSettingsViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/DevSettingsViewModel.kt @@ -4,17 +4,14 @@ import android.content.Context import android.net.Uri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.google.firebase.messaging.FirebaseMessaging import com.synonym.bitkitcore.LegacyRnCloseRecoveryScanResult import com.synonym.bitkitcore.LegacyRnCloseRecoverySweepPreview -import com.synonym.bitkitcore.testNotification import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.coroutines.tasks.await import to.bitkit.R import to.bitkit.data.AppDb import to.bitkit.data.CacheStore @@ -48,7 +45,6 @@ data class LegacyRnRecoveryUiState( @HiltViewModel class DevSettingsViewModel @Inject constructor( @ApplicationContext private val context: Context, - private val firebaseMessaging: FirebaseMessaging, private val lightningRepo: LightningRepo, private val walletRepo: WalletRepo, private val widgetsStore: WidgetsStore, @@ -166,30 +162,6 @@ class DevSettingsViewModel @Inject constructor( .onFailure { ToastEventBus.send(it) } } - fun registerForNotifications() = viewModelScope.launch { - lightningRepo.registerForNotifications() - .onSuccess { - @Suppress("KotlinConstantConditions") - val title = if (Env.isE2eTest) "Skipped push registration" else "Registered for notifications" - ToastEventBus.send(type = Toast.ToastType.INFO, title = title) - } - .onFailure { ToastEventBus.send(it) } - } - - fun testLspNotification() = viewModelScope.launch { - runCatching { - testNotification( - deviceToken = firebaseMessaging.token.await(), - secretMessage = "hello", - notificationType = "incomingHtlc", - customUrl = Env.blocktankNotificationApiUrl, - ) - ToastEventBus.send(type = Toast.ToastType.INFO, title = "LSP notification sent to this device") - }.onFailure { - ToastEventBus.send(type = Toast.ToastType.WARNING, title = "Error testing LSP notification") - } - } - fun fakeBgReceive() = viewModelScope.launch { cacheStore.setBackgroundReceive( NewTransactionSheetDetails( diff --git a/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt index 5b3bdf5964..5e22b9718a 100644 --- a/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt @@ -204,14 +204,6 @@ class SettingsViewModel @Inject constructor( } } - val bgPaymentsIntroSeen = settingsStore.data.map { it.bgPaymentsIntroSeen } - .asStateFlow(initialValue = false) - - fun setBgPaymentsIntroSeen(value: Boolean) { - viewModelScope.launch { - settingsStore.update { it.copy(bgPaymentsIntroSeen = value) } - } - } val isPinForPaymentsEnabled = settingsStore.data.map { it.isPinForPaymentsEnabled } .asStateFlow(initialValue = false) diff --git a/app/src/main/res/drawable-hdpi/bell.webp b/app/src/main/res/drawable-hdpi/bell.webp deleted file mode 100644 index 07acc5057b2c04ccc0d4790b4ff1a775c679b4d5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4634 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ੳ zSsLPkY3;cpzOF4@_y|MW4IPWpXx{1oahCN*xPwjtp6Q9J0 zCwNJHcHr|Ux6e0w=RjC=>_)S+kJy20YO_WU%gD6q4*fE7PD~6VVC;`Yf5WX=Z%8G6 z%t;Id2JCq3haRR=HGhn}h~0rVndtDEUs0EGV8^pD#(}JOCmHsZ0)379e1sh-p#0oZ z?^y)EFE#tBuGe|;8Fh-X-<)dUd(BW%0AZ8z_XMGZV^f8lmN60VqB2L-i~nvi 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

`Z6cCdN6_FUt=nAjn27*L*G(J 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

@Mv6;a~l6So2|yiDhG>Ttip}^UcGrQz4JLPD6Zs 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 C`euCq diff --git a/app/src/main/res/drawable-xxhdpi/bell.webp b/app/src/main/res/drawable-xxhdpi/bell.webp deleted file mode 100644 index 58926beafec5c14bfa57722f617736165708a7b0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10840 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 X+kd@^&$1IPN7VDH^IzpP0PueR*vuJe diff --git a/app/src/main/res/drawable-xxxhdpi/bell.webp b/app/src/main/res/drawable-xxxhdpi/bell.webp deleted file mode 100644 index e2fa18f153fcdd4065faa7c43a7c4d1be04e130d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16892 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 G`hNg{;v~xe diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 5ce33fff83..6e5d220e88 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -9,8 +9,6 @@ دعوة مدفوعات فورية إنفاق - عندما يكون Bitkit مغلقًا - استلم أموالك إعداد رمز PIN تأمين امسح وادفع @@ -588,17 +586,9 @@ آخر نسخة احتياطية: {time} نسخ محفظتك احتياطيًا نسخ البيانات الاحتياطية - تخصيص في إعدادات Bitkit على Android - المدفوعات في الخلفية معطلة، لأنك رفضت الإشعارات. - المدفوعات في الخلفية مفعلة. يمكنك استلام الأموال حتى عندما يكون التطبيق مغلقًا (إذا كان جهازك متصلاً بالإنترنت). - فعّل الإشعارات لاستلام الأموال، حتى عندما يكون تطبيق Bitkit مغلقًا. - استلم الأموال\n<accent>بشكل سلبي</accent> - الإشعارات إيقاف تشغيل الإعداد في الخلفية - استلم الأموال عندما يكون Bitkit مغلقًا - المدفوعات في الخلفية يجد تطابقات دقيقة للمبلغ لتقليل الصرف Branch and Bound اختيار عشوائي للخصوصية @@ -1448,9 +1438,6 @@ تم تحديث الإعدادات نوع العنوان محدد حاليًا - تفعيل - يؤدي إبقاء Bitkit نشطًا إلى مدفوعات أكثر موثوقية. قد يعرض Android إشعارًا دائمًا أثناء تفعيل هذا الخيار. - إبقاء Bitkit نشطًا في الخلفية شهادة هذا الخادم غير موثوقة. استخدم خادمًا بشهادة موثوقة، أو اتصل بمنفذ TCP الخاص به. هذا الخادم على شبكة Bitcoin مختلفة. اختر خادمًا للشبكة التي يستخدمها Bitkit. فشل الاتصال الآمن. تحقق من أن البروتوكول (TCP أو TLS) يطابق منفذ الخادم. diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml index dac24de7f3..fa6b1e1eff 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -9,8 +9,6 @@ Invitar Pagos instantáneos Gastar - Sin Bitkit abierto - Cobrar Configurar un código PIN PROTEGER Escanear y pagar @@ -610,17 +608,9 @@ Última Copia de Seguridad: {time} Haga una copia de seguridad de su cartera Copias de seguridad - Personalizar en Ajustes de Bitkit en Android - Los pagos en segundo plano están desactivados porque has denegado las notificaciones. - Los pagos en segundo plano están activados. Puedes recibir fondos incluso cuando la app está cerrada (si tu dispositivo está conectado a internet). - Activa las notificaciones para recibir pagos, incluso cuando tu app Bitkit está cerrada. - RECIBE PAGOS\n<accent>PASIVAMENTE</accent> - Notificaciones Apagado Encendido Configurar en segundo plano - Recibir pagos cuando Bitkit está cerrado - Pagos en segundo plano Encuentra coincidencias exactas de monto para minimizar el cambio Branch and Bound Selección aleatoria para mayor privacidad @@ -1464,9 +1454,6 @@ Ajustes actualizados Tipo de dirección Seleccionado actualmente - Activar - Mantener Bitkit activo hace que los pagos sean más confiables. Android puede mostrar una notificación persistente mientras esto esté activado. - Mantener Bitkit activo en segundo plano El certificado de este servidor no es de confianza. Use un servidor con un certificado de confianza o conéctese a su puerto TCP. Este servidor está en otra red de Bitcoin. Elija un servidor de la red que usa Bitkit. Falló la conexión segura. Verifique que el protocolo (TCP o TLS) coincida con el puerto del servidor. diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index de71725e65..a5e77f3241 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -9,8 +9,6 @@ Convidar Pagaments instantanis Gastar - Quan Bitkit està tancat - Rep pagaments Configurar un codi PIN Segur Escaneja i paga @@ -603,17 +601,9 @@ Última còpia de seguretat: {time} Fes còpia de seguretat de la cartera Còpies de seguretat - Personalitza a la configuració de Bitkit d\'Android - Els pagaments en segon pla estan desactivats perquè has denegat les notificacions. - Els pagaments en segon pla estan habilitats. Pots rebre fons fins i tot quan l\'aplicació està tancada (si el teu dispositiu està connectat a Internet). - Activa les notificacions per rebre pagaments, fins i tot quan la teva aplicació Bitkit està tancada. - REP PAGAMENTS\n<accent>PASSIVAMENT</accent> - Notificacions Desactivat Activat Configurar en segon pla - Rep pagaments quan Bitkit està tancat - Pagaments en segon pla Troba coincidències d\'import exactes per minimitzar el canvi Branch and Bound Selecció aleatòria per a privacitat @@ -1461,9 +1451,6 @@ Configuració actualitzada Tipus d\'adreça Seleccionat actualment - Activar - Mantenir Bitkit actiu fa que els pagaments siguin més fiables. Android pot mostrar una notificació persistent mentre això estigui activat. - Mantenir Bitkit actiu en segon pla El certificat d\'aquest servidor no és de confiança. Utilitza un servidor amb un certificat de confiança o connecta\'t al seu port TCP. Aquest servidor és d\'una xarxa Bitcoin diferent. Tria un servidor per a la xarxa que utilitza Bitkit. Ha fallat la connexió segura. Comprova que el protocol (TCP o TLS) coincideix amb el port del servidor. diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 20fef8d846..b6eb02407a 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -9,8 +9,6 @@ Pozvat Okamžité platby Utratit - Když je Bitkit zavřený - Přijímat platby Nastavte PIN kód Zabezpečit Naskenuj a zaplať @@ -608,17 +606,9 @@ Poslední záloha: {time} Zálohujte svou peněženku Zálohy dat - Přizpůsobit v nastavení Android Bitkit - Platby na pozadí jsou zakázány, protože jste odmítli oznámení. - Platby na pozadí jsou povoleny. Prostředky můžete přijímat i při zavřené aplikaci (pokud je zařízení připojeno k internetu). - Zapněte oznámení, abyste dostávali platby i když je aplikace Bitkit zavřená. - DOSTÁVAT PLATBY\nPASIVNĚ - Oznámení Vypnuto Zapnuto Nastavit na pozadí - Dostávat platby když je Bitkit zavřený - Platby na pozadí Najde přesné shody částek pro minimalizaci zbytku Branch and Bound Náhodný výběr pro soukromí @@ -1463,9 +1453,6 @@ Nastavení aktualizováno Typ adresy Aktuálně vybráno - Povolit - Udržování Bitkitu aktivního vede ke spolehlivějším platbám. Android může při zapnutí této funkce zobrazovat trvalé oznámení. - Udržovat Bitkit aktivní na pozadí Certifikát tohoto serveru není důvěryhodný. Použijte server s důvěryhodným certifikátem, nebo se připojte k jeho TCP portu. Tento server je v jiné síti Bitcoin. Vyberte server pro síť, kterou Bitkit používá. Zabezpečené připojení se nezdařilo. Zkontrolujte, zda protokol (TCP nebo TLS) odpovídá portu serveru. diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 5e6ba5821f..9360040581 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -19,8 +19,6 @@ Hilfe bekommen Kaufen Kaufe bitcoin - Wenn Bitkit geschlossen ist - Empfangen Wallet-Übersicht Diese Funktion wird derzeit entwickelt und ist bald verfügbar. Bald\n<accent>verfügbar</accent> @@ -609,14 +607,6 @@ Quickpay-Schwelle * Bitkit QuickPay unterstützt ausschließlich Zahlungen von deinem Guthaben. QuickPay beschleunigt den Bezahlvorgang, indem gescannte QR-Codes automatisch bezahlt werden. - Hintergrund-Zahlungen - PASSIV\n<accent>EMPFANGEN</accent> - Aktiviere Benachrichtigungen, um Zahlungen zu empfangen, auch wenn deine Bitkit-App geschlossen ist. - Hintergrund-Zahlungen sind aktiviert. Du kannst Gelder empfangen, auch wenn die App geschlossen ist (wenn dein Gerät mit dem Internet verbunden ist). - Hintergrund-Zahlungen sind deaktiviert, da du Benachrichtigungen abgelehnt hast. - Benachrichtigungen - Empfange Zahlungen wenn Bitkit geschlossen ist - In Android Bitkit-Einstellungen anpassen An Aus Im Hintergrund einrichten @@ -1461,9 +1451,6 @@ Einstellungen aktualisiert Adresstyp Derzeit ausgewählt - Aktivieren - Wenn Bitkit aktiv bleibt, sind Zahlungen zuverlässiger. Android zeigt möglicherweise eine dauerhafte Benachrichtigung an, solange dies aktiviert ist. - Bitkit im Hintergrund aktiv halten Das Zertifikat dieses Servers ist nicht vertrauenswürdig. Verwende einen Server mit vertrauenswürdigem Zertifikat oder verbinde dich mit seinem TCP-Port. Dieser Server befindet sich in einem anderen Bitcoin-Netzwerk. Wähle einen Server für das Netzwerk, das Bitkit verwendet. Sichere Verbindung fehlgeschlagen. Überprüfe, ob das Protokoll (TCP oder TLS) zum Server-Port passt. diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index ae396e2e75..e57c0a8a49 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -9,8 +9,6 @@ Πρόσκληση Άμεσες πληρωμές Δαπάνη - Όταν το Bitkit είναι κλειστό - Λήψη πληρωμών Καταχωρήστε κωδικό PIN Ασφάλεια Σάρωσε και πλήρωσε @@ -589,17 +587,9 @@ Τελευταίο αντίγραφο: {time} Αντίγραφο ασφαλείας πορτοφολιού Αντίγραφα δεδομένων - Προσαρμογή στις ρυθμίσεις Android Bitkit - Οι πληρωμές παρασκηνίου είναι απενεργοποιημένες επειδή αρνήθηκες τις ειδοποιήσεις. - Οι πληρωμές παρασκηνίου είναι ενεργοποιημένες. Μπορείς να λαμβάνεις κεφάλαια ακόμα και όταν η εφαρμογή είναι κλειστή (αν η συσκευή σου είναι συνδεδεμένη στο διαδίκτυο). - Ενεργοποίησε τις ειδοποιήσεις για να λαμβάνεις πληρωμές ακόμα και όταν η εφαρμογή Bitkit είναι κλειστή. - ΛΑΒΕ ΠΛΗΡΩΜΕΣ\n<accent>ΠΑΘΗΤΙΚΑ</accent> - Ειδοποιήσεις Ανενεργό Ενεργό Ρύθμιση στο παρασκήνιο - Λήψη πληρωμών όταν το Bitkit είναι κλειστό - Πληρωμές παρασκηνίου Βρίσκει ακριβείς αντιστοιχίσεις ποσού για ελαχιστοποίηση ρέστων Branch and Bound Τυχαία επιλογή για απόρρητο @@ -1454,9 +1444,6 @@ Οι ρυθμίσεις ενημερώθηκαν Τύπος διεύθυνσης Τρέχουσα επιλογή - Ενεργοποίηση - Η διατήρηση του Bitkit ενεργού κάνει τις πληρωμές πιο αξιόπιστες. Το Android μπορεί να εμφανίζει μόνιμη ειδοποίηση όσο είναι ενεργό. - Διατήρηση του Bitkit ενεργού στο παρασκήνιο Το πιστοποιητικό αυτού του διακομιστή δεν είναι αξιόπιστο. Χρησιμοποίησε διακομιστή με αξιόπιστο πιστοποιητικό ή σύνδεσου στη θύρα TCP του. Αυτός ο διακομιστής είναι σε διαφορετικό δίκτυο Bitcoin. Επίλεξε διακομιστή για το δίκτυο που χρησιμοποιεί το Bitkit. Η ασφαλής σύνδεση απέτυχε. Έλεγξε ότι το πρωτόκολλο (TCP ή TLS) αντιστοιχεί στη θύρα του διακομιστή. diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index 534c95d8f2..a076ba3526 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -9,8 +9,6 @@ Invitar Pagos instantáneos Gastar - Cuando Bitkit está cerrado - Recibe pagos Establezca un código PIN PROTEGER Escanea y paga @@ -607,17 +605,9 @@ Último respaldo: {time} Respalda tu monedero Copias de seguridad - Personalizar en ajustes de Bitkit en Android - Los pagos en segundo plano están desactivados porque has denegado las notificaciones. - Los pagos en segundo plano están activados. Puedes recibir fondos incluso cuando la aplicación está cerrada (si tu dispositivo está conectado a internet). - Activa las notificaciones para recibir pagos, incluso cuando tu aplicación Bitkit está cerrada. - RECIBE PAGOS\n<accent>PASIVAMENTE</accent> - Notificaciones Desactivado Activado Configurar en segundo plano - Recibe pagos cuando Bitkit está cerrado - Pagos en segundo plano Encuentra coincidencias exactas para minimizar el cambio Ramificación y acotación Selección aleatoria para privacidad @@ -1458,9 +1448,6 @@ Ajustes actualizados Tipo de dirección Seleccionado actualmente - Activar - Mantener Bitkit activo hace que los pagos sean más fiables. Android puede mostrar una notificación persistente mientras esté activado. - Mantener Bitkit activo en segundo plano El certificado de este servidor no es de confianza. Usa un servidor con un certificado de confianza o conéctate a su puerto TCP. Este servidor está en otra red de Bitcoin. Elige un servidor para la red que usa Bitkit. Error en la conexión segura. Comprueba que el protocolo (TCP o TLS) coincide con el puerto del servidor. diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 1452b5b3aa..54699b9db0 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -8,8 +8,6 @@ Invitar Pagos instantáneos Gastar - Cuando Bitkit está cerrado - Recibir pagos Establezca un código PIN PROTEGER Escanear y pagar @@ -629,17 +627,9 @@ Bitkit no pudo hacer una copia de seguridad de los datos del monedero. Reintentando en {interval, plural, one {# minuto} other {# minutos}}. Fallo de Copia de Seguridad de Datos Copias de seguridad - Personalizar en los Ajustes de Bitkit Android - Los pagos en segundo plano están desactivados porque has denegado las notificaciones. - Los pagos en segundo plano están activados. Puedes recibir fondos incluso cuando la aplicación está cerrada (si tu dispositivo está conectado a internet). - Activa las notificaciones para recibir pagos, incluso cuando tu aplicación Bitkit está cerrada. - RECIBE PAGOS\n<accent>PASIVAMENTE</accent> - Notificaciones Desactivado Activado Configurar en segundo plano - Recibir pagos cuando Bitkit está cerrado - Pagos en Segundo Plano Encuentra coincidencias de cantidad exacta para minimizar el cambio Branch and Bound Selección aleatoria para privacidad @@ -1457,9 +1447,6 @@ Ajustes actualizados Tipo de dirección Seleccionado actualmente - Activar - Mantener Bitkit activo hace que los pagos sean más fiables. Android puede mostrar una notificación persistente mientras esté activado. - Mantener Bitkit activo en segundo plano El certificado de este servidor no es de confianza. Use un servidor con un certificado de confianza o conéctese a su puerto TCP. Este servidor está en otra red de Bitcoin. Elija un servidor para la red que usa Bitkit. Error en la conexión segura. Compruebe que el protocolo (TCP o TLS) coincide con el puerto del servidor. diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index fe18474473..d82017aca1 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -19,8 +19,6 @@ Obtenir de l\'aide Acheter Achetez des bitcoins - Soyez rémunéré - Lorsque Bitkit est fermé Aperçu du portefeuille Cette fonctionnalité est actuellement en développement et sera bientôt disponible. Bientôt\n<accent>disponible</accent> @@ -641,17 +639,9 @@ Profil Contacts Sauvegardes des données - Personnaliser dans les paramètres Android de Bitkit - Les paiements en arrière-plan sont désactivés car vous avez refusé les notifications. - Les paiements en arrière-plan sont activés. Vous pouvez recevoir des fonds même lorsque l\'application est fermée (si votre appareil est connecté à Internet). - Activez les notifications pour être payé, même lorsque votre application Bitkit est fermée. - ÊTRE PAYÉ\n<accent>PASSIVEMENT</accent> - Notifications Désactivé Activé Configurer en arrière-plan - Être payé quand Bitkit est fermé - Paiements en arrière-plan Besoin d\'aide ? Signalez votre problème depuis Bitkit, visitez le centre d\'aide, vérifiez le statut ou contactez-nous sur nos canaux sociaux. Rapporter un problème Centre d\'aide @@ -1459,9 +1449,6 @@ Paramètres mis à jour Type d\'adresse Actuellement sélectionné - Activer - Garder Bitkit actif rend les paiements plus fiables. Android peut afficher une notification permanente tant que cette option est activée. - Garder Bitkit actif en arrière-plan Le certificat de ce serveur n\'est pas fiable. Utilisez un serveur avec un certificat fiable ou connectez-vous à son port TCP. Ce serveur est sur un autre réseau Bitcoin. Choisissez un serveur pour le réseau utilisé par Bitkit. Échec de la connexion sécurisée. Vérifiez que le protocole (TCP ou TLS) correspond au port du serveur. diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 26b4e0eda3..268d77f92b 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -9,8 +9,6 @@ Invita Pagamenti istantanei Spendi - Quando Bitkit e\' chiuso - Ricevi pagamenti Imposta un codice PIN Sicuro Scansiona e paga @@ -606,17 +604,9 @@ Ultimo backup: {time} Esegui il backup del tuo wallet Backup dei dati - Personalizza nelle Impostazioni Android di Bitkit - I pagamenti in background sono disabilitati perche\' hai negato le notifiche. - I pagamenti in background sono abilitati. Puoi ricevere fondi anche quando l\'app e\' chiusa (se il dispositivo e\' connesso a internet). - Attiva le notifiche per ricevere pagamenti anche quando l\'app Bitkit e\' chiusa. - RICEVI PAGAMENTI\n<accent>PASSIVAMENTE</accent> - Notifiche Disattivo Attivo Configura in background - Ricevi pagamenti quando Bitkit e\' chiuso - Pagamenti in Background Trova corrispondenze esatte per minimizzare il resto Branch and Bound Selezione casuale per la privacy @@ -1463,9 +1453,6 @@ Impostazioni aggiornate Tipo di Indirizzo Attualmente selezionato - Abilita - Mantenere Bitkit attivo rende i pagamenti più affidabili. Android potrebbe mostrare una notifica persistente mentre questa opzione è attiva. - Mantieni Bitkit attivo in background Il certificato di questo server non è attendibile. Usa un server con un certificato attendibile, oppure connettiti alla sua porta TCP. Questo server è su una rete Bitcoin diversa. Scegli un server per la rete che Bitkit sta usando. Connessione sicura non riuscita. Verifica che il protocollo (TCP o TLS) corrisponda alla porta del server. diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 886256ef87..ab12a2ccbd 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -9,8 +9,6 @@ Uitnodiging Directe betalingen Besteed - Als Bitkit gesloten is - Ontvang betaling Pincode instellen Beveilig Scan en betaal @@ -604,17 +602,9 @@ Laatste backup: {time} Back-up maken van je wallet Gegevensback-ups - Aanpassen in Android Bitkit-instellingen - Achtergrondbetalingen zijn uitgeschakeld omdat je notificaties hebt geweigerd. - Achtergrondbetalingen zijn ingeschakeld. Je kunt geld ontvangen zelfs als de app gesloten is (als je apparaat verbonden is met internet). - Schakel notificaties in om betaald te worden, zelfs als je Bitkit-app gesloten is. - ONTVANG BETALING\n<accent>PASSIEF</accent> - Notificaties Uit Aan Op achtergrond instellen - Ontvang betaling als Bitkit gesloten is - Achtergrondbetalingen Vindt exacte bedragovereenkomsten om wisselgeld te minimaliseren Branch and Bound Willekeurige selectie voor privacy @@ -1460,9 +1450,6 @@ Instellingen bijgewerkt Adrestype Momenteel geselecteerd - Inschakelen - Bitkit actief houden zorgt voor betrouwbaardere betalingen. Android kan een permanente melding tonen zolang dit is ingeschakeld. - Bitkit actief houden op de achtergrond Het certificaat van deze server wordt niet vertrouwd. Gebruik een server met een vertrouwd certificaat, of maak verbinding met de TCP-poort. Deze server zit op een ander Bitcoin-netwerk. Kies een server voor het netwerk dat Bitkit gebruikt. Beveiligde verbinding mislukt. Controleer of het protocol (TCP of TLS) overeenkomt met de serverpoort. diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 7bf22f59e5..62cccbb6a2 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -19,8 +19,6 @@ Otrzymaj wsparcie Kup Kup trochę bitcoina - Gdy Bitkit jest zamknięty - Odbieraj płatności Przegląd portfela Ta funkcja jest w trakcie rozwoju i wkrótce będzie dostępna. Wkrótce\n<accent>dostępne</accent> @@ -641,17 +639,9 @@ Kontakty W toku Kopie zapasowe danych - Dostosuj w ustawieniach Bitkit na Androidzie - Płatności w tle są wyłączone, ponieważ odmówiono powiadomień. - Płatności w tle są włączone. Możesz otrzymywać środki nawet gdy aplikacja jest zamknięta (jeśli Twoje urządzenie jest połączone z internetem). - Włącz powiadomienia, aby otrzymywać płatności nawet gdy Bitkit jest zamknięty. - OTRZYMUJ\n<accent>PASYWNIE</accent> - Powiadomienia Wył. Wł. Konfiguracja w tle - Otrzymuj płatności gdy Bitkit jest zamknięty - Płatności w tle Znajduje dokładne dopasowania kwot, aby zminimalizować resztę Branch and Bound Losowy wybór dla prywatności @@ -1461,9 +1451,6 @@ Ustawienia zaktualizowane Typ adresu Obecnie wybrany - Włącz - Utrzymywanie aktywności Bitkit zapewnia bardziej niezawodne płatności. Gdy ta opcja jest włączona, Android może wyświetlać stałe powiadomienie. - Utrzymuj Bitkit aktywny w tle Certyfikat tego serwera nie jest zaufany. Użyj serwera z zaufanym certyfikatem lub połącz się z jego portem TCP. Ten serwer działa w innej sieci Bitcoin. Wybierz serwer dla sieci, której używa Bitkit. Nie udało się nawiązać bezpiecznego połączenia. Sprawdź, czy protokół (TCP lub TLS) odpowiada portowi serwera. diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 427b4993cb..8fd9ca0a92 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -9,8 +9,6 @@ Convide Pagamentos instantâneos Pagar - Quando a Bitkit está fechada - Receba pagamentos Configure um PIN Segurança Escanear e pagar @@ -611,17 +609,9 @@ Último backup: {time} Faça backup da sua carteira Backups de dados - Personalizar nas Configurações do Bitkit Android - Pagamentos em segundo plano estão desativados porque você negou notificações. - Pagamentos em segundo plano estão ativados. Você pode receber fundos mesmo quando o app está fechado (se seu dispositivo estiver conectado à internet). - Ative as notificações para receber pagamentos, mesmo quando o app Bitkit está fechado. - RECEBA\n<accent>PASSIVAMENTE</accent> - Notificações Desligado Ligado Configurar em segundo plano - Receba pagamentos quando a Bitkit está fechada - Pagamentos em Segundo Plano Encontra correspondências exatas para minimizar troco Branch and Bound Seleção aleatória para privacidade @@ -1463,9 +1453,6 @@ Configurações atualizadas Tipo de Endereço Selecionado no momento - Ativar - Manter a Bitkit ativa resulta em pagamentos mais confiáveis. O Android pode exibir uma notificação persistente enquanto isso estiver ativado. - Manter a Bitkit ativa em segundo plano O certificado deste servidor não é confiável. Use um servidor com um certificado confiável ou conecte-se à porta TCP dele. Este servidor está em uma rede Bitcoin diferente. Escolha um servidor para a rede que a Bitkit está usando. Falha na conexão segura. Verifique se o protocolo (TCP ou TLS) corresponde à porta do servidor. diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index bd5a9e81aa..5a53a369d1 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -19,8 +19,6 @@ Peça ajuda Comprar Compre bitcoin - Quando a Bitkit estiver fechada - Receba pagamentos Visão geral da carteira Este recurso está em desenvolvimento e estará disponível em breve. Em\n<accent>breve</accent> @@ -725,17 +723,9 @@ Conectar Servidor de Rapid-Gossip-Sync Atualizado Pode ser necessário reiniciar o aplicativo uma ou duas vezes para que esta alteração seja aplicada. - Personalizar nas Configurações da Bitkit no Android - Os pagamentos em segundo plano estão desativados porque você negou as notificações. - Pagamentos em segundo plano estão ativados. Você pode receber fundos mesmo quando o app está fechado (se seu dispositivo estiver conectado à internet). - Ative as notificações para receber pagamentos, mesmo quando a Bitkit está fechada. - RECEBA\n<accent>PASSIVAMENTE</accent> - Notificações Desativado Ativado Configurar em segundo plano - Receba pagamentos com a Bitkit fechada - Pagamentos em Segundo Plano Encontra correspondências exatas para minimizar troco Branch and Bound Seleção aleatória para privacidade @@ -1447,9 +1437,6 @@ Definições atualizadas Tipo de endereço Selecionado - Ativar - Manter a Bitkit ativa torna os pagamentos mais fiáveis. O Android pode mostrar uma notificação persistente enquanto esta opção estiver ativada. - Manter a Bitkit ativa em segundo plano O certificado deste servidor não é fidedigno. Use um servidor com um certificado fidedigno ou ligue à respetiva porta TCP. Este servidor está numa rede Bitcoin diferente. Escolha um servidor para a rede que a Bitkit está a usar. Falha na ligação segura. Verifique se o protocolo (TCP ou TLS) corresponde à porta do servidor. diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index eb50149477..5bf1057818 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -9,8 +9,6 @@ Пригласить Мгновенные платежи Тратить - Когда Bitkit закрыт - Получать платежи Установите PIN-код Безопасность Сканируйте и платите @@ -626,18 +624,9 @@ Последнее Резервное Копирование: {time} Создать Резервную Копию Кошелька Резервные копии данных - Настроить в настройках Android Bitkit - Фоновые платежи отключены, потому что вы отклонили уведомления. - Фоновые платежи включены. Вы можете получать средства, даже когда приложение закрыто (если ваше устройство подключено к интернету). - Включите уведомления, чтобы получать платежи, даже когда приложение Bitkit закрыто. - ПОЛУЧАЙТЕ -<accent>ПАССИВНО</accent> - Уведомления Выкл Вкл Настроить в фоне - Получать платежи при закрытом Bitkit - Фоновые платежи Находит точные совпадения сумм для минимизации сдачи Branch and Bound Случайный выбор для приватности @@ -1491,9 +1480,6 @@ Bitkit должен увеличить приёмную ёмкость ваше Настройки обновлены Тип Адреса Выбрано сейчас - Включить - Если Bitkit остаётся активным, платежи проходят надёжнее. Пока эта функция включена, Android может показывать постоянное уведомление. - Держать Bitkit активным в фоне Сертификат этого сервера не является доверенным. Используйте сервер с доверенным сертификатом или подключитесь к его TCP-порту. Этот сервер работает в другой сети Bitcoin. Выберите сервер для сети, которую использует Bitkit. Не удалось установить защищённое соединение. Проверьте, что протокол (TCP или TLS) соответствует порту сервера. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c75646852b..763139f9db 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -38,8 +38,6 @@ Invite Instant payments Spend - When Bitkit is closed - Get paid Set up a PIN code Secure Scan and pay @@ -925,20 +923,9 @@ Running Latest Backup: {time} Back up your wallet - 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). - Enable - Turn on notifications to get paid, even when your Bitkit app is closed. - GET PAID\n<accent>PASSIVELY</accent> - Keeping Bitkit active results in more reliable payments. Android may show a persistent notification while this is enabled. - Keep Bitkit active in background - Notifications Off On 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 diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index 2555185782..09ced5a8ee 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -1,7 +1,6 @@ package to.bitkit.repositories import app.cash.turbine.test -import com.google.firebase.messaging.FirebaseMessaging import com.synonym.bitkitcore.AddressType import com.synonym.bitkitcore.FeeRates import com.synonym.bitkitcore.IBtInfo @@ -82,7 +81,6 @@ import to.bitkit.services.ElectrumProbeError import to.bitkit.services.ElectrumProbeService import to.bitkit.services.LightningService import to.bitkit.services.LnurlService -import to.bitkit.services.LspNotificationsService import to.bitkit.services.NetworkGraphInfo import to.bitkit.services.NodeEventHandler import to.bitkit.test.BaseUnitTest @@ -122,8 +120,6 @@ class LightningRepoTest : BaseUnitTest() { private val lightningService = mock() private val settingsStore = mock() private val coreService = mock() - private val lspNotificationsService = mock() - private val firebaseMessaging = mock() private val keychain = mock() private val cacheStore = mock() private val preActivityMetadataRepo = mock() @@ -155,8 +151,6 @@ class LightningRepoTest : BaseUnitTest() { lightningService = lightningService, settingsStore = settingsStore, coreService = coreService, - lspNotificationsService = lspNotificationsService, - firebaseMessaging = firebaseMessaging, keychain = keychain, lnurlService = lnurlService, cacheStore = cacheStore, @@ -1970,8 +1964,6 @@ class LightningRepoTest : BaseUnitTest() { lightningService = lightningService, settingsStore = settingsStore, coreService = coreService, - lspNotificationsService = lspNotificationsService, - firebaseMessaging = firebaseMessaging, keychain = keychain, lnurlService = lnurlService, cacheStore = cacheStore, @@ -2400,8 +2392,8 @@ class LightningRepoTest : BaseUnitTest() { val store = OnchainSendAttemptStore(testDispatcher, keychain, lightningService, kotlin.time.Clock.System) sut = LightningRepo( bgDispatcher = testDispatcher, lightningService = lightningService, settingsStore = settingsStore, - coreService = coreService, lspNotificationsService = lspNotificationsService, - firebaseMessaging = firebaseMessaging, keychain = keychain, lnurlService = lnurlService, + coreService = coreService, + keychain = keychain, lnurlService = lnurlService, cacheStore = cacheStore, preActivityMetadataRepo = preActivityMetadataRepo, onchainSendAttemptStore = store, connectivityRepo = connectivityRepo, vssBackupClientLdk = vssBackupClientLdk, urlValidator = urlValidator, @@ -2470,8 +2462,8 @@ class LightningRepoTest : BaseUnitTest() { val store = OnchainSendAttemptStore(testDispatcher, keychain, lightningService, kotlin.time.Clock.System) sut = LightningRepo( bgDispatcher = testDispatcher, lightningService = lightningService, settingsStore = settingsStore, - coreService = coreService, lspNotificationsService = lspNotificationsService, - firebaseMessaging = firebaseMessaging, keychain = keychain, lnurlService = lnurlService, + coreService = coreService, + keychain = keychain, lnurlService = lnurlService, cacheStore = cacheStore, preActivityMetadataRepo = preActivityMetadataRepo, onchainSendAttemptStore = store, connectivityRepo = connectivityRepo, vssBackupClientLdk = vssBackupClientLdk, urlValidator = urlValidator, @@ -2551,8 +2543,8 @@ class LightningRepoTest : BaseUnitTest() { }.whenever(store).broadcastPreparedCandidate(any(), any(), any(), any()) sut = LightningRepo( bgDispatcher = testDispatcher, lightningService = lightningService, settingsStore = settingsStore, - coreService = coreService, lspNotificationsService = lspNotificationsService, - firebaseMessaging = firebaseMessaging, keychain = keychain, lnurlService = lnurlService, + coreService = coreService, + keychain = keychain, lnurlService = lnurlService, cacheStore = cacheStore, preActivityMetadataRepo = preActivityMetadataRepo, onchainSendAttemptStore = store, connectivityRepo = connectivityRepo, vssBackupClientLdk = vssBackupClientLdk, urlValidator = urlValidator, @@ -2760,12 +2752,6 @@ class LightningRepoTest : BaseUnitTest() { ) } - @Test - fun `registerForNotifications should fail when node is not running`() = test { - val result = sut.registerForNotifications() - assertTrue(result.isFailure) - } - @Test fun `restartWithElectrumServer should setup with new server`() = test { startNodeForTesting() @@ -3033,8 +3019,6 @@ class LightningRepoTest : BaseUnitTest() { lightningService = lightningService, settingsStore = settingsStore, coreService = coreService, - lspNotificationsService = lspNotificationsService, - firebaseMessaging = firebaseMessaging, keychain = keychain, lnurlService = lnurlService, cacheStore = cacheStore, diff --git a/app/src/test/java/to/bitkit/utils/timedsheets/sheets/NotificationsTimedSheetTest.kt b/app/src/test/java/to/bitkit/utils/timedsheets/sheets/NotificationsTimedSheetTest.kt deleted file mode 100644 index a62bcba3eb..0000000000 --- a/app/src/test/java/to/bitkit/utils/timedsheets/sheets/NotificationsTimedSheetTest.kt +++ /dev/null @@ -1,123 +0,0 @@ -package to.bitkit.utils.timedsheets.sheets - -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.runBlocking -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Test -import org.mockito.kotlin.any -import org.mockito.kotlin.mock -import org.mockito.kotlin.verify -import org.mockito.kotlin.whenever -import to.bitkit.data.SettingsData -import to.bitkit.data.SettingsStore -import to.bitkit.models.BalanceState -import to.bitkit.repositories.WalletRepo -import to.bitkit.test.BaseUnitTest -import to.bitkit.ui.components.TimedSheetType - -class NotificationsTimedSheetTest : BaseUnitTest() { - private lateinit var settingsStore: SettingsStore - private lateinit var walletRepo: WalletRepo - private lateinit var sut: NotificationsTimedSheet - - private val defaultSettings = SettingsData() - private val settingsFlow = MutableStateFlow(defaultSettings) - - @Before - fun setUp() = runBlocking { - settingsStore = mock() - walletRepo = mock() - - whenever(settingsStore.data).thenReturn(settingsFlow) - whenever(settingsStore.update(any())).then { - val transform = it.getArgument<(SettingsData) -> SettingsData>(0) - settingsFlow.value = transform(settingsFlow.value) - flowOf(settingsFlow.value) - } - - whenever(walletRepo.balanceState).thenReturn( - MutableStateFlow(BalanceState(totalLightningSats = 1000UL)) - ) - - sut = NotificationsTimedSheet(settingsStore, walletRepo) - } - - @Test - fun `type is NOTIFICATIONS`() { - assertTrue(sut.type == TimedSheetType.NOTIFICATIONS) - } - - @Test - fun `priority is 3`() { - assertTrue(sut.priority == 3) - } - - @Test - fun `should not show when notifications granted`() = test { - settingsFlow.value = defaultSettings.copy(notificationsGranted = true) - - val result = sut.shouldShow() - - assertFalse(result) - } - - @Test - fun `should not show when no lightning balance`() = test { - whenever(walletRepo.balanceState).thenReturn( - MutableStateFlow(BalanceState(totalLightningSats = 0UL)) - ) - - val result = sut.shouldShow() - - assertFalse(result) - } - - @Test - fun `should not show within one week timeout`() = test { - val sixDaysAgo = System.currentTimeMillis() - (6L * 24 * 60 * 60 * 1000) - settingsFlow.value = defaultSettings.copy( - notificationsGranted = false, - notificationsIgnoredMillis = sixDaysAgo - ) - - val result = sut.shouldShow() - - assertFalse(result) - } - - @Test - fun `should show after one week timeout with lightning balance`() = test { - val eightDaysAgo = System.currentTimeMillis() - (8L * 24 * 60 * 60 * 1000) - settingsFlow.value = defaultSettings.copy( - notificationsGranted = false, - notificationsIgnoredMillis = eightDaysAgo - ) - - val result = sut.shouldShow() - - assertTrue(result) - } - - @Test - fun `should show when never dismissed`() = test { - settingsFlow.value = defaultSettings.copy( - notificationsGranted = false, - notificationsIgnoredMillis = 0L - ) - - val result = sut.shouldShow() - - assertTrue(result) - } - - @Test - fun `updates ignored timestamp on dismissed`() = test { - sut.onDismissed() - - verify(settingsStore).update(any()) - assertTrue(settingsFlow.value.notificationsIgnoredMillis > 0) - } -} diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index b21e8ee307..1af661a6ee 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -929,7 +929,6 @@ class AppViewModelSendFlowTest : BaseUnitTest() { samRockRepo = samRockRepo, appUpdateSheet = mock { on { type } doReturn TimedSheetType.APP_UPDATE }, backupSheet = backupSheet, - notificationsSheet = mock { on { type } doReturn TimedSheetType.NOTIFICATIONS }, quickPaySheet = mock { on { type } doReturn TimedSheetType.QUICK_PAY }, usdtSheet = usdtSheet, diff --git a/docs/screens-map.md b/docs/screens-map.md index bfde34c946..3b371e883b 100644 --- a/docs/screens-map.md +++ b/docs/screens-map.md @@ -284,12 +284,6 @@ Frame names are stable across handoff iterations; node ids are not, so the map l | - | - | | AppStatusScreen.kt | Support & App Status › App Status OK | -## ui/settings/backgroundPayments - -| Android | Figma | -| - | - | -| BackgroundPaymentsIntroScreen.kt | Settings > General › BackgroundPayments Intro | - ## ui/settings/backups | Android | Figma | diff --git a/docs/strings.md b/docs/strings.md index f411b80f70..4a994cb867 100644 --- a/docs/strings.md +++ b/docs/strings.md @@ -115,4 +115,3 @@ Hardcoded strings in `@Preview` functions throughout the codebase do not need tr | 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/docs/timed-sheets.md b/docs/timed-sheets.md index 9a7995eb27..fe1db331b5 100644 --- a/docs/timed-sheets.md +++ b/docs/timed-sheets.md @@ -7,9 +7,8 @@ The manager checks sheets two seconds after the home screen resumes and cancels 1. App update 2. USDT 3. Backup -4. Background payments -5. QuickPay -6. High balance +4. QuickPay +5. High balance A busy sheet host leaves prompts queued for a later Home visit. While an unseen, undecided USDT intro has not yet been presented in this app session, becoming idle on Home also retries the queue after the same two-second delay. Once the intro is presented, closing sheets does not advance other timed prompts. Eligibility checks that finish after leaving Home or opening another flow cannot open a sheet. @@ -55,16 +54,6 @@ Backup uses a snooze model instead of a permanent seen flag: After the one-day ask interval passes, the Backup timed sheet can appear again if the backup is still unverified and the wallet still has a balance. -## Background Payments - -The Background Payments timed sheet is eligible when notification permission has not been granted, the wallet has a Lightning balance, and `notificationsIgnoredMillis` is older than one week. - -- `Later` marks the background-payments intro as seen and dismisses the sheet. Dismissal records `notificationsIgnoredMillis`. -- `Enable` marks the intro as seen, dismisses the sheet, records `notificationsIgnoredMillis`, and requests notification permission. -- A generic timed sheet dismissal records `notificationsIgnoredMillis` without marking the intro as seen. - -After the one-week ask interval passes, the timed sheet can appear again if notification permission is still not granted and the wallet still has a Lightning balance. The full-screen settings intro is a separate route with only an enable action. - ## High Balance The High Balance sheet is eligible when the wallet balance converts to more than USD 500, fewer than three warnings have been dismissed while above the threshold, and `balanceWarningIgnoredMillis` is older than one day. diff --git a/journeys/README.md b/journeys/README.md index 5647c3bef5..0426e22bf1 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -171,7 +171,7 @@ Known differences in the corpus, as of the iOS port (synonymdev/bitkit-ios#691): | `send/prepared-fee-recovery.md` | Same four manual fault cases and file on both platforms; the recipe records original-send versus replacement fee retention and iOS Max/identity-flow adaptations. These faults are not journey capabilities. | | `settings/electrum-server-error-toasts.xml` | not ported — iOS still shows one generic message for every manual Electrum connect failure | | `transfers/closed-channel-transfer-settles.xml` | not ported — the closed-channel and order-closure settle rules are an iOS follow-up | -| `onchain-receive/*` | port pending in synonymdev/bitkit-ios#588, which wires `onchainTransactionConfirmed` into the same received-sheet flow but carries no `journeys/` files. Two adaptations when it lands: iOS suppresses replayed historical receives with a `pendingRestoreActivitySeen` flag cleared by the first post-restore on-chain sync, not the one-hour block-timestamp guard used here, so a stale-confirmation step has to drive a restore instead of a clock; and iOS has no foreground-service path, so `confirmed-only-background-notification.xml` has no counterpart | +| `onchain-receive/*` | port pending in synonymdev/bitkit-ios#588, which wires `onchainTransactionConfirmed` into the same received-sheet flow but carries no `journeys/` files. Two adaptations when it lands: iOS suppresses replayed historical receives with a `pendingRestoreActivitySeen` flag cleared by the first post-restore on-chain sync, not the one-hour block-timestamp guard used here, so a stale-confirmation step has to drive a restore instead of a clock | | `backup/show-mnemonic-long-words.xml` | not ported — the long-word fit is an Android-only change (synonymdev/bitkit-android#633); whether iOS wraps long words at larger text sizes is unchecked | | `security/forgot-pin-nav-bar-inset.xml` | not ported — Android-only fix (synonymdev/bitkit-android#1572): the Android sheet host adds no navigation-bar inset, so each sheet screen pads itself. iOS sheets keep their content inside the bottom safe area | | `deeplinks/pubky-contact.xml` | ported — same contact routing and unlock behavior | @@ -253,7 +253,6 @@ and Settings (`Tab-general`, `Tab-security`, `Tab-advanced`, `NavigationBack`, ` | Send available balance | `AvailableAmount` **and** `available_balance` | `AvailableAmount` | | Send max | `SendAmountMax` | *(no button — tap `AvailableAmount`)* | | External amount available | — | `ExternalAmountAvailable` | -| Background payments setting row | `BackgroundPaymentSettings` | `NotificationsSettings` | | Send over-max toast | — *(no tag; assert it from a screenshot)* | `SendAmountExceededToast` | | Confirm mnemonic selected slot | `SelectedWord-` | — *(no identifier on `ConfirmWordView` rows)* | | Widgets intro screen container | — | `WidgetsOnboarding` | diff --git a/journeys/onchain-receive/README.md b/journeys/onchain-receive/README.md index fe6d651e8e..648684f010 100644 --- a/journeys/onchain-receive/README.md +++ b/journeys/onchain-receive/README.md @@ -25,7 +25,6 @@ throwaway emulator, since it wipes the app and restores a public test seed. - Wallet sync runs every 10s. For the confirmed-only journeys, run the deposit and the mine in one shell command, then check the log: an `OnchainTransactionReceived` line for the txid means the sync saw the mempool first and the run tested the other path. -- The background journey needs background payments enabled (Settings > Notifications). - Every event the node emits is logged by `LightningService` as `LDK event fired: ` under the `APP` logcat tag, so `adb logcat -d -s APP:V` is enough to tell the two paths apart. - The Receive sheet's tabs carry no test tag; select the Savings tab by its label. diff --git a/journeys/onchain-receive/confirmed-only-background-notification.xml b/journeys/onchain-receive/confirmed-only-background-notification.xml deleted file mode 100644 index a5834da7fb..0000000000 --- a/journeys/onchain-receive/confirmed-only-background-notification.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - Covers issue #797 on the LightningNodeService path. With background payments enabled and the app - in the background, a deposit first seen already confirmed must post exactly one "Payment - Received" notification. - - Precondition: onboarded regtest wallet with background payments enabled, so the "Bitkit is - running in background" foreground service notification is present. - - - Tap Receive (testTag "Receive") and verify the Receive sheet opens (testTag "ReceiveScreen") - Tap the "Savings" receive tab (the tab row carries no test tag), tap "Show Details" (testTag "ShowDetails") and read the address from testTag "ReceiveOnchainAddress" - Press back, then send the app to the background: adb shell input keyevent KEYCODE_HOME - Run in one command: ./lsp POST /regtest/chain/deposit '{"address":"<savings addr>","amountSat":7970}' && ./lsp POST /regtest/chain/mine '{"count":1}' - Wait 40s - Run: adb shell dumpsys notification --noredact | grep -E "android\.title=String \(Payment Received\)|android\.text=String \(Received " - Verify exactly one "android.title=String (Payment Received)" line is printed, and an "android.text=String (Received <amount>)" line with it carrying the fiat and BTC amounts in the order set by the primary display setting. The foreground-service notification is titled "Bitkit Regtest" on the dev flavour, so it does not match either pattern - Open the notification shade and tap the "Payment Received" notification - Verify the app opens with the received sheet (testTag "ReceivedTransaction") showing the deposited amount - Tap the sheet button (testTag "ReceivedTransactionButton") and verify the home screen shows with no second sheet - - From a1c333c0d4e7ae8c31900a51e966366743cc15c6 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 26 Oct 2026 18:11:44 +0100 Subject: [PATCH 08/47] fix: clear lint findings after background payments removal --- app/src/main/java/to/bitkit/ui/ContentView.kt | 3 +-- app/src/main/java/to/bitkit/viewmodels/DevSettingsViewModel.kt | 1 - app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt | 1 - 3 files changed, 1 insertion(+), 4 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index d6f39abf9b..4c7a1b9017 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -940,7 +940,7 @@ private fun RootNavHost( contacts(navController, settingsViewModel, appViewModel) profile(navController, settingsViewModel) shop(navController, settingsViewModel, appViewModel) - generalSettingsSubScreens(navController, appViewModel, settingsViewModel) + generalSettingsSubScreens(navController, appViewModel) advancedSettingsSubScreens(navController) transactionSpeedSettings(navController) pinManagement(navController) @@ -1741,7 +1741,6 @@ private fun NavGraphBuilder.shop( private fun NavGraphBuilder.generalSettingsSubScreens( navController: NavHostController, appViewModel: AppViewModel, - settingsViewModel: SettingsViewModel, ) { deepLinkableComposable { WidgetsSettingsScreen(navController) diff --git a/app/src/main/java/to/bitkit/viewmodels/DevSettingsViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/DevSettingsViewModel.kt index 2bd5e38ad1..b0ef143132 100644 --- a/app/src/main/java/to/bitkit/viewmodels/DevSettingsViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/DevSettingsViewModel.kt @@ -16,7 +16,6 @@ import to.bitkit.R import to.bitkit.data.AppDb import to.bitkit.data.CacheStore import to.bitkit.data.WidgetsStore -import to.bitkit.env.Env import to.bitkit.models.NewTransactionSheetDetails import to.bitkit.models.NewTransactionSheetDirection import to.bitkit.models.NewTransactionSheetType diff --git a/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt index 5e22b9718a..c134cec51d 100644 --- a/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt @@ -204,7 +204,6 @@ class SettingsViewModel @Inject constructor( } } - val isPinForPaymentsEnabled = settingsStore.data.map { it.isPinForPaymentsEnabled } .asStateFlow(initialValue = false) From f8641f4e74705cec8a4b49cfb90d5bb38c81d025 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 26 Oct 2026 18:51:59 +0100 Subject: [PATCH 09/47] fix: repurpose background payments for paykit notifications --- .../commands/ReceivedNotificationContent.kt | 3 +- app/src/main/java/to/bitkit/env/Env.kt | 9 - app/src/main/java/to/bitkit/fcm/FcmService.kt | 133 -------- .../main/java/to/bitkit/fcm/WakeNodeWorker.kt | 298 ------------------ .../models/BlocktankNotificationType.kt | 15 - .../main/java/to/bitkit/models/Suggestion.kt | 7 + .../to/bitkit/repositories/SuggestionsRepo.kt | 1 + .../to/bitkit/services/NodeServiceFgState.kt | 4 +- app/src/main/java/to/bitkit/ui/ContentView.kt | 57 +++- .../java/to/bitkit/ui/components/SheetHost.kt | 1 + .../screens/subscriptions/AllowanceSheet.kt | 7 +- .../subscriptions/CreateSubscriptionScreen.kt | 3 + .../subscriptions/SubscriptionsScreen.kt | 3 + .../bitkit/ui/screens/wallets/HomeScreen.kt | 9 + .../to/bitkit/ui/settings/SettingsScreen.kt | 24 ++ .../BackgroundPaymentsIntroScreen.kt | 98 ++++++ .../BackgroundPaymentsSettings.kt | 179 +++++++++++ .../ui/sheets/BackgroundPaymentsIntroSheet.kt | 50 +++ .../utils/RequestNotificationPermissions.kt | 22 ++ .../sheets/NotificationsTimedSheet.kt | 49 +++ .../java/to/bitkit/viewmodels/AppViewModel.kt | 3 + .../to/bitkit/viewmodels/SettingsViewModel.kt | 9 + app/src/main/res/drawable-hdpi/bell.webp | Bin 0 -> 4634 bytes app/src/main/res/drawable-mdpi/bell.webp | Bin 0 -> 2982 bytes .../main/res/drawable-nodpi/bell_figure.png | Bin 0 -> 139700 bytes app/src/main/res/drawable-xhdpi/bell.webp | Bin 0 -> 6440 bytes app/src/main/res/drawable-xxhdpi/bell.webp | Bin 0 -> 10840 bytes app/src/main/res/drawable-xxxhdpi/bell.webp | Bin 0 -> 16892 bytes app/src/main/res/values-ar/strings.xml | 24 +- app/src/main/res/values-b+es+419/strings.xml | 24 +- app/src/main/res/values-ca/strings.xml | 24 +- app/src/main/res/values-cs/strings.xml | 24 +- app/src/main/res/values-de/strings.xml | 24 +- app/src/main/res/values-el/strings.xml | 24 +- app/src/main/res/values-es-rES/strings.xml | 24 +- app/src/main/res/values-es/strings.xml | 24 +- app/src/main/res/values-fr/strings.xml | 24 +- app/src/main/res/values-it/strings.xml | 24 +- app/src/main/res/values-nl/strings.xml | 24 +- app/src/main/res/values-pl/strings.xml | 24 +- app/src/main/res/values-pt-rBR/strings.xml | 24 +- app/src/main/res/values-pt/strings.xml | 24 +- app/src/main/res/values-ru/strings.xml | 24 +- app/src/main/res/values/strings.xml | 24 +- .../java/to/bitkit/fcm/WakeNodeWorkerTest.kt | 248 --------------- .../test/java/to/bitkit/utils/CryptoTest.kt | 26 +- .../sheets/NotificationsTimedSheetTest.kt | 123 ++++++++ .../viewmodels/AppViewModelSendFlowTest.kt | 1 + docs/screens-map.md | 6 + docs/strings.md | 1 + docs/timed-sheets.md | 15 +- journeys/README.md | 3 +- journeys/onchain-receive/README.md | 1 + ...confirmed-only-background-notification.xml | 22 ++ 54 files changed, 914 insertions(+), 900 deletions(-) delete mode 100644 app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt delete mode 100644 app/src/main/java/to/bitkit/models/BlocktankNotificationType.kt create mode 100644 app/src/main/java/to/bitkit/ui/settings/backgroundPayments/BackgroundPaymentsIntroScreen.kt create mode 100644 app/src/main/java/to/bitkit/ui/settings/backgroundPayments/BackgroundPaymentsSettings.kt create mode 100644 app/src/main/java/to/bitkit/ui/sheets/BackgroundPaymentsIntroSheet.kt create mode 100644 app/src/main/java/to/bitkit/utils/timedsheets/sheets/NotificationsTimedSheet.kt create mode 100644 app/src/main/res/drawable-hdpi/bell.webp create mode 100644 app/src/main/res/drawable-mdpi/bell.webp create mode 100644 app/src/main/res/drawable-nodpi/bell_figure.png create mode 100644 app/src/main/res/drawable-xhdpi/bell.webp create mode 100644 app/src/main/res/drawable-xxhdpi/bell.webp create mode 100644 app/src/main/res/drawable-xxxhdpi/bell.webp delete mode 100644 app/src/test/java/to/bitkit/fcm/WakeNodeWorkerTest.kt create mode 100644 app/src/test/java/to/bitkit/utils/timedsheets/sheets/NotificationsTimedSheetTest.kt create mode 100644 journeys/onchain-receive/confirmed-only-background-notification.xml diff --git a/app/src/main/java/to/bitkit/domain/commands/ReceivedNotificationContent.kt b/app/src/main/java/to/bitkit/domain/commands/ReceivedNotificationContent.kt index 822d6a54a5..1b1ea6c721 100644 --- a/app/src/main/java/to/bitkit/domain/commands/ReceivedNotificationContent.kt +++ b/app/src/main/java/to/bitkit/domain/commands/ReceivedNotificationContent.kt @@ -16,8 +16,7 @@ import javax.inject.Singleton /** * Builds the user-facing "Payment Received" notification content, applying the same currency - * formatting rules everywhere a received payment is surfaced (foreground service, in-app handler, - * and the background [to.bitkit.fcm.WakeNodeWorker] push path). + * formatting rules everywhere a received payment is surfaced (foreground service and in-app handler). */ @Singleton class ReceivedNotificationContent @Inject constructor( diff --git a/app/src/main/java/to/bitkit/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt index 370b7e17da..ce3c90be7c 100644 --- a/app/src/main/java/to/bitkit/env/Env.kt +++ b/app/src/main/java/to/bitkit/env/Env.kt @@ -9,7 +9,6 @@ import to.bitkit.BuildConfig import to.bitkit.BuildConfig.VERSION_NAME import to.bitkit.ext.ensureDir import to.bitkit.ext.of -import to.bitkit.models.BlocktankNotificationType import to.bitkit.models.NodePeer import to.bitkit.utils.Logger import java.io.File @@ -71,9 +70,6 @@ internal object Env { const val fxRateStaleThreshold = 10 * 60 * 1000L // 10 minutes in millis const val lspOrdersRefreshInterval = 2 * 60 * 1000L // 2 minutes in millis - 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" const val PIN_LENGTH = 4 @@ -140,11 +136,6 @@ internal object Env { Network.BITCOIN -> "$blocktankBaseUrl/api" else -> "$blocktankBaseUrl/blocktank/api/v2" } - val blocktankNotificationApiUrl - get() = when (network) { - Network.BITCOIN -> "$blocktankBaseUrl/api/notifications" - else -> "$blocktankBaseUrl/notifications/api" - } val btcRatesServer get() = when (network) { diff --git a/app/src/main/java/to/bitkit/fcm/FcmService.kt b/app/src/main/java/to/bitkit/fcm/FcmService.kt index 5456a24d89..e8441718ab 100644 --- a/app/src/main/java/to/bitkit/fcm/FcmService.kt +++ b/app/src/main/java/to/bitkit/fcm/FcmService.kt @@ -2,27 +2,12 @@ package to.bitkit.fcm import android.os.Bundle import androidx.core.os.toPersistableBundle -import androidx.work.OneTimeWorkRequestBuilder -import androidx.work.OutOfQuotaPolicy -import androidx.work.WorkManager -import androidx.work.workDataOf import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage import dagger.hilt.android.AndroidEntryPoint -import kotlinx.serialization.Serializable -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.derivationName -import to.bitkit.ext.fromBase64 -import to.bitkit.ext.fromHex -import to.bitkit.models.BlocktankNotificationType import to.bitkit.ui.pushNotification -import to.bitkit.utils.Crypto import to.bitkit.utils.Logger import java.util.Date -import javax.inject.Inject @AndroidEntryPoint class FcmService : FirebaseMessagingService() { @@ -30,15 +15,6 @@ class FcmService : FirebaseMessagingService() { private const val TAG = "FcmService" } - private var notificationType: BlocktankNotificationType? = null - private var notificationPayload: JsonObject? = null - - @Inject - lateinit var crypto: Crypto - - @Inject - lateinit var keychain: Keychain - /** * Act on received messages. [Debug](https://goo.gl/39bRNJ) */ @@ -50,122 +26,13 @@ class FcmService : FirebaseMessagingService() { Logger.debug("FCM body: $body", context = TAG) sendNotification(title, body, Bundle(message.data.toPersistableBundle())) } - - if (message.data.isNotEmpty()) { - Logger.debug("FCM data: ${message.data}", context = TAG) - - val shouldSchedule = runCatching { - val isEncryptedNotification = message.data.tryAs { - decryptPayload(it) - } - isEncryptedNotification - }.getOrElse { - Logger.error("Failed to read encrypted notification payload", it, context = TAG) - // Let the node to spin up and handle incoming events - true - } - - when (shouldSchedule) { - true -> handleAsync() - else -> handleNow(message.data) - } - } - } - - private fun handleAsync() { - val work = OneTimeWorkRequestBuilder() - .setInputData( - workDataOf( - "type" to notificationType?.name, - "payload" to notificationPayload?.toString(), - ) - ) - .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) - .build() - WorkManager.getInstance(this) - .beginWith(work) - .enqueue() - } - - private fun handleNow(data: Map) { - Logger.warn("FCM handler not implemented for: $data", context = TAG) - } - - private fun decryptPayload(response: EncryptedNotification) { - val ciphertext = runCatching { response.cipher.fromBase64() }.getOrElse { - Logger.error("Failed to decode cipher", it, context = TAG) - return - } - val privateKey = runCatching { keychain.load(Keychain.Key.PUSH_NOTIFICATION_PRIVATE_KEY.name)!! }.getOrElse { - Logger.error("Missing PUSH_NOTIFICATION_PRIVATE_KEY", it, context = TAG) - return - } - val password = - runCatching { crypto.generateSharedSecret(privateKey, response.publicKey, derivationName) }.getOrElse { - Logger.error("Failed to generate shared secret", it, context = TAG) - return - } - - val decrypted = crypto.decrypt( - encryptedPayload = Crypto.EncryptedPayload(ciphertext, response.iv.fromHex(), response.tag.fromHex()), - secretKey = password, - ) - - val decoded = decrypted.decodeToString() - Logger.debug("Decrypted payload: $decoded", context = TAG) - - val (payload, type) = runCatching { json.decodeFromString(decoded) }.getOrElse { - Logger.error("Failed to decode decrypted data", it, context = TAG) - return - } - - if (payload == null) { - Logger.error("Missing payload", context = TAG) - return - } - - if (type == null) { - Logger.error("Missing type", context = TAG) - return - } - - notificationType = type - notificationPayload = payload } private fun sendNotification(title: String?, body: String?, extras: Bundle? = null) { applicationContext.pushNotification(title, body, extras) } - private inline fun Map.tryAs(block: (T) -> Unit): Boolean { - val encoded = json.encodeToString(this) - return try { - val decoded = json.decodeFromString(encoded) - block(decoded) - true - } catch (_: SerializationException) { - false - } - } - override fun onNewToken(token: String) { Logger.debug("Got new FCM token via FirebaseMessagingService.onNewToken: '$token'", context = TAG) } } - -@Serializable -data class EncryptedNotification( - val cipher: String, - val iv: String, - val tag: String, - val sound: String = "", - val title: String = "", - val message: String = "", - val publicKey: String = "", -) - -@Serializable -data class DecryptedNotification( - val payload: JsonObject? = null, - val type: BlocktankNotificationType? = null, -) diff --git a/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt b/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt deleted file mode 100644 index 8edf0af18e..0000000000 --- a/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt +++ /dev/null @@ -1,298 +0,0 @@ -package to.bitkit.fcm - -import android.content.Context -import androidx.hilt.work.HiltWorker -import androidx.work.CoroutineWorker -import androidx.work.WorkerParameters -import androidx.work.workDataOf -import dagger.assisted.Assisted -import dagger.assisted.AssistedInject -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.delay -import kotlinx.coroutines.withTimeout -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.contentOrNull -import kotlinx.serialization.json.jsonObject -import org.lightningdevkit.ldknode.Event -import to.bitkit.App -import to.bitkit.R -import to.bitkit.data.CacheStore -import to.bitkit.di.json -import to.bitkit.domain.commands.ReceivedNotificationContent -import to.bitkit.ext.amountOnClose -import to.bitkit.ext.toUserMessage -import to.bitkit.models.BlocktankNotificationType -import to.bitkit.models.BlocktankNotificationType.cjitPaymentArrived -import to.bitkit.models.BlocktankNotificationType.incomingHtlc -import to.bitkit.models.BlocktankNotificationType.mutualClose -import to.bitkit.models.BlocktankNotificationType.orderPaymentConfirmed -import to.bitkit.models.BlocktankNotificationType.wakeToTimeout -import to.bitkit.models.NewTransactionSheetDetails -import to.bitkit.models.NewTransactionSheetDirection -import to.bitkit.models.NewTransactionSheetType -import to.bitkit.models.NotificationDetails -import to.bitkit.models.msatCeilOf -import to.bitkit.repositories.ActivityRepo -import to.bitkit.repositories.BlocktankRepo -import to.bitkit.repositories.LightningRepo -import to.bitkit.services.NodeServiceFgState -import to.bitkit.ui.pushNotification -import to.bitkit.utils.Logger -import to.bitkit.utils.measured -import kotlin.time.Duration.Companion.minutes -import kotlin.time.Duration.Companion.seconds - -@HiltWorker -class WakeNodeWorker @AssistedInject constructor( - @Assisted private val appContext: Context, - @Assisted private val workerParams: WorkerParameters, - private val lightningRepo: LightningRepo, - private val blocktankRepo: BlocktankRepo, - private val activityRepo: ActivityRepo, - private val cacheStore: CacheStore, - private val receivedNotificationContent: ReceivedNotificationContent, - private val nodeServiceFgState: NodeServiceFgState, -) : CoroutineWorker(appContext, workerParams) { - private var bestAttemptContent: NotificationDetails? = null - - private var notificationType: BlocktankNotificationType? = null - private var notificationPayload: JsonObject? = null - - private val timeout = 2.minutes - private val slowWakeNodeThreshold = 10.seconds - private val deliverSignal = CompletableDeferred() - - override suspend fun doWork(): Result { - Logger.debug("Node wakeup from notification…", context = TAG) - - notificationType = workerParams.inputData.getString("type")?.let { BlocktankNotificationType.valueOf(it) } - notificationPayload = workerParams.inputData.getString("payload")?.let { - runCatching { json.parseToJsonElement(it).jsonObject }.getOrNull() - } - - Logger.debug("notification type: $notificationType", context = TAG) - Logger.debug("notification payload: $notificationPayload", context = TAG) - - if (notificationType == null) { - Logger.warn("Notification type is null, proceeding with node wake", context = TAG) - } - - return runCatching { - measured(label = "doWork", context = TAG, slowThreshold = slowWakeNodeThreshold) { - lightningRepo.start( - walletIndex = 0, - timeout = timeout, - eventHandler = { event -> handleLdkEvent(event) } - ) - - // Once node is started, handle the manual channel opening if needed - if (notificationType == orderPaymentConfirmed) { - val orderId = (notificationPayload?.get("orderId") as? JsonPrimitive)?.contentOrNull - - if (orderId == null) { - Logger.error("Missing orderId", context = TAG) - } else { - Logger.info("Open channel request for order $orderId", 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 = it.message ?: appContext.getString(R.string.common__error_body), - ) - deliver() - } - } - } - } - // Stops node on timeout & avoids notification replay by OS - withTimeout(timeout) { deliverSignal.await() } - } - .fold( - onSuccess = { Result.success() }, - onFailure = { e -> - if (e is CancellationException) { - Logger.debug("Work cancelled", context = TAG) - return@fold Result.failure(workDataOf("Reason" to "Cancelled")) - } - - 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() - - Result.failure(workDataOf("Reason" to reason)) - } - ) - } - - /** - * Listens for LDK events and delivers the notification if the event matches the notification type. - * @param event The LDK event to check. - */ - private suspend fun handleLdkEvent(event: Event) { - when (event) { - is Event.PaymentReceived -> onPaymentReceived(event) - - is Event.ChannelPending -> { - bestAttemptContent = NotificationDetails( - 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 - } - - is Event.ChannelReady -> onChannelReady(event) - is Event.ChannelClosed -> onChannelClosed(event) - - is Event.PaymentFailed -> { - bestAttemptContent = NotificationDetails( - title = appContext.getString(R.string.notification__payment_failed_title), - body = "⚡ ${event.reason.toUserMessage(appContext)}", - ) - - if (notificationType == wakeToTimeout) { - deliver() - } - } - - else -> Unit - } - } - - 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), - ) - - orderPaymentConfirmed -> NotificationDetails( - 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), - ) - } - - deliver() - } - - private suspend fun onPaymentReceived(event: Event.PaymentReceived) { - val sats = msatCeilOf(event.amountMsat) - // Save for UI to pick up - cacheStore.setBackgroundReceive( - NewTransactionSheetDetails( - type = NewTransactionSheetType.LIGHTNING, - direction = NewTransactionSheetDirection.RECEIVED, - paymentHashOrTxId = event.paymentHash, - sats = sats.toLong(), - ) - ) - - // The in-app UI or foreground service shows a richer notification for this event; avoid duplicating it - if (isHandledInProcess()) { - Logger.debug("Skipping payment notification: handled in-process", context = TAG) - bestAttemptContent = null - } else { - bestAttemptContent = receivedNotificationContent.build(sats.toLong()) - } - - if (notificationType == incomingHtlc) { - deliver() - } - } - - private suspend fun onChannelReady(event: Event.ChannelReady) { - when (notificationType) { - cjitPaymentArrived -> onCjitChannelReady(event) - - orderPaymentConfirmed -> bestAttemptContent = NotificationDetails( - title = appContext.getString(R.string.notification__channel_opened_title), - body = appContext.getString(R.string.notification__channel_ready_body), - ) - - else -> Unit - } - deliver() - } - - private suspend fun onCjitChannelReady(event: Event.ChannelReady) { - val channel = lightningRepo.getChannels()?.find { it.channelId == event.channelId } - val cjitEntry = channel?.let { blocktankRepo.getCjitEntry(it) } - - // A regular (non-CJIT) channel opening must not be reported as a received payment - if (channel == null || cjitEntry == null) { - Logger.debug("Skipping CJIT notification: no cjit entry for channel '${event.channelId}'", context = TAG) - bestAttemptContent = null - return - } - - // The in-app UI or foreground service owns this event: it records the activity (via the same - // insertActivityFromCjit dedup) and shows the richer notification/sheet. Defer entirely — - // do NOT insert here, or we'd win the dedup race and silence the in-process handler. - if (isHandledInProcess()) { - Logger.debug("Skipping CJIT notification: handled in-process", context = TAG) - bestAttemptContent = null - return - } - - // A duplicate channel ready event for an already recorded CJIT receive must not notify again - val inserted = activityRepo.insertActivityFromCjit(cjitEntry = cjitEntry, channel = channel) - .getOrDefault(false) - if (!inserted) { - Logger.debug("Skipping CJIT notification: already recorded for '${event.channelId}'", context = TAG) - bestAttemptContent = null - return - } - - val sats = channel.amountOnClose - // Save for UI to pick up - cacheStore.setBackgroundReceive( - NewTransactionSheetDetails( - type = NewTransactionSheetType.LIGHTNING, - direction = NewTransactionSheetDirection.RECEIVED, - sats = sats.toLong(), - ) - ) - - bestAttemptContent = receivedNotificationContent.build(sats.toLong()) - } - - private fun isHandledInProcess(): Boolean = - App.currentActivity?.value != null || nodeServiceFgState.isForegroundServiceRunning - - private suspend fun deliver() { - // Send notification first - bestAttemptContent?.run { - appContext.pushNotification(title, body) - Logger.info("Delivered notification", context = TAG) - } - - // Delay briefly to allow app to come to foreground if user clicked notification - delay(1.seconds) - - // Only stop node if app is not in foreground - // LightningNodeService will keep node running in background when notifications are enabled - if (App.currentActivity?.value == null) { - Logger.debug("App in background, stopping node after notification delivery", context = TAG) - lightningRepo.stop() - } else { - Logger.debug("App in foreground, keeping node running", context = TAG) - } - - deliverSignal.complete(Unit) - } - - companion object { - private const val TAG = "WakeNodeWorker" - } -} diff --git a/app/src/main/java/to/bitkit/models/BlocktankNotificationType.kt b/app/src/main/java/to/bitkit/models/BlocktankNotificationType.kt deleted file mode 100644 index abc02cb537..0000000000 --- a/app/src/main/java/to/bitkit/models/BlocktankNotificationType.kt +++ /dev/null @@ -1,15 +0,0 @@ -package to.bitkit.models - -import kotlinx.serialization.Serializable - -@Suppress("EnumEntryNameCase", "EnumNaming") -@Serializable -enum class BlocktankNotificationType { - incomingHtlc, - mutualClose, - orderPaymentConfirmed, - cjitPaymentArrived, - wakeToTimeout; - - override fun toString(): String = "blocktank.$name" -} diff --git a/app/src/main/java/to/bitkit/models/Suggestion.kt b/app/src/main/java/to/bitkit/models/Suggestion.kt index 6aecb0aa04..c66ee2ffb5 100644 --- a/app/src/main/java/to/bitkit/models/Suggestion.kt +++ b/app/src/main/java/to/bitkit/models/Suggestion.kt @@ -73,6 +73,13 @@ enum class Suggestion( color = Colors.Green24, icon = R.drawable.fast_forward ), + NOTIFICATIONS( + title = R.string.cards__notifications__title, + description = R.string.cards__notifications__description, + color = Colors.Purple24, + icon = R.drawable.bell, + dismissible = true, + ), } fun String.toSuggestionOrNull() = Suggestion.entries.firstOrNull { it.name == this } diff --git a/app/src/main/java/to/bitkit/repositories/SuggestionsRepo.kt b/app/src/main/java/to/bitkit/repositories/SuggestionsRepo.kt index acea815e3b..6d88fe02f9 100644 --- a/app/src/main/java/to/bitkit/repositories/SuggestionsRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/SuggestionsRepo.kt @@ -61,6 +61,7 @@ class SuggestionsRepo @Inject constructor( hasHardwareWallet: Boolean, ) = listOfNotNull( Suggestion.QUICK_PAY.takeIf { !settings.isQuickPayEnabled }, + Suggestion.NOTIFICATIONS.takeIf { !settings.notificationsGranted }, Suggestion.HARDWARE.takeIf { !hasHardwareWallet }, Suggestion.SHOP, Suggestion.PROFILE.takeIf { !profileAuthenticated }, diff --git a/app/src/main/java/to/bitkit/services/NodeServiceFgState.kt b/app/src/main/java/to/bitkit/services/NodeServiceFgState.kt index cc69b35d84..588d892e28 100644 --- a/app/src/main/java/to/bitkit/services/NodeServiceFgState.kt +++ b/app/src/main/java/to/bitkit/services/NodeServiceFgState.kt @@ -5,9 +5,7 @@ import javax.inject.Singleton /** * Process-wide flag tracking whether [to.bitkit.androidServices.LightningNodeService] is running as a - * foreground service. Used to coordinate notification ownership: when the foreground service is alive - * it handles LDK events in-process, so the background [to.bitkit.fcm.WakeNodeWorker] defers to it - * instead of posting its own (duplicate) notification. + * foreground service. When it is alive it handles LDK events in-process. */ @Singleton class NodeServiceFgState @Inject constructor() { diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 4c7a1b9017..639629c5d1 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -1,7 +1,11 @@ package to.bitkit.ui +import android.Manifest import android.content.Intent +import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.DrawerState @@ -187,6 +191,8 @@ import to.bitkit.ui.settings.advanced.ElectrumConfigScreen import to.bitkit.ui.settings.advanced.RgsServerScreen import to.bitkit.ui.settings.advanced.WatchOnlyAccountsScreen import to.bitkit.ui.settings.appStatus.AppStatusScreen +import to.bitkit.ui.settings.backgroundPayments.BackgroundPaymentsIntroScreen +import to.bitkit.ui.settings.backgroundPayments.BackgroundPaymentsSettings import to.bitkit.ui.settings.backups.ResetAndRestoreScreen import to.bitkit.ui.settings.general.DefaultUnitSettingsScreen import to.bitkit.ui.settings.general.HardwareWalletsSettingsScreen @@ -206,6 +212,7 @@ import to.bitkit.ui.settings.support.SupportScreen import to.bitkit.ui.settings.transactionSpeed.CustomFeeSettingsScreen import to.bitkit.ui.settings.transactionSpeed.TransactionSpeedSettingsScreen import to.bitkit.ui.sheets.BTCPayConnectionSheet +import to.bitkit.ui.sheets.BackgroundPaymentsIntroSheet import to.bitkit.ui.sheets.BackupRoute import to.bitkit.ui.sheets.BackupSheet import to.bitkit.ui.sheets.ChangePinSheet @@ -233,6 +240,7 @@ import to.bitkit.ui.utils.composableWithDefaultTransitions import to.bitkit.ui.utils.deepLinkableComposable import to.bitkit.ui.utils.navigationWithDefaultTransitions import to.bitkit.ui.utils.rememberIs24HourFormat +import to.bitkit.ui.utils.rememberRequestNotificationPermission import to.bitkit.utils.Logger import to.bitkit.viewmodels.ActivityListViewModel import to.bitkit.viewmodels.AppViewModel @@ -288,6 +296,11 @@ fun ContentView( val keepActiveInBackground by settingsViewModel.keepBitkitActiveInBackground.collectAsStateWithLifecycle() val walletExists = walletUiState.walletExists + val requestNotificationPermission = rememberRequestNotificationPermission( + onPermissionResult = { granted -> settingsViewModel.setNotificationPreference(granted) }, + onPreTiramisu = { navController.navigateTo(Routes.BackgroundPaymentsSettings) }, + ) + // Effects on app entering fg (ON_START) / bg (ON_STOP) DisposableEffect(lifecycle) { val observer = LifecycleEventObserver { _, event -> @@ -684,6 +697,20 @@ fun ContentView( ) } + TimedSheetType.NOTIFICATIONS -> { + BackgroundPaymentsIntroSheet( + onLater = { + appViewModel.dismissTimedSheet() + settingsViewModel.setBgPaymentsIntroSeen(true) + }, + onEnable = { + appViewModel.dismissTimedSheet() + settingsViewModel.setBgPaymentsIntroSeen(true) + requestNotificationPermission() + }, + ) + } + TimedSheetType.QUICK_PAY -> { QuickPayIntroSheet( onLater = { @@ -940,7 +967,7 @@ private fun RootNavHost( contacts(navController, settingsViewModel, appViewModel) profile(navController, settingsViewModel) shop(navController, settingsViewModel, appViewModel) - generalSettingsSubScreens(navController, appViewModel) + generalSettingsSubScreens(navController, appViewModel, settingsViewModel) advancedSettingsSubScreens(navController) transactionSpeedSettings(navController) pinManagement(navController) @@ -1741,6 +1768,7 @@ private fun NavGraphBuilder.shop( private fun NavGraphBuilder.generalSettingsSubScreens( navController: NavHostController, appViewModel: AppViewModel, + settingsViewModel: SettingsViewModel, ) { deepLinkableComposable { WidgetsSettingsScreen(navController) @@ -1755,6 +1783,27 @@ private fun NavGraphBuilder.generalSettingsSubScreens( onClickAdd = { appViewModel.showSheet(Sheet.Hardware()) }, ) } + deepLinkableComposable { + BackgroundPaymentsSettings( + onBack = { navController.popBackStack() }, + ) + } + deepLinkableComposable { + val notificationPermissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { granted -> + settingsViewModel.setNotificationPreference(granted) + } + BackgroundPaymentsIntroScreen( + onBack = { navController.popBackStack() }, + onEnable = { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + navController.navigateTo(Routes.BackgroundPaymentsSettings) + }, + ) + } } private fun NavGraphBuilder.advancedSettingsSubScreens(navController: NavHostController) { @@ -2583,6 +2632,12 @@ sealed interface Routes { @Serializable data object RecoveryMnemonic : Routes.InternalOnly + @Serializable + data object BackgroundPaymentsIntro : Routes.DeepLinkable + + @Serializable + data object BackgroundPaymentsSettings : Routes.DeepLinkable + @Serializable data object AllActivity : Routes.DeepLinkable diff --git a/app/src/main/java/to/bitkit/ui/components/SheetHost.kt b/app/src/main/java/to/bitkit/ui/components/SheetHost.kt index 4773f5380a..18c366380e 100644 --- a/app/src/main/java/to/bitkit/ui/components/SheetHost.kt +++ b/app/src/main/java/to/bitkit/ui/components/SheetHost.kt @@ -118,6 +118,7 @@ enum class TimedSheetType(val priority: Int) { APP_UPDATE(priority = 6), USDT(priority = 5), BACKUP(priority = 4), + NOTIFICATIONS(priority = 3), QUICK_PAY(priority = 2), HIGH_BALANCE(priority = 1) } diff --git a/app/src/main/java/to/bitkit/ui/screens/subscriptions/AllowanceSheet.kt b/app/src/main/java/to/bitkit/ui/screens/subscriptions/AllowanceSheet.kt index 6bc695b0fa..fc91ca97dc 100644 --- a/app/src/main/java/to/bitkit/ui/screens/subscriptions/AllowanceSheet.kt +++ b/app/src/main/java/to/bitkit/ui/screens/subscriptions/AllowanceSheet.kt @@ -68,6 +68,7 @@ import to.bitkit.ui.shared.modifiers.sheetHeight import to.bitkit.ui.shared.util.gradientBackground import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.rememberSubscriptionNotificationsRequest import to.bitkit.ui.utils.withAccent import to.bitkit.viewmodels.AppViewModel import kotlin.time.Duration.Companion.seconds @@ -85,6 +86,7 @@ fun AllowanceSheet( viewModel: AllowancesViewModel = hiltViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val requestNotifications = rememberSubscriptionNotificationsRequest() // The view model outlives this sheet, so a check left open must never run in the next one. DisposableEffect(route) { onDispose { viewModel.onAuthorizationCancelled() } @@ -128,7 +130,10 @@ fun AllowanceSheet( allowance = allowance, isWorking = uiState.isWorking, onClickDecline = { viewModel.decline(allowance.id, onSuccess = appViewModel::hideSheet) }, - onClickAccept = { viewModel.accept(allowance.id, onSuccess = appViewModel::hideSheet) }, + onClickAccept = { + requestNotifications() + viewModel.accept(allowance.id, onSuccess = appViewModel::hideSheet) + }, modifier = Modifier.sheetHeight() ) } diff --git a/app/src/main/java/to/bitkit/ui/screens/subscriptions/CreateSubscriptionScreen.kt b/app/src/main/java/to/bitkit/ui/screens/subscriptions/CreateSubscriptionScreen.kt index 21591fec10..8896558436 100644 --- a/app/src/main/java/to/bitkit/ui/screens/subscriptions/CreateSubscriptionScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/subscriptions/CreateSubscriptionScreen.kt @@ -110,6 +110,7 @@ import to.bitkit.ui.shared.util.gradientBackground import to.bitkit.ui.theme.AppTextStyles import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.rememberSubscriptionNotificationsRequest import to.bitkit.ui.utils.withAccent import to.bitkit.utils.SubscriptionClockOffset import to.bitkit.utils.SubscriptionIcon @@ -127,6 +128,7 @@ fun CreateSubscriptionSheet( val resources = LocalResources.current val currencies = LocalCurrencies.current val scope = rememberCoroutineScope() + val requestNotifications = rememberSubscriptionNotificationsRequest() val contacts by appViewModel.pubkyContacts.collectAsStateWithLifecycle() val targets by appViewModel.eligiblePaymentRequestTargets.collectAsStateWithLifecycle() val isCreating by appViewModel.isCreatingPaymentRequest.collectAsStateWithLifecycle() @@ -242,6 +244,7 @@ fun CreateSubscriptionSheet( onSelected = { selectedTarget = it }, onExpirationChange = { expiration = it }, onPropose = { + requestNotifications() selectedTarget?.let { target -> appViewModel.createSubscription( draft = PaykitSubscriptionDraft( diff --git a/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt index a76e5a551a..70a851e4cb 100644 --- a/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt @@ -118,6 +118,7 @@ import to.bitkit.ui.shared.modifiers.sheetHeight import to.bitkit.ui.shared.util.gradientBackground import to.bitkit.ui.sheets.TransactionConfetti import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.rememberSubscriptionNotificationsRequest import to.bitkit.ui.utils.removeAccentTags import to.bitkit.ui.utils.withAccent import to.bitkit.utils.SubscriptionClockOffset @@ -697,6 +698,7 @@ fun SubscriptionSheet(appViewModel: AppViewModel, initialRoute: SubscriptionRout val subscription = subscriptions.firstOrNull { it.id == route.id } val contacts by appViewModel.pubkyContacts.collectAsStateWithLifecycle() val now = rememberSubscriptionNow(listOfNotNull(subscription).toImmutableList()) + val requestNotifications = rememberSubscriptionNotificationsRequest() LaunchedEffect(route, subscription, isProcessing) { val proposalIsUnavailable = route is SubscriptionRoute.Review && @@ -734,6 +736,7 @@ fun SubscriptionSheet(appViewModel: AppViewModel, initialRoute: SubscriptionRout } }, onSubscribe = { + requestNotifications() isProcessing = true appViewModel.acceptSubscriptionAndStartPayment(subscription).fold( onSuccess = { startedPayment -> 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 406036ff5f..5c75d59a64 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 @@ -250,6 +250,7 @@ fun HomeScreen( val profileDisplayName by homeViewModel.profileDisplayName.collectAsStateWithLifecycle() val profileDisplayImageUri by homeViewModel.profileDisplayImageUri.collectAsStateWithLifecycle() val hasSeenWidgetsIntro: Boolean by settingsViewModel.hasSeenWidgetsIntro.collectAsStateWithLifecycle() + val bgPaymentsIntroSeen: Boolean by settingsViewModel.bgPaymentsIntroSeen.collectAsStateWithLifecycle() val quickPayIntroSeen by settingsViewModel.quickPayIntroSeen.collectAsStateWithLifecycle() val latestActivities by activityListViewModel.latestActivities.collectAsStateWithLifecycle() val hardwareIds by activityListViewModel.hardwareIds.collectAsStateWithLifecycle() @@ -365,6 +366,14 @@ fun HomeScreen( rootNavController.navigateTo(Routes.QuickPaySettings) } } + + Suggestion.NOTIFICATIONS -> { + if (bgPaymentsIntroSeen) { + rootNavController.navigateTo(Routes.BackgroundPaymentsSettings) + } else { + rootNavController.navigateTo(Routes.BackgroundPaymentsIntro) + } + } } }, onClickAddWidget = { 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 b07c79bf9d..a2ec2e6cbd 100644 --- a/app/src/main/java/to/bitkit/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/SettingsScreen.kt @@ -91,6 +91,8 @@ fun SettingsScreen( val showWidgets by settings.showWidgets.collectAsStateWithLifecycle() val isQuickPayEnabled by settings.isQuickpayEnabled.collectAsStateWithLifecycle() val quickPayIntroSeen by settings.quickPayIntroSeen.collectAsStateWithLifecycle() + val bgPaymentsIntroSeen by settings.bgPaymentsIntroSeen.collectAsStateWithLifecycle() + val notificationsGranted by settings.notificationsGranted.collectAsStateWithLifecycle() val isPubkyAuthenticated by settings.isPubkyAuthenticated.collectAsStateWithLifecycle() val isPaykitEnabled by settings.isPaykitEnabled.collectAsStateWithLifecycle() val contactPaymentsEnabled by settings.contactPaymentsEnabled.collectAsStateWithLifecycle() @@ -134,6 +136,7 @@ fun SettingsScreen( showWidgets = showWidgets, tagCount = lastUsedTags.size, isQuickPayEnabled = isQuickPayEnabled, + notificationsGranted = notificationsGranted, isPubkyAuthenticated = isPubkyAuthenticated, isPaykitEnabled = isPaykitEnabled, contactPaymentsEnabled = contactPaymentsEnabled, @@ -176,6 +179,13 @@ fun SettingsScreen( } SettingsEvent.UsdtClick -> settings.setUsdtEnabled(!isUsdtEnabled) SettingsEvent.QuickPayClick -> navController.navigateToQuickPaySettings(quickPayIntroSeen) + SettingsEvent.BgPaymentsClick -> { + if (bgPaymentsIntroSeen || notificationsGranted) { + navController.navigateTo(Routes.BackgroundPaymentsSettings) + } else { + navController.navigateTo(Routes.BackgroundPaymentsIntro) + } + } SettingsEvent.HardwareWalletsClick -> navController.navigateTo(Routes.HardwareWalletsSettings) SettingsEvent.BackupWalletClick -> app.showSheet(Sheet.Backup()) SettingsEvent.DataBackupsClick -> navController.navigateTo(Routes.BackupSettings) @@ -357,6 +367,17 @@ private fun GeneralTabContent( onClick = { onEvent(SettingsEvent.QuickPayClick) }, modifier = Modifier.testTag("QuickpaySettings") ) + SettingsButtonRow( + title = stringResource(R.string.settings__bg__title), + icon = { SettingsIcon(R.drawable.ic_bell) }, + value = SettingsButtonValue.StringValue( + stringResource( + if (state.notificationsGranted) R.string.settings__bg__on else R.string.settings__bg__off + ) + ), + onClick = { onEvent(SettingsEvent.BgPaymentsClick) }, + modifier = Modifier.testTag("BackgroundPaymentSettings") + ) SettingsButtonRow( title = stringResource(R.string.settings__hardware_wallets__nav_title), icon = { SettingsIcon(R.drawable.ic_device_mobile_speaker) }, @@ -633,6 +654,7 @@ private fun PreviewGeneral() { selectedLanguage = "System Settings", tagCount = 8, isQuickPayEnabled = true, + notificationsGranted = true, ), ) } @@ -681,6 +703,7 @@ sealed interface SettingsEvent { data object ContactPaymentsClick : SettingsEvent data object UsdtClick : SettingsEvent data object QuickPayClick : SettingsEvent + data object BgPaymentsClick : SettingsEvent data object HardwareWalletsClick : SettingsEvent // Security @@ -722,6 +745,7 @@ data class GeneralTabState( val showWidgets: Boolean = true, val tagCount: Int = 0, val isQuickPayEnabled: Boolean = false, + val notificationsGranted: Boolean = false, val isPubkyAuthenticated: Boolean = false, val isPaykitEnabled: Boolean = false, val contactPaymentsEnabled: Boolean = false, 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 new file mode 100644 index 0000000000..e94b99f3a7 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/settings/backgroundPayments/BackgroundPaymentsIntroScreen.kt @@ -0,0 +1,98 @@ +package to.bitkit.ui.settings.backgroundPayments + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.testTag +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 +import to.bitkit.R +import to.bitkit.ui.components.BodyM +import to.bitkit.ui.components.Display +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.VerticalSpacer +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.withAccent +import to.bitkit.viewmodels.SettingsViewModel + +@Composable +fun BackgroundPaymentsIntroScreen( + onBack: () -> Unit, + onEnable: () -> Unit, + modifier: Modifier = Modifier, + settingsViewModel: SettingsViewModel = hiltViewModel(), +) { + Column( + modifier = modifier.screen() + ) { + AppTopBar( + titleText = stringResource(R.string.settings__bg__title), + onBackClick = onBack, + actions = { DrawerNavIcon() }, + ) + BackgroundPaymentsIntroContent( + onEnable = { + settingsViewModel.setBgPaymentsIntroSeen(true) + onEnable() + }, + ) + } +} + +@Composable +fun BackgroundPaymentsIntroContent( + onEnable: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.padding(horizontal = 32.dp) + ) { + Image( + painter = painterResource(R.drawable.bell_figure), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .fillMaxWidth() + .weight(1f) + .heightIn(max = 320.dp) + ) + VerticalSpacer(32.dp) + Display( + text = stringResource(R.string.settings__bg__intro_title).withAccent(accentColor = Colors.Purple), + color = Colors.White, + ) + VerticalSpacer(8.dp) + BodyM(text = stringResource(R.string.settings__bg__intro_desc), color = Colors.White64) + VerticalSpacer(32.dp) + PrimaryButton( + text = stringResource(R.string.settings__bg__intro_button), + onClick = onEnable, + modifier = Modifier.testTag("BackgroundPaymentsIntro-enable") + ) + VerticalSpacer(16.dp) + } +} + +@Preview(showSystemUi = true) +@Composable +private fun Preview() { + AppThemeSurface { + BackgroundPaymentsIntroContent( + onEnable = {}, + ) + } +} 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 new file mode 100644 index 0000000000..67fd099008 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/settings/backgroundPayments/BackgroundPaymentsSettings.kt @@ -0,0 +1,179 @@ +package to.bitkit.ui.settings.backgroundPayments + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +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 +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import to.bitkit.R +import to.bitkit.ui.components.BodyM +import to.bitkit.ui.components.BodyMB +import to.bitkit.ui.components.NotificationPreview +import to.bitkit.ui.components.SecondaryButton +import to.bitkit.ui.components.Text13Up +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.shared.util.screen +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.RequestNotificationPermissions +import to.bitkit.viewmodels.SettingsViewModel + +@Composable +fun BackgroundPaymentsSettings( + settingsViewModel: SettingsViewModel = hiltViewModel(), + onBack: () -> Unit, +) { + val context = LocalContext.current + val notificationsGranted by settingsViewModel.notificationsGranted.collectAsStateWithLifecycle() + val keepActive by settingsViewModel.keepBitkitActiveInBackground.collectAsStateWithLifecycle() + + RequestNotificationPermissions( + onPermissionChange = settingsViewModel::setNotificationPreference, + showPermissionDialog = false, + ) + + Content( + hasPermission = notificationsGranted, + keepActive = keepActive, + onBack = onBack, + onSystemSettingsClick = context::openNotificationSettings, + onKeepActiveClick = { settingsViewModel.setKeepBitkitActiveInBackground(!keepActive) }, + ) +} + +@Composable +private fun Content( + hasPermission: Boolean, + keepActive: Boolean, + onBack: () -> Unit, + onSystemSettingsClick: () -> Unit, + onKeepActiveClick: () -> Unit, +) { + Column( + modifier = Modifier.screen() + ) { + AppTopBar( + titleText = stringResource(R.string.settings__bg__title), + onBackClick = onBack, + actions = { DrawerNavIcon() }, + ) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + VerticalSpacer(16.dp) + + SettingsSwitchRow( + title = stringResource(R.string.settings__bg__switch_title), + isChecked = hasPermission, + onClick = onSystemSettingsClick, + ) + + if (hasPermission) { + BodyM( + text = stringResource(R.string.settings__bg__enabled), + color = Colors.White64, + modifier = Modifier.padding(vertical = 16.dp), + ) + } + + AnimatedVisibility( + visible = !hasPermission, + modifier = Modifier.padding(vertical = 16.dp) + ) { + BodyMB( + text = stringResource(R.string.settings__bg__disabled), + color = Colors.Red, + ) + } + + SettingsSwitchRow( + title = stringResource(R.string.settings__bg__keep_active_title), + isChecked = keepActive, + onClick = onKeepActiveClick, + enabled = hasPermission, + ) + + BodyM( + text = stringResource(R.string.settings__bg__keep_active_desc), + color = Colors.White64, + modifier = Modifier.padding(vertical = 16.dp), + ) + + VerticalSpacer(32.dp) + + Text13Up( + text = stringResource(R.string.settings__bg__notifications_header), + color = Colors.White64 + ) + + VerticalSpacer(16.dp) + + SecondaryButton( + stringResource(R.string.settings__bg__customize), + icon = { Image(painter = painterResource(R.drawable.ic_bell), contentDescription = null) }, + onClick = onSystemSettingsClick, + ) + + VerticalSpacer(32.dp) + + Text13Up( + text = stringResource(R.string.common__preview), + color = Colors.White64 + ) + + VerticalSpacer(16.dp) + + NotificationPreview( + enabled = hasPermission, + title = stringResource(R.string.subscriptions__payment_due_title), + description = stringResource(R.string.subscriptions__payment_due_description), + modifier = Modifier.fillMaxWidth() + ) + } + } +} + +@Preview(showSystemUi = true) +@Composable +private fun Preview1() { + AppThemeSurface { + Content( + hasPermission = true, + keepActive = true, + onBack = {}, + onSystemSettingsClick = {}, + onKeepActiveClick = {}, + ) + } +} + +@Preview(showSystemUi = true) +@Composable +private fun Preview2() { + AppThemeSurface { + Content( + hasPermission = false, + keepActive = false, + onBack = {}, + onSystemSettingsClick = {}, + onKeepActiveClick = {}, + ) + } +} diff --git a/app/src/main/java/to/bitkit/ui/sheets/BackgroundPaymentsIntroSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/BackgroundPaymentsIntroSheet.kt new file mode 100644 index 0000000000..0529fb4a64 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/sheets/BackgroundPaymentsIntroSheet.kt @@ -0,0 +1,50 @@ +package to.bitkit.ui.sheets + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.tooling.preview.Preview +import to.bitkit.R +import to.bitkit.ui.components.BottomSheetPreview +import to.bitkit.ui.components.SheetIntro +import to.bitkit.ui.shared.modifiers.sheetHeight +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.withAccent + +@Composable +fun BackgroundPaymentsIntroSheet( + onLater: () -> Unit, + onEnable: () -> Unit, + modifier: Modifier = Modifier, +) { + SheetIntro( + navTitle = stringResource(R.string.settings__bg__title), + title = stringResource(R.string.settings__bg__intro_title).withAccent(accentColor = Colors.Purple), + description = AnnotatedString(stringResource(R.string.settings__bg__intro_desc)), + image = R.drawable.bell_figure, + continueText = stringResource(R.string.settings__bg__intro_button), + onContinue = onEnable, + cancelText = stringResource(R.string.common__later), + onCancel = onLater, + testTag = "BackgroundPaymentsIntro", + cancelTestTag = "BackgroundPaymentsIntro-later", + continueTestTag = "BackgroundPaymentsIntro-enable", + modifier = modifier + .sheetHeight(isModal = true) + ) +} + +@Preview(showSystemUi = true) +@Composable +private fun Preview() { + AppThemeSurface { + BottomSheetPreview { + BackgroundPaymentsIntroSheet( + onLater = {}, + onEnable = {}, + ) + } + } +} 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 fe43ccd1f5..53080db8ac 100644 --- a/app/src/main/java/to/bitkit/ui/utils/RequestNotificationPermissions.kt +++ b/app/src/main/java/to/bitkit/ui/utils/RequestNotificationPermissions.kt @@ -13,10 +13,13 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle import to.bitkit.ui.areNotificationsEnabled +import to.bitkit.viewmodels.SettingsViewModel @Composable fun RequestNotificationPermissions( @@ -120,3 +123,22 @@ fun rememberNotificationToggleClick( } } } + +/** Asks for notification permission when a Paykit subscription starts, unless it is already granted. */ +@Composable +fun rememberSubscriptionNotificationsRequest( + settingsViewModel: SettingsViewModel = hiltViewModel(), +): () -> Unit { + val isGranted by settingsViewModel.notificationsGranted.collectAsStateWithLifecycle() + val requestPermission = rememberRequestNotificationPermission( + onPermissionResult = settingsViewModel::setNotificationPreference, + onPreTiramisu = {}, + ) + val currentIsGranted by rememberUpdatedState(isGranted) + + return remember(requestPermission) { + { + if (!currentIsGranted) requestPermission() + } + } +} diff --git a/app/src/main/java/to/bitkit/utils/timedsheets/sheets/NotificationsTimedSheet.kt b/app/src/main/java/to/bitkit/utils/timedsheets/sheets/NotificationsTimedSheet.kt new file mode 100644 index 0000000000..dbba61a0e6 --- /dev/null +++ b/app/src/main/java/to/bitkit/utils/timedsheets/sheets/NotificationsTimedSheet.kt @@ -0,0 +1,49 @@ +package to.bitkit.utils.timedsheets.sheets + +import kotlinx.coroutines.flow.first +import to.bitkit.data.SettingsStore +import to.bitkit.ext.nowMillis +import to.bitkit.repositories.PaykitPaymentRequestRepo +import to.bitkit.ui.components.TimedSheetType +import to.bitkit.utils.Logger +import to.bitkit.utils.timedsheets.ONE_WEEK_ASK_INTERVAL_MILLIS +import to.bitkit.utils.timedsheets.TimedSheetItem +import to.bitkit.utils.timedsheets.checkTimeout +import javax.inject.Inject +import kotlin.time.ExperimentalTime + +class NotificationsTimedSheet @Inject constructor( + private val settingsStore: SettingsStore, + private val paykitPaymentRequestRepo: PaykitPaymentRequestRepo, +) : TimedSheetItem { + override val type = TimedSheetType.NOTIFICATIONS + override val priority = 3 + + override suspend fun shouldShow(): Boolean { + val settings = settingsStore.data.first() + if (settings.notificationsGranted) return false + if (paykitPaymentRequestRepo.subscriptions.value.isEmpty()) return false + + return checkTimeout( + lastIgnoredMillis = settings.notificationsIgnoredMillis, + intervalMillis = ONE_WEEK_ASK_INTERVAL_MILLIS + ) + } + + override suspend fun onShown() { + Logger.debug("Notifications sheet shown", context = TAG) + } + + @OptIn(ExperimentalTime::class) + override suspend fun onDismissed() { + val currentTime = nowMillis() + settingsStore.update { + it.copy(notificationsIgnoredMillis = currentTime) + } + Logger.debug("Notifications sheet dismissed", context = TAG) + } + + companion object { + private const val TAG = "NotificationsTimedSheet" + } +} diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 8f3f5e590e..c6cefa81e4 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -255,6 +255,7 @@ import to.bitkit.utils.timedsheets.TimedSheetManager import to.bitkit.utils.timedsheets.sheets.AppUpdateTimedSheet import to.bitkit.utils.timedsheets.sheets.BackupTimedSheet import to.bitkit.utils.timedsheets.sheets.HighBalanceTimedSheet +import to.bitkit.utils.timedsheets.sheets.NotificationsTimedSheet import to.bitkit.utils.timedsheets.sheets.QuickPayTimedSheet import to.bitkit.utils.timedsheets.sheets.UsdtTimedSheet import java.math.BigDecimal @@ -314,6 +315,7 @@ class AppViewModel @Inject constructor( private val samRockRepo: SamRockRepo, private val appUpdateSheet: AppUpdateTimedSheet, private val backupSheet: BackupTimedSheet, + private val notificationsSheet: NotificationsTimedSheet, private val quickPaySheet: QuickPayTimedSheet, private val usdtSheet: UsdtTimedSheet, private val usdtPreferences: UsdtPreferencesRepo, @@ -546,6 +548,7 @@ class AppViewModel @Inject constructor( registerSheet(appUpdateSheet) registerSheet(usdtSheet) registerSheet(backupSheet) + registerSheet(notificationsSheet) registerSheet(quickPaySheet) registerSheet(highBalanceSheet) } diff --git a/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt index c134cec51d..5b3bdf5964 100644 --- a/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt @@ -204,6 +204,15 @@ class SettingsViewModel @Inject constructor( } } + val bgPaymentsIntroSeen = settingsStore.data.map { it.bgPaymentsIntroSeen } + .asStateFlow(initialValue = false) + + fun setBgPaymentsIntroSeen(value: Boolean) { + viewModelScope.launch { + settingsStore.update { it.copy(bgPaymentsIntroSeen = value) } + } + } + val isPinForPaymentsEnabled = settingsStore.data.map { it.isPinForPaymentsEnabled } .asStateFlow(initialValue = false) diff --git a/app/src/main/res/drawable-hdpi/bell.webp b/app/src/main/res/drawable-hdpi/bell.webp new file mode 100644 index 0000000000000000000000000000000000000000..07acc5057b2c04ccc0d4790b4ff1a775c679b4d5 GIT binary patch literal 4634 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ੳ zSsLPkY3;cpzOF4@_y|MW4IPWpXx{1oahCN*xPwjtp6Q9J0 zCwNJHcHr|Ux6e0w=RjC=>_)S+kJy20YO_WU%gD6q4*fE7PD~6VVC;`Yf5WX=Z%8G6 z%t;Id2JCq3haRR=HGhn}h~0rVndtDEUs0EGV8^pD#(}JOCmHsZ0)379e1sh-p#0oZ z?^y)EFE#tBuGe|;8Fh-X-<)dUd(BW%0AZ8z_XMGZV^f8lmN60VqB2L-i~nvi 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

`Z6cCdN6_FUt=nAjn27*L*G(J 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

@Mv6;a~l6So2|yiDhG>Ttip}^UcGrQz4JLPD6Zs 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 C`euCq literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxhdpi/bell.webp b/app/src/main/res/drawable-xxhdpi/bell.webp new file mode 100644 index 0000000000000000000000000000000000000000..58926beafec5c14bfa57722f617736165708a7b0 GIT binary patch literal 10840 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 X+kd@^&$1IPN7VDH^IzpP0PueR*vuJe literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxxhdpi/bell.webp b/app/src/main/res/drawable-xxxhdpi/bell.webp new file mode 100644 index 0000000000000000000000000000000000000000..e2fa18f153fcdd4065faa7c43a7c4d1be04e130d GIT binary patch literal 16892 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 G`hNg{;v~xe literal 0 HcmV?d00001 diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 6e5d220e88..e571177d05 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -9,6 +9,8 @@ دعوة مدفوعات فورية إنفاق + تذكيرات الدفع + الإشعارات إعداد رمز PIN تأمين امسح وادفع @@ -269,19 +271,8 @@ يرجى الانتظار أثناء ترحيل بيانات محفظتك القديمة إلى إصدار Bitkit الجديد. عادةً ما يستغرق هذا أقل من دقيقة. ترحيل المحفظة جارٍ ترحيل\n<accent>المحفظة</accent> - تم نقل الرصيد من الإنفاق إلى المدخرات - السبب: %s - تم إغلاق القناة قناة الإشعارات لخدمة عقدة Lightning في المقدمة إشعار عقدة Lightning - فشل فتح القناة في الخلفية - فشل فتح القناة - تم فتح القناة - معلق - جاهز للإرسال - خطأ Lightning - فشل الدفع - يرجى المحاولة مرة أخرى تم استلام %s تم استلام الدفع Bitkit يعمل في الخلفية حتى تتمكن من استلام مدفوعات Lightning @@ -586,9 +577,17 @@ آخر نسخة احتياطية: {time} نسخ محفظتك احتياطيًا نسخ البيانات الاحتياطية + تخصيص في إعدادات Bitkit على Android + الإشعارات متوقفة لأنك رفضتها. + الإشعارات مفعّلة. يمكن لـ Bitkit تذكيرك بالاشتراكات وطلبات الدفع. + فعّل الإشعارات لتصلك تذكيرات باشتراكاتك وطلبات الدفع. + ابقَ\n<accent>على اطلاع</accent> + الإشعارات إيقاف تشغيل الإعداد في الخلفية + السماح بالإشعارات + الإشعارات يجد تطابقات دقيقة للمبلغ لتقليل الصرف Branch and Bound اختيار عشوائي للخصوصية @@ -1438,6 +1437,9 @@ تم تحديث الإعدادات نوع العنوان محدد حاليًا + تفعيل + يؤدي إبقاء Bitkit نشطًا إلى مدفوعات أكثر موثوقية. قد يعرض Android إشعارًا دائمًا أثناء تفعيل هذا الخيار. + إبقاء Bitkit نشطًا في الخلفية شهادة هذا الخادم غير موثوقة. استخدم خادمًا بشهادة موثوقة، أو اتصل بمنفذ TCP الخاص به. هذا الخادم على شبكة Bitcoin مختلفة. اختر خادمًا للشبكة التي يستخدمها Bitkit. فشل الاتصال الآمن. تحقق من أن البروتوكول (TCP أو TLS) يطابق منفذ الخادم. diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml index fa6b1e1eff..518d382793 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -9,6 +9,8 @@ Invitar Pagos instantáneos Gastar + Recordatorios de pago + Notificaciones Configurar un código PIN PROTEGER Escanear y pagar @@ -277,19 +279,8 @@ Por favor espera mientras se migran los datos de tu billetera anterior a esta nueva versión de Bitkit. Esto normalmente tarda menos de un minuto. Migración de billetera MIGRANDO\n<accent>BILLETERA</accent> - Saldo movido de gastos a ahorros - Razón: %s - Canal cerrado Canal de notificación para el servicio en segundo plano del nodo Lightning Notificación del nodo Lightning - El canal no se pudo abrir en segundo plano - Apertura del canal fallida - Canal abierto - Pendiente - Listo para enviar - Error de Lightning - Pago fallido - Por favor, inténtalo de nuevo Recibido %s Pago recibido Bitkit está ejecutándose en segundo plano para que puedas recibir pagos Lightning @@ -608,9 +599,17 @@ Última Copia de Seguridad: {time} Haga una copia de seguridad de su cartera Copias de seguridad + Personalizar en Ajustes de Bitkit en Android + Las notificaciones están desactivadas porque las rechazaste. + Las notificaciones están activadas. Bitkit puede recordarte tus suscripciones y solicitudes de pago. + Activa las notificaciones para recibir recordatorios de tus suscripciones y solicitudes de pago. + Mantente\n<accent>informado</accent> + Notificaciones Apagado Encendido Configurar en segundo plano + Permitir notificaciones + Notificaciones Encuentra coincidencias exactas de monto para minimizar el cambio Branch and Bound Selección aleatoria para mayor privacidad @@ -1454,6 +1453,9 @@ Ajustes actualizados Tipo de dirección Seleccionado actualmente + Activar + Mantener Bitkit activo hace que los pagos sean más confiables. Android puede mostrar una notificación persistente mientras esto esté activado. + Mantener Bitkit activo en segundo plano El certificado de este servidor no es de confianza. Use un servidor con un certificado de confianza o conéctese a su puerto TCP. Este servidor está en otra red de Bitcoin. Elija un servidor de la red que usa Bitkit. Falló la conexión segura. Verifique que el protocolo (TCP o TLS) coincida con el puerto del servidor. diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index a5e77f3241..d464e9fad9 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -9,6 +9,8 @@ Convidar Pagaments instantanis Gastar + Recordatoris de pagament + Notificacions Configurar un codi PIN Segur Escaneja i paga @@ -273,19 +275,8 @@ Si us plau, espera mentre les dades de la teva antiga cartera es migren a aquesta nova versió de Bitkit. Normalment triga menys d\'un minut. Migració de cartera MIGRANT\n<accent>CARTERA</accent> - Saldo mogut de despesa a estalvis - Motiu: %s - Canal tancat Canal de notificació per al servei en primer pla del node Lightning Notificació del node Lightning - El canal no s\'ha pogut obrir en segon pla - Obertura de canal fallida - Canal obert - Pendent - Llest per enviar - Error de Lightning - Pagament fallit - Si us plau, torna-ho a provar Rebut %s Pagament rebut Bitkit s\'executa en segon pla perquè puguis rebre pagaments Lightning @@ -601,9 +592,17 @@ Última còpia de seguretat: {time} Fes còpia de seguretat de la cartera Còpies de seguretat + Personalitza a la configuració de Bitkit d\'Android + Les notificacions estan desactivades perquè les has rebutjat. + Les notificacions estan activades. Bitkit et pot recordar les subscripcions i sol·licituds de pagament. + Activa les notificacions per rebre recordatoris de les teves subscripcions i sol·licituds de pagament. + Mantén-te\n<accent>informat</accent> + Notificacions Desactivat Activat Configurar en segon pla + Permet les notificacions + Notificacions Troba coincidències d\'import exactes per minimitzar el canvi Branch and Bound Selecció aleatòria per a privacitat @@ -1451,6 +1450,9 @@ Configuració actualitzada Tipus d\'adreça Seleccionat actualment + Activar + Mantenir Bitkit actiu fa que els pagaments siguin més fiables. Android pot mostrar una notificació persistent mentre això estigui activat. + Mantenir Bitkit actiu en segon pla El certificat d\'aquest servidor no és de confiança. Utilitza un servidor amb un certificat de confiança o connecta\'t al seu port TCP. Aquest servidor és d\'una xarxa Bitcoin diferent. Tria un servidor per a la xarxa que utilitza Bitkit. Ha fallat la connexió segura. Comprova que el protocol (TCP o TLS) coincideix amb el port del servidor. diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index b6eb02407a..8fb93f9533 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -9,6 +9,8 @@ Pozvat Okamžité platby Utratit + Připomínky plateb + Oznámení Nastavte PIN kód Zabezpečit Naskenuj a zaplať @@ -276,19 +278,8 @@ Počkejte prosím, než budou vaše stará data peněženky migrována do této nové verze Bitkit. Obvykle to trvá méně než minutu. Migrace peněženky MIGRACE\nPENĚŽENKY - Zůstatek byl přesunut z útrat do úspor - Důvod: %s - Kanál uzavřen Notifikační kanál pro službu Lightning uzlu na pozadí Notifikace Lightning uzlu - Kanál se nepodařilo otevřít na pozadí - Otevření kanálu selhalo - Kanál otevřen - Čekající - Připraveno k odeslání - Chyba Lightning - Platba selhala - Zkuste to prosím znovu Přijato %s Platba přijata Bitkit běží na pozadí, abyste mohli přijímat Lightning platby @@ -606,9 +597,17 @@ Poslední záloha: {time} Zálohujte svou peněženku Zálohy dat + Přizpůsobit v nastavení Android Bitkit + Oznámení jsou vypnutá, protože jste je zamítli. + Oznámení jsou zapnutá. Bitkit vám může připomínat předplatná a žádosti o platbu. + Zapněte oznámení a dostávejte připomínky předplatných a žádostí o platbu. + Buďte\n<accent>v obraze</accent> + Oznámení Vypnuto Zapnuto Nastavit na pozadí + Povolit oznámení + Oznámení Najde přesné shody částek pro minimalizaci zbytku Branch and Bound Náhodný výběr pro soukromí @@ -1453,6 +1452,9 @@ Nastavení aktualizováno Typ adresy Aktuálně vybráno + Povolit + Udržování Bitkitu aktivního vede ke spolehlivějším platbám. Android může při zapnutí této funkce zobrazovat trvalé oznámení. + Udržovat Bitkit aktivní na pozadí Certifikát tohoto serveru není důvěryhodný. Použijte server s důvěryhodným certifikátem, nebo se připojte k jeho TCP portu. Tento server je v jiné síti Bitcoin. Vyberte server pro síť, kterou Bitkit používá. Zabezpečené připojení se nezdařilo. Zkontrolujte, zda protokol (TCP nebo TLS) odpovídá portu serveru. diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 9360040581..49f175c3da 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -19,6 +19,8 @@ Hilfe bekommen Kaufen Kaufe bitcoin + Zahlungserinnerungen + Benachrichtigungen Wallet-Übersicht Diese Funktion wird derzeit entwickelt und ist bald verfügbar. Bald\n<accent>verfügbar</accent> @@ -276,19 +278,8 @@ Bitte warte, während deine alten Wallet-Daten auf diese neue Bitkit-Version migriert werden. Das dauert normalerweise weniger als eine Minute. Wallet-Migration WALLET\n<accent>MIGRATION</accent> - Guthaben von Ausgaben- auf Sparkonto verschoben - Grund: %s - Channel geschlossen Benachrichtigungskanal für den Lightning-Node-Vordergrunddienst Lightning-Node-Benachrichtigung - Channel konnte im Hintergrund nicht geöffnet werden - Channel-Öffnung fehlgeschlagen - Channel geöffnet - Ausstehend - Bereit zum Senden - Lightning-Fehler - Zahlung fehlgeschlagen - Bitte versuche es erneut %s empfangen Zahlung empfangen Bitkit läuft im Hintergrund, damit du Lightning-Zahlungen empfangen kannst @@ -607,6 +598,14 @@ Quickpay-Schwelle * Bitkit QuickPay unterstützt ausschließlich Zahlungen von deinem Guthaben. QuickPay beschleunigt den Bezahlvorgang, indem gescannte QR-Codes automatisch bezahlt werden. + Benachrichtigungen + Bleib\n<accent>informiert</accent> + Aktiviere Benachrichtigungen, um an deine Abonnements und Zahlungsanfragen erinnert zu werden. + Benachrichtigungen sind aktiviert. Bitkit kann dich an Abonnements und Zahlungsanfragen erinnern. + Benachrichtigungen sind deaktiviert, weil du sie abgelehnt hast. + Benachrichtigungen + Benachrichtigungen erlauben + In Android Bitkit-Einstellungen anpassen An Aus Im Hintergrund einrichten @@ -1451,6 +1450,9 @@ Einstellungen aktualisiert Adresstyp Derzeit ausgewählt + Aktivieren + Wenn Bitkit aktiv bleibt, sind Zahlungen zuverlässiger. Android zeigt möglicherweise eine dauerhafte Benachrichtigung an, solange dies aktiviert ist. + Bitkit im Hintergrund aktiv halten Das Zertifikat dieses Servers ist nicht vertrauenswürdig. Verwende einen Server mit vertrauenswürdigem Zertifikat oder verbinde dich mit seinem TCP-Port. Dieser Server befindet sich in einem anderen Bitcoin-Netzwerk. Wähle einen Server für das Netzwerk, das Bitkit verwendet. Sichere Verbindung fehlgeschlagen. Überprüfe, ob das Protokoll (TCP oder TLS) zum Server-Port passt. diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index e57c0a8a49..6562b6011a 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -9,6 +9,8 @@ Πρόσκληση Άμεσες πληρωμές Δαπάνη + Υπενθυμίσεις πληρωμών + Ειδοποιήσεις Καταχωρήστε κωδικό PIN Ασφάλεια Σάρωσε και πλήρωσε @@ -270,19 +272,8 @@ Περίμενε όσο τα παλιά δεδομένα του πορτοφολιού μεταφέρονται σε αυτή τη νέα έκδοση του Bitkit. Αυτό συνήθως διαρκεί λιγότερο από ένα λεπτό. Μετάβαση πορτοφολιού ΜΕΤΑΒΑΣΗ\n<accent>ΠΟΡΤΟΦΟΛΙΟΥ</accent> - Το υπόλοιπο μετακινήθηκε από δαπάνες σε αποταμιεύσεις - Λόγος: %s - Το κανάλι έκλεισε Κανάλι ειδοποιήσεων για υπηρεσία προσκηνίου κόμβου Lightning Ειδοποίηση κόμβου Lightning - Το κανάλι απέτυχε να ανοίξει στο παρασκήνιο - Αποτυχία ανοίγματος καναλιού - Το κανάλι άνοιξε - Εκκρεμεί - Έτοιμο για αποστολή - Σφάλμα Lightning - Η πληρωμή απέτυχε - Δοκίμασε ξανά Ελήφθησαν %s Ελήφθη πληρωμή Το Bitkit εκτελείται στο παρασκήνιο για να μπορείς να λαμβάνεις πληρωμές Lightning @@ -587,9 +578,17 @@ Τελευταίο αντίγραφο: {time} Αντίγραφο ασφαλείας πορτοφολιού Αντίγραφα δεδομένων + Προσαρμογή στις ρυθμίσεις Android Bitkit + Οι ειδοποιήσεις είναι απενεργοποιημένες, επειδή τις απορρίψατε. + Οι ειδοποιήσεις είναι ενεργές. Το Bitkit μπορεί να σας υπενθυμίζει συνδρομές και αιτήματα πληρωμής. + Ενεργοποιήστε τις ειδοποιήσεις για να λαμβάνετε υπενθυμίσεις για τις συνδρομές και τα αιτήματα πληρωμής σας. + Μείνε\n<accent>ενημερωμένος</accent> + Ειδοποιήσεις Ανενεργό Ενεργό Ρύθμιση στο παρασκήνιο + Να επιτρέπονται οι ειδοποιήσεις + Ειδοποιήσεις Βρίσκει ακριβείς αντιστοιχίσεις ποσού για ελαχιστοποίηση ρέστων Branch and Bound Τυχαία επιλογή για απόρρητο @@ -1444,6 +1443,9 @@ Οι ρυθμίσεις ενημερώθηκαν Τύπος διεύθυνσης Τρέχουσα επιλογή + Ενεργοποίηση + Η διατήρηση του Bitkit ενεργού κάνει τις πληρωμές πιο αξιόπιστες. Το Android μπορεί να εμφανίζει μόνιμη ειδοποίηση όσο είναι ενεργό. + Διατήρηση του Bitkit ενεργού στο παρασκήνιο Το πιστοποιητικό αυτού του διακομιστή δεν είναι αξιόπιστο. Χρησιμοποίησε διακομιστή με αξιόπιστο πιστοποιητικό ή σύνδεσου στη θύρα TCP του. Αυτός ο διακομιστής είναι σε διαφορετικό δίκτυο Bitcoin. Επίλεξε διακομιστή για το δίκτυο που χρησιμοποιεί το Bitkit. Η ασφαλής σύνδεση απέτυχε. Έλεγξε ότι το πρωτόκολλο (TCP ή TLS) αντιστοιχεί στη θύρα του διακομιστή. diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index a076ba3526..6689fa457c 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -9,6 +9,8 @@ Invitar Pagos instantáneos Gastar + Recordatorios de pago + Notificaciones Establezca un código PIN PROTEGER Escanea y paga @@ -276,19 +278,8 @@ Por favor espera mientras se migran los datos de tu antiguo monedero a esta nueva versión de Bitkit. Esto suele tardar menos de un minuto. Migración del monedero MIGRANDO\n<accent>MONEDERO</accent> - Saldo movido de gasto a ahorros - Motivo: %s - Canal cerrado Canal de notificación para el servicio en primer plano del nodo Lightning Notificación del nodo Lightning - Error al abrir canal en segundo plano - Error al abrir canal - Canal abierto - Pendiente - Listo para enviar - Error de Lightning - Pago fallido - Inténtalo de nuevo Recibido %s Pago recibido Bitkit se está ejecutando en segundo plano para que puedas recibir pagos Lightning @@ -605,9 +596,17 @@ Último respaldo: {time} Respalda tu monedero Copias de seguridad + Personalizar en ajustes de Bitkit en Android + Las notificaciones están desactivadas porque las rechazaste. + Las notificaciones están activadas. Bitkit puede recordarte tus suscripciones y solicitudes de pago. + Activa las notificaciones para recibir recordatorios de tus suscripciones y solicitudes de pago. + Mantente\n<accent>informado</accent> + Notificaciones Desactivado Activado Configurar en segundo plano + Permitir notificaciones + Notificaciones Encuentra coincidencias exactas para minimizar el cambio Ramificación y acotación Selección aleatoria para privacidad @@ -1448,6 +1447,9 @@ Ajustes actualizados Tipo de dirección Seleccionado actualmente + Activar + Mantener Bitkit activo hace que los pagos sean más fiables. Android puede mostrar una notificación persistente mientras esté activado. + Mantener Bitkit activo en segundo plano El certificado de este servidor no es de confianza. Usa un servidor con un certificado de confianza o conéctate a su puerto TCP. Este servidor está en otra red de Bitcoin. Elige un servidor para la red que usa Bitkit. Error en la conexión segura. Comprueba que el protocolo (TCP o TLS) coincide con el puerto del servidor. diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 54699b9db0..de25d93483 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -8,6 +8,8 @@ Invitar Pagos instantáneos Gastar + Recordatorios de pago + Notificaciones Establezca un código PIN PROTEGER Escanear y pagar @@ -274,19 +276,8 @@ Por favor espera mientras los datos de tu antiguo monedero migran a esta nueva versión de Bitkit. Esto normalmente tarda menos de un minuto. Migración del Monedero MIGRANDO\n<accent>MONEDERO</accent> - Saldo movido de gastos a ahorros - Motivo: %s - Canal cerrado Canal de notificaciones para el servicio en segundo plano del nodo Lightning Notificación del nodo Lightning - El canal no pudo abrirse en segundo plano - Apertura de canal fallida - Canal abierto - Pendiente - Listo para enviar - Error de Lightning - Pago fallido - Por favor, inténtalo de nuevo Recibido %s Pago Recibido Bitkit se está ejecutando en segundo plano para que puedas recibir pagos Lightning @@ -627,9 +618,17 @@ Bitkit no pudo hacer una copia de seguridad de los datos del monedero. Reintentando en {interval, plural, one {# minuto} other {# minutos}}. Fallo de Copia de Seguridad de Datos Copias de seguridad + Personalizar en los Ajustes de Bitkit Android + Las notificaciones están desactivadas porque las rechazaste. + Las notificaciones están activadas. Bitkit puede recordarte tus suscripciones y solicitudes de pago. + Activa las notificaciones para recibir recordatorios de tus suscripciones y solicitudes de pago. + Mantente\n<accent>informado</accent> + Notificaciones Desactivado Activado Configurar en segundo plano + Permitir notificaciones + Notificaciones Encuentra coincidencias de cantidad exacta para minimizar el cambio Branch and Bound Selección aleatoria para privacidad @@ -1447,6 +1446,9 @@ Ajustes actualizados Tipo de dirección Seleccionado actualmente + Activar + Mantener Bitkit activo hace que los pagos sean más fiables. Android puede mostrar una notificación persistente mientras esté activado. + Mantener Bitkit activo en segundo plano El certificado de este servidor no es de confianza. Use un servidor con un certificado de confianza o conéctese a su puerto TCP. Este servidor está en otra red de Bitcoin. Elija un servidor para la red que usa Bitkit. Error en la conexión segura. Compruebe que el protocolo (TCP o TLS) coincide con el puerto del servidor. diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index d82017aca1..9b0f1c04fa 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -19,6 +19,8 @@ Obtenir de l\'aide Acheter Achetez des bitcoins + Notifications + Rappels de paiement Aperçu du portefeuille Cette fonctionnalité est actuellement en développement et sera bientôt disponible. Bientôt\n<accent>disponible</accent> @@ -276,19 +278,8 @@ Veuillez patienter pendant la migration de vos anciennes données vers cette nouvelle version de Bitkit. Cela prend généralement moins d\'une minute. Migration du portefeuille MIGRATION\n<accent>DU PORTEFEUILLE</accent> - Solde transféré des dépenses vers l\'épargne - Raison : %s - Canal fermé Canal de notification pour le service de nœud Lightning en arrière-plan Notification du nœud Lightning - Échec de l\'ouverture du canal en arrière-plan - Échec de l\'ouverture du canal - Canal ouvert - En attente - Prêt à envoyer - Erreur Lightning - Échec du paiement - Veuillez réessayer Reçu %s Paiement reçu Bitkit fonctionne en arrière-plan pour que vous puissiez recevoir des paiements Lightning @@ -639,9 +630,17 @@ Profil Contacts Sauvegardes des données + Personnaliser dans les paramètres Android de Bitkit + Les notifications sont désactivées, car vous les avez refusées. + Les notifications sont activées. Bitkit peut vous rappeler vos abonnements et demandes de paiement. + Activez les notifications pour recevoir des rappels de vos abonnements et demandes de paiement. + Restez\n<accent>informé</accent> + Notifications Désactivé Activé Configurer en arrière-plan + Autoriser les notifications + Notifications Besoin d\'aide ? Signalez votre problème depuis Bitkit, visitez le centre d\'aide, vérifiez le statut ou contactez-nous sur nos canaux sociaux. Rapporter un problème Centre d\'aide @@ -1449,6 +1448,9 @@ Paramètres mis à jour Type d\'adresse Actuellement sélectionné + Activer + Garder Bitkit actif rend les paiements plus fiables. Android peut afficher une notification permanente tant que cette option est activée. + Garder Bitkit actif en arrière-plan Le certificat de ce serveur n\'est pas fiable. Utilisez un serveur avec un certificat fiable ou connectez-vous à son port TCP. Ce serveur est sur un autre réseau Bitcoin. Choisissez un serveur pour le réseau utilisé par Bitkit. Échec de la connexion sécurisée. Vérifiez que le protocole (TCP ou TLS) correspond au port du serveur. diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 268d77f92b..30f5dc17a5 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -9,6 +9,8 @@ Invita Pagamenti istantanei Spendi + Promemoria di pagamento + Notifiche Imposta un codice PIN Sicuro Scansiona e paga @@ -275,19 +277,8 @@ Attendi mentre i dati del tuo vecchio wallet migrano a questa nuova versione di Bitkit. Questo di solito richiede meno di un minuto. Migrazione Wallet MIGRAZIONE\n<accent>WALLET</accent> - Saldo spostato dalle spese ai risparmi - Motivo: %s - Canale chiuso Canale di notifica per il servizio in primo piano del nodo Lightning Notifica nodo Lightning - Apertura canale in background non riuscita - Apertura canale non riuscita - Canale aperto - In attesa - Pronto per inviare - Errore Lightning - Pagamento fallito - Per favore riprova Ricevuto %s Pagamento Ricevuto Bitkit e\' in esecuzione in background per permetterti di ricevere pagamenti Lightning @@ -604,9 +595,17 @@ Ultimo backup: {time} Esegui il backup del tuo wallet Backup dei dati + Personalizza nelle Impostazioni Android di Bitkit + Le notifiche sono disattivate perché le hai rifiutate. + Le notifiche sono attive. Bitkit può ricordarti abbonamenti e richieste di pagamento. + Attiva le notifiche per ricevere promemoria su abbonamenti e richieste di pagamento. + Resta\n<accent>aggiornato</accent> + Notifiche Disattivo Attivo Configura in background + Consenti notifiche + Notifiche Trova corrispondenze esatte per minimizzare il resto Branch and Bound Selezione casuale per la privacy @@ -1453,6 +1452,9 @@ Impostazioni aggiornate Tipo di Indirizzo Attualmente selezionato + Abilita + Mantenere Bitkit attivo rende i pagamenti più affidabili. Android potrebbe mostrare una notifica persistente mentre questa opzione è attiva. + Mantieni Bitkit attivo in background Il certificato di questo server non è attendibile. Usa un server con un certificato attendibile, oppure connettiti alla sua porta TCP. Questo server è su una rete Bitcoin diversa. Scegli un server per la rete che Bitkit sta usando. Connessione sicura non riuscita. Verifica che il protocollo (TCP o TLS) corrisponda alla porta del server. diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index ab12a2ccbd..4ad315829d 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -9,6 +9,8 @@ Uitnodiging Directe betalingen Besteed + Betaalherinneringen + Meldingen Pincode instellen Beveilig Scan en betaal @@ -274,19 +276,8 @@ Even geduld terwijl je oude wallet-gegevens migreren naar deze nieuwe Bitkit-versie. Dit duurt meestal minder dan een minuut. Wallet-migratie WALLET\n<accent>MIGREREN</accent> - Saldo verplaatst van bestedingssaldo naar spaargeld - Reden: %s - Kanaal gesloten Notificatiekanaal voor Lightning node voorgrondservice Lightning node notificatie - Kanaal kon niet op de achtergrond worden geopend - Kanaal openen mislukt - Kanaal geopend - In behandeling - Klaar om te versturen - Lightning-fout - Betaling mislukt - Probeer het opnieuw %s ontvangen Betaling ontvangen Bitkit draait op de achtergrond zodat je Lightning-betalingen kunt ontvangen @@ -602,9 +593,17 @@ Laatste backup: {time} Back-up maken van je wallet Gegevensback-ups + Aanpassen in Android Bitkit-instellingen + Meldingen staan uit, omdat je ze hebt geweigerd. + Meldingen staan aan. Bitkit kan je herinneren aan abonnementen en betaalverzoeken. + Zet meldingen aan om herinneringen te krijgen voor je abonnementen en betaalverzoeken. + Blijf\n<accent>op de hoogte</accent> + Notificaties Uit Aan Op achtergrond instellen + Meldingen toestaan + Meldingen Vindt exacte bedragovereenkomsten om wisselgeld te minimaliseren Branch and Bound Willekeurige selectie voor privacy @@ -1450,6 +1449,9 @@ Instellingen bijgewerkt Adrestype Momenteel geselecteerd + Inschakelen + Bitkit actief houden zorgt voor betrouwbaardere betalingen. Android kan een permanente melding tonen zolang dit is ingeschakeld. + Bitkit actief houden op de achtergrond Het certificaat van deze server wordt niet vertrouwd. Gebruik een server met een vertrouwd certificaat, of maak verbinding met de TCP-poort. Deze server zit op een ander Bitcoin-netwerk. Kies een server voor het netwerk dat Bitkit gebruikt. Beveiligde verbinding mislukt. Controleer of het protocol (TCP of TLS) overeenkomt met de serverpoort. diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 62cccbb6a2..cfd1f51ee2 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -19,6 +19,8 @@ Otrzymaj wsparcie Kup Kup trochę bitcoina + Przypomnienia o płatnościach + Powiadomienia Przegląd portfela Ta funkcja jest w trakcie rozwoju i wkrótce będzie dostępna. Wkrótce\n<accent>dostępne</accent> @@ -278,19 +280,8 @@ Proszę czekać, trwa migracja danych starego portfela do nowej wersji Bitkit. Zwykle trwa to mniej niż minutę. Migracja portfela MIGRACJA\n<accent>PORTFELA</accent> - Saldo przeniesione z wydatków do oszczędności - Powód: %s - Kanał zamknięty Kanał powiadomień dla usługi Lightning w tle Powiadomienie węzła Lightning - Nie udało się otworzyć kanału w tle - Otwarcie kanału nie powiodło się - Kanał otwarty - Oczekujące - Gotowy do wysłania - Błąd Lightning - Płatność nie powiodła się - Proszę spróbować ponownie Otrzymano %s Płatność otrzymana Bitkit działa w tle, abyś mógł otrzymywać płatności Lightning @@ -639,9 +630,17 @@ Kontakty W toku Kopie zapasowe danych + Dostosuj w ustawieniach Bitkit na Androidzie + Powiadomienia są wyłączone, ponieważ ich odmówiono. + Powiadomienia są włączone. Bitkit może przypominać o subskrypcjach i prośbach o płatność. + Włącz powiadomienia, aby otrzymywać przypomnienia o subskrypcjach i prośbach o płatność. + Bądź\n<accent>na bieżąco</accent> + Powiadomienia Wył. Wł. Konfiguracja w tle + Zezwól na powiadomienia + Powiadomienia Znajduje dokładne dopasowania kwot, aby zminimalizować resztę Branch and Bound Losowy wybór dla prywatności @@ -1451,6 +1450,9 @@ Ustawienia zaktualizowane Typ adresu Obecnie wybrany + Włącz + Utrzymywanie aktywności Bitkit zapewnia bardziej niezawodne płatności. Gdy ta opcja jest włączona, Android może wyświetlać stałe powiadomienie. + Utrzymuj Bitkit aktywny w tle Certyfikat tego serwera nie jest zaufany. Użyj serwera z zaufanym certyfikatem lub połącz się z jego portem TCP. Ten serwer działa w innej sieci Bitcoin. Wybierz serwer dla sieci, której używa Bitkit. Nie udało się nawiązać bezpiecznego połączenia. Sprawdź, czy protokół (TCP lub TLS) odpowiada portowi serwera. diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 8fd9ca0a92..5ec81c6d34 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -9,6 +9,8 @@ Convide Pagamentos instantâneos Pagar + Lembretes de pagamento + Notificações Configure um PIN Segurança Escanear e pagar @@ -278,19 +280,8 @@ Aguarde enquanto os dados da sua carteira antiga são migrados para esta nova versão da Bitkit. Isso geralmente leva menos de um minuto. Migração da Carteira MIGRANDO\n<accent>CARTEIRA</accent> - Saldo movido de gastos para poupança - Motivo: %s - Canal fechado Canal de notificação para serviço em primeiro plano do nó Lightning Notificação do nó Lightning - Falha ao abrir canal em segundo plano - Falha ao abrir canal - Canal aberto - Pendente - Pronto para enviar - Erro Lightning - Pagamento falhou - Tente novamente Recebido %s Pagamento Recebido Bitkit está executando em segundo plano para que você possa receber pagamentos Lightning @@ -609,9 +600,17 @@ Último backup: {time} Faça backup da sua carteira Backups de dados + Personalizar nas Configurações do Bitkit Android + As notificações estão desativadas porque você as recusou. + As notificações estão ativadas. O Bitkit pode lembrar você de assinaturas e solicitações de pagamento. + Ative as notificações para receber lembretes das suas assinaturas e solicitações de pagamento. + Fique\n<accent>por dentro</accent> + Notificações Desligado Ligado Configurar em segundo plano + Permitir notificações + Notificações Encontra correspondências exatas para minimizar troco Branch and Bound Seleção aleatória para privacidade @@ -1453,6 +1452,9 @@ Configurações atualizadas Tipo de Endereço Selecionado no momento + Ativar + Manter a Bitkit ativa resulta em pagamentos mais confiáveis. O Android pode exibir uma notificação persistente enquanto isso estiver ativado. + Manter a Bitkit ativa em segundo plano O certificado deste servidor não é confiável. Use um servidor com um certificado confiável ou conecte-se à porta TCP dele. Este servidor está em uma rede Bitcoin diferente. Escolha um servidor para a rede que a Bitkit está usando. Falha na conexão segura. Verifique se o protocolo (TCP ou TLS) corresponde à porta do servidor. diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 5a53a369d1..2e468c10bc 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -19,6 +19,8 @@ Peça ajuda Comprar Compre bitcoin + Lembretes de pagamento + Notificações Visão geral da carteira Este recurso está em desenvolvimento e estará disponível em breve. Em\n<accent>breve</accent> @@ -271,19 +273,8 @@ Por favor aguarde enquanto seus dados da carteira antiga migram para esta nova versão da Bitkit. Isso geralmente leva menos de um minuto. Migração da Carteira MIGRANDO\n<accent>CARTEIRA</accent> - Saldo movido de gastos para poupança - Motivo: %s - Canal fechado Canal de notificação para o serviço em primeiro plano do nó Lightning Notificação do nó Lightning - Canal falhou ao abrir em segundo plano - Falha na abertura do canal - Canal aberto - Pendente - Pronto para enviar - Erro Lightning - Pagamento falhou - Por favor, tente novamente Recebido %s Pagamento Recebido A Bitkit está rodando em segundo plano para você receber pagamentos Lightning @@ -723,9 +714,17 @@ Conectar Servidor de Rapid-Gossip-Sync Atualizado Pode ser necessário reiniciar o aplicativo uma ou duas vezes para que esta alteração seja aplicada. + Personalizar nas Configurações da Bitkit no Android + As notificações estão desativadas porque as recusou. + As notificações estão ativadas. A Bitkit pode lembrá-lo de subscrições e pedidos de pagamento. + Ative as notificações para receber lembretes das suas subscrições e pedidos de pagamento. + Mantenha-se\n<accent>informado</accent> + Notificações Desativado Ativado Configurar em segundo plano + Permitir notificações + Notificações Encontra correspondências exatas para minimizar troco Branch and Bound Seleção aleatória para privacidade @@ -1437,6 +1436,9 @@ Definições atualizadas Tipo de endereço Selecionado + Ativar + Manter a Bitkit ativa torna os pagamentos mais fiáveis. O Android pode mostrar uma notificação persistente enquanto esta opção estiver ativada. + Manter a Bitkit ativa em segundo plano O certificado deste servidor não é fidedigno. Use um servidor com um certificado fidedigno ou ligue à respetiva porta TCP. Este servidor está numa rede Bitcoin diferente. Escolha um servidor para a rede que a Bitkit está a usar. Falha na ligação segura. Verifique se o protocolo (TCP ou TLS) corresponde à porta do servidor. diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 5bf1057818..c6a7eca8c7 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -9,6 +9,8 @@ Пригласить Мгновенные платежи Тратить + Напоминания об оплате + Уведомления Установите PIN-код Безопасность Сканируйте и платите @@ -291,19 +293,8 @@ Миграция кошелька МИГРАЦИЯ <accent>КОШЕЛЬКА</accent> - Баланс перемещён с расходов на сбережения - Причина: %s - Канал закрыт Канал уведомлений для фоновой службы Lightning ноды Уведомление Lightning ноды - Не удалось открыть канал в фоновом режиме - Не удалось открыть канал - Канал открыт - В ожидании - Готов к отправке - Ошибка Lightning - Платёж не удался - Пожалуйста, попробуйте снова Получено %s Платёж получен Bitkit работает в фоновом режиме, чтобы вы могли получать Lightning платежи @@ -624,9 +615,17 @@ Последнее Резервное Копирование: {time} Создать Резервную Копию Кошелька Резервные копии данных + Настроить в настройках Android Bitkit + Уведомления отключены, потому что вы их запретили. + Уведомления включены. Bitkit может напоминать о подписках и запросах на оплату. + Включите уведомления, чтобы получать напоминания о подписках и запросах на оплату. + Будьте\n<accent>в курсе</accent> + Уведомления Выкл Вкл Настроить в фоне + Разрешить уведомления + Уведомления Находит точные совпадения сумм для минимизации сдачи Branch and Bound Случайный выбор для приватности @@ -1480,6 +1479,9 @@ Bitkit должен увеличить приёмную ёмкость ваше Настройки обновлены Тип Адреса Выбрано сейчас + Включить + Если Bitkit остаётся активным, платежи проходят надёжнее. Пока эта функция включена, Android может показывать постоянное уведомление. + Держать Bitkit активным в фоне Сертификат этого сервера не является доверенным. Используйте сервер с доверенным сертификатом или подключитесь к его TCP-порту. Этот сервер работает в другой сети Bitcoin. Выберите сервер для сети, которую использует Bitkit. Не удалось установить защищённое соединение. Проверьте, что протокол (TCP или TLS) соответствует порту сервера. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 763139f9db..e1baafada8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -38,6 +38,8 @@ Invite Instant payments Spend + Payment reminders + Notifications Set up a PIN code Secure Scan and pay @@ -449,19 +451,8 @@ Please ensure you have a stable internet connection. Data may show incorrectly while trying to connect. Network Issues Detected 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 Payment Received Bitkit is running in background so you can receive Lightning payments @@ -923,9 +914,20 @@ Running Latest Backup: {time} Back up your wallet + Customize in Android Bitkit Settings + Notifications are off, because you have denied them. + Notifications are on. Bitkit can remind you about subscriptions and payment requests. + Enable + Turn on notifications to get reminders for your subscriptions and payment requests. + Stay\n<accent>notified</accent> + Keeping Bitkit active results in more reliable payments. Android may show a persistent notification while this is enabled. + Keep Bitkit active in background + Notifications Off On Set up in background + Allow notifications + Notifications Finds exact amount matches to minimize change Branch and Bound Random selection for privacy diff --git a/app/src/test/java/to/bitkit/fcm/WakeNodeWorkerTest.kt b/app/src/test/java/to/bitkit/fcm/WakeNodeWorkerTest.kt deleted file mode 100644 index a4c692524d..0000000000 --- a/app/src/test/java/to/bitkit/fcm/WakeNodeWorkerTest.kt +++ /dev/null @@ -1,248 +0,0 @@ -package to.bitkit.fcm - -import android.Manifest -import android.app.Activity -import android.app.Application -import android.app.Notification -import android.content.Context -import androidx.test.core.app.ApplicationProvider -import androidx.work.ListenableWorker -import androidx.work.WorkerParameters -import androidx.work.workDataOf -import com.synonym.bitkitcore.IcJitEntry -import org.junit.After -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.lightningdevkit.ldknode.ChannelDetails -import org.lightningdevkit.ldknode.Event -import org.mockito.kotlin.any -import org.mockito.kotlin.anyOrNull -import org.mockito.kotlin.doReturn -import org.mockito.kotlin.doSuspendableAnswer -import org.mockito.kotlin.mock -import org.mockito.kotlin.never -import org.mockito.kotlin.verify -import org.mockito.kotlin.whenever -import org.robolectric.RobolectricTestRunner -import org.robolectric.Shadows -import org.robolectric.annotation.Config -import to.bitkit.App -import to.bitkit.CurrentActivity -import to.bitkit.R -import to.bitkit.data.CacheStore -import to.bitkit.domain.commands.ReceivedNotificationContent -import to.bitkit.ext.createChannelDetails -import to.bitkit.ext.mock -import to.bitkit.ext.notificationManager -import to.bitkit.models.BlocktankNotificationType -import to.bitkit.models.NotificationDetails -import to.bitkit.repositories.ActivityRepo -import to.bitkit.repositories.BlocktankRepo -import to.bitkit.repositories.LightningRepo -import to.bitkit.services.NodeEventHandler -import to.bitkit.services.NodeServiceFgState -import to.bitkit.test.BaseUnitTest -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertNull - -@Config(sdk = [34]) -@RunWith(RobolectricTestRunner::class) -class WakeNodeWorkerTest : BaseUnitTest() { - private val context = ApplicationProvider.getApplicationContext() - private val workerParams = mock() - private val lightningRepo = mock() - private val blocktankRepo = mock() - private val activityRepo = mock() - private val cacheStore = mock() - private val receivedNotificationContent = mock() - private val nodeServiceFgState = NodeServiceFgState() - - private val channelId = "channel-1" - private val receivedTitle by lazy { context.getString(R.string.notification__received__title) } - - @Before - fun setUp() { - whenever(workerParams.inputData).thenReturn( - workDataOf("type" to BlocktankNotificationType.cjitPaymentArrived.name), - ) - - val app = context as Application - Shadows.shadowOf(app).grantPermissions(Manifest.permission.POST_NOTIFICATIONS) - - // Default: app killed (no foreground activity); nodeServiceFgState defaults to not-running - App.currentActivity = CurrentActivity() - } - - @After - fun tearDown() { - App.currentActivity = null - } - - @Test - fun `cjit channel ready delivers rich notification content with fiat`() = test { - val body = $$"Received ₿ 48 064 ($30.79)" - whenever(receivedNotificationContent.build(48_064L)).thenReturn(NotificationDetails(receivedTitle, body)) - val channel = cjitChannel(sats = 48_064) - stubChannel(channel, cjitEntry = IcJitEntry.mock()) - stubStartFiring(channelReadyEvent()) - - val result = worker().doWork() - - assertEquals(ListenableWorker.Result.success(), result) - val notification = findNotificationByTitle(receivedTitle) - assertNotNull(notification, "CJIT notification should be delivered when app is killed") - assertEquals(body, notification.extras?.getString(Notification.EXTRA_TEXT)) - verify(activityRepo).insertActivityFromCjit(any(), any()) - } - - @Test - fun `non-cjit channel ready delivers no payment notification`() = test { - val channel = cjitChannel(sats = 45_000) - stubChannel(channel, cjitEntry = null) - stubStartFiring(channelReadyEvent()) - - val result = worker().doWork() - - assertEquals(ListenableWorker.Result.success(), result) - assertNull(findNotificationByTitle(receivedTitle), "A non-CJIT channel must not show a payment notification") - verify(activityRepo, never()).insertActivityFromCjit(any(), any()) - verify(cacheStore, never()).setBackgroundReceive(any()) - } - - @Test - fun `cjit channel ready skips notification when foreground service is running`() = test { - nodeServiceFgState.setForegroundServiceRunning(true) - val channel = cjitChannel(sats = 48_064) - stubChannel(channel, cjitEntry = IcJitEntry.mock()) - stubStartFiring(channelReadyEvent()) - - val result = worker().doWork() - - assertEquals(ListenableWorker.Result.success(), result) - assertNull(findNotificationByTitle(receivedTitle), "Deduped when the foreground service handles it") - // Defers entirely: the in-process handler owns the insert, so the worker must not win the dedup race - verify(activityRepo, never()).insertActivityFromCjit(any(), any()) - verify(cacheStore, never()).setBackgroundReceive(any()) - } - - @Test - fun `cjit channel ready skips notification when activity already exists`() = test { - val channel = cjitChannel(sats = 48_064) - stubChannel(channel, cjitEntry = IcJitEntry.mock()) - whenever(activityRepo.insertActivityFromCjit(any(), any())).thenReturn(Result.success(false)) - stubStartFiring(channelReadyEvent()) - - val result = worker().doWork() - - assertEquals(ListenableWorker.Result.success(), result) - assertNull(findNotificationByTitle(receivedTitle), "A duplicate CJIT channel ready must not notify again") - verify(cacheStore, never()).setBackgroundReceive(any()) - } - - @Test - fun `cjit channel ready skips notification when app is in foreground`() = test { - App.currentActivity?.onActivityStarted(mock()) - val channel = cjitChannel(sats = 48_064) - stubChannel(channel, cjitEntry = IcJitEntry.mock()) - stubStartFiring(channelReadyEvent()) - - val result = worker().doWork() - - assertEquals(ListenableWorker.Result.success(), result) - assertNull(findNotificationByTitle(receivedTitle), "Notification is deduped when the in-app UI handles it") - // Defers entirely: the in-app handler owns the insert, so the worker must not win the dedup race - verify(activityRepo, never()).insertActivityFromCjit(any(), any()) - verify(cacheStore, never()).setBackgroundReceive(any()) - } - - @Test - fun `payment received delivers rich notification content with fiat`() = test { - val title = context.getString(R.string.notification__received__title) - val body = $$"Received ₿ 48 064 ($30.79)" - whenever(receivedNotificationContent.build(48_064L)).thenReturn(NotificationDetails(title, body)) - whenever(workerParams.inputData).thenReturn( - workDataOf("type" to BlocktankNotificationType.incomingHtlc.name), - ) - whenever(lightningRepo.stop()).thenReturn(Result.success(Unit)) - stubStartFiring(paymentReceivedEvent(sats = 48_064)) - - val result = worker().doWork() - - assertEquals(ListenableWorker.Result.success(), result) - val notification = findNotificationByTitle(title) - assertNotNull(notification, "Payment notification should be delivered when app is killed") - assertEquals(body, notification.extras?.getString(Notification.EXTRA_TEXT)) - } - - @Test - fun `payment received skips notification when foreground service is running`() = test { - nodeServiceFgState.setForegroundServiceRunning(true) - whenever(workerParams.inputData).thenReturn( - workDataOf("type" to BlocktankNotificationType.incomingHtlc.name), - ) - whenever(lightningRepo.stop()).thenReturn(Result.success(Unit)) - stubStartFiring(paymentReceivedEvent(sats = 48_064)) - - val result = worker().doWork() - - assertEquals(ListenableWorker.Result.success(), result) - assertNull( - findNotificationByTitle(context.getString(R.string.notification__received__title)), - "Notification is deduped when the foreground service handles it", - ) - // Receive is still cached for the in-app UI to pick up - verify(cacheStore).setBackgroundReceive(any()) - } - - private fun worker() = WakeNodeWorker( - appContext = context, - workerParams = workerParams, - lightningRepo = lightningRepo, - blocktankRepo = blocktankRepo, - activityRepo = activityRepo, - cacheStore = cacheStore, - receivedNotificationContent = receivedNotificationContent, - nodeServiceFgState = nodeServiceFgState, - ) - - private fun channelReadyEvent() = mock { - on { this.channelId } doReturn this@WakeNodeWorkerTest.channelId - } - - private fun paymentReceivedEvent(sats: Long) = Event.PaymentReceived( - paymentId = "payment-1", - paymentHash = "hash-1", - amountMsat = (sats * 1000).toULong(), - customRecords = emptyList(), - ) - - private fun cjitChannel(sats: Long) = createChannelDetails().copy( - channelId = channelId, - outboundCapacityMsat = (sats * 1000).toULong(), - unspendablePunishmentReserve = 0u, - ) - - private suspend fun stubChannel(channel: ChannelDetails, cjitEntry: IcJitEntry?) { - whenever(lightningRepo.getChannels()).thenReturn(listOf(channel)) - whenever(blocktankRepo.getCjitEntry(channel)).thenReturn(cjitEntry) - whenever(activityRepo.insertActivityFromCjit(any(), any())).thenReturn(Result.success(true)) - whenever(lightningRepo.stop()).thenReturn(Result.success(Unit)) - } - - private fun stubStartFiring(event: Event) { - whenever { - lightningRepo.start(any(), anyOrNull(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), any()) - }.doSuspendableAnswer { - val handler = it.getArgument(5) - handler?.invoke(event) - Result.success(Unit) - } - } - - private fun findNotificationByTitle(title: String): Notification? { - val shadows = Shadows.shadowOf(context.notificationManager) - return shadows.allNotifications.find { it.extras.getString(Notification.EXTRA_TITLE) == title } - } -} diff --git a/app/src/test/java/to/bitkit/utils/CryptoTest.kt b/app/src/test/java/to/bitkit/utils/CryptoTest.kt index f1e9ff416a..aae64886f8 100644 --- a/app/src/test/java/to/bitkit/utils/CryptoTest.kt +++ b/app/src/test/java/to/bitkit/utils/CryptoTest.kt @@ -4,12 +4,10 @@ import org.bouncycastle.jce.provider.BouncyCastleProvider import org.junit.After import org.junit.Before import org.junit.Test -import to.bitkit.env.Env.derivationName import to.bitkit.ext.fromBase64 import to.bitkit.ext.fromHex import to.bitkit.ext.toBase64 import to.bitkit.ext.toHex -import to.bitkit.fcm.EncryptedNotification import java.security.Provider import java.security.Security import kotlin.test.assertContentEquals @@ -57,7 +55,7 @@ class CryptoTest { val crypto = Crypto() val keys = crypto.generateKeyPair() assertContentEquals(keys.publicKey, crypto.getPublicKey(keys.privateKey)) - val secret = crypto.generateSharedSecret(keys.privateKey, keys.publicKey.toHex(), derivationName) + val secret = crypto.generateSharedSecret(keys.privateKey, keys.publicKey.toHex(), DERIVATION_NAME) val plaintext = "Provider preservation".toByteArray() val encrypted = crypto.encrypt(plaintext, secret) assertContentEquals(plaintext, crypto.decrypt(encrypted, secret)) @@ -81,14 +79,12 @@ class CryptoTest { val sharedSecret = sut.generateSharedSecret(privateKey, publicKey.toHex()) assertEquals(33, sharedSecret.size) - val sharedSecretHash = sut.generateSharedSecret(privateKey, publicKey.toHex(), derivationName) + val sharedSecretHash = sut.generateSharedSecret(privateKey, publicKey.toHex(), DERIVATION_NAME) assertEquals(32, sharedSecretHash.size) } @Test fun `it should decrypt payload it encrypted`() { - val derivationName = derivationName - // Step 1: Client generates a key pair val clientKeys = sut.generateKeyPair() val clientPublicKey = clientKeys.publicKey @@ -100,14 +96,14 @@ class CryptoTest { val serverPrivateKey = serverKeys.privateKey // Step 3: Server generates shared secret using its private key and client public key - val serverSecret = sut.generateSharedSecret(serverPrivateKey, clientPublicKey.toHex(), derivationName) + val serverSecret = sut.generateSharedSecret(serverPrivateKey, clientPublicKey.toHex(), DERIVATION_NAME) // Step 4: Server encrypts data using the shared secret val dataToEncrypt = "Hello from the server!" val encrypted = sut.encrypt(dataToEncrypt.toByteArray(), serverSecret) assertEquals(12, encrypted.iv.size) assertEquals(16, encrypted.tag.size) - val response = EncryptedNotification( + val response = EncryptedResponse( cipher = encrypted.cipher.toBase64(), iv = encrypted.iv.toHex(), tag = encrypted.tag.toHex(), @@ -115,7 +111,7 @@ class CryptoTest { ) // Step 5: Client generates its shared secret using its private key and server public key - val clientSecret = sut.generateSharedSecret(clientPrivateKey, response.publicKey, derivationName) + val clientSecret = sut.generateSharedSecret(clientPrivateKey, response.publicKey, DERIVATION_NAME) // Step 6: Client decrypts the payload using the shared secret val decrypted = sut.decrypt( @@ -137,7 +133,6 @@ class CryptoTest { fun testBlocktankEncryptedPayload() { val clientPrivateKey = "cc74b1a4fdcd35916c766d3318c5a93b7e33a36ebeff0463128bf284975c2680" val serverPublicKey = "031e9923e689a181a803486b7d8c0d4a5aad360edb70c8bb413a98458d91652213" - val derivationName = "bitkit-notifications" @Suppress("MaxLineLength") val ciphertext = ("l2fInfyw64gO12odo8iipISloQJ45Rc4WjFmpe95brdaAMDq+T/L9ZChcmMCXnR0J6BXd8sSIJe/0bmby8uSZZJuVCzwF76XHfY5oq0Y1/hKzyZTn8nG3dqfiLHnAPy1tZFQfm5ALgjwWnViYJLXoGFpXs7kLMA=").fromBase64() @@ -152,7 +147,7 @@ class CryptoTest { val sharedSecretOnServer = "028ce542975d6d7b2307c92e527d507b03ffb3d897eb2e0830d29f40d5efd80ee3".fromHex() assertEquals(sharedSecretOnServer.toHex(), sharedSecret.toHex()) - val sharedHash = sut.generateSharedSecret(clientPrivateKey.fromHex(), serverPublicKey, derivationName) + val sharedHash = sut.generateSharedSecret(clientPrivateKey.fromHex(), serverPublicKey, DERIVATION_NAME) val sharedHashOnServer = "3a9d552cb16dfae40feae644254c4ca46cab82e570de5662aacc4018e33b609b".fromHex() assertEquals(sharedHashOnServer.toHex(), sharedHash.toHex()) @@ -215,3 +210,12 @@ class CryptoTest { private class OutdatedBcProvider : Provider(BC, 1.0, "Stub for the BC provider that Android registers") } + +private const val DERIVATION_NAME = "bitkit-notifications" + +private data class EncryptedResponse( + val cipher: String, + val iv: String, + val tag: String, + val publicKey: String, +) diff --git a/app/src/test/java/to/bitkit/utils/timedsheets/sheets/NotificationsTimedSheetTest.kt b/app/src/test/java/to/bitkit/utils/timedsheets/sheets/NotificationsTimedSheetTest.kt new file mode 100644 index 0000000000..114cee5ec4 --- /dev/null +++ b/app/src/test/java/to/bitkit/utils/timedsheets/sheets/NotificationsTimedSheetTest.kt @@ -0,0 +1,123 @@ +package to.bitkit.utils.timedsheets.sheets + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.data.SettingsData +import to.bitkit.data.SettingsStore +import to.bitkit.repositories.PaykitPaymentRequestRepo +import to.bitkit.repositories.PaykitSubscription +import to.bitkit.test.BaseUnitTest +import to.bitkit.ui.components.TimedSheetType + +class NotificationsTimedSheetTest : BaseUnitTest() { + private lateinit var settingsStore: SettingsStore + private lateinit var paykitPaymentRequestRepo: PaykitPaymentRequestRepo + private lateinit var sut: NotificationsTimedSheet + + private val defaultSettings = SettingsData() + private val settingsFlow = MutableStateFlow(defaultSettings) + + @Before + fun setUp() = runBlocking { + settingsStore = mock() + paykitPaymentRequestRepo = mock() + + whenever(settingsStore.data).thenReturn(settingsFlow) + whenever(settingsStore.update(any())).then { + val transform = it.getArgument<(SettingsData) -> SettingsData>(0) + settingsFlow.value = transform(settingsFlow.value) + flowOf(settingsFlow.value) + } + + whenever(paykitPaymentRequestRepo.subscriptions).thenReturn( + MutableStateFlow(listOf(mock())) + ) + + sut = NotificationsTimedSheet(settingsStore, paykitPaymentRequestRepo) + } + + @Test + fun `type is NOTIFICATIONS`() { + assertTrue(sut.type == TimedSheetType.NOTIFICATIONS) + } + + @Test + fun `priority is 3`() { + assertTrue(sut.priority == 3) + } + + @Test + fun `should not show when notifications granted`() = test { + settingsFlow.value = defaultSettings.copy(notificationsGranted = true) + + val result = sut.shouldShow() + + assertFalse(result) + } + + @Test + fun `should not show without subscriptions`() = test { + whenever(paykitPaymentRequestRepo.subscriptions).thenReturn( + MutableStateFlow(emptyList()) + ) + + val result = sut.shouldShow() + + assertFalse(result) + } + + @Test + fun `should not show within one week timeout`() = test { + val sixDaysAgo = System.currentTimeMillis() - (6L * 24 * 60 * 60 * 1000) + settingsFlow.value = defaultSettings.copy( + notificationsGranted = false, + notificationsIgnoredMillis = sixDaysAgo + ) + + val result = sut.shouldShow() + + assertFalse(result) + } + + @Test + fun `should show after one week timeout with subscriptions`() = test { + val eightDaysAgo = System.currentTimeMillis() - (8L * 24 * 60 * 60 * 1000) + settingsFlow.value = defaultSettings.copy( + notificationsGranted = false, + notificationsIgnoredMillis = eightDaysAgo + ) + + val result = sut.shouldShow() + + assertTrue(result) + } + + @Test + fun `should show when never dismissed`() = test { + settingsFlow.value = defaultSettings.copy( + notificationsGranted = false, + notificationsIgnoredMillis = 0L + ) + + val result = sut.shouldShow() + + assertTrue(result) + } + + @Test + fun `updates ignored timestamp on dismissed`() = test { + sut.onDismissed() + + verify(settingsStore).update(any()) + assertTrue(settingsFlow.value.notificationsIgnoredMillis > 0) + } +} diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 1af661a6ee..b21e8ee307 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -929,6 +929,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { samRockRepo = samRockRepo, appUpdateSheet = mock { on { type } doReturn TimedSheetType.APP_UPDATE }, backupSheet = backupSheet, + notificationsSheet = mock { on { type } doReturn TimedSheetType.NOTIFICATIONS }, quickPaySheet = mock { on { type } doReturn TimedSheetType.QUICK_PAY }, usdtSheet = usdtSheet, diff --git a/docs/screens-map.md b/docs/screens-map.md index 3b371e883b..bfde34c946 100644 --- a/docs/screens-map.md +++ b/docs/screens-map.md @@ -284,6 +284,12 @@ Frame names are stable across handoff iterations; node ids are not, so the map l | - | - | | AppStatusScreen.kt | Support & App Status › App Status OK | +## ui/settings/backgroundPayments + +| Android | Figma | +| - | - | +| BackgroundPaymentsIntroScreen.kt | Settings > General › BackgroundPayments Intro | + ## ui/settings/backups | Android | Figma | diff --git a/docs/strings.md b/docs/strings.md index 4a994cb867..f411b80f70 100644 --- a/docs/strings.md +++ b/docs/strings.md @@ -115,3 +115,4 @@ Hardcoded strings in `@Preview` functions throughout the codebase do not need tr | 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/docs/timed-sheets.md b/docs/timed-sheets.md index fe1db331b5..9a7995eb27 100644 --- a/docs/timed-sheets.md +++ b/docs/timed-sheets.md @@ -7,8 +7,9 @@ The manager checks sheets two seconds after the home screen resumes and cancels 1. App update 2. USDT 3. Backup -4. QuickPay -5. High balance +4. Background payments +5. QuickPay +6. High balance A busy sheet host leaves prompts queued for a later Home visit. While an unseen, undecided USDT intro has not yet been presented in this app session, becoming idle on Home also retries the queue after the same two-second delay. Once the intro is presented, closing sheets does not advance other timed prompts. Eligibility checks that finish after leaving Home or opening another flow cannot open a sheet. @@ -54,6 +55,16 @@ Backup uses a snooze model instead of a permanent seen flag: After the one-day ask interval passes, the Backup timed sheet can appear again if the backup is still unverified and the wallet still has a balance. +## Background Payments + +The Background Payments timed sheet is eligible when notification permission has not been granted, the wallet has a Lightning balance, and `notificationsIgnoredMillis` is older than one week. + +- `Later` marks the background-payments intro as seen and dismisses the sheet. Dismissal records `notificationsIgnoredMillis`. +- `Enable` marks the intro as seen, dismisses the sheet, records `notificationsIgnoredMillis`, and requests notification permission. +- A generic timed sheet dismissal records `notificationsIgnoredMillis` without marking the intro as seen. + +After the one-week ask interval passes, the timed sheet can appear again if notification permission is still not granted and the wallet still has a Lightning balance. The full-screen settings intro is a separate route with only an enable action. + ## High Balance The High Balance sheet is eligible when the wallet balance converts to more than USD 500, fewer than three warnings have been dismissed while above the threshold, and `balanceWarningIgnoredMillis` is older than one day. diff --git a/journeys/README.md b/journeys/README.md index 0426e22bf1..5647c3bef5 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -171,7 +171,7 @@ Known differences in the corpus, as of the iOS port (synonymdev/bitkit-ios#691): | `send/prepared-fee-recovery.md` | Same four manual fault cases and file on both platforms; the recipe records original-send versus replacement fee retention and iOS Max/identity-flow adaptations. These faults are not journey capabilities. | | `settings/electrum-server-error-toasts.xml` | not ported — iOS still shows one generic message for every manual Electrum connect failure | | `transfers/closed-channel-transfer-settles.xml` | not ported — the closed-channel and order-closure settle rules are an iOS follow-up | -| `onchain-receive/*` | port pending in synonymdev/bitkit-ios#588, which wires `onchainTransactionConfirmed` into the same received-sheet flow but carries no `journeys/` files. Two adaptations when it lands: iOS suppresses replayed historical receives with a `pendingRestoreActivitySeen` flag cleared by the first post-restore on-chain sync, not the one-hour block-timestamp guard used here, so a stale-confirmation step has to drive a restore instead of a clock | +| `onchain-receive/*` | port pending in synonymdev/bitkit-ios#588, which wires `onchainTransactionConfirmed` into the same received-sheet flow but carries no `journeys/` files. Two adaptations when it lands: iOS suppresses replayed historical receives with a `pendingRestoreActivitySeen` flag cleared by the first post-restore on-chain sync, not the one-hour block-timestamp guard used here, so a stale-confirmation step has to drive a restore instead of a clock; and iOS has no foreground-service path, so `confirmed-only-background-notification.xml` has no counterpart | | `backup/show-mnemonic-long-words.xml` | not ported — the long-word fit is an Android-only change (synonymdev/bitkit-android#633); whether iOS wraps long words at larger text sizes is unchecked | | `security/forgot-pin-nav-bar-inset.xml` | not ported — Android-only fix (synonymdev/bitkit-android#1572): the Android sheet host adds no navigation-bar inset, so each sheet screen pads itself. iOS sheets keep their content inside the bottom safe area | | `deeplinks/pubky-contact.xml` | ported — same contact routing and unlock behavior | @@ -253,6 +253,7 @@ and Settings (`Tab-general`, `Tab-security`, `Tab-advanced`, `NavigationBack`, ` | Send available balance | `AvailableAmount` **and** `available_balance` | `AvailableAmount` | | Send max | `SendAmountMax` | *(no button — tap `AvailableAmount`)* | | External amount available | — | `ExternalAmountAvailable` | +| Background payments setting row | `BackgroundPaymentSettings` | `NotificationsSettings` | | Send over-max toast | — *(no tag; assert it from a screenshot)* | `SendAmountExceededToast` | | Confirm mnemonic selected slot | `SelectedWord-` | — *(no identifier on `ConfirmWordView` rows)* | | Widgets intro screen container | — | `WidgetsOnboarding` | diff --git a/journeys/onchain-receive/README.md b/journeys/onchain-receive/README.md index 648684f010..fe6d651e8e 100644 --- a/journeys/onchain-receive/README.md +++ b/journeys/onchain-receive/README.md @@ -25,6 +25,7 @@ throwaway emulator, since it wipes the app and restores a public test seed. - Wallet sync runs every 10s. For the confirmed-only journeys, run the deposit and the mine in one shell command, then check the log: an `OnchainTransactionReceived` line for the txid means the sync saw the mempool first and the run tested the other path. +- The background journey needs background payments enabled (Settings > Notifications). - Every event the node emits is logged by `LightningService` as `LDK event fired: ` under the `APP` logcat tag, so `adb logcat -d -s APP:V` is enough to tell the two paths apart. - The Receive sheet's tabs carry no test tag; select the Savings tab by its label. diff --git a/journeys/onchain-receive/confirmed-only-background-notification.xml b/journeys/onchain-receive/confirmed-only-background-notification.xml new file mode 100644 index 0000000000..a5834da7fb --- /dev/null +++ b/journeys/onchain-receive/confirmed-only-background-notification.xml @@ -0,0 +1,22 @@ + + + Covers issue #797 on the LightningNodeService path. With background payments enabled and the app + in the background, a deposit first seen already confirmed must post exactly one "Payment + Received" notification. + + Precondition: onboarded regtest wallet with background payments enabled, so the "Bitkit is + running in background" foreground service notification is present. + + + Tap Receive (testTag "Receive") and verify the Receive sheet opens (testTag "ReceiveScreen") + Tap the "Savings" receive tab (the tab row carries no test tag), tap "Show Details" (testTag "ShowDetails") and read the address from testTag "ReceiveOnchainAddress" + Press back, then send the app to the background: adb shell input keyevent KEYCODE_HOME + Run in one command: ./lsp POST /regtest/chain/deposit '{"address":"<savings addr>","amountSat":7970}' && ./lsp POST /regtest/chain/mine '{"count":1}' + Wait 40s + Run: adb shell dumpsys notification --noredact | grep -E "android\.title=String \(Payment Received\)|android\.text=String \(Received " + Verify exactly one "android.title=String (Payment Received)" line is printed, and an "android.text=String (Received <amount>)" line with it carrying the fiat and BTC amounts in the order set by the primary display setting. The foreground-service notification is titled "Bitkit Regtest" on the dev flavour, so it does not match either pattern + Open the notification shade and tap the "Payment Received" notification + Verify the app opens with the received sheet (testTag "ReceivedTransaction") showing the deposited amount + Tap the sheet button (testTag "ReceivedTransactionButton") and verify the home screen shows with no second sheet + + From 64e36372b0514376ed510991d09b1a756ba1dde4 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 26 Oct 2026 19:06:07 +0100 Subject: [PATCH 10/47] fix: block transfers when the geocheck fails --- .../main/java/to/bitkit/repositories/LightningRepo.kt | 7 +++++++ app/src/main/java/to/bitkit/services/CoreService.kt | 11 ++++++----- .../java/to/bitkit/viewmodels/TransferViewModel.kt | 3 +-- .../to/bitkit/viewmodels/TransferViewModelTest.kt | 6 ++---- 4 files changed, 16 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index a7e3671d16..9c20d39bb4 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -669,6 +669,13 @@ class LightningRepo @Inject constructor( fun setWiping(enabled: Boolean) = run { isWiping = enabled } + /** Blocks a transfer to spending unless the geocheck confirms the region is allowed. */ + suspend fun isTransferBlocked(): Boolean = withContext(bgDispatcher) { + coreService.isGeoBlocked(blockOnError = true).also { blocked -> + _lightningState.update { it.copy(isGeoBlocked = blocked) } + } + } + suspend fun updateGeoBlockState() = withContext(bgDispatcher) { _lightningState.update { it.copy(isGeoBlocked = coreService.isGeoBlocked()) diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index b59c70edd1..5a1116803d 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -171,8 +171,9 @@ class CoreService @Inject constructor( } } + /** @param blockOnError whether a failed or unexpected geocheck counts as blocked. */ @Suppress("KotlinConstantConditions") - suspend fun isGeoBlocked(): Boolean { + suspend fun isGeoBlocked(blockOnError: Boolean = false): Boolean { val tag = "GeoCheck" if (!Env.isGeoblockingEnabled) { Logger.verbose("Geoblocking disabled via build config", context = tag) @@ -197,15 +198,15 @@ class CoreService @Inject constructor( else -> { Logger.warn( - "Unexpected status code: ${response.status.value}, defaulting to false", + "Unexpected status code: ${response.status.value}, defaulting to '$blockOnError'", context = tag ) - false + blockOnError } } }.onFailure { - Logger.warn("Error. defaulting isGeoBlocked to false", context = tag) - }.getOrDefault(false) + Logger.warn("Failed geocheck, defaulting isGeoBlocked to '$blockOnError'", it, context = tag) + }.getOrDefault(blockOnError) } } diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 5f254987d0..6ea6e7f614 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -481,8 +481,7 @@ class TransferViewModel @Inject constructor( } private suspend fun isTransferUnavailable(): Boolean { - lightningRepo.updateGeoBlockState() - if (!lightningRepo.lightningState.value.isGeoBlocked) return false + if (!lightningRepo.isTransferBlocked()) return false Logger.warn("Skipped paying spending order: transfers are unavailable", context = TAG) transferEffects.emit(TransferEffect.OnTransferUnavailable) return true diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index 6770f92c62..f915475380 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -168,6 +168,7 @@ class TransferViewModelTest : BaseUnitTest() { } .thenReturn(Result.success("transfer-id")) whenever { hwWalletRepo.needsPassphrase(any()) }.thenReturn(false) + whenever { lightningRepo.isTransferBlocked() }.thenReturn(false) val nodeStatus = mock() whenever(nodeStatus.isRunning).thenReturn(true) whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState(nodeStatus = nodeStatus))) @@ -1214,10 +1215,7 @@ class TransferViewModelTest : BaseUnitTest() { backgroundScope.launch { sut.transferEffects.collect { effects += it } } quoteOrder(order) prepareConfirm() - val nodeStatus = mock() - whenever(nodeStatus.isRunning).thenReturn(true) - whenever(lightningRepo.lightningState) - .thenReturn(MutableStateFlow(LightningState(nodeStatus = nodeStatus, isGeoBlocked = true))) + whenever(lightningRepo.isTransferBlocked()).thenReturn(true) sut.onTransferToSpendingConfirm() advanceUntilIdle() From c5f14db469148ee546d86012d45f1552cbecd389 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 26 Oct 2026 19:56:41 +0100 Subject: [PATCH 11/47] fix: shorten the payment reminder text --- app/src/main/res/values-ar/strings.xml | 2 +- app/src/main/res/values-b+es+419/strings.xml | 2 +- app/src/main/res/values-ca/strings.xml | 2 +- app/src/main/res/values-cs/strings.xml | 2 +- app/src/main/res/values-de/strings.xml | 2 +- app/src/main/res/values-el/strings.xml | 2 +- app/src/main/res/values-es-rES/strings.xml | 2 +- app/src/main/res/values-es/strings.xml | 2 +- app/src/main/res/values-fr/strings.xml | 2 +- app/src/main/res/values-it/strings.xml | 2 +- app/src/main/res/values-nl/strings.xml | 2 +- app/src/main/res/values-pl/strings.xml | 2 +- app/src/main/res/values-pt-rBR/strings.xml | 2 +- app/src/main/res/values-pt/strings.xml | 2 +- app/src/main/res/values-ru/strings.xml | 2 +- app/src/main/res/values/strings.xml | 2 +- 16 files changed, 16 insertions(+), 16 deletions(-) diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index e571177d05..0557a9fa54 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -1542,7 +1542,7 @@ اسم الاشتراك مستمر نظرة عامة - افتح Bitkit لمراجعة دفعة اشتراك. + افتح Bitkit لمراجعة الدفعة دفعة اشتراك مستحقة طرق الدفع يوميًا diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml index 518d382793..d29efc194c 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -1557,7 +1557,7 @@ Nombre de la suscripción En curso Resumen - Abra Bitkit para revisar un pago de suscripción. + Abra Bitkit para revisar el pago Pago de suscripción pendiente Métodos de pago Por día diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index d464e9fad9..b930b580e5 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -1554,7 +1554,7 @@ Nom de la subscripció En curs Resum - Obre Bitkit per revisar un pagament de subscripció. + Obre Bitkit per revisar el pagament Pagament de subscripció pendent Mètodes de pagament Per dia diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 8fb93f9533..6623ff5985 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -1556,7 +1556,7 @@ Název předplatného Průběžné Přehled - Otevřete Bitkit a zkontrolujte platbu předplatného. + Otevřete Bitkit a zkontrolujte platbu Splatná platba předplatného Platební metody Za den diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 49f175c3da..7ef2bdcf5e 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1555,7 +1555,7 @@ Name des Abonnements Laufend Übersicht - Öffne Bitkit, um eine Abonnementzahlung zu prüfen. + Öffne Bitkit, um die Zahlung zu prüfen Abonnementzahlung fällig Zahlungsmethoden Pro Tag diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 6562b6011a..45868ca7d5 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -1548,7 +1548,7 @@ Όνομα συνδρομής Σε εξέλιξη Επισκόπηση - Άνοιξε το Bitkit για να ελέγξεις μια πληρωμή συνδρομής. + Άνοιξε το Bitkit για να ελέγξεις την πληρωμή Οφειλόμενη πληρωμή συνδρομής Τρόποι πληρωμής Ανά ημέρα diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index 6689fa457c..6fe203798a 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -1552,7 +1552,7 @@ Nombre de la suscripción En curso Resumen - Abre Bitkit para revisar un pago de suscripción. + Abre Bitkit para revisar el pago Pago de suscripción pendiente Métodos de pago Al día diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index de25d93483..88841384e3 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1552,7 +1552,7 @@ Nombre de la suscripción En curso Resumen - Abra Bitkit para revisar un pago de suscripción. + Abra Bitkit para revisar el pago Pago de suscripción pendiente Métodos de pago Al día diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 9b0f1c04fa..b3088dcc4b 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -1553,7 +1553,7 @@ Nom de l\'abonnement En cours Aperçu - Ouvrez Bitkit pour examiner un paiement d\'abonnement. + Ouvrez Bitkit pour examiner le paiement Paiement d\'abonnement dû Moyens de paiement Par jour diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 30f5dc17a5..83724f66cf 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -1556,7 +1556,7 @@ Nome abbonamento In corso Panoramica - Apri Bitkit per controllare un pagamento di abbonamento. + Apri Bitkit per controllare il pagamento Pagamento Abbonamento Dovuto Metodi di pagamento Al Giorno diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 4ad315829d..a148df7096 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -1554,7 +1554,7 @@ Naam abonnement Doorlopend Overzicht - Open Bitkit om een abonnementsbetaling te bekijken. + Open Bitkit om de betaling te bekijken Abonnementsbetaling verschuldigd Betaalmethoden Per dag diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index cfd1f51ee2..4c6ed9bd0c 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -1555,7 +1555,7 @@ Nazwa subskrypcji W toku Przegląd - Otwórz Bitkit, aby przejrzeć płatność za subskrypcję. + Otwórz Bitkit, aby przejrzeć płatność Termin płatności za subskrypcję Metody płatności Dziennie diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 5ec81c6d34..4c47ce0aa0 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -1556,7 +1556,7 @@ Nome da assinatura Em andamento Visão Geral - Abra a Bitkit para revisar um pagamento de assinatura. + Abra a Bitkit para revisar o pagamento Pagamento de Assinatura Pendente Formas de pagamento Por Dia diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 2e468c10bc..a08d71dbd5 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -1542,7 +1542,7 @@ Nome da subscrição Em curso Vista geral - Abra a Bitkit para rever um pagamento de subscrição. + Abra a Bitkit para rever o pagamento Pagamento de subscrição pendente Métodos de pagamento Por dia diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index c6a7eca8c7..26f399a824 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1583,7 +1583,7 @@ Bitkit должен увеличить приёмную ёмкость ваше Название подписки Бессрочно Обзор - Откройте Bitkit, чтобы проверить платёж по подписке. + Откройте Bitkit, чтобы проверить платёж Пора Оплатить Подписку Способы оплаты В День diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e1baafada8..7241893e1b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1171,7 +1171,7 @@ Subscription name Ongoing Overview - Open Bitkit to review a subscription payment. + Open Bitkit to review payment Subscription Payment Due Payment methods Payments From 2dfbc86b91bdd7f54084787970f997f70cfa4f49 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 26 Oct 2026 21:13:20 +0100 Subject: [PATCH 12/47] fix: word the background service notification for all payments --- app/src/main/res/values-ar/strings.xml | 2 +- app/src/main/res/values-b+es+419/strings.xml | 2 +- app/src/main/res/values-ca/strings.xml | 2 +- app/src/main/res/values-cs/strings.xml | 2 +- app/src/main/res/values-de/strings.xml | 2 +- app/src/main/res/values-el/strings.xml | 2 +- app/src/main/res/values-es-rES/strings.xml | 2 +- app/src/main/res/values-es/strings.xml | 2 +- app/src/main/res/values-fr/strings.xml | 2 +- app/src/main/res/values-it/strings.xml | 2 +- app/src/main/res/values-nl/strings.xml | 2 +- app/src/main/res/values-pl/strings.xml | 2 +- app/src/main/res/values-pt-rBR/strings.xml | 2 +- app/src/main/res/values-pt/strings.xml | 2 +- app/src/main/res/values-ru/strings.xml | 2 +- app/src/main/res/values/strings.xml | 2 +- 16 files changed, 16 insertions(+), 16 deletions(-) diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 0557a9fa54..2c82b886c0 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -275,7 +275,7 @@ إشعار عقدة Lightning تم استلام %s تم استلام الدفع - Bitkit يعمل في الخلفية حتى تتمكن من استلام مدفوعات Lightning + Bitkit يعمل في الخلفية حتى تتمكن من استلام المدفوعات والطلبات إيقاف التطبيق متقدم إعداد متقدم diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml index d29efc194c..0f789af101 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -283,7 +283,7 @@ Notificación del nodo Lightning Recibido %s Pago recibido - Bitkit está ejecutándose en segundo plano para que puedas recibir pagos Lightning + Bitkit está ejecutándose en segundo plano para que puedas recibir pagos y solicitudes Detener app Avanzado Configuración avanzada diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index b930b580e5..373d38a86b 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -279,7 +279,7 @@ Notificació del node Lightning Rebut %s Pagament rebut - Bitkit s\'executa en segon pla perquè puguis rebre pagaments Lightning + Bitkit s\'executa en segon pla perquè puguis rebre pagaments i sol·licituds Aturar aplicació Avançat Configuració avançada diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 6623ff5985..30db55c9b8 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -282,7 +282,7 @@ Notifikace Lightning uzlu Přijato %s Platba přijata - Bitkit běží na pozadí, abyste mohli přijímat Lightning platby + Bitkit běží na pozadí, abyste mohli přijímat platby a žádosti Zastavit aplikaci Pokročilé Pokročilé nastavení diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 7ef2bdcf5e..3a97c6bed7 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -282,7 +282,7 @@ Lightning-Node-Benachrichtigung %s empfangen Zahlung empfangen - Bitkit läuft im Hintergrund, damit du Lightning-Zahlungen empfangen kannst + Bitkit läuft im Hintergrund, damit du Zahlungen und Anfragen empfangen kannst App stoppen Bitkit\n<accent>Nutzungs-bedingungen</accent> Nutzungsbedingungen diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 45868ca7d5..1add6d9bc0 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -276,7 +276,7 @@ Ειδοποίηση κόμβου Lightning Ελήφθησαν %s Ελήφθη πληρωμή - Το Bitkit εκτελείται στο παρασκήνιο για να μπορείς να λαμβάνεις πληρωμές Lightning + Το Bitkit εκτελείται στο παρασκήνιο για να μπορείς να λαμβάνεις πληρωμές και αιτήματα Διακοπή εφαρμογής Για προχωρημένους Ρύθμιση για προχωρημένους diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index 6fe203798a..5658ffdf28 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -282,7 +282,7 @@ Notificación del nodo Lightning Recibido %s Pago recibido - Bitkit se está ejecutando en segundo plano para que puedas recibir pagos Lightning + Bitkit se está ejecutando en segundo plano para que puedas recibir pagos y solicitudes Detener aplicación Avanzado Configuración avanzada diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 88841384e3..7f66eb52d5 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -280,7 +280,7 @@ Notificación del nodo Lightning Recibido %s Pago Recibido - Bitkit se está ejecutando en segundo plano para que puedas recibir pagos Lightning + Bitkit se está ejecutando en segundo plano para que puedas recibir pagos y solicitudes Detener App Condiciones de uso Al continuar, declaras que has leído y aceptas los términos de uso. diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index b3088dcc4b..9f42c167d1 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -282,7 +282,7 @@ Notification du nœud Lightning Reçu %s Paiement reçu - Bitkit fonctionne en arrière-plan pour que vous puissiez recevoir des paiements Lightning + Bitkit fonctionne en arrière-plan pour que vous puissiez recevoir des paiements et des demandes Arrêter l\'application Bitkit \n<accent>Conditions d\'utilisation</accent> Conditions d\'utilisation diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 83724f66cf..99c960b41b 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -281,7 +281,7 @@ Notifica nodo Lightning Ricevuto %s Pagamento Ricevuto - Bitkit e\' in esecuzione in background per permetterti di ricevere pagamenti Lightning + Bitkit è in esecuzione in background per permetterti di ricevere pagamenti e richieste Ferma App Avanzate Impostazioni Avanzate diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index a148df7096..dc098d6af1 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -280,7 +280,7 @@ Lightning node notificatie %s ontvangen Betaling ontvangen - Bitkit draait op de achtergrond zodat je Lightning-betalingen kunt ontvangen + Bitkit draait op de achtergrond zodat je betalingen en verzoeken kunt ontvangen App stoppen Geavanceerd Geavanceerde configuratie diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 4c6ed9bd0c..02f4840e92 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -284,7 +284,7 @@ Powiadomienie węzła Lightning Otrzymano %s Płatność otrzymana - Bitkit działa w tle, abyś mógł otrzymywać płatności Lightning + Bitkit działa w tle, abyś mógł otrzymywać płatności i prośby Zatrzymaj aplikację Bitkit\n<accent>warunki użytkowania</accent> Warunki użytkowania diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 4c47ce0aa0..c9c6f1dc85 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -284,7 +284,7 @@ Notificação do nó Lightning Recebido %s Pagamento Recebido - Bitkit está executando em segundo plano para que você possa receber pagamentos Lightning + Bitkit está executando em segundo plano para que você possa receber pagamentos e solicitações Parar Aplicativo Avançado Configuração Avançada diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index a08d71dbd5..f5763219ca 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -277,7 +277,7 @@ Notificação do nó Lightning Recebido %s Pagamento Recebido - A Bitkit está rodando em segundo plano para você receber pagamentos Lightning + A Bitkit está a ser executada em segundo plano para poder receber pagamentos e pedidos Parar App Bitkit\n<accent>termos de uso</accent> Termos de uso diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 26f399a824..72ac063e3c 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -297,7 +297,7 @@ Уведомление Lightning ноды Получено %s Платёж получен - Bitkit работает в фоновом режиме, чтобы вы могли получать Lightning платежи + Bitkit работает в фоновом режиме, чтобы вы могли получать платежи и запросы Остановить приложение Дополнительно Дополнительно diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7241893e1b..872f32e243 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -455,7 +455,7 @@ Lightning node notification Received %s Payment Received - Bitkit is running in background so you can receive Lightning payments + Bitkit is running in the background so you can receive payments and requests Stop App Advanced Advanced Setup From 1cda841bb44ce165997e9282e9456ff75b0b8fe2 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 26 Oct 2026 22:47:19 +0100 Subject: [PATCH 13/47] fix: keep the notification grant after the asking sheet closes --- app/src/main/java/to/bitkit/ui/MainActivity.kt | 2 ++ docs/timed-sheets.md | 10 +++++----- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/MainActivity.kt b/app/src/main/java/to/bitkit/ui/MainActivity.kt index 2f2b47d664..b98222b557 100644 --- a/app/src/main/java/to/bitkit/ui/MainActivity.kt +++ b/app/src/main/java/to/bitkit/ui/MainActivity.kt @@ -348,6 +348,8 @@ class MainActivity : FragmentActivity() { override fun onResume() { super.onResume() appViewModel.onAppResumed() + // A permission dialog launched from a sheet that has since closed loses its result callback. + settingsViewModel.setNotificationPreference(areNotificationsEnabled()) } override fun onStop() { diff --git a/docs/timed-sheets.md b/docs/timed-sheets.md index 9a7995eb27..124f8d9c60 100644 --- a/docs/timed-sheets.md +++ b/docs/timed-sheets.md @@ -7,7 +7,7 @@ The manager checks sheets two seconds after the home screen resumes and cancels 1. App update 2. USDT 3. Backup -4. Background payments +4. Notifications 5. QuickPay 6. High balance @@ -55,15 +55,15 @@ Backup uses a snooze model instead of a permanent seen flag: After the one-day ask interval passes, the Backup timed sheet can appear again if the backup is still unverified and the wallet still has a balance. -## Background Payments +## Notifications -The Background Payments timed sheet is eligible when notification permission has not been granted, the wallet has a Lightning balance, and `notificationsIgnoredMillis` is older than one week. +The Notifications timed sheet is eligible when notification permission has not been granted, the wallet has at least one Paykit subscription, and `notificationsIgnoredMillis` is older than one week. -- `Later` marks the background-payments intro as seen and dismisses the sheet. Dismissal records `notificationsIgnoredMillis`. +- `Later` marks the notifications intro as seen and dismisses the sheet. Dismissal records `notificationsIgnoredMillis`. - `Enable` marks the intro as seen, dismisses the sheet, records `notificationsIgnoredMillis`, and requests notification permission. - A generic timed sheet dismissal records `notificationsIgnoredMillis` without marking the intro as seen. -After the one-week ask interval passes, the timed sheet can appear again if notification permission is still not granted and the wallet still has a Lightning balance. The full-screen settings intro is a separate route with only an enable action. +After the one-week ask interval passes, the timed sheet can appear again if notification permission is still not granted and the wallet still has a Paykit subscription. The full-screen settings intro is a separate route with only an enable action. ## High Balance From 9e10dc31f852a4b7ef16ad75cfd03fa3019886d8 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 26 Oct 2026 23:17:14 +0100 Subject: [PATCH 14/47] fix: stop treating blocktank nodes as trusted peers --- app/src/main/java/to/bitkit/env/Env.kt | 33 ---- .../java/to/bitkit/ext/LightningBalance.kt | 11 ++ .../main/java/to/bitkit/models/NodePeer.kt | 2 - .../to/bitkit/repositories/LightningRepo.kt | 26 +-- .../to/bitkit/services/LightningService.kt | 162 +++++++----------- .../to/bitkit/services/MigrationService.kt | 5 +- .../main/java/to/bitkit/ui/NodeInfoScreen.kt | 12 +- .../java/to/bitkit/ui/NodeInfoViewModel.kt | 2 - .../screens/transfer/SavingsProgressScreen.kt | 30 +--- .../transfer/external/LnurlChannelScreen.kt | 9 +- .../main/java/to/bitkit/utils/LoggerLdk.kt | 16 +- .../to/bitkit/viewmodels/TransferViewModel.kt | 54 +----- app/src/main/res/values-ar/strings.xml | 1 - app/src/main/res/values-b+es+419/strings.xml | 1 - app/src/main/res/values-ca/strings.xml | 1 - app/src/main/res/values-cs/strings.xml | 1 - app/src/main/res/values-de/strings.xml | 1 - app/src/main/res/values-el/strings.xml | 1 - app/src/main/res/values-es-rES/strings.xml | 1 - app/src/main/res/values-es/strings.xml | 1 - app/src/main/res/values-fr/strings.xml | 1 - app/src/main/res/values-it/strings.xml | 1 - app/src/main/res/values-nl/strings.xml | 1 - app/src/main/res/values-pl/strings.xml | 1 - app/src/main/res/values-pt-rBR/strings.xml | 1 - app/src/main/res/values-pt/strings.xml | 1 - app/src/main/res/values-ru/strings.xml | 1 - app/src/main/res/values/strings.xml | 1 - .../bitkit/repositories/LightningRepoTest.kt | 130 +++----------- .../bitkit/services/LightningServiceTest.kt | 74 ++++++++ changelog.d/next/1696.changed.md | 2 +- 31 files changed, 203 insertions(+), 381 deletions(-) diff --git a/app/src/main/java/to/bitkit/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt index ce3c90be7c..40427869ec 100644 --- a/app/src/main/java/to/bitkit/env/Env.kt +++ b/app/src/main/java/to/bitkit/env/Env.kt @@ -4,12 +4,9 @@ import android.os.Build import com.synonym.bitkitcore.UsdtDestination import org.lightningdevkit.ldknode.LogLevel import org.lightningdevkit.ldknode.Network -import org.lightningdevkit.ldknode.PeerDetails import to.bitkit.BuildConfig import to.bitkit.BuildConfig.VERSION_NAME import to.bitkit.ext.ensureDir -import to.bitkit.ext.of -import to.bitkit.models.NodePeer import to.bitkit.utils.Logger import java.io.File import kotlin.io.path.Path @@ -51,14 +48,6 @@ internal object Env { val ldkLogLevel = LogLevel.TRACE - val trustedLnPeers - get() = when (network) { - Network.BITCOIN -> listOf(Peers.lnd1, Peers.lnd3, Peers.lnd4) - Network.REGTEST -> listOf(Peers.stag) - Network.TESTNET -> listOf(Peers.stag) - else -> listOf() - } - /** * Whether LN -> onchain swaps can reach a Boltz backend. Boltz only serves a public API on * mainnet: its testnet deployment is deprecated and regtest resolves to a local backend that @@ -275,28 +264,6 @@ object Defaults { const val TAG_MAX_LENGTH = 20 } -object Peers { - 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.153.174:9735") - - object Known { - val stag = NodePeer(Peers.stag, name = "Synonym-Own-Regtest-0") - val lnd1 = NodePeer(Peers.lnd1, name = "Blocktank-LND1") - val lnd3 = NodePeer(Peers.lnd3, name = "Blocktank-LND3") - val lnd4 = NodePeer(Peers.lnd4, name = "Blocktank-LND4") - - fun find(peer: PeerDetails): NodePeer? = when (peer.nodeId) { - stag.peerDetails.nodeId -> stag - lnd1.peerDetails.nodeId -> lnd1 - lnd3.peerDetails.nodeId -> lnd3 - lnd4.peerDetails.nodeId -> lnd4 - else -> null - } - } -} - private object ElectrumServers { object MAINNET { const val ESPLORA = "ssl://bitkit.to:9999" diff --git a/app/src/main/java/to/bitkit/ext/LightningBalance.kt b/app/src/main/java/to/bitkit/ext/LightningBalance.kt index 542f3248ee..e4019b5d26 100644 --- a/app/src/main/java/to/bitkit/ext/LightningBalance.kt +++ b/app/src/main/java/to/bitkit/ext/LightningBalance.kt @@ -24,6 +24,17 @@ fun LightningBalance.channelId(): String { } } +fun LightningBalance.counterpartyNodeId(): String { + return when (this) { + is LightningBalance.ClaimableOnChannelClose -> this.counterpartyNodeId + is LightningBalance.ClaimableAwaitingConfirmations -> this.counterpartyNodeId + is LightningBalance.ContentiousClaimable -> this.counterpartyNodeId + is LightningBalance.MaybeTimeoutClaimableHtlc -> this.counterpartyNodeId + is LightningBalance.MaybePreimageClaimableHtlc -> this.counterpartyNodeId + is LightningBalance.CounterpartyRevokedOutputClaimable -> this.counterpartyNodeId + } +} + fun LightningBalance.claimableAtHeight(): UInt? = when (this) { is LightningBalance.ClaimableAwaitingConfirmations -> this.confirmationHeight is LightningBalance.ContentiousClaimable -> this.timeoutHeight diff --git a/app/src/main/java/to/bitkit/models/NodePeer.kt b/app/src/main/java/to/bitkit/models/NodePeer.kt index 584c54ecf3..ba0041844f 100644 --- a/app/src/main/java/to/bitkit/models/NodePeer.kt +++ b/app/src/main/java/to/bitkit/models/NodePeer.kt @@ -7,10 +7,8 @@ import to.bitkit.ext.ellipsisMiddle data class NodePeer( val peerDetails: PeerDetails, val lspNode: ILspNode? = null, - val name: String? = null, ) fun NodePeer.alias(): String = lspNode?.alias - ?: name ?: peerDetails.nodeId.ellipsisMiddle(16) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 9c20d39bb4..db09d7ac9e 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -70,7 +70,6 @@ import to.bitkit.env.Env import to.bitkit.ext.getSatsPerVByteFor import to.bitkit.ext.nowMillis import to.bitkit.ext.runSuspendCatching -import to.bitkit.ext.toPeerDetailsList import to.bitkit.ext.totalNextOutboundHtlcLimitSats import to.bitkit.models.ALL_ADDRESS_TYPE_STRINGS import to.bitkit.models.CoinSelectionPreference @@ -189,7 +188,6 @@ class LightningRepo @Inject constructor( } if (_lightningState.value.nodeLifecycleState.isRunning()) { - connectToTrustedPeers() scheduleOrdinaryFollowupRetry() } @@ -301,12 +299,10 @@ class LightningRepo @Inject constructor( channelMigration: ChannelDataMigration? = null, ) = withContext(bgDispatcher) { runSuspendCatching { - val trustedPeers = fetchTrustedPeers() lightningService.setup( walletIndex, customServerUrl, customRgsServerUrl, - trustedPeers, channelMigration, ) }.onFailure { @@ -314,16 +310,6 @@ class LightningRepo @Inject constructor( } } - private suspend fun fetchTrustedPeers(): List? = runSuspendCatching { - val info = coreService.blocktank.info(refresh = false) - ?: coreService.blocktank.info(refresh = true) - info?.nodes?.toPeerDetailsList()?.also { - Logger.info("Fetched ${it.size} trusted peers from remote", context = TAG) - } - }.onFailure { - Logger.warn("fetchTrustedPeers error", it, context = TAG) - }.getOrNull() - suspend fun start( walletIndex: Int = 0, timeout: Duration? = null, @@ -434,10 +420,10 @@ class LightningRepo @Inject constructor( } // Post-startup tasks (non-blocking) - connectToTrustedPeers().onFailure { - Logger.error("Failed to connect to trusted peers", it, context = TAG) + disconnectIdlePeers().onFailure { + Logger.error("Failed to disconnect idle peers", it, context = TAG) } - logNodeSupportSummary("trusted peers connected") + logNodeSupportSummary("idle peers disconnected") sync().onFailure { e -> Logger.warn("Initial sync failed, event-driven sync will retry", e, context = TAG) @@ -1263,8 +1249,8 @@ class LightningRepo @Inject constructor( return@withContext Result.success(Unit) } - suspend fun connectToTrustedPeers(): Result = executeWhenNodeRunning("connectToTrustedPeers") { - runCatching { lightningService.connectToTrustedPeers() }.also { + suspend fun disconnectIdlePeers(): Result = executeWhenNodeRunning("disconnectIdlePeers") { + runSuspendCatching { lightningService.disconnectIdlePeers() }.also { syncState() } } @@ -2255,8 +2241,6 @@ class LightningRepo @Inject constructor( return isRunning && lightningService.canReceive() } - fun separateTrustedChannels(channels: List) = lightningService.separateTrustedChannels(channels) - suspend fun bumpFeeByRbf( originalTxId: Txid, satsPerVByte: ULong, diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index 78a272c4c9..6e3cf85e63 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -35,6 +35,7 @@ import org.lightningdevkit.ldknode.ElectrumSyncConfig import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.FeeRate import org.lightningdevkit.ldknode.KeychainKind +import org.lightningdevkit.ldknode.LightningBalance import org.lightningdevkit.ldknode.Node import org.lightningdevkit.ldknode.NodeException import org.lightningdevkit.ldknode.NodeStatus @@ -45,6 +46,7 @@ import org.lightningdevkit.ldknode.PaymentDetails import org.lightningdevkit.ldknode.PaymentId import org.lightningdevkit.ldknode.PaymentKind import org.lightningdevkit.ldknode.PeerDetails +import org.lightningdevkit.ldknode.PendingSweepBalance import org.lightningdevkit.ldknode.PublicKey import org.lightningdevkit.ldknode.RouteParametersConfig import org.lightningdevkit.ldknode.ScoringFeeParameters @@ -63,6 +65,7 @@ import to.bitkit.di.BgDispatcher import to.bitkit.di.IoDispatcher import to.bitkit.env.Defaults import to.bitkit.env.Env +import to.bitkit.ext.counterpartyNodeId import to.bitkit.ext.runSuspendCatching import to.bitkit.ext.uByteList import to.bitkit.ext.uri @@ -107,6 +110,40 @@ private class EventListenerContext : AbstractCoroutineContextElement(Key) { companion object Key : CoroutineContext.Key } +/** + * Builds the node config. LSP peers are not trusted for zero-conf channels or to broadcast our force + * closes: Blocktank no longer serves channels, so our node must broadcast and fee-bump its own closes. + */ +internal fun nodeConfig( + base: Config, + storageDirPath: String, + onchainWalletAccounts: List, +): Config = base.copy( + storageDirPath = storageDirPath, + network = Env.network, + trustedPeers0conf = emptyList(), + anchorChannelsConfig = AnchorChannelsConfig( + trustedPeersNoReserve = emptyList(), + perChannelReserveSats = 1u, + ), + probingLiquidityLimitMultiplier = 1uL, + includeUntrustedPendingInSpendable = true, + onchainWalletAccounts = onchainWalletAccounts, +) + +/** Returns the stored peers with no channel and no claimable balance, or none while any sweep is pending. */ +internal fun idlePeerIds( + peers: List, + channels: List, + lightningBalances: List, + pendingSweeps: List, +): List { + if (pendingSweeps.isNotEmpty()) return emptyList() + val busyPeerIds = channels.map { it.counterpartyNodeId }.toSet() + + lightningBalances.map { it.counterpartyNodeId() } + return peers.filter { it.isPersisted && it.nodeId !in busyPeerIds }.map { it.nodeId } +} + internal fun enabledOnchainWalletAccountConfigs( records: List, walletIndex: Int, @@ -235,8 +272,6 @@ class LightningService internal constructor( private val _syncStatusChanged = MutableSharedFlow(extraBufferCapacity = 1) val syncStatusChanged: SharedFlow = _syncStatusChanged.asSharedFlow() - private var trustedPeers: List = emptyList() - private var listenerJob: Job? = null // Release of the previous node's rust handle (free_node). Rebuild and destructive storage work @@ -248,13 +283,12 @@ class LightningService internal constructor( walletIndex: Int, customServerUrl: String? = null, customRgsServerUrl: String? = null, - trustedPeers: List? = null, channelMigration: ChannelDataMigration? = null, ) { awaitNodeRelease() Logger.debug("Building node…", context = TAG) - val config = config(walletIndex, trustedPeers) + val config = config(walletIndex) val builtNode = build( walletIndex, customServerUrl, @@ -268,28 +302,12 @@ class LightningService internal constructor( Logger.info("LDK node setup", context = TAG) } - private suspend fun config( - walletIndex: Int, - trustedPeers: List?, - ): Config { + private suspend fun config(walletIndex: Int): Config { val dirPath = Env.ldkStoragePath(walletIndex) - 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( + return nodeConfig( + base = defaultConfig(), storageDirPath = dirPath, - network = Env.network, - trustedPeers0conf = trustedPeerNodeIds, - anchorChannelsConfig = AnchorChannelsConfig( - trustedPeersNoReserve = trustedPeerNodeIds, - perChannelReserveSats = 1u, - ), - probingLiquidityLimitMultiplier = 1uL, - includeUntrustedPendingInSpendable = true, onchainWalletAccounts = enabledOnchainWalletAccountConfigs(watchOnlyAccountStore.load(), walletIndex), ) } @@ -604,20 +622,6 @@ class LightningService internal constructor( return true } - // reset graph if missing trusted peers - val missing = trustedPeers.filter { it.nodeId !in nodes } - if (missing.size == trustedPeers.size) { - val rgsTimestamp = node.status().latestRgsSnapshotTimestamp - val missingIds = missing.joinToString { it.nodeId.take(NODE_ID_PREVIEW_LEN) } - Logger.warn( - "Network graph missing all ${trustedPeers.size} trusted peers: [$missingIds] " + - "(graphNodes=${nodes.size}, channels=${graph.listChannels().size}, " + - "rgsTimestamp=$rgsTimestamp)", - context = TAG, - ) - return false - } - // reset graph if missing channel counterparty val channels = node.listChannels().filter { it.isUsable } val missingCounterparties = channels.filter { it.counterpartyNodeId !in nodes } @@ -631,20 +635,7 @@ class LightningService internal constructor( return false } - if (missing.isNotEmpty()) { - val ids = missing.joinToString { it.nodeId } - Logger.debug( - "Network graph missing ${missing.size}/${trustedPeers.size} trusted peers: [$ids]", - context = TAG, - ) - } - - val total = trustedPeers.size - val count = total - missing.size - Logger.debug( - "Network graph validated: $count/$total trusted peers present", - context = TAG, - ) + Logger.debug("Network graph validated: '${channels.size}' channel counterparties present", context = TAG) return true } @@ -744,39 +735,26 @@ class LightningService internal constructor( } // region peers - suspend fun connectToTrustedPeers() { + /** + * Disconnects stored peers we have nothing pending with, which also drops them from the peer store. + * Skips the run while any closed channel still awaits its sweep, as a sweep does not name its peer. + */ + suspend fun disconnectIdlePeers() { val node = this.node ?: throw ServiceError.NodeNotSetup() ServiceQueue.LDK.background(ldkQueue) { - for (peer in trustedPeers) { - try { - node.connect(peer.nodeId, peer.address, persist = true) - Logger.info("Connected to trusted peer: $peer", context = TAG) - } catch (e: NodeException) { - Logger.error("Peer connect error: $peer", LdkError(e), context = TAG) - } - } - - verifyTrustedPeersOrFallback(node) - } - } - - private fun verifyTrustedPeersOrFallback(node: Node) { - val connectedPeerIds = node.listPeers().map { it.nodeId }.toSet() - val trustedConnected = trustedPeers.count { it.nodeId in connectedPeerIds } - - if (trustedConnected == 0 && trustedPeers.isNotEmpty()) { - 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", context = TAG) - } catch (e: NodeException) { - Logger.error("Fallback peer connect error: $peer", LdkError(e), context = TAG) - } + val balances = node.listBalances() + val idlePeerIds = idlePeerIds( + peers = node.listPeers(), + channels = node.listChannels(), + lightningBalances = balances.lightningBalances, + pendingSweeps = balances.pendingBalancesFromChannelClosures, + ) + for (nodeId in idlePeerIds) { + runCatching { node.disconnect(nodeId) } + .onSuccess { Logger.info("Disconnected idle peer '$nodeId'", context = TAG) } + .onFailure { Logger.warn("Failed to disconnect idle peer '$nodeId'", it, context = TAG) } } - } else { - Logger.info("Connected to $trustedConnected/${trustedPeers.size} trusted peers", context = TAG) } } @@ -815,17 +793,6 @@ class LightningService internal constructor( } } - fun getLspPeerNodeIds(): Set = trustedPeers.map { it.nodeId }.toSet() - - 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 } - return trusted to nonTrusted - } - - fun isTrustedPeer(nodeId: String): Boolean = nodeId in getLspPeerNodeIds() - // endregion // region channels @@ -879,11 +846,6 @@ class LightningService internal constructor( val userChannelId = channel.userChannelId val counterpartyNodeId = channel.counterpartyNodeId - // Prevent force closing channels with trusted peers (LSP nodes) - if (force && isTrustedPeer(counterpartyNodeId)) { - throw TrustedPeerForceCloseException() - } - try { ServiceQueue.LDK.background(ldkQueue) { Logger.debug("Initiating channel close (force=$force): '$channelId'", context = TAG) @@ -1140,7 +1102,7 @@ class LightningService internal constructor( } } }.onFailure { - loggerLdk.dumpNetworkGraphInfo(node, trustedPeers, bolt11) + loggerLdk.dumpNetworkGraphInfo(node, bolt11) }.getOrThrow() } @@ -1545,7 +1507,7 @@ class LightningService internal constructor( private fun dumpNetworkGraphInfo(bolt11: String) { val node = this.node ?: return - loggerLdk.dumpNetworkGraphInfo(node, trustedPeers, bolt11) + loggerLdk.dumpNetworkGraphInfo(node, bolt11) } suspend fun exportNetworkGraphToFile( @@ -1566,10 +1528,6 @@ data class NetworkGraphInfo( val latestRgsSyncTimestamp: ULong?, ) -class TrustedPeerForceCloseException : AppError( - "Cannot force close channel with trusted peer. Force close is disabled for Blocktank LSP channels." -) - class NetworkGraphCacheDeleteError : AppError("Failed to delete network graph cache") /** Exact signed transaction inputs/prevouts, never an estimate or a backend-absence inference. */ diff --git a/app/src/main/java/to/bitkit/services/MigrationService.kt b/app/src/main/java/to/bitkit/services/MigrationService.kt index eaa3af3c4f..352e7f7951 100644 --- a/app/src/main/java/to/bitkit/services/MigrationService.kt +++ b/app/src/main/java/to/bitkit/services/MigrationService.kt @@ -1451,10 +1451,7 @@ class MigrationService internal constructor( val peers = json.decodeFromString>(String(data)) if (peers.isEmpty()) return emptyList() - val trustedIds = Env.trustedLnPeers.map { it.nodeId }.toSet() - val uris = peers - .filter { it.pubKey !in trustedIds } - .map { "${it.pubKey}@${it.address}:${it.port}" } + val uris = peers.map { "${it.pubKey}@${it.address}:${it.port}" } Logger.info("Migration peer recovery: fetched ${uris.size} peer(s) from remote backup", context = TAG) uris diff --git a/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt b/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt index 814325d29e..9b8eec4123 100644 --- a/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt +++ b/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt @@ -42,13 +42,13 @@ import org.lightningdevkit.ldknode.LightningBalance import org.lightningdevkit.ldknode.NodeStatus import org.lightningdevkit.ldknode.PeerDetails import to.bitkit.R -import to.bitkit.env.Peers import to.bitkit.ext.amountSats import to.bitkit.ext.balanceUiText import to.bitkit.ext.channelId import to.bitkit.ext.createChannelDetails import to.bitkit.ext.ellipsisMiddle import to.bitkit.ext.formatToString +import to.bitkit.ext.of import to.bitkit.ext.uri import to.bitkit.models.NodeLifecycleState import to.bitkit.models.NodePeer @@ -476,10 +476,10 @@ private fun ChannelDetailRow( private fun previewPeers() = persistentListOf( NodePeer( - peerDetails = Peers.stag, + peerDetails = previewPeer, lspNode = ILspNode( alias = "Blocktank-LND1", - pubkey = Peers.stag.nodeId, + pubkey = previewPeer.nodeId, connectionStrings = listOf(), readonly = null, ), @@ -532,7 +532,7 @@ private fun Preview() { latestPathfindingScoresSyncTimestamp = null, ), nodeId = "0348a2b7c2d3f4e5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9", - peers = persistentListOf(Peers.stag), + peers = persistentListOf(previewPeer), channels = persistentListOf( createChannelDetails().copy( channelId = "abc123def456789012345678901234567890123456789012345678901234567890", @@ -600,3 +600,7 @@ private fun Preview() { ) } } + +private val previewPeer = PeerDetails.of( + "0248a2b7c2d3f4e5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9@127.0.0.1:9735" +) diff --git a/app/src/main/java/to/bitkit/ui/NodeInfoViewModel.kt b/app/src/main/java/to/bitkit/ui/NodeInfoViewModel.kt index 6eaf854415..1dae06ace5 100644 --- a/app/src/main/java/to/bitkit/ui/NodeInfoViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/NodeInfoViewModel.kt @@ -18,7 +18,6 @@ import kotlinx.coroutines.launch import org.lightningdevkit.ldknode.LightningBalance import org.lightningdevkit.ldknode.PeerDetails import to.bitkit.R -import to.bitkit.env.Peers import to.bitkit.models.NodePeer import to.bitkit.models.Toast import to.bitkit.models.alias @@ -47,7 +46,6 @@ class NodeInfoViewModel @Inject constructor( NodePeer( peerDetails = peer, lspNode = lspNodes?.firstOrNull { it.pubkey == peer.nodeId }, - name = Peers.Known.find(peer)?.name, ) }.sortedBy { it.alias() }.toImmutableList() } 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 d408960dba..cdf45cacdc 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 @@ -76,14 +76,6 @@ fun SavingsProgressScreen( onSuccess = { progressState = SavingsProgressState.SUCCESS }, onInterrupted = { progressState = SavingsProgressState.INTERRUPTED }, onGiveUp = { app.showSheet(Sheet.ForceTransfer) }, - onUnavailable = { - app.toast( - type = Toast.ToastType.ERROR, - title = resources.getString(R.string.lightning__close_error), - description = resources.getString(R.string.lightning__close_error_msg), - ) - onTransferUnavailable() - }, ) } } @@ -130,7 +122,6 @@ private suspend fun runChannelClose( onSuccess: () -> Unit, onInterrupted: () -> Unit, onGiveUp: () -> Unit, - onUnavailable: () -> Unit, ) { val channelsFailedToCoopClose = transfer.closeSelectedChannels() @@ -141,21 +132,12 @@ private suspend fun runChannelClose( return } - // Check if any channels can be retried (filter out trusted peers) - val (_, nonTrustedChannels) = transfer.separateTrustedChannels(channelsFailedToCoopClose) - - if (nonTrustedChannels.isEmpty()) { - // All channels are trusted peers - show error and navigate back immediately - onUnavailable() - } else { - transfer.startCoopCloseRetries( - channels = nonTrustedChannels, - onGiveUp = onGiveUp, - onTransferUnavailable = onUnavailable, - ) - delay(2500) - onInterrupted() - } + transfer.startCoopCloseRetries( + channels = channelsFailedToCoopClose, + onGiveUp = onGiveUp, + ) + delay(2500) + onInterrupted() } @Composable 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 5f6a13d4b0..f6699d04d4 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 @@ -22,9 +22,10 @@ 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 org.lightningdevkit.ldknode.PeerDetails import to.bitkit.R -import to.bitkit.env.Peers 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 @@ -173,7 +174,7 @@ private fun InfoRow( private fun Preview() { AppThemeSurface { Content( - uiState = LnurlChannelUiState(peer = Peers.stag), + uiState = LnurlChannelUiState(peer = previewPeer), ) } } @@ -187,3 +188,7 @@ private fun PreviewLoading() { ) } } + +private val previewPeer = PeerDetails.of( + "0248a2b7c2d3f4e5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9@127.0.0.1:9735" +) diff --git a/app/src/main/java/to/bitkit/utils/LoggerLdk.kt b/app/src/main/java/to/bitkit/utils/LoggerLdk.kt index ecba3cfb79..7c8fc8d52a 100644 --- a/app/src/main/java/to/bitkit/utils/LoggerLdk.kt +++ b/app/src/main/java/to/bitkit/utils/LoggerLdk.kt @@ -6,7 +6,6 @@ import org.lightningdevkit.ldknode.Bolt11Invoice import org.lightningdevkit.ldknode.LogRecord import org.lightningdevkit.ldknode.LogWriter import org.lightningdevkit.ldknode.Node -import org.lightningdevkit.ldknode.PeerDetails import to.bitkit.di.IoDispatcher import to.bitkit.env.Env import java.io.File @@ -24,7 +23,7 @@ class LoggerLdk @Inject constructor( private const val TAG = "LogDumperLdk" } - fun dumpNetworkGraphInfo(node: Node, trustedPeers: List, bolt11: String) { + fun dumpNetworkGraphInfo(node: Node, bolt11: String) { val nodeIdPreviewLen = 20 val sb = StringBuilder() @@ -137,19 +136,6 @@ class LoggerLdk @Inject constructor( } } - sb.appendLine("\n Checking for trusted peers in network graph:") - var foundTrustedNodes = 0 - trustedPeers.forEach { peer -> - val nodeId = peer.nodeId - if (allNodes.any { it == nodeId }) { - foundTrustedNodes++ - sb.appendLine(" OK: ${nodeId.take(nodeIdPreviewLen)}... found in graph") - } else { - sb.appendLine(" MISSING: ${nodeId.take(nodeIdPreviewLen)}... NOT in graph") - } - } - sb.appendLine(" Summary: $foundTrustedNodes/${trustedPeers.size} trusted peers found in graph") - val nodesToShow = minOf(10, allNodes.size) sb.appendLine("\n First $nodesToShow nodes:") allNodes.take(nodesToShow).forEachIndexed { index, nodeId -> diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 6ea6e7f614..2c7aaab98c 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -1873,10 +1873,6 @@ class TransferViewModel @Inject constructor( /** Closes the channels selected earlier, pending closure */ suspend fun closeSelectedChannels() = closeChannels(channelsToClose) - fun separateTrustedChannels( - channels: List, - ): Pair, List> = lightningRepo.separateTrustedChannels(channels) - private suspend fun closeChannels(channels: List): List { lightningRepo.awaitPeerConnected() val channelsFailedToClose = coroutineScope { @@ -1908,7 +1904,6 @@ class TransferViewModel @Inject constructor( fun startCoopCloseRetries( channels: List, onGiveUp: () -> Unit, - onTransferUnavailable: () -> Unit, ) { val startTimeMs = clock.now().toEpochMilliseconds() channelsToClose = channels @@ -1932,59 +1927,20 @@ class TransferViewModel @Inject constructor( delay(RETRY_INTERVAL_MS) } - Logger.info("Giving up on coop close. Checking if force close is possible.", context = TAG) - - // Check if any channels can be force closed (filter out trusted peers) - val (_, nonTrustedChannels) = lightningRepo.separateTrustedChannels(channelsToClose) - - if (nonTrustedChannels.isNotEmpty()) { - onGiveUp() - } else { - Logger.warn("All channels are with trusted peers. Cannot force close.", context = TAG) - channelsToClose = emptyList() - onTransferUnavailable() - } + Logger.info("Giving up on coop close.", context = TAG) + onGiveUp() } } fun forceTransfer(onComplete: () -> Unit) = viewModelScope.launch { _isForceTransferLoading.value = true runCatching { - // Filter out trusted peer channels (cannot force close LSP channels) - val (trustedChannels, nonTrustedChannels) = lightningRepo.separateTrustedChannels(channelsToClose) - - if (trustedChannels.isNotEmpty()) { - Logger.warn("Skipping ${trustedChannels.size} trusted peer channel(s)", context = TAG) - } - - if (nonTrustedChannels.isEmpty()) { - channelsToClose = emptyList() - Logger.error("Cannot force close channels with trusted peer", context = TAG) - ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.lightning__force_failed_title), - description = context.getString(R.string.lightning__force_failed_msg) - ) - return@runCatching - } - - val failedChannels = forceCloseChannels(nonTrustedChannels) - - // Remove successfully closed channels and trusted peer channels from the list - val successfulChannelIds = nonTrustedChannels - .filterNot { channel -> failedChannels.any { it.channelId == channel.channelId } } - .map { it.channelId } - .toSet() - val trustedChannelIds = trustedChannels.map { it.channelId }.toSet() - channelsToClose = channelsToClose.filterNot { - it.channelId in successfulChannelIds || it.channelId in trustedChannelIds - } + val failedChannels = forceCloseChannels(channelsToClose) + channelsToClose = failedChannels if (failedChannels.isEmpty()) { Logger.info("Force close initiated successfully for all channels", context = TAG) - val initMsg = context.getString(R.string.lightning__force_init_msg) - val skippedMsg = context.getString(R.string.lightning__force_channels_skipped) - val description = if (trustedChannels.isNotEmpty()) "$initMsg $skippedMsg" else initMsg + val description = context.getString(R.string.lightning__force_init_msg) ToastEventBus.send( type = Toast.ToastType.LIGHTNING, title = context.getString(R.string.lightning__force_init_title), diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 2c82b886c0..ca78e334bc 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -150,7 +150,6 @@ وقت تحديث ذاكرة التخزين المؤقت لمعدل الرسوم الرسوم تحويل إجباري - تعذر إغلاق بعض الاتصالات. تعذر تحويل أموالك إلى المدخرات. يرجى المحاولة مرة أخرى. فشل التحويل الإجباري ستكون أموالك متاحة خلال ±14 يومًا. diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml index 0f789af101..449f254bc3 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -157,7 +157,6 @@ Hora de actualización de caché de tarifa Tarifa Fuerzar transferencia - Algunas conexiones no pudieron cerrarse. No se pudo transferir tus fondos a ahorros. Intenta de nuevo. Transferencia forzada falló Sus fondos estarán accesibles en ±14 días. diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 373d38a86b..ebc566b16c 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -154,7 +154,6 @@ Temps d\'actualització de la memòria cau de tarifes Tarifes Forçar transferència - Algunes connexions no s\'han pogut tancar. No es possible transferir els teus fons de tornada a estalvis. Si us plau, intenta-ho de nou. Transferència forçada fallida Els teus fons seran accessibles en ±14 dies. diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 30db55c9b8..f211f782bc 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -157,7 +157,6 @@ Čas aktualizace mezipaměti poplatků Poplatky Vynutit převod - Některá spojení se nepodařilo uzavřít. Nelze převést vaše prostředky zpět do úspor. Zkuste to prosím znovu. Vynucení převodu selhalo. Vaše prostředky budou dostupné do ±14 dnů. diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 3a97c6bed7..00d02eec7d 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -249,7 +249,6 @@ Forcierte Übertragung initiiert Dein Guthaben wird in ±14 Tagen zur Verfügung stehen. Forcierte Übertragung fehlgeschlagen - Einige Verbindungen konnten nicht geschlossen werden. Dein Guthaben konnte nicht auf dein Sparkonto übertragen werden. Bitte versuche es erneut. Ausgabenguthaben bereit Du kannst jetzt jedermann, überall, instant bezahlen. diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 1add6d9bc0..b8321c952e 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -150,7 +150,6 @@ Χρόνος ενημέρωσης cache τελών Τέλη Αναγκαστική μεταφορά - Κάποιες συνδέσεις δεν μπόρεσαν να κλείσουν. Δεν ήταν δυνατή η μεταφορά των κεφαλαίων σου στις αποταμιεύσεις. Δοκίμασε ξανά. Αποτυχία αναγκαστικής μεταφοράς Τα κεφάλαιά σου θα είναι διαθέσιμα σε ±14 ημέρες. diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index 5658ffdf28..86ed051d4f 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -156,7 +156,6 @@ Hora de actualización de caché de tasas Comisiones Fuerzar transferencia - Algunas conexiones no pudieron cerrarse. No se han podido transferir tus fondos a ahorros. Por favor, inténtalo de nuevo. Transferencia forzada falló Sus fondos estarán accesibles en ±14 días. diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 7f66eb52d5..4172a81fa7 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -132,7 +132,6 @@ Tasa de comisión Hora de actualización de caché de comisiones Comisiones - Algunas conexiones no pudieron cerrarse. No se pueden transferir los fondos de vuelta a ahorros. Por favor, inténtalo de nuevo. QR de Canal LNURL Configuración Manual diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 9f42c167d1..b072d8be67 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -258,7 +258,6 @@ Vos fonds seront accessibles dans un délai de ±14 jours. Échec du transfert forcé Impossible de renvoyer vos fonds sur l\'Épargne. Veuillez réessayer. - Certaines connexions n\'ont pas pu être fermées. Solde Dépenses Vous pouvez désormais payer n\'importe qui, n\'importe où, instantanément. Traitement du paiement diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 99c960b41b..21f7c48147 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -155,7 +155,6 @@ Tempo di Aggiornamento Cache Commissioni Commissioni Trasferimento Forzato - Alcune connessioni non sono state chiuse. Impossibile trasferire nuovamente i fondi nei risparmi. Per favore riprova. Trasferimento Forzato Fallito I tuoi fondi saranno accessibili in ±14 giorni. diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index dc098d6af1..341a98ab46 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -154,7 +154,6 @@ Vergoedingstarief cache update tijd Vergoedingen Forceer overboeking - Sommige verbindingen konden niet worden gesloten. Kon het geld niet terugboeken naar spaargeld. Probeer het opnieuw. Geforceerde overboeking mislukt Je geld is beschikbaar in ±14 dagen. diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 02f4840e92..90c7f9c390 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -250,7 +250,6 @@ Wymuszenie transferu zainicjowane Środki będą dostępne w ciągu ±14 dni. Wymuszenie transferu nie powiodło się - Niektórych połączeń nie można było zamknąć. Nie można przelać środków z powrotem na saldo oszczędności. Proszę spróbować ponownie. Saldo wydatków gotowe Teraz możesz zapłacić każdemu, gdziekolwiek, natychmiast. diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index c9c6f1dc85..15d2f9aae1 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -158,7 +158,6 @@ Atualização do Cache de Taxa Taxas Forçar Transferência - Algumas conexões não puderam ser fechadas. Não foi possível transferir seus fundos de volta para sua poupança. Por favor, tente novamente. Falha na transferência forçada Seus fundos estarão disponíveis em ±14 dias. diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index f5763219ca..70081cffc2 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -265,7 +265,6 @@ Máximo do HTLC de Entrada Mínimo do HTLC de Entrada Altura do Arquivo do Monitor de Canal - Algumas conexões não puderam ser fechadas. TRANSFERÊNCIA EM ANDAMENTO Começar Configure o seu saldo de despesas para desfrutar de transações instantâneas e económicas com amigos, familiares e comerciantes. diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 72ac063e3c..9fcc5a4ff2 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -166,7 +166,6 @@ Время обновления кэша комиссии Комиссии Принудительный Перевод - Некоторые соединения не удалось закрыть. Не удалось перевести ваши средства обратно в сбережения. Пожалуйста, попробуйте снова. Неудачный Принудительный Перевод Ваши средства будут доступны через ±14 дней. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 872f32e243..4b5dd29299 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -303,7 +303,6 @@ 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. diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index 09ced5a8ee..22d8c5b51a 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -3,8 +3,6 @@ package to.bitkit.repositories import app.cash.turbine.test import com.synonym.bitkitcore.AddressType import com.synonym.bitkitcore.FeeRates -import com.synonym.bitkitcore.IBtInfo -import com.synonym.bitkitcore.ILspNode import com.synonym.bitkitcore.LightningInvoice import com.synonym.bitkitcore.LnurlException import com.synonym.bitkitcore.LnurlPayData @@ -137,7 +135,7 @@ class LightningRepoTest : BaseUnitTest() { @Before fun setUp() = runBlocking { - whenever(lightningService.setup(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())) + whenever(lightningService.setup(any(), anyOrNull(), anyOrNull(), anyOrNull())) .thenReturn(Unit) whenever(lightningService.start(anyOrNull(), any())).thenReturn(Unit) whenever { electrumProbeService.probe(any(), any()) }.thenReturn(Result.success(Unit)) @@ -636,7 +634,7 @@ class LightningRepoTest : BaseUnitTest() { @Test fun `start restores the initial state when cancelled during node setup`() = test { stubNodeStatus { false } - whenever(lightningService.setup(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())) + whenever(lightningService.setup(any(), anyOrNull(), anyOrNull(), anyOrNull())) .doSuspendableAnswer { awaitCancellation() } var result: Result? = null @@ -650,28 +648,6 @@ class LightningRepoTest : BaseUnitTest() { verifyBlocking(lightningService, never()) { start(anyOrNull(), any()) } } - @Test - fun `start restores the initial state when cancelled while trusted peers are fetched`() = test { - stubNodeStatus { false } - val blocktank = coreService.blocktank - whenever(blocktank.info(any())).doSuspendableAnswer { awaitCancellation() } - var result: Result? = null - - val job = launch { result = sut.start(shouldRetry = false) } - runCurrent() - job.cancelAndJoin() - testScheduler.advanceUntilIdle() - - assertNull(result) - assertEquals(NodeLifecycleState.Initializing, sut.lightningState.value.nodeLifecycleState) - // The cancellation must unwind out of the trusted peers fetch instead of starting a node - verifyBlocking(lightningService, never()) { - setup(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) - } - verifyBlocking(lightningService, never()) { start(anyOrNull(), any()) } - } - - // Regression #845: a node started by another path between the status check and start must not latch an error @Test fun `start adopts the running node when start throws AlreadyRunning`() = test { var nodeRunning = false @@ -987,7 +963,7 @@ class LightningRepoTest : BaseUnitTest() { assertTrue(result.isSuccess) verifyBlocking(lightningService, times(2)) { - setup(any(), anyOrNull(), anyOrNull(), anyOrNull(), eq(migration)) + setup(any(), anyOrNull(), anyOrNull(), eq(migration)) } assertEquals(NodeLifecycleState.Running, sut.lightningState.value.nodeLifecycleState) } @@ -1050,7 +1026,7 @@ class LightningRepoTest : BaseUnitTest() { val inOrder = inOrder(lightningService) inOrder.verify(lightningService).stop() - inOrder.verify(lightningService).setup(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) + inOrder.verify(lightningService).setup(any(), anyOrNull(), anyOrNull(), anyOrNull()) inOrder.verify(lightningService).start(anyOrNull(), any()) } @@ -1377,7 +1353,7 @@ class LightningRepoTest : BaseUnitTest() { sut.stop() whenever(lightningService.nodeId).thenReturn(null) whenever(lightningService.node).thenReturn(null) - whenever(lightningService.setup(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())) + whenever(lightningService.setup(any(), anyOrNull(), anyOrNull(), anyOrNull())) .thenThrow(IllegalStateException("electrum server unreachable")) assertTrue(sut.start(shouldRetry = false).isFailure) @@ -1642,18 +1618,17 @@ class LightningRepoTest : BaseUnitTest() { } @Test - fun `connectToTrustedPeers should fail when node is not running`() = test { - val result = sut.connectToTrustedPeers() + fun `disconnectIdlePeers should fail when node is not running`() = test { + val result = sut.disconnectIdlePeers() assertTrue(result.isFailure) } @Test - fun `connectToTrustedPeers should succeed when node is running`() = test { + fun `start disconnects idle peers instead of connecting to fixed peers`() = test { startNodeForTesting() - whenever(lightningService.connectToTrustedPeers()).thenReturn(Unit) - val result = sut.connectToTrustedPeers() - assertTrue(result.isSuccess) + verifyBlocking(lightningService) { disconnectIdlePeers() } + verifyBlocking(lightningService, never()) { connectPeer(any()) } } @Test @@ -2765,7 +2740,7 @@ class LightningRepoTest : BaseUnitTest() { val inOrder = inOrder(lightningService) inOrder.verify(lightningService).stop() inOrder.verify(lightningService) - .setup(any(), eq(customServerUrl), anyOrNull(), anyOrNull(), anyOrNull()) + .setup(any(), eq(customServerUrl), anyOrNull(), anyOrNull()) inOrder.verify(lightningService).start(anyOrNull(), any()) assertEquals(NodeLifecycleState.Running, sut.lightningState.value.nodeLifecycleState) } @@ -2806,12 +2781,12 @@ class LightningRepoTest : BaseUnitTest() { whenever(lightningService.node).thenReturn(null) whenever(lightningService.stop()).thenReturn(Unit) // The switch to the new server fails. - whenever(lightningService.setup(any(), eq(badUrl), anyOrNull(), anyOrNull(), anyOrNull())) + whenever(lightningService.setup(any(), eq(badUrl), anyOrNull(), anyOrNull())) .thenThrow(RuntimeException("start failed")) // The background recovery (previous config) blocks until released. val recoveryStarted = CompletableDeferred() val releaseRecovery = CompletableDeferred() - whenever { lightningService.setup(any(), isNull(), isNull(), anyOrNull(), anyOrNull()) } + whenever { lightningService.setup(any(), isNull(), isNull(), anyOrNull()) } .doSuspendableAnswer { recoveryStarted.complete(Unit) releaseRecovery.await() @@ -2842,7 +2817,7 @@ class LightningRepoTest : BaseUnitTest() { assertEquals(probeError, result.exceptionOrNull()) verify(lightningService, never()).stop() verify(lightningService, never()) - .setup(any(), eq(badUrl), anyOrNull(), anyOrNull(), anyOrNull()) + .setup(any(), eq(badUrl), anyOrNull(), anyOrNull()) assertEquals(NodeLifecycleState.Running, sut.lightningState.value.nodeLifecycleState) } @@ -2859,7 +2834,7 @@ class LightningRepoTest : BaseUnitTest() { assertTrue(result.isFailure) assertTrue(result.exceptionOrNull() is NodeConfigNotAppliedError) verify(lightningService, never()) - .setup(any(), eq(newServerUrl), anyOrNull(), anyOrNull(), anyOrNull()) + .setup(any(), eq(newServerUrl), anyOrNull(), anyOrNull()) } // Two-request barrier: a change issued while the background recovery of a previous failed @@ -2874,12 +2849,12 @@ class LightningRepoTest : BaseUnitTest() { val nextUrl = "ssl://next.example.com:50002" whenever(lightningService.node).thenReturn(null) whenever(lightningService.stop()).thenReturn(Unit) - whenever(lightningService.setup(any(), eq(badUrl), anyOrNull(), anyOrNull(), anyOrNull())) + whenever(lightningService.setup(any(), eq(badUrl), anyOrNull(), anyOrNull())) .thenThrow(RuntimeException("start failed")) // Hold the background recovery mid-flight so the second change is issued while it runs. val recoveryStarted = CompletableDeferred() val releaseRecovery = CompletableDeferred() - whenever { lightningService.setup(any(), isNull(), isNull(), anyOrNull(), anyOrNull()) } + whenever { lightningService.setup(any(), isNull(), isNull(), anyOrNull()) } .doSuspendableAnswer { recoveryStarted.complete(Unit) releaseRecovery.await() @@ -2897,7 +2872,7 @@ class LightningRepoTest : BaseUnitTest() { // Success must mean the node was actually rebuilt with the requested server. assertTrue(result.isSuccess) - verify(lightningService).setup(any(), eq(nextUrl), anyOrNull(), anyOrNull(), anyOrNull()) + verify(lightningService).setup(any(), eq(nextUrl), anyOrNull(), anyOrNull()) } // Regression: the caller is a route-scoped ViewModel, so leaving the screen mid-change cancels @@ -2911,7 +2886,7 @@ class LightningRepoTest : BaseUnitTest() { whenever(lightningService.stop()).thenReturn(Unit) // Hold the rebuild open so the caller can be cancelled while it is in flight. val rebuildStarted = CompletableDeferred() - whenever { lightningService.setup(any(), eq(newUrl), anyOrNull(), anyOrNull(), anyOrNull()) } + whenever { lightningService.setup(any(), eq(newUrl), anyOrNull(), anyOrNull()) } .doSuspendableAnswer { rebuildStarted.complete(Unit) awaitCancellation() @@ -2932,7 +2907,7 @@ class LightningRepoTest : BaseUnitTest() { assertTrue(rethrown) // structured concurrency preserved // The detached recovery rebuilt with the previous config and the node is back up. - verify(lightningService).setup(any(), isNull(), isNull(), anyOrNull(), anyOrNull()) + verify(lightningService).setup(any(), isNull(), isNull(), anyOrNull()) assertEquals(NodeLifecycleState.Running, sut.lightningState.value.nodeLifecycleState) // A cancelled change must never persist the server it failed to apply. verifyBlocking(settingsStore, never()) { update(any()) } @@ -2945,7 +2920,7 @@ class LightningRepoTest : BaseUnitTest() { whenever(lightningService.node).thenReturn(null) whenever(lightningService.stop()).thenReturn(Unit) val rebuildStarted = CompletableDeferred() - whenever { lightningService.setup(any(), anyOrNull(), eq(newRgsUrl), anyOrNull(), anyOrNull()) } + whenever { lightningService.setup(any(), anyOrNull(), eq(newRgsUrl), anyOrNull()) } .doSuspendableAnswer { rebuildStarted.complete(Unit) awaitCancellation() @@ -2965,7 +2940,7 @@ class LightningRepoTest : BaseUnitTest() { testScheduler.advanceUntilIdle() assertTrue(rethrown) - verify(lightningService).setup(any(), isNull(), isNull(), anyOrNull(), anyOrNull()) + verify(lightningService).setup(any(), isNull(), isNull(), anyOrNull()) assertEquals(NodeLifecycleState.Running, sut.lightningState.value.nodeLifecycleState) verifyBlocking(settingsStore, never()) { update(any()) } } @@ -2982,7 +2957,7 @@ class LightningRepoTest : BaseUnitTest() { assertTrue(result.isSuccess) val inOrder = inOrder(lightningService) inOrder.verify(lightningService).stop() - inOrder.verify(lightningService).setup(any(), isNull(), eq(customRgsUrl), anyOrNull(), anyOrNull()) + inOrder.verify(lightningService).setup(any(), isNull(), eq(customRgsUrl), anyOrNull()) inOrder.verify(lightningService).start(anyOrNull(), any()) assertEquals(NodeLifecycleState.Running, sut.lightningState.value.nodeLifecycleState) } @@ -3003,7 +2978,7 @@ class LightningRepoTest : BaseUnitTest() { whenever(lightningService.node).thenReturn(null) whenever(lightningService.stop()).thenReturn(Unit) whenever( - lightningService.setup(any(), isNull(), eq("https://bad.rgs/snapshot"), anyOrNull(), anyOrNull()), + lightningService.setup(any(), isNull(), eq("https://bad.rgs/snapshot"), anyOrNull()), ).thenThrow(RuntimeException("Failed to start node")) val result = sut.restartWithRgsServer("https://bad.rgs/snapshot") @@ -3176,63 +3151,6 @@ class LightningRepoTest : BaseUnitTest() { assertEquals(serviceError, result.exceptionOrNull()) } - @Test - fun `start should load trusted peers from blocktank info`() = test { - sut.setInitNodeLifecycleState() - whenever(lightningService.node).thenReturn(null) - - val blocktank = mock() - whenever(coreService.blocktank).thenReturn(blocktank) - - val mockNodes = listOf( - ILspNode( - alias = "LSP1", - pubkey = "node1pubkey", - connectionStrings = listOf("node1pubkey@node1.example.com:9735"), - readonly = null, - ), - ILspNode( - alias = "LSP2", - pubkey = "node2pubkey", - connectionStrings = listOf("node2pubkey@node2.example.com:9735"), - readonly = null, - ), - ) - val mockInfo = mock { on { nodes } doReturn mockNodes } - whenever(blocktank.info(refresh = false)).thenReturn(mockInfo) - - val result = sut.start() - - assertTrue(result.isSuccess) - verify(lightningService).setup( - any(), - anyOrNull(), - anyOrNull(), - argThat { peers -> - peers?.size == 2 && - peers.any { it.nodeId == "node1pubkey" && it.address == "node1.example.com:9735" } && - peers.any { it.nodeId == "node2pubkey" && it.address == "node2.example.com:9735" } - }, - anyOrNull(), - ) - } - - @Test - fun `start should pass null trusted peers when blocktank returns null`() = test { - sut.setInitNodeLifecycleState() - whenever(lightningService.node).thenReturn(null) - - val blocktank = mock() - whenever(coreService.blocktank).thenReturn(blocktank) - whenever(blocktank.info(refresh = false)).thenReturn(null) - whenever(blocktank.info(refresh = true)).thenReturn(null) - - val result = sut.start() - - assertTrue(result.isSuccess) - verify(lightningService).setup(any(), anyOrNull(), anyOrNull(), isNull(), anyOrNull()) - } - @Test fun `getBalanceForAddressType should succeed when node is running`() = test { startNodeForTesting() diff --git a/app/src/test/java/to/bitkit/services/LightningServiceTest.kt b/app/src/test/java/to/bitkit/services/LightningServiceTest.kt index 2e9736f816..388fc6c08b 100644 --- a/app/src/test/java/to/bitkit/services/LightningServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/LightningServiceTest.kt @@ -18,10 +18,15 @@ import org.junit.Test import org.junit.rules.TemporaryFolder import org.lightningdevkit.ldknode.AddressInfo import org.lightningdevkit.ldknode.AddressType +import org.lightningdevkit.ldknode.AnchorChannelsConfig +import org.lightningdevkit.ldknode.BalanceSource +import org.lightningdevkit.ldknode.Config import org.lightningdevkit.ldknode.ConfirmationStatus import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.FeeRate import org.lightningdevkit.ldknode.KeychainKind +import org.lightningdevkit.ldknode.LightningBalance +import org.lightningdevkit.ldknode.Network import org.lightningdevkit.ldknode.Node import org.lightningdevkit.ldknode.NodeException import org.lightningdevkit.ldknode.NodeStatus @@ -33,6 +38,8 @@ import org.lightningdevkit.ldknode.PaymentDetails import org.lightningdevkit.ldknode.PaymentDirection import org.lightningdevkit.ldknode.PaymentKind import org.lightningdevkit.ldknode.PaymentStatus +import org.lightningdevkit.ldknode.PeerDetails +import org.lightningdevkit.ldknode.PendingSweepBalance import org.lightningdevkit.ldknode.SpendableUtxo import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull @@ -627,6 +634,73 @@ class LightningServiceTest : BaseUnitTest() { assertEquals(listOf(enabled.xpub, authorizing.xpub), configs.map { it.xpub }) } + @Test + fun `node config trusts no peer for zero-conf or force-close broadcasts`() { + val lspNodeIds = listOf("lspNodeId") + val base = Config( + storageDirPath = "", + network = Network.REGTEST, + listeningAddresses = null, + announcementAddresses = null, + nodeAlias = null, + trustedPeers0conf = lspNodeIds, + probingLiquidityLimitMultiplier = 3uL, + anchorChannelsConfig = AnchorChannelsConfig( + trustedPeersNoReserve = lspNodeIds, + perChannelReserveSats = 25_000uL + ), + routeParameters = null, + scoringFeeParams = null, + scoringDecayParams = null, + includeUntrustedPendingInSpendable = false, + addressType = AddressType.NATIVE_SEGWIT, + addressTypesToMonitor = emptyList(), + onchainWalletAccounts = emptyList(), + ) + + val config = nodeConfig(base = base, storageDirPath = "ldk", onchainWalletAccounts = emptyList()) + + assertTrue(config.trustedPeers0conf.isEmpty()) + assertEquals(emptyList(), config.anchorChannelsConfig?.trustedPeersNoReserve) + assertEquals(1uL, config.anchorChannelsConfig?.perChannelReserveSats) + } + + @Test + fun `idle peers are stored peers with no channel and no claimable balance`() { + val peers = listOf( + peer("idle"), + peer("withChannel"), + peer("withClaim"), + peer("notStored", isPersisted = false), + ) + val channels = listOf(createChannelDetails().copy(counterpartyNodeId = "withChannel")) + val claims = listOf( + LightningBalance.ClaimableAwaitingConfirmations( + channelId = "closed", + counterpartyNodeId = "withClaim", + amountSatoshis = 1_000uL, + confirmationHeight = 100u, + source = BalanceSource.HOLDER_FORCE_CLOSED, + ), + ) + + val idle = idlePeerIds(peers, channels, claims, pendingSweeps = emptyList()) + + assertEquals(listOf("idle"), idle) + } + + @Test + fun `no peer is idle while a closed channel awaits its sweep`() { + val sweeps = listOf(PendingSweepBalance.PendingBroadcast(channelId = "closed", amountSatoshis = 1_000uL)) + + val idle = idlePeerIds(listOf(peer("idle")), emptyList(), emptyList(), sweeps) + + assertTrue(idle.isEmpty()) + } + + private fun peer(nodeId: String, isPersisted: Boolean = true) = + PeerDetails(nodeId = nodeId, address = "127.0.0.1:9735", isPersisted = isPersisted, isConnected = true) + @Test fun `reconciliation pre-reveals index 999 for an already tracked authorizing account`() = test { val account = watchOnlyAccount(accountIndex = 5, walletIndex = 0, enabled = true) diff --git a/changelog.d/next/1696.changed.md b/changelog.d/next/1696.changed.md index d724a48892..3020a38b33 100644 --- a/changelog.d/next/1696.changed.md +++ b/changelog.d/next/1696.changed.md @@ -1 +1 @@ -Transfers to spending now point to Manual Setup, since Bitkit no longer opens Lightning connections automatically. +Transfers to spending now point to Manual Setup, since Bitkit no longer opens Lightning connections automatically, and a forced close of a Blocktank connection no longer leaves funds stuck. From b99d87435d401c071846e9aa2ea5dcf60cf1d065 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 27 Oct 2026 01:36:45 +0100 Subject: [PATCH 15/47] fix: stop retrying an unpaid blocktank order while transfers are unavailable --- .../java/to/bitkit/viewmodels/AppViewModel.kt | 8 +++++ .../viewmodels/AppViewModelSendFlowTest.kt | 35 +++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index c6cefa81e4..f74ec12fc5 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -6048,6 +6048,14 @@ class AppViewModel @Inject constructor( feeRateSatsPerVByte: ULong, approvePrepared: suspend (OnchainPreparedReceipt, List) -> Unit = { _, _ -> }, ): Result { + // Blocktank no longer sells channels, so an unpaid order is never paid again. + if (original.orderId != null && lightningRepo.isTransferBlocked()) { + Logger.warn("Stopped retrying a transfer to spending: transfers are unavailable", context = TAG) + showSheet(Sheet.TransferUnavailable) + return Result.failure( + IllegalStateException(context.getString(R.string.lightning__transfer_unavailable__nav_title)) + ) + } val request = original.requestId?.let { id -> (paykitPaymentRequestRepo.pendingRequests.value + paykitPaymentRequestRepo.paymentRequestHistory.value) .firstOrNull { it.id == id } diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index b21e8ee307..c816598e0c 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -12980,6 +12980,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { OnchainSendOutcome.Accepted("ef".repeat(32)) } } + whenever(lightningRepo.isTransferBlocked()).thenReturn(false) val invalidOrders = listOf( order.copy(state2 = com.synonym.bitkitcore.BtOrderState2.PAID), order.copy(orderExpiresAt = (Clock.System.now() - 1.seconds).toString()), @@ -12996,6 +12997,40 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(1, dispatched) } + @Test + fun `recovery of a Blocktank order broadcasts nothing while transfers are unavailable`() = test { + val order = to.bitkit.ext.mockOrder().copy( + orderExpiresAt = (Clock.System.now() + 60.seconds).toString(), + ) + val original = OnchainSendAttempt( + walletId = WalletScope.default, + attemptId = "original-order-attempt", + transferContext = to.bitkit.repositories.OnchainTransferContext( + 1_200uL, + 100_000uL, + order.clientBalanceSat, + order.feeSat, + ), + requestId = null, + orderId = order.id, + address = requireNotNull(order.payment?.onchain?.address), + amountSats = order.feeSat, + isMaxAmount = false, + feeRateSatsPerVByte = 1uL, + isTransfer = true, + channelId = null, + tags = emptyList(), + evidence = OnchainSendEvidence.Unknown, + ) + whenever(lightningRepo.isTransferBlocked()).thenReturn(true) + whenever(blocktankRepo.fetchOrders(listOf(order.id))).thenReturn(Result.success(listOf(order))) + + assertTrue(sut.retryOriginalOnchainSend(original, 2uL).isFailure) + + verify(lightningRepo, never()).retryOriginalOnchainSend(any(), any(), any(), anyOrNull(), any(), any()) + assertEquals(Sheet.TransferUnavailable, sut.currentSheet.value) + } + @Test fun `private lightning contact payment consumes private list before send`() = test { val bolt11 = "lnbcrt1privatecontact" From 4b6ab7780e2ebf0c1edab2ae2b9f2c067a179cdc Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 26 Oct 2026 14:42:26 +0100 Subject: [PATCH 16/47] feat: keep lightning through breez sdk on spark --- app/build.gradle.kts | 2 + app/src/main/java/to/bitkit/env/Env.kt | 3 + .../to/bitkit/repositories/LightningRepo.kt | 71 +++++- .../java/to/bitkit/repositories/SparkRepo.kt | 240 ++++++++++++++++++ .../java/to/bitkit/repositories/WalletRepo.kt | 17 +- .../java/to/bitkit/services/SparkService.kt | 182 +++++++++++++ app/src/main/java/to/bitkit/ui/ContentView.kt | 17 +- .../screens/transfer/SavingsConfirmScreen.kt | 8 +- .../screens/transfer/SpendingConfirmScreen.kt | 38 +-- .../screens/wallets/SpendingWalletScreen.kt | 5 +- .../wallets/receive/ReceiveQrScreen.kt | 4 +- .../screens/wallets/receive/ReceiveSheet.kt | 2 + .../usecases/DeriveBalanceStateUseCase.kt | 11 +- .../to/bitkit/viewmodels/TransferViewModel.kt | 97 ++++++- .../to/bitkit/viewmodels/WalletViewModel.kt | 3 + .../bitkit/repositories/LightningRepoTest.kt | 9 + .../to/bitkit/repositories/WalletRepoTest.kt | 3 + .../test/java/to/bitkit/test/IdleSparkRepo.kt | 14 + .../java/to/bitkit/ui/WalletViewModelTest.kt | 8 + .../usecases/DeriveBalanceStateUseCaseTest.kt | 20 ++ .../viewmodels/TransferViewModelTest.kt | 4 + changelog.d/next/1697.added.md | 1 + gradle/libs.versions.toml | 1 + settings.gradle.kts | 4 + 24 files changed, 728 insertions(+), 36 deletions(-) create mode 100644 app/src/main/java/to/bitkit/repositories/SparkRepo.kt create mode 100644 app/src/main/java/to/bitkit/services/SparkService.kt create mode 100644 app/src/test/java/to/bitkit/test/IdleSparkRepo.kt create mode 100644 changelog.d/next/1697.added.md diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b0140d5d77..e8bed45d5c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -357,6 +357,7 @@ androidComponents { "USDT_RPC_URL", localProp("USDT_RPC_URL").orElse("https://bitkit.to/v1/usdt/chain-rpc").stringField(), ) + buildConfigFields.put("BREEZ_API_KEY", localProp("BREEZ_API_KEY").orElse("").stringField()) buildConfigFields.put("E2E", e2eEnv.booleanField()) buildConfigFields.put("E2E_BACKEND", e2eBackendEnv.stringField()) buildConfigFields.put("E2E_LOCAL_HOST", e2eLocalHostEnv.stringField()) @@ -564,6 +565,7 @@ dependencies { implementation(libs.bitkit.core) } implementation(libs.paykit) + implementation(libs.breez.sdk.spark) { exclude(group = "net.java.dev.jna", module = "jna") } implementation(libs.vss.client) if (localBitkitCoreAar == null) nativeDebugSymbols(libs.bitkit.core.nativeDebugSymbolsArtifact()) nativeDebugSymbols(libs.ldk.node.android.nativeDebugSymbolsArtifact()) diff --git a/app/src/main/java/to/bitkit/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt index 40427869ec..39caeffca1 100644 --- a/app/src/main/java/to/bitkit/env/Env.kt +++ b/app/src/main/java/to/bitkit/env/Env.kt @@ -31,6 +31,7 @@ internal object Env { ).filter { it.first in enabled }.map { it.second } } + val breezApiKey: String? get() = BuildConfig.BREEZ_API_KEY.takeIf { it.isNotBlank() } val isDebug = BuildConfig.DEBUG const val isE2eTest = BuildConfig.E2E const val isGeoblockingEnabled = BuildConfig.GEO @@ -219,6 +220,8 @@ internal object Env { fun ldkStoragePath(walletIndex: Int) = storagePathOf(walletIndex, network.name.lowercase(), "ldk") + fun sparkStoragePath(walletIndex: Int) = storagePathOf(walletIndex, network.name.lowercase(), "spark") + fun bitkitCoreStoragePath(walletIndex: Int): String { return storagePathOf(walletIndex, network.name.lowercase(), "core") } diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index db09d7ac9e..a551853560 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -67,6 +67,7 @@ import to.bitkit.data.keychain.Keychain import to.bitkit.di.BgDispatcher import to.bitkit.env.Defaults import to.bitkit.env.Env +import to.bitkit.ext.calculateRemoteBalance import to.bitkit.ext.getSatsPerVByteFor import to.bitkit.ext.nowMillis import to.bitkit.ext.runSuspendCatching @@ -78,6 +79,7 @@ import to.bitkit.models.ElectrumServer import to.bitkit.models.NATIVE_WITNESS_TYPES import to.bitkit.models.NodeLifecycleState import to.bitkit.models.OpenChannelResult +import to.bitkit.models.ReceiveLiquidityDecision import to.bitkit.models.TransactionSpeed import to.bitkit.models.WalletScope import to.bitkit.models.safe @@ -128,6 +130,7 @@ class LightningRepo @Inject constructor( private val vssBackupClientLdk: VssBackupClientLdk, private val urlValidator: UrlValidator, private val electrumProbeService: ElectrumProbeService, + private val sparkRepo: SparkRepo, ) : OnchainReleasing { private val _lightningState = MutableStateFlow(LightningState()) val lightningState = _lightningState.asStateFlow() @@ -173,6 +176,7 @@ class LightningRepo @Inject constructor( init { observeConnectivityForSyncRetry() + scope.launch { sparkRepo.nodeEvents.collect { onSparkEvent(it) } } } private fun observeConnectivityForSyncRetry() { @@ -352,6 +356,9 @@ class LightningRepo @Inject constructor( // The geocheck needs no node, so screens gated on it are right before the node is up. scope.launch { updateGeoBlockState() } + // Spark needs no node, so it starts alongside it and stays up while the node stops in background. + scope.launch { sparkRepo.start(walletIndex) } + eventHandler?.let { _eventHandlers.add(it) } // Track retry state outside mutex to avoid deadlock (Mutex is non-reentrant) @@ -601,6 +608,14 @@ class LightningRepo @Inject constructor( ) } + private suspend fun onSparkEvent(event: Event) { + val settledReceiveInvoice = (event as? Event.PaymentReceived)?.let { invalidateSettledReceiveInvoice(it) } + _eventHandlers.toList().forEach { + runCatching { it.invoke(event) } + } + _nodeEventUpdates.emit(NodeEventUpdate(event = event, settledReceiveInvoice = settledReceiveInvoice)) + } + private suspend fun invalidateSettledReceiveInvoice(event: Event.PaymentReceived): SettledReceiveInvoice? { val cachedReceive = runSuspendCatching { cacheStore.data.first() @@ -913,6 +928,7 @@ class LightningRepo @Inject constructor( Logger.debug("node stopped, calling wipeStorage", context = TAG) lastKnownNodeId = null lightningService.wipeStorage(walletIndex) + sparkRepo.wipe(walletIndex) clearProbeOutcomes() _lightningState.update { LightningState( @@ -1307,6 +1323,9 @@ class LightningRepo @Inject constructor( expirySeconds: UInt = Defaults.bolt11ExpirySec, ): Result = executeWhenNodeRunning("createInvoice") { updateGeoBlockState() + if (shouldReceiveWithSpark(amountSats)) { + return@executeWhenNodeRunning sparkRepo.createInvoice(amountSats, description, expirySeconds) + } runCatching { lightningService.receive(amountSats, description, expirySeconds) } } @@ -1316,9 +1335,34 @@ class LightningRepo @Inject constructor( expirySeconds: UInt = Defaults.bolt11ExpirySec, ): Result = executeWhenNodeRunning("createInvoiceMsats") { updateGeoBlockState() + if (shouldReceiveWithSpark(amountMsats / MSATS_PER_SAT)) { + val amountSats = (amountMsats / MSATS_PER_SAT).takeIf { it > 0uL } + return@executeWhenNodeRunning sparkRepo.createInvoice(amountSats, description, expirySeconds) + } runCatching { lightningService.receiveMsats(amountMsats, description, expirySeconds) } } + /** Receives on Spark when no ready channel has the inbound capacity for [amountSats]. */ + private fun shouldReceiveWithSpark(amountSats: ULong?): Boolean { + if (!sparkRepo.canReceive()) return false + val readyChannels = _lightningState.value.channels.filter { it.isChannelReady } + return !ReceiveLiquidityDecision.canCreateLightningInvoice( + hasReadyChannels = readyChannels.isNotEmpty(), + inboundCapacitySats = readyChannels.calculateRemoteBalance(), + invoiceAmountSats = amountSats, + ) + } + + /** Pays with Spark when no channel has the outbound capacity for the invoice amount. */ + private fun shouldPayWithSpark(bolt11: String, sats: ULong?): Boolean { + val amountSats = sats ?: runCatching { Bolt11Invoice.fromStr(bolt11).amountMilliSatoshis() }.getOrNull() + ?.let { it / MSATS_PER_SAT } ?: return false + val state = _lightningState.value + val ldkCanSend = state.nodeLifecycleState.canRun() && + state.channels.totalNextOutboundHtlcLimitSats() >= amountSats + return !ldkCanSend && sparkRepo.canSend(amountSats) + } + suspend fun fetchLnurlInvoice( data: LnurlPayData, amountMsats: ULong, @@ -1410,6 +1454,20 @@ class LightningRepo @Inject constructor( paymentDeadlineAt: Instant?, maxTotalRoutingFeeMsat: ULong?, onBeforeSend: suspend () -> Boolean, + ): Result { + if (shouldPayWithSpark(bolt11, sats)) { + if (!onBeforeSend()) return Result.failure(PaymentAbortedBeforeSend()) + return sparkRepo.payInvoice(bolt11, sats) + } + return payInvoiceWithLdk(bolt11, sats, paymentDeadlineAt, maxTotalRoutingFeeMsat, onBeforeSend) + } + + private suspend fun payInvoiceWithLdk( + bolt11: String, + sats: ULong?, + paymentDeadlineAt: Instant?, + maxTotalRoutingFeeMsat: ULong?, + onBeforeSend: suspend () -> Boolean, ): Result = executeWhenNodeRunning("payInvoice") { waitForUsableChannels() if (!onBeforeSend()) return@executeWhenNodeRunning Result.failure(PaymentAbortedBeforeSend()) @@ -1863,9 +1921,10 @@ class LightningRepo @Inject constructor( } } - suspend fun completeAcceptedTransferFollowup(orderId: String, txid: String) { - val attempt = onchainSendAttemptStore.current() - if (attempt?.orderId == orderId && attempt.txid.equals(txid, ignoreCase = true) && + /** Finishes an accepted transfer locally; [orderId] is null for a transfer that funds the Spark wallet. */ + suspend fun completeAcceptedTransferFollowup(orderId: String?, txid: String) { + val attempt = onchainSendAttemptStore.current() ?: return + if (attempt.isTransfer && attempt.orderId == orderId && attempt.txid.equals(txid, ignoreCase = true) && attempt.hasPositiveEvidence ) { finishOnchainSendLocally(attempt) @@ -2191,6 +2250,7 @@ class LightningRepo @Inject constructor( fun canSend(amountSats: ULong): Boolean { val state = _lightningState.value + if (sparkRepo.canSend(amountSats)) return true if (!state.nodeLifecycleState.canRun()) return false return state.channels.totalNextOutboundHtlcLimitSats() >= amountSats } @@ -2306,6 +2366,7 @@ class LightningRepo @Inject constructor( } suspend fun estimateRoutingFees(bolt11: String): Result = executeWhenNodeRunning("estimateRoutingFees") { + if (shouldPayWithSpark(bolt11, null)) return@executeWhenNodeRunning sparkRepo.estimateFeeSats(bolt11, null) Logger.info("Estimating routing fees for bolt11: $bolt11", context = TAG) lightningService.estimateRoutingFees(bolt11).onSuccess { Logger.info("Routing fees estimated: '$it'", context = TAG) @@ -2316,6 +2377,9 @@ class LightningRepo @Inject constructor( suspend fun estimateRoutingFeesForAmount(bolt11: String, amountSats: ULong): Result = executeWhenNodeRunning("estimateRoutingFeesForAmount") { + if (shouldPayWithSpark(bolt11, amountSats)) { + return@executeWhenNodeRunning sparkRepo.estimateFeeSats(bolt11, amountSats) + } Logger.info("Estimating routing fees for amount: '$amountSats'", context = TAG) lightningService.estimateRoutingFeesForAmount(bolt11, amountSats).onSuccess { Logger.info("Routing fees estimated: '$it'", context = TAG) @@ -2610,6 +2674,7 @@ class LightningRepo @Inject constructor( private const val VSS_KEY_EXTERNAL_SCORES_CACHE = "external_pathfinding_scores_cache" private const val MS_SYNC_LOOP_DEBOUNCE = 500L private const val SYNC_RETRY_DELAY_MS = 15_000L + private const val MSATS_PER_SAT = 1_000uL private val BACKGROUND_STOP_DELAY = 5.seconds private val CHANNELS_USABLE_TIMEOUT = 15.seconds private val CHANNELS_USABLE_POLL_DELAY = 1.seconds diff --git a/app/src/main/java/to/bitkit/repositories/SparkRepo.kt b/app/src/main/java/to/bitkit/repositories/SparkRepo.kt new file mode 100644 index 0000000000..1425c547ea --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/SparkRepo.kt @@ -0,0 +1,240 @@ +package to.bitkit.repositories + +import breez_sdk_spark.FeePolicy +import breez_sdk_spark.Payment +import breez_sdk_spark.PaymentDetails +import breez_sdk_spark.PaymentStatus +import breez_sdk_spark.SdkEvent +import breez_sdk_spark.SendPaymentMethod +import com.synonym.bitkitcore.Activity +import com.synonym.bitkitcore.LightningActivity +import com.synonym.bitkitcore.PaymentState +import com.synonym.bitkitcore.PaymentType +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.lightningdevkit.ldknode.Event +import org.lightningdevkit.ldknode.PaymentFailureReason +import org.lightningdevkit.ldknode.PaymentId +import to.bitkit.async.appScope +import to.bitkit.data.SettingsStore +import to.bitkit.di.BgDispatcher +import to.bitkit.ext.create +import to.bitkit.ext.runSuspendCatching +import to.bitkit.services.SparkService +import to.bitkit.utils.Logger +import javax.inject.Inject +import javax.inject.Provider +import javax.inject.Singleton +import breez_sdk_spark.PaymentType as SparkPaymentType + +/** + * Lightning without channels, through the Breez SDK on Spark. Savings to Spending funds this wallet, + * receives fall back to it when no channel has the inbound capacity, and sends fall back to it when no + * channel has the outbound capacity. Its payments surface as ldk-node [Event]s through [nodeEvents], so the + * receive and send flows built on node events work for both rails. + */ +@Singleton +class SparkRepo @Inject constructor( + @BgDispatcher private val bgDispatcher: CoroutineDispatcher, + private val sparkService: SparkService, + private val activityRepo: Provider, + private val settingsStore: SettingsStore, +) { + companion object { + private const val TAG = "SparkRepo" + private const val MSATS_PER_SAT = 1_000uL + + /** Last step of the Spending setup progress, shown once a funding deposit is claimed. */ + private const val SETUP_STEP_DONE = 3 + } + + private val scope = appScope(bgDispatcher, TAG) + private var eventsJob: Job? = null + + private val _state = MutableStateFlow(SparkState(isSupported = sparkService.isSupported)) + val state: StateFlow = _state.asStateFlow() + + private val _nodeEvents = MutableSharedFlow(extraBufferCapacity = 64) + val nodeEvents: SharedFlow = _nodeEvents.asSharedFlow() + + suspend fun start(walletIndex: Int): Result = withContext(bgDispatcher) { + if (!sparkService.isSupported) return@withContext Result.success(Unit) + runSuspendCatching { + if (eventsJob == null) eventsJob = scope.launch { sparkService.events.collect { onSdkEvent(it) } } + sparkService.connect(walletIndex) + refresh() + syncActivities() + }.onFailure { + Logger.error("Failed to start Spark wallet", it, context = TAG) + } + } + + suspend fun stop() = withContext(bgDispatcher) { + runSuspendCatching { sparkService.disconnect() } + .onFailure { Logger.warn("Failed to stop Spark wallet", it, context = TAG) } + _state.update { it.copy(isReady = false) } + } + + suspend fun wipe(walletIndex: Int) = withContext(bgDispatcher) { + runSuspendCatching { sparkService.wipeStorage(walletIndex) } + .onFailure { Logger.error("Failed to wipe Spark wallet", it, context = TAG) } + _state.update { SparkState(isSupported = sparkService.isSupported) } + } + + fun canReceive(): Boolean = _state.value.isReady + + fun canSend(amountSats: ULong): Boolean = _state.value.isReady && _state.value.balanceSats >= amountSats + + suspend fun createInvoice(amountSats: ULong?, description: String, expirySecs: UInt): Result = + withContext(bgDispatcher) { + runSuspendCatching { sparkService.receiveBolt11(amountSats, description, expirySecs) } + } + + /** Returns the Lightning routing fee plus Spark's 0.25% service fee for paying [bolt11]. */ + suspend fun estimateFeeSats(bolt11: String, amountSats: ULong?): Result = withContext(bgDispatcher) { + runSuspendCatching { + val method = sparkService.prepareSend(bolt11, amountSats).paymentMethod + (method as SendPaymentMethod.Bolt11Invoice).lightningFeeSats + } + } + + /** Pays [bolt11] and returns its payment hash, the id ldk-node payments use, so the send flow can match it. */ + suspend fun payInvoice(bolt11: String, amountSats: ULong?): Result = withContext(bgDispatcher) { + runSuspendCatching { + val prepared = sparkService.prepareSend(bolt11, amountSats) + val paymentHash = (prepared.paymentMethod as SendPaymentMethod.Bolt11Invoice).invoiceDetails.paymentHash + val payment = sparkService.send(prepared) + Logger.info("Sent Spark payment '${payment.id}' with status '${payment.status}'", context = TAG) + paymentHash + }.onFailure { + Logger.error("Failed to pay invoice with Spark", it, context = TAG) + } + } + + suspend fun depositAddress(): Result = withContext(bgDispatcher) { + runSuspendCatching { sparkService.depositAddress() } + } + + /** Moves the whole Spark balance to the on-chain [address], fees included, for Spending to Savings. */ + suspend fun withdrawAll(address: String): Result = withContext(bgDispatcher) { + runSuspendCatching { + val prepared = sparkService.prepareSend(address, _state.value.balanceSats, FeePolicy.FEES_INCLUDED) + sparkService.send(prepared) + refresh() + }.onFailure { + Logger.error("Failed to withdraw from Spark", it, context = TAG) + } + } + + private suspend fun refresh() { + val info = sparkService.getInfo() + val pendingDepositSats = sparkService.listUnclaimedDeposits().sumOf { it.amountSats } + _state.update { + it.copy( + isReady = true, + balanceSats = info.balanceSats, + pendingDepositSats = pendingDepositSats, + recoverableFundsSats = info.recoverableFundsSats, + ) + } + } + + private suspend fun syncActivities() { + sparkService.listPayments().forEach { recordActivity(it) } + } + + private suspend fun onSdkEvent(event: SdkEvent) { + runSuspendCatching { + when (event) { + is SdkEvent.Synced -> refresh() + is SdkEvent.PaymentSucceeded -> onPayment(event.payment) + is SdkEvent.PaymentFailed -> onPayment(event.payment) + is SdkEvent.PaymentPending -> recordActivity(event.payment) + is SdkEvent.UnclaimedDeposits -> Logger.warn( + "Found '${event.unclaimedDeposits.size}' unclaimed Spark deposits", + context = TAG, + ) + is SdkEvent.NewDeposits -> refresh() + is SdkEvent.ClaimedDeposits -> { + refresh() + settingsStore.update { + if (it.lightningSetupStep > 0) it.copy(lightningSetupStep = SETUP_STEP_DONE) else it + } + } + else -> Unit + } + }.onFailure { + Logger.error("Failed to handle Spark event '$event'", it, context = TAG) + } + } + + private suspend fun onPayment(payment: Payment) { + refresh() + recordActivity(payment) + payment.toNodeEvent()?.let { _nodeEvents.emit(it) } + } + + private suspend fun recordActivity(payment: Payment) { + val lightning = payment.details as? PaymentDetails.Lightning ?: return + if (payment.status == PaymentStatus.PENDING && payment.paymentType == SparkPaymentType.RECEIVE) { + return + } + val activity = LightningActivity.create( + id = lightning.htlcDetails.paymentHash, + txType = when (payment.paymentType) { + SparkPaymentType.SEND -> PaymentType.SENT + SparkPaymentType.RECEIVE -> PaymentType.RECEIVED + }, + status = when (payment.status) { + PaymentStatus.COMPLETED -> PaymentState.SUCCEEDED + PaymentStatus.PENDING -> PaymentState.PENDING + PaymentStatus.FAILED -> PaymentState.FAILED + }, + value = payment.amount.toLong().toULong(), + invoice = lightning.invoice, + timestamp = payment.timestamp, + fee = payment.fees.toLong().toULong(), + message = lightning.description.orEmpty(), + preimage = lightning.htlcDetails.preimage, + ) + activityRepo.get().upsertActivity(Activity.Lightning(activity)) + } + + private fun Payment.toNodeEvent(): Event? { + val lightning = details as? PaymentDetails.Lightning ?: return null + val hash = lightning.htlcDetails.paymentHash + val amountMsat = amount.toLong().toULong() * MSATS_PER_SAT + return when (paymentType to status) { + SparkPaymentType.RECEIVE to PaymentStatus.COMPLETED -> + Event.PaymentReceived(hash, hash, amountMsat, emptyList()) + SparkPaymentType.SEND to PaymentStatus.COMPLETED -> + Event.PaymentSuccessful( + hash, + hash, + lightning.htlcDetails.preimage, + fees.toLong().toULong() * MSATS_PER_SAT + ) + SparkPaymentType.SEND to PaymentStatus.FAILED -> + Event.PaymentFailed(hash, hash, PaymentFailureReason.UNEXPECTED_ERROR) + else -> null + } + } +} + +data class SparkState( + val isSupported: Boolean = false, + val isReady: Boolean = false, + val balanceSats: ULong = 0uL, + /** Deposits seen on-chain that Spark claims into [balanceSats] after 3 confirmations. */ + val pendingDepositSats: ULong = 0uL, + val recoverableFundsSats: ULong = 0uL, +) diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index c7c9173a01..1be27f436c 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -15,7 +15,9 @@ import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex @@ -74,6 +76,7 @@ class WalletRepo @Inject constructor( private val transferRepo: TransferRepo, private val activityRepo: ActivityRepo, private val hwWalletRepo: HwWalletRepo, + private val sparkRepo: SparkRepo, ) { private val repoScope = appScope(bgDispatcher, TAG) @@ -112,6 +115,16 @@ class WalletRepo @Inject constructor( _balanceState.update { state -> state.copy(hardwareWallets = wallets.map { it.toBalance() }) } } } + repoScope.launch { + sparkRepo.state.map { it.isReady }.distinctUntilChanged().collect { isReady -> + if (isReady && walletExists()) refreshBip21() + } + } + repoScope.launch { + sparkRepo.state.map { it.balanceSats to it.pendingDepositSats }.distinctUntilChanged().collect { + if (walletExists()) syncBalances() + } + } } private suspend fun updateReceiveState( @@ -403,7 +416,8 @@ class WalletRepo @Inject constructor( fun refreshMaxSendLightning() { val state = lightningRepo.lightningState.value if (!state.nodeLifecycleState.isRunning()) return - _balanceState.update { it.copy(maxSendLightningSats = state.channels.totalNextOutboundHtlcLimitSats()) } + val maxSendSats = maxOf(state.channels.totalNextOutboundHtlcLimitSats(), sparkRepo.state.value.balanceSats) + _balanceState.update { it.copy(maxSendLightningSats = maxSendSats) } } suspend fun syncBalances() { @@ -965,6 +979,7 @@ class WalletRepo @Inject constructor( } private fun canCreateLightningInvoice(amountSats: ULong?): Boolean { + if (sparkRepo.canReceive()) return true val readyChannels = currentReadyChannels() return ReceiveLiquidityDecision.canCreateLightningInvoice( hasReadyChannels = readyChannels.isNotEmpty(), diff --git a/app/src/main/java/to/bitkit/services/SparkService.kt b/app/src/main/java/to/bitkit/services/SparkService.kt new file mode 100644 index 0000000000..0bba34ff34 --- /dev/null +++ b/app/src/main/java/to/bitkit/services/SparkService.kt @@ -0,0 +1,182 @@ +package to.bitkit.services + +import breez_sdk_spark.BreezSdk +import breez_sdk_spark.ConnectRequest +import breez_sdk_spark.DepositInfo +import breez_sdk_spark.EventListener +import breez_sdk_spark.FeePolicy +import breez_sdk_spark.GetInfoRequest +import breez_sdk_spark.GetInfoResponse +import breez_sdk_spark.ListPaymentsRequest +import breez_sdk_spark.ListUnclaimedDepositsRequest +import breez_sdk_spark.MaxFee +import breez_sdk_spark.MaxOnchainFee +import breez_sdk_spark.OnchainConfirmationSpeed +import breez_sdk_spark.Payment +import breez_sdk_spark.PaymentRequest +import breez_sdk_spark.PrepareSendPaymentRequest +import breez_sdk_spark.PrepareSendPaymentResponse +import breez_sdk_spark.ReceivePaymentMethod +import breez_sdk_spark.ReceivePaymentRequest +import breez_sdk_spark.SdkEvent +import breez_sdk_spark.Seed +import breez_sdk_spark.SendPaymentMethod +import breez_sdk_spark.SendPaymentOptions +import breez_sdk_spark.SendPaymentRequest +import breez_sdk_spark.connect +import breez_sdk_spark.defaultConfig +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.lightningdevkit.ldknode.Network +import to.bitkit.data.keychain.Keychain +import to.bitkit.env.Env +import to.bitkit.utils.AppError +import to.bitkit.utils.Logger +import to.bitkit.utils.ServiceError +import java.io.File +import java.math.BigInteger +import javax.inject.Inject +import javax.inject.Singleton +import breez_sdk_spark.Network as SparkNetwork + +/** + * Wraps the Breez SDK on Spark, which keeps Lightning available without channels after Blocktank stopped + * selling them. The Spark wallet derives its keys from the Bitkit seed on its own path (`m/8797555'`). + */ +@Singleton +class SparkService @Inject constructor( + private val keychain: Keychain, +) { + companion object { + private const val TAG = "SparkService" + + /** Seconds a Lightning send waits for completion before it returns the payment as pending. */ + private const val SEND_COMPLETION_TIMEOUT_SECS = 30u + + /** Leeway in sat/vB over the recommended fee that an automatic deposit claim may pay. */ + private const val DEPOSIT_CLAIM_FEE_LEEWAY_SAT_PER_VB = 2uL + + /** Share of a deposit, in parts per million, that an automatic deposit claim may pay on top. */ + private const val DEPOSIT_CLAIM_FEE_PPM = 2_000u + } + + private val mutex = Mutex() + private var sdk: BreezSdk? = null + private var listenerId: String? = null + + private val _events = MutableSharedFlow(extraBufferCapacity = 64) + val events: SharedFlow = _events.asSharedFlow() + + /** Spark runs on mainnet with a Breez API key, and on Lightspark's hosted regtest, which needs none. */ + val isSupported: Boolean + get() = when (Env.network) { + Network.BITCOIN -> Env.breezApiKey != null + Network.REGTEST -> true + else -> false + } + + val isConnected: Boolean get() = sdk != null + + suspend fun connect(walletIndex: Int) = mutex.withLock { + if (sdk != null) return@withLock + val network = sparkNetwork() ?: throw SparkError.Unsupported() + val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) ?: throw ServiceError.MnemonicNotFound() + val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) + val config = defaultConfig(network).apply { + apiKey = Env.breezApiKey + maxDepositClaimFee = MaxFee( + onchain = MaxOnchainFee.NetworkRecommended(DEPOSIT_CLAIM_FEE_LEEWAY_SAT_PER_VB), + proportionalPpm = DEPOSIT_CLAIM_FEE_PPM, + ) + } + val storageDir = File(Env.sparkStoragePath(walletIndex)).apply { mkdirs() }.path + val connected = connect(ConnectRequest(config, Seed.Mnemonic(mnemonic, passphrase), storageDir)) + listenerId = connected.addEventListener( + object : EventListener { + override suspend fun onEvent(event: SdkEvent) { + _events.emit(event) + } + }, + ) + sdk = connected + Logger.info("Connected Spark wallet on '$network'", context = TAG) + } + + suspend fun disconnect() = mutex.withLock { + val current = sdk ?: return@withLock + listenerId?.let { current.removeEventListener(it) } + current.disconnect() + sdk = null + listenerId = null + Logger.info("Disconnected Spark wallet", context = TAG) + } + + suspend fun wipeStorage(walletIndex: Int) { + disconnect() + File(Env.sparkStoragePath(walletIndex)).deleteRecursively() + } + + suspend fun getInfo(): GetInfoResponse = requireSdk().getInfo(GetInfoRequest(ensureSynced = false)) + + suspend fun receiveBolt11(amountSats: ULong?, description: String, expirySecs: UInt): String { + val method = ReceivePaymentMethod.Bolt11Invoice(description, amountSats, expirySecs, null, null) + return requireSdk().receivePayment(ReceivePaymentRequest(method)).paymentRequest + } + + /** Returns the static on-chain address that funds the Spark wallet, claimed automatically once confirmed. */ + suspend fun depositAddress(): String { + val method = ReceivePaymentMethod.BitcoinAddress(newAddress = null) + return requireSdk().receivePayment(ReceivePaymentRequest(method)).paymentRequest + } + + suspend fun prepareSend( + paymentRequest: String, + amountSats: ULong?, + feePolicy: FeePolicy? = null, + ): PrepareSendPaymentResponse = + requireSdk().prepareSendPayment( + PrepareSendPaymentRequest( + paymentRequest = PaymentRequest.Input(input = paymentRequest), + amount = amountSats?.let { BigInteger.valueOf(it.toLong()) }, + tokenIdentifier = null, + conversionOptions = null, + feePolicy = feePolicy, + ), + ) + + suspend fun send(prepared: PrepareSendPaymentResponse): Payment { + val options = when (prepared.paymentMethod) { + is SendPaymentMethod.Bolt11Invoice -> SendPaymentOptions.Bolt11Invoice( + preferSpark = false, + completionTimeoutSecs = SEND_COMPLETION_TIMEOUT_SECS, + ) + is SendPaymentMethod.BitcoinAddress -> SendPaymentOptions.BitcoinAddress(OnchainConfirmationSpeed.MEDIUM) + else -> null + } + return requireSdk().sendPayment(SendPaymentRequest(prepared, options)).payment + } + + suspend fun listUnclaimedDeposits(): List = + requireSdk().listUnclaimedDeposits(ListUnclaimedDepositsRequest).deposits + + suspend fun listPayments(): List = requireSdk().listPayments(ListPaymentsRequest()).payments + + /** Exports what a unilateral exit needs if the Spark operators stop cooperating; it can reach several MB. */ + suspend fun exportExitState(): String = requireSdk().exportUnilateralExitState().exitState + + private fun requireSdk(): BreezSdk = sdk ?: throw SparkError.NotConnected() + + private fun sparkNetwork(): SparkNetwork? = when (Env.network) { + Network.BITCOIN if Env.breezApiKey != null -> SparkNetwork.MAINNET + Network.REGTEST -> SparkNetwork.REGTEST + else -> null + } +} + +sealed class SparkError(message: String) : AppError(message) { + class Unsupported : SparkError("Spark is not available on this network or build") + class NotConnected : SparkError("Spark wallet is not connected") +} diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 639629c5d1..f346ad89f3 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -1147,6 +1147,8 @@ private fun RootNavHost( deepLinkableComposable { val hasSeenSpendingIntro by settingsViewModel.hasSeenSpendingIntro.collectAsStateWithLifecycle() val isGeoBlocked by appViewModel.isGeoBlocked.collectAsStateWithLifecycle() + val sparkState by walletViewModel.sparkState.collectAsStateWithLifecycle() + val isTransferUnavailable = isGeoBlocked && !sparkState.isReady FundingScreen( onTransfer = { @@ -1161,7 +1163,7 @@ private fun RootNavHost( }, onManual = { navController.navigateTo(Routes.ExternalNav) }, onBackClick = { navController.popBackStack() }, - isGeoBlocked = isGeoBlocked, + isGeoBlocked = isTransferUnavailable, ) } deepLinkableComposable { @@ -1303,6 +1305,8 @@ private fun NavGraphBuilder.home( deepLinkableComposable { val hasSeenSpendingIntro by settingsViewModel.hasSeenSpendingIntro.collectAsStateWithLifecycle() val isGeoBlocked by appViewModel.isGeoBlocked.collectAsStateWithLifecycle() + val sparkState by walletViewModel.sparkState.collectAsStateWithLifecycle() + val isTransferUnavailable = isGeoBlocked && !sparkState.isReady val onchainActivities by activityListViewModel.onchainActivities.collectAsStateWithLifecycle() val forceCloseRemainingDuration by appViewModel.forceCloseRemainingDuration.collectAsStateWithLifecycle() @@ -1312,7 +1316,7 @@ private fun NavGraphBuilder.home( onActivityItemClick = { navController.navToActivityDetail(it) }, onEmptyActivityRowClick = { appViewModel.showSheet(Sheet.Receive()) }, onTransferToSpendingClick = { - if (isGeoBlocked) { + if (isTransferUnavailable) { appViewModel.showSheet(Sheet.TransferUnavailable) } else { navController.navigateToTransferSpendingStart(hasSeenSpendingIntro) @@ -1326,11 +1330,14 @@ private fun NavGraphBuilder.home( val hasSeenSavingsIntro by settingsViewModel.hasSeenSavingsIntro.collectAsStateWithLifecycle() val hasSeenSpendingIntro by settingsViewModel.hasSeenSpendingIntro.collectAsStateWithLifecycle() val isGeoBlocked by appViewModel.isGeoBlocked.collectAsStateWithLifecycle() + val sparkState by walletViewModel.sparkState.collectAsStateWithLifecycle() + val isTransferUnavailable = isGeoBlocked && !sparkState.isReady val lightningState by walletViewModel.lightningState.collectAsStateWithLifecycle() val lightningActivities by activityListViewModel.lightningActivities.collectAsStateWithLifecycle() SpendingWalletScreen( channels = lightningState.channels, + hasSparkBalance = sparkState.balanceSats > 0uL, lightningActivities = lightningActivities ?: persistentListOf(), onAllActivityButtonClick = { navController.navigateToAllActivity(activityListViewModel::clearFilters) }, onActivityItemClick = { navController.navToActivityDetail(it) }, @@ -1343,7 +1350,7 @@ private fun NavGraphBuilder.home( } }, onTransferFromSavingsClick = { - if (isGeoBlocked) { + if (isTransferUnavailable) { appViewModel.showSheet(Sheet.TransferUnavailable) } else { navController.navigateToTransferSpendingStart(hasSeenSpendingIntro) @@ -1356,11 +1363,13 @@ private fun NavGraphBuilder.home( val walletId = it.toRoute().walletId val hasSeenSpendingIntro by settingsViewModel.hasSeenSpendingIntro.collectAsStateWithLifecycle() val isGeoBlocked by appViewModel.isGeoBlocked.collectAsStateWithLifecycle() + val sparkState by walletViewModel.sparkState.collectAsStateWithLifecycle() + val isTransferUnavailable = isGeoBlocked && !sparkState.isReady HardwareWalletScreen( walletId = walletId, onActivityItemClick = { navController.navToActivityDetail(it) }, onTransferToSpendingClick = { selectedDeviceId -> - if (isGeoBlocked) { + if (isTransferUnavailable) { appViewModel.showSheet(Sheet.TransferUnavailable) } else { navController.navigateToTransferSpendingStart(hasSeenSpendingIntro, selectedDeviceId) 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 4b18c10d88..d76fd1a4ca 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 @@ -86,7 +86,9 @@ fun SavingsConfirmScreen( val channels = selectedChannels ?: openChannels - val amount = channels.sumOf { it.amountOnClose } + val sparkState by wallet.sparkState.collectAsStateWithLifecycle() + val channelsAmount = channels.sumOf { it.amountOnClose } + val amount = channelsAmount + sparkState.balanceSats val swapState by transfer.savingsSwapState.collectAsStateWithLifecycle() @@ -96,8 +98,8 @@ fun SavingsConfirmScreen( } // Present the swap fee before the user commits. Recomputed when the amount changes. - LaunchedEffect(amount) { - if (amount > 0uL) transfer.loadSavingsSwapQuote(amount) + LaunchedEffect(channelsAmount) { + if (channelsAmount > 0uL) transfer.loadSavingsSwapQuote(channelsAmount) } Box { 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 9567fdbbd1..c091da8af9 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 @@ -248,23 +248,29 @@ private fun Content( onClick = onLearnMoreClick, modifier = Modifier.testTag("SpendingConfirmMore") ) - PrimaryButton( - text = stringResource( - if (isAdvanced) R.string.lightning__spending_confirm__default else R.string.common__advanced - ), - size = ButtonSize.Small, - fullWidth = false, - onClick = { - if (isAdvanced) { - onUseDefaultLspBalanceClick() - } else { - onAdvancedClick() - } - }, - modifier = Modifier.testTag( - if (isAdvanced) "SpendingConfirmDefault" else "SpendingConfirmAdvanced" + if (!state.isSpark) { + PrimaryButton( + text = stringResource( + if (isAdvanced) { + R.string.lightning__spending_confirm__default + } else { + R.string.common__advanced + } + ), + size = ButtonSize.Small, + fullWidth = false, + onClick = { + if (isAdvanced) { + onUseDefaultLspBalanceClick() + } else { + onAdvancedClick() + } + }, + modifier = Modifier.testTag( + if (isAdvanced) "SpendingConfirmDefault" else "SpendingConfirmAdvanced" + ) ) - ) + } } VerticalSpacer(16.dp) 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 2f2a40b2ad..169ae8b03a 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 @@ -69,16 +69,17 @@ fun SpendingWalletScreen( onTransferFromSavingsClick: () -> Unit, onBackClick: () -> Unit, balances: BalanceState = LocalBalances.current, + hasSparkBalance: Boolean = false, ) { val showEmptyState by remember(balances.totalLightningSats, lightningActivities.size) { val hasLnFunds = balances.totalLightningSats == 0uL val hasActivity = lightningActivities.isNotEmpty() mutableStateOf(hasLnFunds && !hasActivity) } - val canTransfer by remember(balances.totalLightningSats, channels.size) { + val canTransfer by remember(balances.totalLightningSats, channels.size, hasSparkBalance) { val hasLnBalance = balances.totalLightningSats > 0uL val hasChannels = channels.isNotEmpty() - mutableStateOf(hasLnBalance && hasChannels) + mutableStateOf(hasLnBalance && (hasChannels || hasSparkBalance)) } val canTransferFromSavings by remember(showEmptyState, balances.totalOnchainSats) { mutableStateOf(showEmptyState && balances.totalOnchainSats > 0uL) 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 f99e7a16c5..1edec88d00 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 @@ -123,6 +123,7 @@ fun ReceiveQrScreen( onVerifyHardwareAddress: () -> Unit = {}, showPaymentRequestContacts: Boolean = false, onClickPaymentRequestContacts: () -> Unit = {}, + canReceiveWithSpark: Boolean = false, ) { SetMaxBrightness() @@ -135,8 +136,9 @@ fun ReceiveQrScreen( hasReadyChannels, readyInboundLiquiditySats, walletState.bip21AmountSats, + canReceiveWithSpark, ) { - ReceiveLiquidityDecision.canCreateLightningInvoice( + canReceiveWithSpark || ReceiveLiquidityDecision.canCreateLightningInvoice( hasReadyChannels = hasReadyChannels, inboundCapacitySats = readyInboundLiquiditySats, invoiceAmountSats = walletState.bip21AmountSats, 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 b6b56c32fc..e01c880e88 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 @@ -92,6 +92,7 @@ fun ReceiveSheet( var editInvoiceSourceTab by remember { mutableStateOf(ReceiveTab.SAVINGS) } var isAdditionalLiquidityAmountEntry by remember { mutableStateOf(false) } val lightningState: LightningState by wallet.lightningState.collectAsStateWithLifecycle() + val sparkState by wallet.sparkState.collectAsStateWithLifecycle() val paymentRequestTargets by appViewModel.eligiblePaymentRequestTargets.collectAsStateWithLifecycle() val paymentRequestContacts by appViewModel.pubkyContacts.collectAsStateWithLifecycle() var paymentRequestDraft by remember { @@ -157,6 +158,7 @@ fun ReceiveSheet( cjitInvoice = cjitSessionState.cjitInvoice, walletState = walletState, lightningState = lightningState, + canReceiveWithSpark = sparkState.isReady, onClickReceiveCjit = { if (lightningState.isGeoBlocked) { navController.navigateTo(ReceiveRoute.GeoBlock) diff --git a/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt b/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt index 6ae7003a66..e7014d0476 100644 --- a/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt +++ b/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt @@ -20,6 +20,7 @@ import to.bitkit.models.safe import to.bitkit.models.toBalance import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.LightningRepo +import to.bitkit.repositories.SparkRepo import to.bitkit.repositories.TransferRepo import to.bitkit.utils.Logger import to.bitkit.utils.jsonLogOf @@ -33,6 +34,7 @@ class DeriveBalanceStateUseCase @Inject constructor( private val transferRepo: TransferRepo, private val settingsStore: SettingsStore, private val hwWalletRepo: HwWalletRepo, + private val sparkRepo: SparkRepo, ) { suspend operator fun invoke(): Result = withContext(bgDispatcher) { runCatching { @@ -56,16 +58,19 @@ class DeriveBalanceStateUseCase @Inject constructor( val channelFundableBalance = getMaxChannelFundableAmount(lightningRepo.getChannelFundableBalance()) val afterPendingChannels = balanceDetails.totalLightningBalanceSats.safe() - pendingChannelsSats.safe() val afterClosingChannels = afterPendingChannels.safe() - toSavingsAmount.safe() - val totalLightningSats = afterClosingChannels.safe() - lingeringCoopCloseSats.safe() + val sparkSats = sparkRepo.state.value.balanceSats + val afterLingeringCoopClose = afterClosingChannels.safe() - lingeringCoopCloseSats.safe() + val totalLightningSats = afterLingeringCoopClose.safe() + sparkSats.safe() val balanceState = BalanceState( totalOnchainSats = totalOnchainSats, channelFundableBalance = channelFundableBalance, totalLightningSats = totalLightningSats, - maxSendLightningSats = lightningRepo.getChannels().totalNextOutboundHtlcLimitSats(), + // A payment goes over one rail, so the larger of the two balances caps it. + maxSendLightningSats = maxOf(lightningRepo.getChannels().totalNextOutboundHtlcLimitSats(), sparkSats), maxSendOnchainSats = getMaxSendAmount(balanceDetails), balanceInTransferToSavings = toSavingsAmount.safe() - coopCloseSavingsSats.safe(), - balanceInTransferToSpending = toSpendingAmount, + balanceInTransferToSpending = toSpendingAmount.safe() + sparkRepo.state.value.pendingDepositSats.safe(), hardwareWallets = hwWalletRepo.wallets.value.map { it.toBalance() }, ) diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 2c7aaab98c..5413b6bc29 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -75,6 +75,7 @@ import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.OnchainSendOutcome import to.bitkit.repositories.OnchainTransferContext +import to.bitkit.repositories.SparkRepo import to.bitkit.repositories.TransferRepo import to.bitkit.repositories.WalletRepo import to.bitkit.services.BoltzService @@ -107,6 +108,7 @@ class TransferViewModel @Inject constructor( private val boltzService: BoltzService, private val currencyRepo: CurrencyRepo, private val clock: Clock, + private val sparkRepo: SparkRepo, ) : ViewModel() { /** Paired hardware wallets, for screens that render vendor-specific device visuals. */ @@ -145,6 +147,7 @@ class TransferViewModel @Inject constructor( fun onConfirmAmount(satsAmount: Long) { if (confirmPayJob?.isActive == true || hwTransferSignJob?.isActive == true) return + if (isSparkFunding()) return onConfirmSparkAmount(satsAmount.toULong()) val values = blocktankRepo.calculateLiquidityOptions(satsAmount.toULong()).getOrNull() if (values == null || values.maxLspBalanceSat == 0uL) { setTransferEffect( @@ -197,6 +200,26 @@ class TransferViewModel @Inject constructor( } } + /** Blocktank no longer sells channels, so Spending is funded by a deposit into the Spark wallet. */ + private fun isSparkFunding(): Boolean = sparkRepo.state.value.isReady + + private fun onConfirmSparkAmount(amountSats: ULong) { + viewModelScope.launch { + if (!canFundOrder(amountSats)) { + setTransferEffect( + TransferEffect.ToastError( + title = context.getString(R.string.lightning__spending_amount__error_balance__title), + description = context.getString( + R.string.lightning__spending_amount__error_balance__description + ), + ) + ) + return@launch + } + onEstimateReady(clientBalanceSat = amountSats, lspBalanceSat = 0uL, feeSat = amountSats, isSpark = true) + } + } + private suspend fun estimateSpendingFee( clientBalanceSat: ULong, lspBalanceSat: ULong, @@ -295,9 +318,14 @@ class TransferViewModel @Inject constructor( confirmFeeJob = viewModelScope.launch { _spendingUiState.update { it.copy(isConfirmFeeReady = false) } val order = _spendingUiState.value.order + val address = if (_spendingUiState.value.isSpark) { + sparkRepo.depositAddress().onFailure { ToastEventBus.send(it) }.getOrNull() + } else { + order?.fundingAddress ?: spendingSizingAddress() + } val target = SpendingFundingTarget( feeSat = _spendingUiState.value.feeSat, - address = order?.fundingAddress ?: spendingSizingAddress() ?: return@launch, + address = address ?: return@launch, orderId = order?.id, ) buildSpendingConfirmFundingPlan(target) @@ -337,6 +365,7 @@ class TransferViewModel @Inject constructor( confirmPayJob = viewModelScope.launch { try { val paid = runSuspendCatching { + if (state.isSpark) return@runSuspendCatching paySparkDeposit(state) val order = getOrCreateSpendingOrder() ?: return@runSuspendCatching false paySpendingConfirmOrder(order, shown = state) }.onFailure { @@ -355,6 +384,34 @@ class TransferViewModel @Inject constructor( } } + private suspend fun paySparkDeposit(shown: TransferToSpendingUiState): Boolean { + val plan = spendingConfirmFundingPlan?.takeIf { it.miningFeeSats > 0uL } ?: run { + ToastEventBus.send(type = Toast.ToastType.ERROR, title = context.getString(R.string.common__try_again)) + return false + } + val address = sparkRepo.depositAddress().getOrThrow() + val outcome = lightningRepo.sendOnChain( + address = address, + sats = shown.clientBalanceSat, + speed = TransactionSpeed.Fast, + utxosToSpend = if (plan.shouldUseSendAll) null else plan.selectedUtxos, + feeRates = plan.feeRates, + isTransfer = true, + isMaxAmount = plan.shouldUseSendAll, + ).getOrThrow() + if (outcome !is OnchainSendOutcome.Accepted) { + showRetainedFundingPending() + return false + } + withContext(NonCancellable) { + runSuspendCatching { lightningRepo.completeAcceptedTransferFollowup(orderId = null, txid = outcome.txid) } + .onFailure { Logger.warn("Failed to finish accepted Spark funding locally", it, context = TAG) } + } + settingsStore.update { it.copy(lightningSetupStep = LN_SETUP_STEP_2) } + Logger.info("Funded Spark wallet with '${shown.clientBalanceSat}' sats in '${outcome.txid}'", context = TAG) + return true + } + private suspend fun getOrCreateSpendingOrder(): IBtOrder? { val state = _spendingUiState.value state.order?.let { return it } @@ -826,7 +883,12 @@ class TransferViewModel @Inject constructor( } } - private suspend fun onEstimateReady(clientBalanceSat: ULong, lspBalanceSat: ULong, feeSat: ULong) { + private suspend fun onEstimateReady( + clientBalanceSat: ULong, + lspBalanceSat: ULong, + feeSat: ULong, + isSpark: Boolean = false, + ) { settingsStore.update { it.copy(lightningSetupStep = 0) } if (confirmPayJob?.isActive == true || hwTransferSignJob?.isActive == true) return pendingHwFundingBroadcast = null @@ -839,6 +901,7 @@ class TransferViewModel @Inject constructor( feeSat = feeSat, order = null, isAdvanced = false, + isSpark = isSpark, isConfirmPaying = false, hasPendingHwBroadcast = false, hwMiningFeeSats = 0uL, @@ -857,6 +920,19 @@ class TransferViewModel @Inject constructor( _spendingUiState.update { it.copy(fundingBudgetSats = fundingBudget, hwFundingWalletId = null) } val availableAmount = fundingBudget ?: 0uL + if (isSparkFunding()) { + val maxSend = availableAmount.toLong() + _spendingUiState.update { + it.copy( + maxAllowedToSend = maxSend, + isLoading = false, + balanceAfterFee = maxSend, + quarterAmount = (maxSend.toDouble() * 0.25).roundToLong(), + ) + } + return@launch + } + val initialLspFees = estimateInitialLspFees(availableAmount) if (initialLspFees == null) { _spendingUiState.update { it.copy(isLoading = false) } @@ -1871,7 +1947,20 @@ class TransferViewModel @Inject constructor( if (_savingsSwapState.value.quote != null) SavingsTransferMode.SWAP else SavingsTransferMode.CLOSE /** Closes the channels selected earlier, pending closure */ - suspend fun closeSelectedChannels() = closeChannels(channelsToClose) + suspend fun closeSelectedChannels(): List { + withdrawSparkToSavings() + return closeChannels(channelsToClose) + } + + private suspend fun withdrawSparkToSavings() { + val balance = sparkRepo.state.value.balanceSats + if (balance == 0uL) return + val address = lightningRepo.newAddress().getOrElse { + Logger.error("Failed to get address for Spark withdrawal", it, context = TAG) + return + } + sparkRepo.withdrawAll(address).onFailure { ToastEventBus.send(it) } + } private suspend fun closeChannels(channels: List): List { lightningRepo.awaitPeerConnected() @@ -2061,6 +2150,8 @@ data class TransferToSpendingUiState( val feeSat: ULong = 0uL, val order: IBtOrder? = null, val isAdvanced: Boolean = false, + /** Funds the Spark wallet instead of buying a Blocktank channel. */ + val isSpark: Boolean = false, val maxAllowedToSend: Long = 0, val balanceAfterFee: Long = 0, val quarterAmount: Long = 0, diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index 05ed1b131e..8f35c9b0be 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -42,6 +42,7 @@ import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.NodeStartYieldedToStopError import to.bitkit.repositories.PubkyRepo import to.bitkit.repositories.RecoveryModeError +import to.bitkit.repositories.SparkRepo import to.bitkit.repositories.SyncSource import to.bitkit.repositories.WalletRepo import to.bitkit.repositories.WipeInProgressError @@ -70,6 +71,7 @@ class WalletViewModel @Inject constructor( private val migrationService: MigrationService, private val connectivityRepo: ConnectivityRepo, private val boltzService: BoltzService, + sparkRepo: SparkRepo, ) : ViewModel() { companion object { private const val TAG = "WalletViewModel" @@ -89,6 +91,7 @@ class WalletViewModel @Inject constructor( } val lightningState = lightningRepo.lightningState + val sparkState = sparkRepo.state val walletState = walletRepo.walletState val balanceState = walletRepo.balanceState diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index 22d8c5b51a..668af1981a 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -82,6 +82,7 @@ import to.bitkit.services.LnurlService import to.bitkit.services.NetworkGraphInfo import to.bitkit.services.NodeEventHandler import to.bitkit.test.BaseUnitTest +import to.bitkit.test.idleSparkRepo import to.bitkit.utils.AppError import to.bitkit.utils.LdkError import to.bitkit.utils.UrlValidator @@ -116,6 +117,7 @@ class LightningRepoTest : BaseUnitTest() { private lateinit var sut: LightningRepo private val lightningService = mock() + private val sparkRepo = idleSparkRepo() private val settingsStore = mock() private val coreService = mock() private val keychain = mock() @@ -158,6 +160,7 @@ class LightningRepoTest : BaseUnitTest() { vssBackupClientLdk = vssBackupClientLdk, urlValidator = urlValidator, electrumProbeService = electrumProbeService, + sparkRepo = sparkRepo, ) } @@ -222,6 +225,7 @@ class LightningRepoTest : BaseUnitTest() { transferRepo = mock(), activityRepo = mock(), hwWalletRepo = hardware, + sparkRepo = sparkRepo, ) } @@ -1948,6 +1952,7 @@ class LightningRepoTest : BaseUnitTest() { vssBackupClientLdk = vssBackupClientLdk, urlValidator = urlValidator, electrumProbeService = electrumProbeService, + sparkRepo = sparkRepo, ) whenever(settingsStore.data).thenReturn(flowOf(SettingsData(coinSelectAuto = false))) whenever(coreService.activity).thenReturn(mock()) @@ -2373,6 +2378,7 @@ class LightningRepoTest : BaseUnitTest() { onchainSendAttemptStore = store, connectivityRepo = connectivityRepo, vssBackupClientLdk = vssBackupClientLdk, urlValidator = urlValidator, electrumProbeService = electrumProbeService, + sparkRepo = sparkRepo, ) whenever(settingsStore.data).thenReturn(flowOf(SettingsData(coinSelectAuto = false))) val activityService = mock() @@ -2443,6 +2449,7 @@ class LightningRepoTest : BaseUnitTest() { onchainSendAttemptStore = store, connectivityRepo = connectivityRepo, vssBackupClientLdk = vssBackupClientLdk, urlValidator = urlValidator, electrumProbeService = electrumProbeService, + sparkRepo = sparkRepo, ) whenever(settingsStore.data).thenReturn(flowOf(SettingsData(coinSelectAuto = false))) val activityService = mock() @@ -2524,6 +2531,7 @@ class LightningRepoTest : BaseUnitTest() { onchainSendAttemptStore = store, connectivityRepo = connectivityRepo, vssBackupClientLdk = vssBackupClientLdk, urlValidator = urlValidator, electrumProbeService = electrumProbeService, + sparkRepo = sparkRepo, ) whenever(settingsStore.data).thenReturn(flowOf(SettingsData(coinSelectAuto = false))) val activityService = mock() @@ -3003,6 +3011,7 @@ class LightningRepoTest : BaseUnitTest() { vssBackupClientLdk = vssBackupClientLdk, urlValidator = failingValidator, electrumProbeService = electrumProbeService, + sparkRepo = sparkRepo, ) sutWithFailingValidator.setInitNodeLifecycleState() whenever(lightningService.node).thenReturn(mock()) diff --git a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt index e4506b8a6b..45924daf34 100644 --- a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt @@ -49,6 +49,7 @@ import to.bitkit.services.AddressDerivationInfo import to.bitkit.services.CoreService import to.bitkit.services.OnchainService import to.bitkit.test.BaseUnitTest +import to.bitkit.test.idleSparkRepo import to.bitkit.usecases.DeriveBalanceStateUseCase import to.bitkit.usecases.WipeIncomplete import to.bitkit.usecases.WipeWalletUseCase @@ -66,6 +67,7 @@ class WalletRepoTest : BaseUnitTest() { private lateinit var sut: WalletRepo private val keychain = mock() + private val sparkRepo = idleSparkRepo() private val coreService = mock() private val settingsStore = mock() private val lightningRepo = mock() @@ -173,6 +175,7 @@ class WalletRepoTest : BaseUnitTest() { transferRepo = transferRepo, activityRepo = activityRepo, hwWalletRepo = hwWalletRepo, + sparkRepo = sparkRepo, ) private fun lightningInvoice(bolt11: String, isExpired: Boolean = false) = LightningInvoice( diff --git a/app/src/test/java/to/bitkit/test/IdleSparkRepo.kt b/app/src/test/java/to/bitkit/test/IdleSparkRepo.kt new file mode 100644 index 0000000000..e77e84ceb7 --- /dev/null +++ b/app/src/test/java/to/bitkit/test/IdleSparkRepo.kt @@ -0,0 +1,14 @@ +package to.bitkit.test + +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import to.bitkit.repositories.SparkRepo +import to.bitkit.repositories.SparkState + +/** A Spark wallet that is not connected, so every flow takes its Blocktank and channel path. */ +fun idleSparkRepo(): SparkRepo = mock { + on { state } doReturn MutableStateFlow(SparkState()) + on { nodeEvents } doReturn MutableSharedFlow() +} diff --git a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt index 203f6163ea..b9be1eac41 100644 --- a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt @@ -44,6 +44,7 @@ import to.bitkit.services.BoltzService import to.bitkit.services.MigrationService import to.bitkit.services.PendingChannelMigration import to.bitkit.test.BaseUnitTest +import to.bitkit.test.idleSparkRepo import to.bitkit.utils.AppError import to.bitkit.viewmodels.RestoreState import to.bitkit.viewmodels.WalletViewModel @@ -67,6 +68,7 @@ class WalletViewModelTest : BaseUnitTest() { private val migrationService = mock() private val connectivityRepo = mock() private val boltzService = mock() + private val sparkRepo = idleSparkRepo() private val lightningState = MutableStateFlow(LightningState()) private val walletState = MutableStateFlow(WalletState()) @@ -104,6 +106,7 @@ class WalletViewModelTest : BaseUnitTest() { migrationService = migrationService, connectivityRepo = connectivityRepo, boltzService = boltzService, + sparkRepo = sparkRepo, ) } @@ -536,6 +539,7 @@ class WalletViewModelTest : BaseUnitTest() { migrationService = migrationService, connectivityRepo = connectivityRepo, boltzService = boltzService, + sparkRepo = sparkRepo, ) assertEquals(RestoreState.Initial, testSut.restoreState.value) @@ -602,6 +606,7 @@ class WalletViewModelTest : BaseUnitTest() { migrationService = migrationService, connectivityRepo = connectivityRepo, boltzService = boltzService, + sparkRepo = sparkRepo, ) testSut.start() @@ -657,6 +662,7 @@ class WalletViewModelTest : BaseUnitTest() { migrationService = migrationService, connectivityRepo = connectivityRepo, boltzService = boltzService, + sparkRepo = sparkRepo, ) // Trigger restore to put state in non-idle @@ -696,6 +702,7 @@ class WalletViewModelTest : BaseUnitTest() { migrationService = migrationService, connectivityRepo = connectivityRepo, boltzService = boltzService, + sparkRepo = sparkRepo, ) testSut.restoreWallet("mnemonic", null) @@ -786,6 +793,7 @@ class WalletViewModelTest : BaseUnitTest() { migrationService = migrationService, connectivityRepo = connectivityRepo, boltzService = boltzService, + sparkRepo = sparkRepo, ) advanceUntilIdle() diff --git a/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt b/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt index b663a8bdf9..02767d96bd 100644 --- a/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt +++ b/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt @@ -23,8 +23,11 @@ import to.bitkit.models.TransferType import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LightningState +import to.bitkit.repositories.SparkRepo +import to.bitkit.repositories.SparkState import to.bitkit.repositories.TransferRepo import to.bitkit.test.BaseUnitTest +import to.bitkit.test.idleSparkRepo import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -34,6 +37,7 @@ class DeriveBalanceStateUseCaseTest : BaseUnitTest() { private val transferRepo: TransferRepo = mock() private val settingsStore: SettingsStore = mock() private val hwWalletRepo: HwWalletRepo = mock() + private val sparkRepo: SparkRepo = idleSparkRepo() private lateinit var sut: DeriveBalanceStateUseCase @@ -60,6 +64,7 @@ class DeriveBalanceStateUseCaseTest : BaseUnitTest() { transferRepo = transferRepo, settingsStore = settingsStore, hwWalletRepo = hwWalletRepo, + sparkRepo = sparkRepo, ) } @@ -97,6 +102,21 @@ class DeriveBalanceStateUseCaseTest : BaseUnitTest() { assertEquals(200_000uL, balanceState.totalSats) } + @Test + fun `should add Spark balance to spending and pending deposits to transfer`() = test { + newBalanceDetails() + whenever(lightningRepo.getChannels()).thenReturn(emptyList()) + whenever(sparkRepo.state).thenReturn( + MutableStateFlow(SparkState(isReady = true, balanceSats = 30_000uL, pendingDepositSats = 20_000uL)), + ) + + val balanceState = sut().getOrThrow() + + assertEquals(80_000uL, balanceState.totalLightningSats) + assertEquals(30_000uL, balanceState.maxSendLightningSats) + assertEquals(20_000uL, balanceState.balanceInTransferToSpending) + } + @Test fun `should subtract estimated fee from channelFundableBalance`() = test { val fundableBalance = 75_000uL diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index f915475380..b9d3968ee0 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -99,6 +99,7 @@ import to.bitkit.repositories.TransferRepo import to.bitkit.repositories.WalletRepo import to.bitkit.services.BoltzService import to.bitkit.test.BaseUnitTest +import to.bitkit.test.idleSparkRepo import to.bitkit.ui.screens.transfer.previewBtOrder import to.bitkit.ui.screens.transfer.previewSpendingState import to.bitkit.ui.shared.toast.ToastEventBus @@ -120,6 +121,7 @@ class TransferViewModelTest : BaseUnitTest() { private lateinit var sut: TransferViewModel private val context = mock() + private val sparkRepo = idleSparkRepo() private val lightningRepo = mock() private val blocktankRepo = mock() private val hwWalletRepo = mock() @@ -204,6 +206,7 @@ class TransferViewModelTest : BaseUnitTest() { cacheStore = cacheStore, transferRepo = transferRepo, clock = clock, + sparkRepo = sparkRepo, boltzService = boltzService, currencyRepo = currencyRepo, ) @@ -2864,6 +2867,7 @@ class TransferViewModelTest : BaseUnitTest() { cacheStore = cacheStore, transferRepo = transferRepo, clock = clock, + sparkRepo = sparkRepo, boltzService = boltzService, currencyRepo = currencyRepo, ) diff --git a/changelog.d/next/1697.added.md b/changelog.d/next/1697.added.md new file mode 100644 index 0000000000..e1e6a1d79d --- /dev/null +++ b/changelog.d/next/1697.added.md @@ -0,0 +1 @@ +Spending keeps Lightning without channels: transfers from Savings now fund a Spark wallet that sends and receives Lightning payments. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1b857067ed..3af9a3faa9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,6 +22,7 @@ appcompat = { module = "androidx.appcompat:appcompat", version = "1.7.1" } barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version = "17.3.0" } biometric = { module = "androidx.biometric:biometric", version = "1.4.0-alpha05" } bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.8.0-rc10" } +breez-sdk-spark = { module = "breez_sdk_spark:bindings-android", version = "0.27.2" } paykit = { module = "com.synonym:paykit-android", version = "0.1.1-rc1" } bouncycastle-pkix-jdk = { module = "org.bouncycastle:bcpkix-jdk18on", version = "1.83" } bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version = "1.83" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 00170049a1..c9570f5271 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -87,6 +87,10 @@ dependencyResolutionManagement { password = pass } } + maven { + url = uri("https://mvn.breez.technology/releases") + content { includeGroup("breez_sdk_spark") } + } maven { url = uri("https://maven.pkg.github.com/pubky/paykit-rs") credentials { From ad2842e7fd40232680dfd12ba1e595e0f2f665c7 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 26 Oct 2026 16:48:48 +0100 Subject: [PATCH 17/47] feat: show spark in lightning connections --- .../to/bitkit/repositories/LightningRepo.kt | 13 ++ .../java/to/bitkit/repositories/SparkRepo.kt | 38 +++- app/src/main/java/to/bitkit/ui/ContentView.kt | 17 ++ .../settings/lightning/ChannelDetailScreen.kt | 4 +- .../lightning/LightningConnectionsScreen.kt | 99 ++++++++++- .../LightningConnectionsViewModel.kt | 41 ++++- .../settings/lightning/SparkDetailScreen.kt | 164 ++++++++++++++++++ .../lightning/SparkDetailViewModel.kt | 49 ++++++ app/src/main/res/values-ar/strings.xml | 9 + app/src/main/res/values-b+es+419/strings.xml | 9 + app/src/main/res/values-ca/strings.xml | 9 + app/src/main/res/values-cs/strings.xml | 9 + app/src/main/res/values-de/strings.xml | 9 + app/src/main/res/values-el/strings.xml | 9 + app/src/main/res/values-es-rES/strings.xml | 9 + app/src/main/res/values-es/strings.xml | 9 + app/src/main/res/values-fr/strings.xml | 9 + app/src/main/res/values-it/strings.xml | 9 + app/src/main/res/values-nl/strings.xml | 9 + app/src/main/res/values-pl/strings.xml | 9 + app/src/main/res/values-pt-rBR/strings.xml | 9 + app/src/main/res/values-pt/strings.xml | 9 + app/src/main/res/values-ru/strings.xml | 9 + app/src/main/res/values/strings.xml | 9 + docs/screens-map.md | 1 + 25 files changed, 561 insertions(+), 9 deletions(-) create mode 100644 app/src/main/java/to/bitkit/ui/settings/lightning/SparkDetailScreen.kt create mode 100644 app/src/main/java/to/bitkit/ui/settings/lightning/SparkDetailViewModel.kt diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index a551853560..46d90393e8 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -46,6 +46,7 @@ import org.lightningdevkit.ldknode.Bolt11Invoice import org.lightningdevkit.ldknode.ChannelConfig import org.lightningdevkit.ldknode.ChannelDataMigration import org.lightningdevkit.ldknode.ChannelDetails +import org.lightningdevkit.ldknode.ConfirmationStatus import org.lightningdevkit.ldknode.ClosureReason import org.lightningdevkit.ldknode.CoinSelectionAlgorithm import org.lightningdevkit.ldknode.Event @@ -55,6 +56,7 @@ import org.lightningdevkit.ldknode.OutPoint import org.lightningdevkit.ldknode.PaymentDetails import org.lightningdevkit.ldknode.PaymentHash import org.lightningdevkit.ldknode.PaymentId +import org.lightningdevkit.ldknode.PaymentKind import org.lightningdevkit.ldknode.PeerDetails import org.lightningdevkit.ldknode.SpendableUtxo import org.lightningdevkit.ldknode.Txid @@ -1476,6 +1478,17 @@ class LightningRepo @Inject constructor( } } + /** Confirmations of an on-chain transaction this wallet sent or received, 0 while unconfirmed. */ + suspend fun onchainConfirmations(txid: String): UInt? = withContext(bgDispatcher) { + val kind = lightningService.listPayments() + ?.map { it.kind } + ?.filterIsInstance() + ?.firstOrNull { it.txid.equals(txid, ignoreCase = true) } ?: return@withContext null + val confirmed = kind.status as? ConfirmationStatus.Confirmed ?: return@withContext 0u + val tip = _lightningState.value.block()?.height ?: return@withContext null + if (tip < confirmed.height) 0u else tip - confirmed.height + 1u + } + suspend fun listPaymentsOrNull(): List? = withContext(bgDispatcher) { lightningService.listPayments() } diff --git a/app/src/main/java/to/bitkit/repositories/SparkRepo.kt b/app/src/main/java/to/bitkit/repositories/SparkRepo.kt index 1425c547ea..f789c47e7a 100644 --- a/app/src/main/java/to/bitkit/repositories/SparkRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/SparkRepo.kt @@ -3,6 +3,7 @@ package to.bitkit.repositories import breez_sdk_spark.FeePolicy import breez_sdk_spark.Payment import breez_sdk_spark.PaymentDetails +import breez_sdk_spark.PaymentMethod import breez_sdk_spark.PaymentStatus import breez_sdk_spark.SdkEvent import breez_sdk_spark.SendPaymentMethod @@ -10,6 +11,9 @@ import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.LightningActivity import com.synonym.bitkitcore.PaymentState import com.synonym.bitkitcore.PaymentType +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableSharedFlow @@ -135,14 +139,34 @@ class SparkRepo @Inject constructor( } } + suspend fun refreshState(): Result = withContext(bgDispatcher) { + if (!sparkService.isConnected) return@withContext Result.success(Unit) + runSuspendCatching { refresh() }.onFailure { Logger.warn("Failed to refresh Spark state", it, context = TAG) } + } + private suspend fun refresh() { val info = sparkService.getInfo() - val pendingDepositSats = sparkService.listUnclaimedDeposits().sumOf { it.amountSats } + val pending = sparkService.listUnclaimedDeposits().map { + SparkDeposit(txid = it.txid, amountSats = it.amountSats, isClaimed = false) + } + val claimed = sparkService.listPayments() + .filter { it.method == PaymentMethod.DEPOSIT && it.status == PaymentStatus.COMPLETED } + .mapNotNull { payment -> + val deposit = payment.details as? PaymentDetails.Deposit ?: return@mapNotNull null + SparkDeposit( + txid = deposit.txId, + amountSats = payment.amount.toLong().toULong(), + isClaimed = true, + feeSats = payment.fees.toLong().toULong(), + claimedAt = payment.timestamp, + ) + } _state.update { it.copy( isReady = true, balanceSats = info.balanceSats, - pendingDepositSats = pendingDepositSats, + pendingDepositSats = pending.sumOf { deposit -> deposit.amountSats }, + deposits = (pending + claimed).toImmutableList(), recoverableFundsSats = info.recoverableFundsSats, ) } @@ -236,5 +260,15 @@ data class SparkState( val balanceSats: ULong = 0uL, /** Deposits seen on-chain that Spark claims into [balanceSats] after 3 confirmations. */ val pendingDepositSats: ULong = 0uL, + val deposits: ImmutableList = persistentListOf(), val recoverableFundsSats: ULong = 0uL, ) + +/** An on-chain deposit into Spark: pending until Spark claims it after 3 confirmations. */ +data class SparkDeposit( + val txid: String, + val amountSats: ULong, + val isClaimed: Boolean, + val feeSats: ULong = 0uL, + val claimedAt: ULong? = null, +) diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index f346ad89f3..c5633ea813 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -202,6 +202,7 @@ import to.bitkit.ui.settings.general.WidgetsSettingsScreen import to.bitkit.ui.settings.lightning.ChannelDetailScreen import to.bitkit.ui.settings.lightning.CloseConnectionScreen import to.bitkit.ui.settings.lightning.LightningConnectionsScreen +import to.bitkit.ui.settings.lightning.SparkDetailScreen import to.bitkit.ui.settings.lightning.LightningConnectionsViewModel import to.bitkit.ui.settings.pin.PinManagementScreen import to.bitkit.ui.settings.quickPay.QuickPayIntroScreen @@ -1963,6 +1964,19 @@ private fun NavGraphBuilder.lightningConnections( navController = navController, ) } + deepLinkableComposable { + val hasSeenSavingsIntro = settingsViewModel?.hasSeenSavingsIntro?.collectAsStateWithLifecycle()?.value == true + SparkDetailScreen( + onBack = { navController.popBackStack() }, + onTransferToSavings = { + if (hasSeenSavingsIntro) { + navController.navigateToTransferSavingsAvailability() + } else { + navController.navigateToTransferSavingsIntro() + } + }, + ) + } deepLinkableComposable { val route = it.toRoute() CloseConnectionScreen( @@ -2443,6 +2457,9 @@ sealed interface Routes { @Serializable data class ChannelDetail(val channelId: String) : Routes.DeepLinkable + @Serializable + data object SparkDetail : Routes.DeepLinkable + @Serializable data class CloseConnection(val channelId: String) : Routes.DeepLinkable 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 2e3a75bb1f..b074892039 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 @@ -522,14 +522,14 @@ private fun ChannelDetailContent( } @Composable -private fun SectionTitle(text: String) { +internal fun SectionTitle(text: String) { VerticalSpacer(32.dp) Caption13Up(text = text, color = Colors.White64) VerticalSpacer(8.dp) } @Composable -private fun SectionRow( +internal fun SectionRow( name: String, valueContent: @Composable () -> Unit, modifier: Modifier = Modifier, 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 84ec1944a8..7f5b18f967 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 @@ -1,5 +1,6 @@ package to.bitkit.ui.settings.lightning +import android.content.Intent import androidx.annotation.DrawableRes import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.Image @@ -38,6 +39,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.core.net.toUri import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController import kotlinx.collections.immutable.ImmutableList @@ -50,11 +52,13 @@ import to.bitkit.models.msatFloorOf import to.bitkit.ui.Routes import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BodyMSB +import to.bitkit.ui.components.CaptionB import to.bitkit.ui.components.Caption13Up import to.bitkit.ui.components.ChannelStatusUi import to.bitkit.ui.components.Display import to.bitkit.ui.components.FillHeight import to.bitkit.ui.components.LightningChannel +import to.bitkit.ui.components.MoneyCaptionB import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SecondaryButton import to.bitkit.ui.components.SyncNodeView @@ -69,6 +73,7 @@ import to.bitkit.ui.shared.modifiers.clickableAlpha import to.bitkit.ui.shared.util.shareZipFile import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.getBlockExplorerUrl import to.bitkit.ui.utils.withAccent private const val CLOSED_CHANNEL_ALPHA = 0.64f @@ -101,6 +106,8 @@ fun LightningConnectionsScreen( onClickChannel = { channelUi -> navController.navigateTo(Routes.ChannelDetail(channelUi.details.channelId)) }, + onClickSpark = { navController.navigateTo(Routes.SparkDetail) }, + onOpenTx = { txId -> context.startActivity(Intent(Intent.ACTION_VIEW, getBlockExplorerUrl(txId).toUri())) }, onRefresh = { viewModel.onPullToRefresh() }, @@ -115,6 +122,8 @@ private fun Content( onClickAddConnection: () -> Unit = {}, onClickExportLogs: () -> Unit = {}, onClickChannel: (ChannelUi) -> Unit = {}, + onClickSpark: () -> Unit = {}, + onOpenTx: (String) -> Unit = {}, onRefresh: () -> Unit = {}, ) { var showClosed by remember { mutableStateOf(false) } @@ -148,7 +157,8 @@ private fun Content( onRefresh = onRefresh, modifier = Modifier.fillMaxSize() ) { - val isEmpty = uiState.openChannels.isEmpty() && + val isEmpty = uiState.spark == null && + uiState.openChannels.isEmpty() && uiState.pendingConnections.isEmpty() && uiState.closedChannels.isEmpty() @@ -184,6 +194,12 @@ private fun Content( LightningBalancesSection(uiState.localBalance, uiState.remoteBalance) HorizontalDivider(modifier = Modifier.padding(top = 16.dp)) + uiState.spark?.let { spark -> + VerticalSpacer(16.dp) + Caption13Up(stringResource(R.string.lightning__spark), color = Colors.White64) + SparkItem(spark = spark, onClick = onClickSpark, onOpenTx = onOpenTx) + } + // Pending Channels Section if (uiState.pendingConnections.isNotEmpty()) { VerticalSpacer(16.dp) @@ -380,6 +396,87 @@ private fun ChannelItem( } } +@Composable +private fun SparkItem( + spark: SparkUi, + onClick: () -> Unit, + onOpenTx: (String) -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .clickableAlpha { onClick() } + .testTag("SparkConnection") + ) { + VerticalSpacer(16.dp) + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + BodyMSB( + text = stringResource(R.string.lightning__spark_provider_value), + modifier = Modifier.weight(1f) + ) + Icon( + painter = painterResource(R.drawable.ic_chevron_right), + contentDescription = null, + tint = Colors.White64, + modifier = Modifier.size(24.dp) + ) + } + VerticalSpacer(8.dp) + LightningChannel( + capacity = spark.balanceSats.toLong(), + localBalance = spark.balanceSats.toLong(), + remoteBalance = 0L, + status = ChannelStatusUi.OPEN, + ) + spark.pendingDeposits.forEach { SparkDepositRow(it, onOpenTx) } + VerticalSpacer(16.dp) + HorizontalDivider() + } +} + +@Composable +internal fun SparkDepositRow( + depositUi: SparkDepositUi, + onOpenTx: (String) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .fillMaxWidth() + .padding(top = 12.dp) + .clickableAlpha { onOpenTx(depositUi.deposit.txid) } + .testTag("SparkDeposit") + ) { + Column(modifier = Modifier.weight(1f)) { + CaptionB(text = stringResource(R.string.lightning__spark_deposit)) + CaptionB( + text = depositUi.deposit.txid, + color = Colors.White64, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, + ) + } + Column( + horizontalAlignment = Alignment.End, + modifier = Modifier.padding(start = 16.dp) + ) { + MoneyCaptionB(sats = depositUi.deposit.amountSats.toLong(), symbol = true) + depositUi.confirmations?.let { + CaptionB( + text = stringResource(R.string.lightning__spark_confirmations, minOf(it.toInt(), 3)), + color = Colors.Purple, + ) + } + } + } +} + @Composable private fun EmptyStateContent(modifier: Modifier = Modifier) { Column(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 74bc1d85be..89bed727fc 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 @@ -37,6 +37,9 @@ import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.BlocktankRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LogsRepo +import to.bitkit.repositories.SparkDeposit +import to.bitkit.repositories.SparkRepo +import to.bitkit.repositories.SparkState import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.Logger import javax.inject.Inject @@ -50,6 +53,7 @@ class LightningConnectionsViewModel @Inject constructor( private val blocktankRepo: BlocktankRepo, private val logsRepo: LogsRepo, private val activityRepo: ActivityRepo, + private val sparkRepo: SparkRepo, ) : ViewModel() { companion object { @@ -96,9 +100,10 @@ class LightningConnectionsViewModel @Inject constructor( combine( lightningRepo.lightningState, blocktankRepo.blocktankState, - ) { lightningState, blocktankState -> - Pair(lightningState, blocktankState) - }.collect { (lightningState, blocktankState) -> + sparkRepo.state, + ) { lightningState, blocktankState, sparkState -> + Triple(lightningState, blocktankState, sparkState) + }.collect { (lightningState, blocktankState, sparkState) -> val channels = lightningState.channels val connectionText = context.getString(R.string.lightning__connection) @@ -114,8 +119,9 @@ class LightningConnectionsViewModel @Inject constructor( failedOrders = getFailedOrdersAsChannels(blocktankState.paidOrders) .map { it.mapToUiModel(channels, blocktankState.paidOrders, connectionText) } .reversed().toImmutableList(), - localBalance = calculateLocalBalance(channels), + localBalance = calculateLocalBalance(channels).safe() + sparkState.balanceSats.safe(), remoteBalance = channels.calculateRemoteBalance(), + spark = sparkState.toUi(lightningRepo), ) } } @@ -149,6 +155,7 @@ class LightningConnectionsViewModel @Inject constructor( } suspend fun refreshObservedState() { + sparkRepo.refreshState() lightningRepo.sync() blocktankRepo.refreshOrders() loadClosedChannels() @@ -300,8 +307,34 @@ data class LightningConnectionsUiState( val closedChannels: ImmutableList = persistentListOf(), val localBalance: ULong = 0u, val remoteBalance: ULong = 0u, + val spark: SparkUi? = null, ) +/** The Spark wallet shown as a connection, present once it holds a balance or a deposit. */ +data class SparkUi( + val balanceSats: ULong, + val deposits: ImmutableList = persistentListOf(), +) { + val pendingDeposits get() = deposits.filterNot { it.deposit.isClaimed } + val claimedDeposits get() = deposits.filter { it.deposit.isClaimed } +} + +data class SparkDepositUi( + val deposit: SparkDeposit, + /** On-chain confirmations, known for deposits this wallet sent. */ + val confirmations: UInt? = null, +) + +internal suspend fun SparkState.toUi(lightningRepo: LightningRepo): SparkUi? { + if (!isReady || (balanceSats == 0uL && deposits.isEmpty())) return null + return SparkUi( + balanceSats = balanceSats, + deposits = deposits.map { + SparkDepositUi(it, if (it.isClaimed) null else lightningRepo.onchainConfirmations(it.txid)) + }.toImmutableList(), + ) +} + data class ChannelUi( val name: String, val details: ChannelDetails, diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/SparkDetailScreen.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/SparkDetailScreen.kt new file mode 100644 index 0000000000..c6719479f2 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/SparkDetailScreen.kt @@ -0,0 +1,164 @@ +package to.bitkit.ui.settings.lightning + +import android.content.Intent +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.collections.immutable.persistentListOf +import to.bitkit.R +import to.bitkit.repositories.SparkDeposit +import to.bitkit.ui.components.BodyS +import to.bitkit.ui.components.CaptionB +import to.bitkit.ui.components.ChannelStatusUi +import to.bitkit.ui.components.FillHeight +import to.bitkit.ui.components.LightningChannel +import to.bitkit.ui.components.MoneyCaptionB +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.scaffold.AppTopBar +import to.bitkit.ui.scaffold.DrawerNavIcon +import to.bitkit.ui.scaffold.ScreenColumn +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.getBlockExplorerUrl + +@Composable +fun SparkDetailScreen( + onBack: () -> Unit, + onTransferToSavings: () -> Unit, + viewModel: SparkDetailViewModel = hiltViewModel(), +) { + val context = LocalContext.current + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + Content( + uiState = uiState, + onBack = onBack, + onRefresh = viewModel::onPullToRefresh, + onOpenTx = { txId -> context.startActivity(Intent(Intent.ACTION_VIEW, getBlockExplorerUrl(txId).toUri())) }, + onTransferToSavings = onTransferToSavings, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun Content( + uiState: SparkDetailUiState, + onBack: () -> Unit = {}, + onRefresh: () -> Unit = {}, + onOpenTx: (String) -> Unit = {}, + onTransferToSavings: () -> Unit = {}, +) { + val spark = uiState.spark ?: SparkUi(balanceSats = 0uL) + ScreenColumn { + AppTopBar( + titleText = stringResource(R.string.lightning__spark), + onBackClick = onBack, + actions = { DrawerNavIcon() }, + ) + PullToRefreshBox( + isRefreshing = uiState.isRefreshing, + onRefresh = onRefresh, + modifier = Modifier.fillMaxSize() + ) { + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .navigationBarsPadding() + .testTag("SparkScrollView") + ) { + VerticalSpacer(16.dp) + LightningChannel( + capacity = spark.balanceSats.toLong(), + localBalance = spark.balanceSats.toLong(), + remoteBalance = 0L, + status = ChannelStatusUi.OPEN, + ) + VerticalSpacer(32.dp) + HorizontalDivider() + + SectionTitle(stringResource(R.string.lightning__spark_provider)) + SectionRow( + name = stringResource(R.string.lightning__spark_provider), + valueContent = { CaptionB(text = stringResource(R.string.lightning__spark_provider_value)) }, + ) + VerticalSpacer(12.dp) + BodyS(text = stringResource(R.string.lightning__spark_trust), color = Colors.White64) + + SectionTitle(stringResource(R.string.lightning__balance)) + SectionRow( + name = stringResource(R.string.lightning__spending_label), + valueContent = { MoneyCaptionB(sats = spark.balanceSats.toLong(), symbol = true) }, + ) + + if (spark.pendingDeposits.isNotEmpty()) { + SectionTitle(stringResource(R.string.lightning__spark_deposits_pending)) + spark.pendingDeposits.forEach { SparkDepositRow(it, onOpenTx) } + } + + if (spark.claimedDeposits.isNotEmpty()) { + SectionTitle(stringResource(R.string.lightning__spark_deposits_claimed)) + spark.claimedDeposits.forEach { depositUi -> + SparkDepositRow(depositUi, onOpenTx) + SectionRow( + name = stringResource(R.string.lightning__spark_claim_fee), + valueContent = { + MoneyCaptionB(sats = depositUi.deposit.feeSats.toLong(), symbol = true) + }, + ) + } + } + + FillHeight() + VerticalSpacer(32.dp) + PrimaryButton( + text = stringResource(R.string.wallet__transfer_to_savings), + onClick = onTransferToSavings, + enabled = spark.balanceSats > 0uL, + modifier = Modifier + .fillMaxWidth() + .testTag("SparkTransferToSavings") + ) + VerticalSpacer(16.dp) + } + } + } +} + +@Preview +@Composable +private fun Preview() { + AppThemeSurface { + Content( + uiState = SparkDetailUiState( + spark = SparkUi( + balanceSats = 35_000uL, + deposits = persistentListOf( + SparkDepositUi(SparkDeposit("6c8706b8a9cbd18f", 15_000uL, isClaimed = false), 1u), + SparkDepositUi(SparkDeposit("d0067b2497c7b905", 35_000uL, isClaimed = true, feeSats = 312uL)), + ), + ), + ), + ) + } +} diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/SparkDetailViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/SparkDetailViewModel.kt new file mode 100644 index 0000000000..7364d569f4 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/SparkDetailViewModel.kt @@ -0,0 +1,49 @@ +package to.bitkit.ui.settings.lightning + +import androidx.compose.runtime.Immutable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import to.bitkit.di.BgDispatcher +import to.bitkit.repositories.LightningRepo +import to.bitkit.repositories.SparkRepo +import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds + +@HiltViewModel +class SparkDetailViewModel @Inject constructor( + @BgDispatcher private val bgDispatcher: CoroutineDispatcher, + private val lightningRepo: LightningRepo, + private val sparkRepo: SparkRepo, +) : ViewModel() { + private val _uiState = MutableStateFlow(SparkDetailUiState()) + val uiState = _uiState.asStateFlow() + + init { + viewModelScope.launch(bgDispatcher) { + sparkRepo.state.collect { state -> _uiState.update { it.copy(spark = state.toUi(lightningRepo)) } } + } + } + + fun onPullToRefresh() { + viewModelScope.launch { + _uiState.update { it.copy(isRefreshing = true) } + sparkRepo.refreshState() + _uiState.update { it.copy(spark = sparkRepo.state.value.toUi(lightningRepo)) } + delay(500.milliseconds) + _uiState.update { it.copy(isRefreshing = false) } + } + } +} + +@Immutable +data class SparkDetailUiState( + val spark: SparkUi? = null, + val isRefreshing: Boolean = false, +) diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index ca78e334bc..5532f68fce 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -225,6 +225,15 @@ في قائمة انتظار الفتح جارٍ فتح الاتصال يرجى الانتظار، جارٍ تحويل أموالك. يجب أن يستغرق هذا <accent>±10 دقائق.</accent> + Spark + رسوم المطالبة + %1$d/3 تأكيدات + إيداع + إيداعات تمت المطالبة بها + إيداعات قيد التنفيذ + المزوّد + Spark عبر Breez + يُحفظ هذا الرصيد على Spark الذي تديره Lightspark وBreez وFlashnet. يبقى آمنًا ما دام أحدهم على الأقل يتصرف بأمانة. يمكنهم إيقاف الوصول مؤقتًا، والخروج من دونهم يتطلب خروجًا على السلسلة. قابل للإنفاق على السلسلة الإنفاق رسوم السيولة diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml index 449f254bc3..5c9215d6de 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -232,6 +232,15 @@ En Fila Para la Apertura Abriendo Conexión Por favor, espere, su transferencia está en curso. Esto debería tardar <accent>±10 minutos.</accent> + Spark + Comisión de reclamo + %1$d/3 confirmaciones + Depósito + Depósitos reclamados + Depósitos en curso + Proveedor + Spark mediante Breez + Este saldo se guarda en Spark, operado por Lightspark, Breez y Flashnet. Está seguro mientras al menos uno de ellos actúe con honestidad. Pueden pausar el acceso, y salir sin ellos requiere una salida on-chain. Gastable onchain Gasto Comisión por liquidez diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index ebc566b16c..9965541bff 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -229,6 +229,15 @@ En cua per obrir Obertura de connexió Si us plau, espera, la transferència de fons està en curs. Hauria de trigar <accent>±10 minuts.</accent> + Spark + Comissió de reclamació + %1$d/3 confirmacions + Dipòsit + Dipòsits reclamats + Dipòsits en curs + Proveïdor + Spark mitjançant Breez + Aquest saldo es guarda a Spark, gestionat per Lightspark, Breez i Flashnet. Està segur mentre almenys un d’ells actuï amb honestedat. Poden pausar l’accés, i sortir-ne sense ells requereix una sortida on-chain. Gastable Onchain Despesa Tarifa de liquiditat diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index f211f782bc..2889713beb 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -232,6 +232,15 @@ Ve frontě na otevření Otevírání spojení Počkejte prosím, probíhá převod finančních prostředků. To by mělo trvat <accent>±10 minut.</accent> + Spark + Poplatek za připsání + %1$d/3 potvrzení + Vklad + Připsané vklady + Probíhající vklady + Poskytovatel + Spark přes Breez + Tento zůstatek je uložen na Spark, který provozují Lightspark, Breez a Flashnet. Je v bezpečí, dokud alespoň jeden z nich jedná poctivě. Mohou pozastavit přístup a odchod bez nich vyžaduje on-chain výstup. Utratitelné onchain Útraty Poplatek za likviditu diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 00d02eec7d..9e73c148b8 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -101,6 +101,15 @@ Abhängig von der Gebühr Abhängig von der Gebühr Abhängig von der Gebühr + Spark + Einlösegebühr + %1$d/3 Bestätigungen + Einzahlung + Eingelöste Einzahlungen + Laufende Einzahlungen + Anbieter + Spark über Breez + Dieses Guthaben liegt auf Spark, betrieben von Lightspark, Breez und Flashnet. Es ist sicher, solange mindestens einer von ihnen ehrlich handelt. Sie können den Zugang sperren, und ein Ausstieg ohne sie erfordert einen On-Chain-Exit. Spending\n<accent>Balance</accent> Fülle dein Guthaben auf, um sofortige und günstige Transaktionen mit Freunden, Familie und Händlern zu genießen. Los geht\'s diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index b8321c952e..c9bd50ca14 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -225,6 +225,15 @@ Σε ουρά για άνοιγμα Άνοιγμα σύνδεσης Περίμενε, η μεταφορά κεφαλαίων σου είναι σε εξέλιξη. Αυτό θα διαρκέσει <accent>±10 λεπτά.</accent> + Spark + Χρέωση εξαργύρωσης + %1$d/3 επιβεβαιώσεις + Κατάθεση + Εξαργυρωμένες καταθέσεις + Καταθέσεις σε εξέλιξη + Πάροχος + Spark μέσω Breez + Αυτό το υπόλοιπο φυλάσσεται στο Spark, που λειτουργούν οι Lightspark, Breez και Flashnet. Είναι ασφαλές όσο τουλάχιστον ένας από αυτούς ενεργεί έντιμα. Μπορούν να αναστείλουν την πρόσβαση, και η έξοδος χωρίς αυτούς απαιτεί έξοδο on-chain. Διαθέσιμα onchain Δαπάνες Τέλος ρευστότητας diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index 86ed051d4f..69ab4400e7 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -231,6 +231,15 @@ En cola para apertura Abriendo Conexión Por favor espera, tu transferencia de fondos está en curso. Debería tardar <accent>±10 minutos.</accent> + Spark + Comisión de reclamación + %1$d/3 confirmaciones + Depósito + Depósitos reclamados + Depósitos en curso + Proveedor + Spark mediante Breez + Este saldo se guarda en Spark, gestionado por Lightspark, Breez y Flashnet. Está seguro mientras al menos uno de ellos actúe con honestidad. Pueden pausar el acceso, y salir sin ellos requiere una salida on-chain. Gastable en cadena Gasto Comisión de liquidez diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 4172a81fa7..d94ac9e729 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -163,6 +163,15 @@ Pago Exitoso En Cola para Apertura Por favor espera, la transferencia de tus fondos está en progreso. Esto debería llevar <accent>±10 minutos.</accent> + Spark + Comisión de reclamo + %1$d/3 confirmaciones + Depósito + Depósitos reclamados + Depósitos en curso + Proveedor + Spark mediante Breez + Este saldo se guarda en Spark, operado por Lightspark, Breez y Flashnet. Está seguro mientras al menos uno de ellos actúe con honestidad. Pueden pausar el acceso, y salir sin ellos requiere una salida on-chain. Gastable onchain Comisión de liquidez Capacidad de\n<accent>recepción</accent> diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index b072d8be67..2fa6d6f9ff 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -100,6 +100,15 @@ Dépend des frais Dépend des frais Dépend des frais + Spark + Frais de réclamation + %1$d/3 confirmations + Dépôt + Dépôts réclamés + Dépôts en cours + Fournisseur + Spark via Breez + Ce solde est détenu sur Spark, géré par Lightspark, Breez et Flashnet. Il est en sécurité tant qu’au moins l’un d’eux agit honnêtement. Ils peuvent suspendre l’accès, et en sortir sans eux nécessite une sortie on-chain. Dépenses \n<accent>Solde</accent> Alimentez votre solde de dépenses pour profiter de transactions instantanées et bon marché avec vos amis, votre famille et les commerçants. Commencer diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 21f7c48147..c3e71226c8 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -230,6 +230,15 @@ In Coda Per l\'Apertura Apertura della Connessione Attendi, il trasferimento dei fondi è in corso. L\'operazione dovrebbe richiedere <accent>±10 minuti.</accent> + Spark + Commissione di riscatto + %1$d/3 conferme + Deposito + Depositi riscattati + Depositi in corso + Fornitore + Spark tramite Breez + Questo saldo è custodito su Spark, gestito da Lightspark, Breez e Flashnet. È al sicuro finché almeno uno di loro agisce onestamente. Possono sospendere l’accesso e uscire senza di loro richiede un’uscita on-chain. Spendibile Onchain Spesa Commissione liquidita\' diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 341a98ab46..17b7558ea3 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -229,6 +229,15 @@ In wachtrij voor openen Verbinding openen Even geduld, je overboeking is bezig. Dit duurt <accent>±10 minuten.</accent> + Spark + Claimkosten + %1$d/3 bevestigingen + Storting + Geclaimde stortingen + Lopende stortingen + Aanbieder + Spark via Breez + Dit saldo staat op Spark, beheerd door Lightspark, Breez en Flashnet. Het is veilig zolang ten minste één van hen eerlijk handelt. Zij kunnen de toegang pauzeren, en vertrekken zonder hen vereist een on-chain exit. Besteedbaar onchain Bestedingssaldo Liquiditeitsvergoeding diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 90c7f9c390..bfb6442611 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -102,6 +102,15 @@ Zależy od opłaty Zależy od opłaty Zależy od opłaty + Spark + Opłata za odbiór + %1$d/3 potwierdzeń + Wpłata + Odebrane wpłaty + Wpłaty w toku + Dostawca + Spark przez Breez + To saldo jest przechowywane na Spark, prowadzonym przez Lightspark, Breez i Flashnet. Jest bezpieczne, dopóki co najmniej jeden z nich działa uczciwie. Mogą wstrzymać dostęp, a wyjście bez nich wymaga wyjścia on-chain. Saldo \n<accent>Wydatków</accent> Zasil swoje saldo wydatków, aby cieszyć się natychmiastowymi i tanimi transakcjami z przyjaciółmi, rodziną i sprzedawcami. Rozpocznij diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 15d2f9aae1..83670f117f 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -233,6 +233,15 @@ Na Fila Para Abertura Abrindo Conexão Aguarde, sua transferência de fundos está em andamento. Isso deve levar <accent>±10 minutos.</accent> + Spark + Taxa de resgate + %1$d/3 confirmações + Depósito + Depósitos resgatados + Depósitos em andamento + Provedor + Spark via Breez + Este saldo fica na Spark, operada pela Lightspark, Breez e Flashnet. Ele está seguro enquanto pelo menos um deles agir com honestidade. Eles podem pausar o acesso, e sair sem eles exige uma saída on-chain. Disponível Onchain Gastos Taxa de liquidez diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 70081cffc2..6f8166350d 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -97,6 +97,15 @@ Depende da taxa Depende da taxa Depende da taxa + Spark + Taxa de resgate + %1$d/3 confirmações + Depósito + Depósitos resgatados + Depósitos em curso + Fornecedor + Spark via Breez + Este saldo é mantido na Spark, operada pela Lightspark, Breez e Flashnet. Está seguro enquanto pelo menos um deles agir com honestidade. Podem suspender o acesso, e sair sem eles exige uma saída on-chain. Saldo de\n<accent>Gastos</accent> Use seu saldo de gastos para desfrutar de transações instantâneas e baratas com amigos, familiares e comerciantes. Começar diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 9fcc5a4ff2..5ce533477b 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -245,6 +245,15 @@ В Очереди на Открытие Открытие Соединения Пожалуйста, подождите, ваш перевод средств выполняется. Это займёт <accent>±10 минут.</accent> + Spark + Комиссия за зачисление + %1$d/3 подтверждений + Депозит + Зачисленные депозиты + Депозиты в обработке + Провайдер + Spark через Breez + Этот баланс хранится в Spark, которым управляют Lightspark, Breez и Flashnet. Он в безопасности, пока хотя бы один из них действует честно. Они могут приостановить доступ, а выход без них требует ончейн-вывода. Доступно Onchain Расходы Комиссия за Ликвидность diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4b5dd29299..66dd5c5de7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -387,6 +387,15 @@ Queued For Opening Opening Connection Please wait, your funds transfer is in progress. This should take <accent>±10 minutes.</accent> + Spark + Claim fee + %1$d/3 confirmations + Deposit + Claimed deposits + Deposits in progress + Provider + Spark via Breez + This balance is held on Spark, run by Lightspark, Breez and Flashnet. It is safe while at least one of them acts honestly. They can pause access, and leaving without them takes an on-chain exit. Spendable Onchain Spending Your savings cannot cover the liquidity fee for this receiving capacity. Choose a smaller amount. diff --git a/docs/screens-map.md b/docs/screens-map.md index bfde34c946..496eec1985 100644 --- a/docs/screens-map.md +++ b/docs/screens-map.md @@ -322,6 +322,7 @@ Frame names are stable across handoff iterations; node ids are not, so the map l | ChannelDetailScreen.kt | Settings > Advanced › Connection details | | CloseConnectionScreen.kt | Settings > Advanced › Close connection | | LightningConnectionsScreen.kt | Settings > Advanced › Lightning Connections overview | +| SparkDetailScreen.kt | `todo` | ## ui/settings/pin From ec6d2f5d0c36d84a1e6489b8a4902ca4ef849b71 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 26 Oct 2026 17:06:49 +0100 Subject: [PATCH 18/47] feat: let dev builds pay spark invoices and fund spark --- .../to/bitkit/repositories/LightningRepo.kt | 2 +- .../java/to/bitkit/repositories/SparkRepo.kt | 3 ++- .../java/to/bitkit/services/SparkService.kt | 8 ++++++- app/src/main/java/to/bitkit/ui/ContentView.kt | 5 +++-- .../lightning/LightningConnectionsScreen.kt | 2 +- .../LightningConnectionsViewModel.kt | 6 +++++- .../settings/lightning/SparkDetailScreen.kt | 21 +++++++++++++++++++ .../lightning/SparkDetailViewModel.kt | 15 +++++++++++++ 8 files changed, 55 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 46d90393e8..c2bbf2cb2f 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -46,9 +46,9 @@ import org.lightningdevkit.ldknode.Bolt11Invoice import org.lightningdevkit.ldknode.ChannelConfig import org.lightningdevkit.ldknode.ChannelDataMigration import org.lightningdevkit.ldknode.ChannelDetails -import org.lightningdevkit.ldknode.ConfirmationStatus import org.lightningdevkit.ldknode.ClosureReason import org.lightningdevkit.ldknode.CoinSelectionAlgorithm +import org.lightningdevkit.ldknode.ConfirmationStatus import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.Network import org.lightningdevkit.ldknode.NodeStatus diff --git a/app/src/main/java/to/bitkit/repositories/SparkRepo.kt b/app/src/main/java/to/bitkit/repositories/SparkRepo.kt index f789c47e7a..c7264fd18d 100644 --- a/app/src/main/java/to/bitkit/repositories/SparkRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/SparkRepo.kt @@ -107,7 +107,8 @@ class SparkRepo @Inject constructor( suspend fun estimateFeeSats(bolt11: String, amountSats: ULong?): Result = withContext(bgDispatcher) { runSuspendCatching { val method = sparkService.prepareSend(bolt11, amountSats).paymentMethod - (method as SendPaymentMethod.Bolt11Invoice).lightningFeeSats + val bolt11 = method as SendPaymentMethod.Bolt11Invoice + bolt11.sparkTransferFeeSats?.takeIf { sparkService.prefersSpark } ?: bolt11.lightningFeeSats } } diff --git a/app/src/main/java/to/bitkit/services/SparkService.kt b/app/src/main/java/to/bitkit/services/SparkService.kt index 0bba34ff34..8a1d92b1b9 100644 --- a/app/src/main/java/to/bitkit/services/SparkService.kt +++ b/app/src/main/java/to/bitkit/services/SparkService.kt @@ -80,6 +80,12 @@ class SparkService @Inject constructor( val isConnected: Boolean get() = sdk != null + /** + * Spark's regtest has no Lightning network, so there a Lightning invoice from another Spark wallet is paid as a + * Spark transfer, which lets two dev builds pay each other's invoices. + */ + val prefersSpark: Boolean get() = Env.network == Network.REGTEST + suspend fun connect(walletIndex: Int) = mutex.withLock { if (sdk != null) return@withLock val network = sparkNetwork() ?: throw SparkError.Unsupported() @@ -150,7 +156,7 @@ class SparkService @Inject constructor( suspend fun send(prepared: PrepareSendPaymentResponse): Payment { val options = when (prepared.paymentMethod) { is SendPaymentMethod.Bolt11Invoice -> SendPaymentOptions.Bolt11Invoice( - preferSpark = false, + preferSpark = prefersSpark, completionTimeoutSecs = SEND_COMPLETION_TIMEOUT_SECS, ) is SendPaymentMethod.BitcoinAddress -> SendPaymentOptions.BitcoinAddress(OnchainConfirmationSpeed.MEDIUM) diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index c5633ea813..8d0da8b68e 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -202,8 +202,8 @@ import to.bitkit.ui.settings.general.WidgetsSettingsScreen import to.bitkit.ui.settings.lightning.ChannelDetailScreen import to.bitkit.ui.settings.lightning.CloseConnectionScreen import to.bitkit.ui.settings.lightning.LightningConnectionsScreen -import to.bitkit.ui.settings.lightning.SparkDetailScreen import to.bitkit.ui.settings.lightning.LightningConnectionsViewModel +import to.bitkit.ui.settings.lightning.SparkDetailScreen import to.bitkit.ui.settings.pin.PinManagementScreen import to.bitkit.ui.settings.quickPay.QuickPayIntroScreen import to.bitkit.ui.settings.quickPay.QuickPaySettingsScreen @@ -1965,7 +1965,8 @@ private fun NavGraphBuilder.lightningConnections( ) } deepLinkableComposable { - val hasSeenSavingsIntro = settingsViewModel?.hasSeenSavingsIntro?.collectAsStateWithLifecycle()?.value == true + val hasSeenSavingsIntro = settingsViewModel?.hasSeenSavingsIntro + ?.collectAsStateWithLifecycle()?.value == true SparkDetailScreen( onBack = { navController.popBackStack() }, onTransferToSavings = { 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 7f5b18f967..b53b46d0ee 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 @@ -52,8 +52,8 @@ import to.bitkit.models.msatFloorOf import to.bitkit.ui.Routes import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BodyMSB -import to.bitkit.ui.components.CaptionB import to.bitkit.ui.components.Caption13Up +import to.bitkit.ui.components.CaptionB import to.bitkit.ui.components.ChannelStatusUi import to.bitkit.ui.components.Display import to.bitkit.ui.components.FillHeight 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 89bed727fc..0a6e0094d1 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 @@ -23,9 +23,11 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.lightningdevkit.ldknode.ChannelDetails import org.lightningdevkit.ldknode.Event +import org.lightningdevkit.ldknode.Network import org.lightningdevkit.ldknode.OutPoint import to.bitkit.R import to.bitkit.di.BgDispatcher +import to.bitkit.env.Env import to.bitkit.ext.amountOnClose import to.bitkit.ext.calculateRemoteBalance import to.bitkit.ext.createChannelDetails @@ -326,7 +328,9 @@ data class SparkDepositUi( ) internal suspend fun SparkState.toUi(lightningRepo: LightningRepo): SparkUi? { - if (!isReady || (balanceSats == 0uL && deposits.isEmpty())) return null + if (!isReady) return null + // On regtest Spark always shows, so testers reach its deposit address to fund it from the faucet. + if (balanceSats == 0uL && deposits.isEmpty() && Env.network != Network.REGTEST) return null return SparkUi( balanceSats = balanceSats, deposits = deposits.map { diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/SparkDetailScreen.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/SparkDetailScreen.kt index c6719479f2..cb250753d3 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/SparkDetailScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/SparkDetailScreen.kt @@ -17,6 +17,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.net.toUri @@ -24,6 +26,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.collections.immutable.persistentListOf import to.bitkit.R +import to.bitkit.ext.setClipboardText import to.bitkit.repositories.SparkDeposit import to.bitkit.ui.components.BodyS import to.bitkit.ui.components.CaptionB @@ -54,6 +57,7 @@ fun SparkDetailScreen( onBack = onBack, onRefresh = viewModel::onPullToRefresh, onOpenTx = { txId -> context.startActivity(Intent(Intent.ACTION_VIEW, getBlockExplorerUrl(txId).toUri())) }, + onCopyText = { context.setClipboardText(it) }, onTransferToSavings = onTransferToSavings, ) } @@ -65,6 +69,7 @@ private fun Content( onBack: () -> Unit = {}, onRefresh: () -> Unit = {}, onOpenTx: (String) -> Unit = {}, + onCopyText: (String) -> Unit = {}, onTransferToSavings: () -> Unit = {}, ) { val spark = uiState.spark ?: SparkUi(balanceSats = 0uL) @@ -105,6 +110,22 @@ private fun Content( VerticalSpacer(12.dp) BodyS(text = stringResource(R.string.lightning__spark_trust), color = Colors.White64) + uiState.depositAddress?.let { address -> + SectionRow( + name = stringResource(R.string.wallet__activity_address), + valueContent = { + CaptionB( + text = address, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, + textAlign = TextAlign.End, + ) + }, + onClick = { onCopyText(address) }, + modifier = Modifier.testTag("SparkDepositAddress"), + ) + } + SectionTitle(stringResource(R.string.lightning__balance)) SectionRow( name = stringResource(R.string.lightning__spending_label), diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/SparkDetailViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/SparkDetailViewModel.kt index 7364d569f4..ac14080045 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/SparkDetailViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/SparkDetailViewModel.kt @@ -10,7 +10,9 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import org.lightningdevkit.ldknode.Network import to.bitkit.di.BgDispatcher +import to.bitkit.env.Env import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.SparkRepo import javax.inject.Inject @@ -29,6 +31,17 @@ class SparkDetailViewModel @Inject constructor( viewModelScope.launch(bgDispatcher) { sparkRepo.state.collect { state -> _uiState.update { it.copy(spark = state.toUi(lightningRepo)) } } } + if (Env.network == Network.REGTEST) { + viewModelScope.launch(bgDispatcher) { + sparkRepo.depositAddress().onSuccess { address -> + _uiState.update { + it.copy( + depositAddress = address + ) + } + } + } + } } fun onPullToRefresh() { @@ -46,4 +59,6 @@ class SparkDetailViewModel @Inject constructor( data class SparkDetailUiState( val spark: SparkUi? = null, val isRefreshing: Boolean = false, + /** Spark's on-chain deposit address, shown on regtest to fund Spark from Lightspark's regtest faucet. */ + val depositAddress: String? = null, ) From dbbe7e403b5ebc90c054f855bb0e174e5aa8efd3 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 26 Oct 2026 17:37:30 +0100 Subject: [PATCH 19/47] feat: run dev builds on spark's regtest chain --- app/build.gradle.kts | 1 + app/src/main/java/to/bitkit/env/Env.kt | 7 ++ .../to/bitkit/services/LightningService.kt | 15 +++ scripts/spark-regtest-esplora.py | 100 ++++++++++++++++++ scripts/spark-regtest-fund.py | 50 +++++++++ 5 files changed, 173 insertions(+) create mode 100755 scripts/spark-regtest-esplora.py create mode 100755 scripts/spark-regtest-fund.py diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e8bed45d5c..451d0593fd 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -358,6 +358,7 @@ androidComponents { localProp("USDT_RPC_URL").orElse("https://bitkit.to/v1/usdt/chain-rpc").stringField(), ) buildConfigFields.put("BREEZ_API_KEY", localProp("BREEZ_API_KEY").orElse("").stringField()) + buildConfigFields.put("ESPLORA_URL", localProp("ESPLORA_URL").orElse("").stringField()) buildConfigFields.put("E2E", e2eEnv.booleanField()) buildConfigFields.put("E2E_BACKEND", e2eBackendEnv.stringField()) buildConfigFields.put("E2E_LOCAL_HOST", e2eLocalHostEnv.stringField()) diff --git a/app/src/main/java/to/bitkit/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt index 39caeffca1..56fe9e83e7 100644 --- a/app/src/main/java/to/bitkit/env/Env.kt +++ b/app/src/main/java/to/bitkit/env/Env.kt @@ -32,6 +32,13 @@ internal object Env { } val breezApiKey: String? get() = BuildConfig.BREEZ_API_KEY.takeIf { it.isNotBlank() } + + /** + * Esplora endpoint for the node's chain source on regtest builds, set to put the wallet on Spark's regtest chain + * through `scripts/spark-regtest-esplora.py`; Electrum is used when unset. + */ + val regtestEsploraUrl: String? + get() = BuildConfig.ESPLORA_URL.takeIf { it.isNotBlank() && network == Network.REGTEST } val isDebug = BuildConfig.DEBUG const val isE2eTest = BuildConfig.E2E const val isGeoblockingEnabled = BuildConfig.GEO diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index 6e3cf85e63..a6c9ca76d7 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -32,6 +32,7 @@ import org.lightningdevkit.ldknode.CoinSelectionAlgorithm import org.lightningdevkit.ldknode.Config import org.lightningdevkit.ldknode.ConfirmationStatus import org.lightningdevkit.ldknode.ElectrumSyncConfig +import org.lightningdevkit.ldknode.EsploraSyncConfig import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.FeeRate import org.lightningdevkit.ldknode.KeychainKind @@ -396,6 +397,20 @@ class LightningService internal constructor( } private suspend fun Builder.configureChainSource(customServerUrl: String? = null) { + Env.regtestEsploraUrl?.let { esploraUrl -> + Logger.info("Using onchain source Esplora url: '$esploraUrl'", context = TAG) + setChainSourceEsplora( + serverUrl = esploraUrl, + config = EsploraSyncConfig( + BackgroundSyncConfig( + onchainWalletSyncIntervalSecs = Env.walletSyncIntervalSecs, + lightningWalletSyncIntervalSecs = Env.walletSyncIntervalSecs, + feeRateCacheUpdateIntervalSecs = Env.walletSyncIntervalSecs, + ), + ), + ) + return + } val serverUrl = customServerUrl ?: settingsStore.data.first().electrumServer Logger.info("Using onchain source Electrum Sever url: $serverUrl", context = TAG) setChainSourceElectrum( diff --git a/scripts/spark-regtest-esplora.py b/scripts/spark-regtest-esplora.py new file mode 100755 index 0000000000..d5a3863116 --- /dev/null +++ b/scripts/spark-regtest-esplora.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""Esplora endpoint on Spark's regtest chain for a dev build's ldk-node. + +Spark's regtest is Lightspark's own chain. Its only public chain API is a mempool.space instance +(the Breez SDK's regtest default), which answers Esplora's address, transaction and block calls +but has no `/fee-estimates`, which ldk-node reads at start, nor `/block//status`, which its +Lightning sync reads. This proxy serves `/fee-estimates` from the instance's `/v1/fees/recommended`, +builds a block's status from `/block/` and `/block-height/`, and forwards everything +else, so a dev build built with `ESPLORA_URL=http://:` runs on Spark's chain. + +Usage: + python3 scripts/spark-regtest-esplora.py [--port 3999] [--bind 0.0.0.0] [--max-seconds 10800] + # emulator on the same host: ESPLORA_URL=http://10.0.2.2:3999 +""" +import argparse +import json +import threading +import time +import urllib.error +import urllib.request +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +UPSTREAM = "https://regtest-mempool.us-west-2.sparkinfra.net/api" + + +def log(*parts): + print(time.strftime("%H:%M:%S"), *parts, flush=True) + + +def fetch(path): + with urllib.request.urlopen(f"{UPSTREAM}{path}", timeout=15) as response: + return response.read().decode() + + +def block_status(block_hash): + height = json.loads(fetch(f"/block/{block_hash}"))["height"] + in_best_chain = fetch(f"/block-height/{height}").strip() == block_hash + try: + next_best = fetch(f"/block-height/{height + 1}").strip() if in_best_chain else None + except urllib.error.HTTPError: + next_best = None + return {"in_best_chain": in_best_chain, "height": height, "next_best": next_best} + + +def fee_estimates(): + with urllib.request.urlopen(f"{UPSTREAM}/v1/fees/recommended", timeout=15) as response: + fees = json.load(response) + targets = {1: "fastestFee", 2: "fastestFee", 3: "halfHourFee", 6: "hourFee", 12: "economyFee", 144: "minimumFee"} + return {str(blocks): float(fees.get(key, 1)) for blocks, key in targets.items()} + + +class Handler(BaseHTTPRequestHandler): + def do_GET(self): + self.forward() + + def do_POST(self): + self.forward() + + def forward(self): + if self.path.rstrip("/") == "/fee-estimates": + return self.reply(200, json.dumps(fee_estimates()).encode(), "application/json") + parts = self.path.strip("/").split("/") + if len(parts) == 3 and parts[0] == "block" and parts[2] == "status": + return self.reply(200, json.dumps(block_status(parts[1])).encode(), "application/json") + body = self.rfile.read(int(self.headers.get("Content-Length") or 0)) if self.command == "POST" else None + request = urllib.request.Request(f"{UPSTREAM}{self.path}", data=body, method=self.command) + if self.headers.get("Content-Type"): + request.add_header("Content-Type", self.headers["Content-Type"]) + try: + with urllib.request.urlopen(request, timeout=30) as response: + self.reply(response.status, response.read(), response.headers.get("Content-Type")) + except urllib.error.HTTPError as error: + self.reply(error.code, error.read(), error.headers.get("Content-Type")) + + def reply(self, status, payload, content_type): + self.send_response(status) + if content_type: + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def log_message(self, fmt, *args): + log(self.command, self.path, *args[1:2]) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--port", type=int, default=3999) + parser.add_argument("--bind", default="127.0.0.1") + parser.add_argument("--max-seconds", type=int, default=3 * 3600) + args = parser.parse_args() + server = ThreadingHTTPServer((args.bind, args.port), Handler) + threading.Timer(args.max_seconds, server.shutdown).start() + log(f"serving Spark regtest Esplora on {args.bind}:{args.port} for {args.max_seconds}s") + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/scripts/spark-regtest-fund.py b/scripts/spark-regtest-fund.py new file mode 100755 index 0000000000..20a3f7de16 --- /dev/null +++ b/scripts/spark-regtest-fund.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +"""Fund an address on Spark's regtest chain from Lightspark's regtest faucet. + +Use it on a dev build running on Spark's chain (see `spark-regtest-esplora.py`): fund the Savings +address to test Savings to Spending and the deposit claim, or Spark's deposit address on the Spark +screen to fund Spending directly. Lightspark's API faucet takes HTTP basic auth: set +FAUCET_USERNAME and FAUCET_PASSWORD (issued by Lightspark). The web faucet at +https://app.lightspark.com/regtest-faucet works without them, by hand. + +Usage: + FAUCET_USERNAME=… FAUCET_PASSWORD=… python3 scripts/spark-regtest-fund.py [sats] +""" +import base64 +import json +import os +import sys +import urllib.request + +FAUCET_URL = os.environ.get("FAUCET_URL", "https://api.lightspark.com/graphql/spark/rc") +QUERY = ( + "mutation RequestRegtestFunds($address: String!, $amount_sats: Long!) " + "{ request_regtest_funds(input: {address: $address, amount_sats: $amount_sats}) { transaction_hash } }" +) + + +def main(): + if len(sys.argv) < 2: + sys.exit(__doc__) + address = sys.argv[1] + amount_sats = int(sys.argv[2]) if len(sys.argv) > 2 else 100_000 + username, password = os.environ.get("FAUCET_USERNAME"), os.environ.get("FAUCET_PASSWORD") + if not username or not password: + sys.exit("Set FAUCET_USERNAME and FAUCET_PASSWORD, or use https://app.lightspark.com/regtest-faucet") + body = json.dumps({ + "operationName": "RequestRegtestFunds", + "variables": {"address": address, "amount_sats": amount_sats}, + "query": QUERY, + }).encode() + request = urllib.request.Request(FAUCET_URL, data=body, method="POST") + request.add_header("Content-Type", "application/json") + request.add_header("Authorization", "Basic " + base64.b64encode(f"{username}:{password}".encode()).decode()) + with urllib.request.urlopen(request, timeout=60) as response: + result = json.load(response) + if result.get("errors"): + sys.exit("Faucet returned errors: " + ", ".join(e["message"] for e in result["errors"])) + print(result["data"]["request_regtest_funds"]["transaction_hash"]) + + +if __name__ == "__main__": + main() From c84786793df9f3ac3fe59131f102b1d197869b32 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 26 Oct 2026 18:13:03 +0100 Subject: [PATCH 20/47] feat: show the spark claim fee before transfer to spending --- .../java/to/bitkit/repositories/SparkRepo.kt | 11 ++++++++++ .../java/to/bitkit/services/SparkService.kt | 3 +++ .../screens/transfer/SpendingConfirmScreen.kt | 21 ++++++++++++++++--- .../to/bitkit/viewmodels/TransferViewModel.kt | 5 +++++ 4 files changed, 37 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/SparkRepo.kt b/app/src/main/java/to/bitkit/repositories/SparkRepo.kt index c7264fd18d..e6146792bf 100644 --- a/app/src/main/java/to/bitkit/repositories/SparkRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/SparkRepo.kt @@ -57,6 +57,12 @@ class SparkRepo @Inject constructor( private const val TAG = "SparkRepo" private const val MSATS_PER_SAT = 1_000uL + /** + * Size of Spark's deposit claim transaction; the SDK prices a standard claim as the fastest recommended fee + * rate times this size before the provider quotes it (breez/spark-sdk `CLAIM_TX_SIZE_VBYTES`). + */ + private const val CLAIM_TX_SIZE_VBYTES = 99uL + /** Last step of the Spending setup progress, shown once a funding deposit is claimed. */ private const val SETUP_STEP_DONE = 3 } @@ -125,6 +131,11 @@ class SparkRepo @Inject constructor( } } + /** Estimates what Spark keeps from a deposit to claim it, the way the SDK does before the provider quotes it. */ + suspend fun estimateDepositClaimFeeSats(): Result = withContext(bgDispatcher) { + runSuspendCatching { sparkService.fastestFeeRate() * CLAIM_TX_SIZE_VBYTES } + } + suspend fun depositAddress(): Result = withContext(bgDispatcher) { runSuspendCatching { sparkService.depositAddress() } } diff --git a/app/src/main/java/to/bitkit/services/SparkService.kt b/app/src/main/java/to/bitkit/services/SparkService.kt index 8a1d92b1b9..db7a7f5f48 100644 --- a/app/src/main/java/to/bitkit/services/SparkService.kt +++ b/app/src/main/java/to/bitkit/services/SparkService.kt @@ -165,6 +165,9 @@ class SparkService @Inject constructor( return requireSdk().sendPayment(SendPaymentRequest(prepared, options)).payment } + /** The fastest recommended fee rate, in sat/vB, from the SDK's own fee source. */ + suspend fun fastestFeeRate(): ULong = requireSdk().recommendedFees().fastestFee + suspend fun listUnclaimedDeposits(): List = requireSdk().listUnclaimedDeposits(ListUnclaimedDepositsRequest).deposits 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 c091da8af9..72c6ea1e5a 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 @@ -180,7 +180,16 @@ private fun Content( .verticalScroll(rememberScrollState()) ) { val clientBalance = state.clientBalanceSat - val lspFee = state.feeSat.safe() - clientBalance.safe() + val lspFee = if (state.isSpark) { + state.sparkClaimFeeSats + } else { + state.feeSat.safe() - clientBalance.safe() + } + val toSpending = if (state.isSpark) { + clientBalance.safe() - state.sparkClaimFeeSats.safe() + } else { + clientBalance + } val total = state.confirmLeavingAmountSats val lspBalance = state.lspBalanceSat @@ -197,7 +206,13 @@ private fun Content( amount = miningFeeSats.toLong(), ) FeeInfo( - label = stringResource(R.string.lightning__spending_confirm__lsp_fee), + label = stringResource( + if (state.isSpark) { + R.string.lightning__spark_claim_fee + } else { + R.string.lightning__spending_confirm__lsp_fee + } + ), amount = lspFee.toLong(), ) } @@ -207,7 +222,7 @@ private fun Content( ) { FeeInfo( label = stringResource(R.string.lightning__spending_confirm__amount), - amount = clientBalance.toLong(), + amount = toSpending.toLong(), ) FeeInfo( label = stringResource(R.string.lightning__spending_confirm__total), diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 5413b6bc29..005598c2e9 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -217,6 +217,9 @@ class TransferViewModel @Inject constructor( return@launch } onEstimateReady(clientBalanceSat = amountSats, lspBalanceSat = 0uL, feeSat = amountSats, isSpark = true) + sparkRepo.estimateDepositClaimFeeSats() + .onSuccess { fee -> _spendingUiState.update { it.copy(sparkClaimFeeSats = fee) } } + .onFailure { Logger.warn("Failed to estimate Spark claim fee", it, context = TAG) } } } @@ -2152,6 +2155,8 @@ data class TransferToSpendingUiState( val isAdvanced: Boolean = false, /** Funds the Spark wallet instead of buying a Blocktank channel. */ val isSpark: Boolean = false, + /** Estimated fee Spark keeps from the deposit to claim it into Spending. */ + val sparkClaimFeeSats: ULong = 0uL, val maxAllowedToSend: Long = 0, val balanceAfterFee: Long = 0, val quarterAmount: Long = 0, From 650ba48a6439f3718ba7c30389ed113d9984f38d Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 26 Oct 2026 18:27:21 +0100 Subject: [PATCH 21/47] feat: show spark as uncapped receiving without channel options --- .../bitkit/ui/components/LightningChannel.kt | 37 ++++++++++++------- .../screens/transfer/SpendingConfirmScreen.kt | 37 ++++++++++--------- .../lightning/LightningConnectionsScreen.kt | 21 +++++++++-- .../settings/lightning/SparkDetailScreen.kt | 1 + app/src/main/res/values-ar/strings.xml | 1 + app/src/main/res/values-b+es+419/strings.xml | 1 + app/src/main/res/values-ca/strings.xml | 1 + app/src/main/res/values-cs/strings.xml | 1 + app/src/main/res/values-de/strings.xml | 1 + app/src/main/res/values-el/strings.xml | 1 + app/src/main/res/values-es-rES/strings.xml | 1 + app/src/main/res/values-es/strings.xml | 1 + app/src/main/res/values-fr/strings.xml | 1 + app/src/main/res/values-it/strings.xml | 1 + app/src/main/res/values-nl/strings.xml | 1 + app/src/main/res/values-pl/strings.xml | 1 + app/src/main/res/values-pt-rBR/strings.xml | 1 + app/src/main/res/values-pt/strings.xml | 1 + app/src/main/res/values-ru/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 20 files changed, 76 insertions(+), 36 deletions(-) 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 2b604ebe71..e2da9f207f 100644 --- a/app/src/main/java/to/bitkit/ui/components/LightningChannel.kt +++ b/app/src/main/java/to/bitkit/ui/components/LightningChannel.kt @@ -39,6 +39,7 @@ fun LightningChannel( modifier: Modifier = Modifier, status: ChannelStatusUi = ChannelStatusUi.PENDING, showLabels: Boolean = false, + showReceiving: Boolean = true, ) { val spendingColor = if (status == ChannelStatusUi.CLOSED) Colors.Gray5 else Colors.Purple50 val spendingAvailableColor = if (status == ChannelStatusUi.CLOSED) Colors.Gray3 else Colors.Purple @@ -58,7 +59,9 @@ fun LightningChannel( modifier = Modifier.fillMaxWidth(), ) { Caption13Up(text = stringResource(R.string.lightning__spending_label), color = Colors.White64) - Caption13Up(text = stringResource(R.string.lightning__receiving_label), color = Colors.White64) + if (showReceiving) { + Caption13Up(text = stringResource(R.string.lightning__receiving_label), color = Colors.White64) + } } Spacer(modifier = Modifier.height(8.dp)) } @@ -78,17 +81,19 @@ fun LightningChannel( color = spendingAvailableColor, ) } - Row(verticalAlignment = CenterVertically) { - Icon( - painter = painterResource(R.drawable.ic_received), - contentDescription = null, - tint = receivingAvailableColor, - modifier = Modifier.size(14.dp) - ) - MoneyCaptionB( - sats = remoteBalance, - color = receivingAvailableColor, - ) + if (showReceiving) { + Row(verticalAlignment = CenterVertically) { + Icon( + painter = painterResource(R.drawable.ic_received), + contentDescription = null, + tint = receivingAvailableColor, + modifier = Modifier.size(14.dp) + ) + MoneyCaptionB( + sats = remoteBalance, + color = receivingAvailableColor, + ) + } } } Spacer(modifier = Modifier.height(8.dp)) @@ -102,16 +107,17 @@ fun LightningChannel( modifier = Modifier .weight(1f) .fillMaxHeight() - .background(spendingColor, RoundedCornerShape(topStart = 8.dp, bottomStart = 8.dp)) + .background(spendingColor, if (showReceiving) SpendingHalfShape else FullBarShape) ) { Box( modifier = Modifier .fillMaxHeight() .fillMaxWidth(spendingAvailableFraction) .align(Alignment.CenterEnd) - .background(spendingAvailableColor, RoundedCornerShape(topStart = 8.dp, bottomStart = 8.dp)) + .background(spendingAvailableColor, if (showReceiving) SpendingHalfShape else FullBarShape) ) } + if (!showReceiving) return@Row Spacer(modifier = Modifier.width(4.dp)) Box( modifier = Modifier @@ -130,6 +136,9 @@ fun LightningChannel( } } +private val SpendingHalfShape = RoundedCornerShape(topStart = 8.dp, bottomStart = 8.dp) +private val FullBarShape = RoundedCornerShape(8.dp) + // region preview @Preview(showBackground = true) 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 72c6ea1e5a..3d0c4afbae 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 @@ -244,26 +244,27 @@ private fun Content( VerticalSpacer(16.dp) } - SettingsSwitchRow( - title = stringResource(R.string.settings__bg__setup), - isChecked = hasNotificationPermission, - colors = AppSwitchDefaults.colorsPurple, - onClick = onSwitchClick, - switchTestTag = "SpendingConfirmNotificationSwitch", - modifier = Modifier.fillMaxWidth() - ) + // Blocktank's order-paid push and channel liquidity don't apply to funding Spark. + if (!state.isSpark) { + SettingsSwitchRow( + title = stringResource(R.string.settings__bg__setup), + isChecked = hasNotificationPermission, + colors = AppSwitchDefaults.colorsPurple, + onClick = onSwitchClick, + switchTestTag = "SpendingConfirmNotificationSwitch", + modifier = Modifier.fillMaxWidth() + ) - VerticalSpacer(16.dp) + VerticalSpacer(16.dp) - Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { - PrimaryButton( - text = stringResource(R.string.common__learn_more), - size = ButtonSize.Small, - fullWidth = false, - onClick = onLearnMoreClick, - modifier = Modifier.testTag("SpendingConfirmMore") - ) - if (!state.isSpark) { + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + PrimaryButton( + text = stringResource(R.string.common__learn_more), + size = ButtonSize.Small, + fullWidth = false, + onClick = onLearnMoreClick, + modifier = Modifier.testTag("SpendingConfirmMore") + ) PrimaryButton( text = stringResource( if (isAdvanced) { 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 b53b46d0ee..0776f294f8 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 @@ -191,7 +191,11 @@ private fun Content( .verticalScroll(rememberScrollState()) ) { VerticalSpacer(16.dp) - LightningBalancesSection(uiState.localBalance, uiState.remoteBalance) + LightningBalancesSection( + uiState.localBalance, + uiState.remoteBalance, + isReceivingUnlimited = uiState.spark != null + ) HorizontalDivider(modifier = Modifier.padding(top = 16.dp)) uiState.spark?.let { spark -> @@ -291,7 +295,7 @@ private fun Content( } @Composable -private fun LightningBalancesSection(localBalance: ULong, remoteBalance: ULong) { +private fun LightningBalancesSection(localBalance: ULong, remoteBalance: ULong, isReceivingUnlimited: Boolean = false) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, @@ -307,12 +311,20 @@ private fun LightningBalancesSection(localBalance: ULong, remoteBalance: ULong) balance = remoteBalance, icon = R.drawable.ic_received, color = Colors.White, + // Spark receives without inbound liquidity, so no channel caps receiving. + valueText = stringResource(R.string.lightning__spark_receiving_unlimited).takeIf { isReceivingUnlimited }, ) } } @Composable -private fun BalanceColumn(label: String, balance: ULong, @DrawableRes icon: Int, color: Color) { +private fun BalanceColumn( + label: String, + balance: ULong, + @DrawableRes icon: Int, + color: Color, + valueText: String? = null, +) { Column { Caption13Up(text = label, color = Colors.White64) VerticalSpacer(8.dp) @@ -325,7 +337,7 @@ private fun BalanceColumn(label: String, balance: ULong, @DrawableRes icon: Int, tint = color, modifier = Modifier.size(22.dp) ) - Title(text = balance.toLong().formatToModernDisplay(), color = color) + Title(text = valueText ?: balance.toLong().formatToModernDisplay(), color = color) } } } @@ -431,6 +443,7 @@ private fun SparkItem( localBalance = spark.balanceSats.toLong(), remoteBalance = 0L, status = ChannelStatusUi.OPEN, + showReceiving = false, ) spark.pendingDeposits.forEach { SparkDepositRow(it, onOpenTx) } VerticalSpacer(16.dp) diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/SparkDetailScreen.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/SparkDetailScreen.kt index cb250753d3..6663e8dbc6 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/SparkDetailScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/SparkDetailScreen.kt @@ -98,6 +98,7 @@ private fun Content( localBalance = spark.balanceSats.toLong(), remoteBalance = 0L, status = ChannelStatusUi.OPEN, + showReceiving = false, ) VerticalSpacer(32.dp) HorizontalDivider() diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 5532f68fce..11f574043c 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -233,6 +233,7 @@ إيداعات قيد التنفيذ المزوّد Spark عبر Breez + بلا حد يُحفظ هذا الرصيد على Spark الذي تديره Lightspark وBreez وFlashnet. يبقى آمنًا ما دام أحدهم على الأقل يتصرف بأمانة. يمكنهم إيقاف الوصول مؤقتًا، والخروج من دونهم يتطلب خروجًا على السلسلة. قابل للإنفاق على السلسلة الإنفاق diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml index 5c9215d6de..a570509551 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -240,6 +240,7 @@ Depósitos en curso Proveedor Spark mediante Breez + Sin límite Este saldo se guarda en Spark, operado por Lightspark, Breez y Flashnet. Está seguro mientras al menos uno de ellos actúe con honestidad. Pueden pausar el acceso, y salir sin ellos requiere una salida on-chain. Gastable onchain Gasto diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 9965541bff..911e5d3491 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -237,6 +237,7 @@ Dipòsits en curs Proveïdor Spark mitjançant Breez + Sense límit Aquest saldo es guarda a Spark, gestionat per Lightspark, Breez i Flashnet. Està segur mentre almenys un d’ells actuï amb honestedat. Poden pausar l’accés, i sortir-ne sense ells requereix una sortida on-chain. Gastable Onchain Despesa diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 2889713beb..44fd8767c1 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -240,6 +240,7 @@ Probíhající vklady Poskytovatel Spark přes Breez + Bez limitu Tento zůstatek je uložen na Spark, který provozují Lightspark, Breez a Flashnet. Je v bezpečí, dokud alespoň jeden z nich jedná poctivě. Mohou pozastavit přístup a odchod bez nich vyžaduje on-chain výstup. Utratitelné onchain Útraty diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 9e73c148b8..aba47a6243 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -109,6 +109,7 @@ Laufende Einzahlungen Anbieter Spark über Breez + Kein Limit Dieses Guthaben liegt auf Spark, betrieben von Lightspark, Breez und Flashnet. Es ist sicher, solange mindestens einer von ihnen ehrlich handelt. Sie können den Zugang sperren, und ein Ausstieg ohne sie erfordert einen On-Chain-Exit. Spending\n<accent>Balance</accent> Fülle dein Guthaben auf, um sofortige und günstige Transaktionen mit Freunden, Familie und Händlern zu genießen. diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index c9bd50ca14..4f04853903 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -233,6 +233,7 @@ Καταθέσεις σε εξέλιξη Πάροχος Spark μέσω Breez + Χωρίς όριο Αυτό το υπόλοιπο φυλάσσεται στο Spark, που λειτουργούν οι Lightspark, Breez και Flashnet. Είναι ασφαλές όσο τουλάχιστον ένας από αυτούς ενεργεί έντιμα. Μπορούν να αναστείλουν την πρόσβαση, και η έξοδος χωρίς αυτούς απαιτεί έξοδο on-chain. Διαθέσιμα onchain Δαπάνες diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index 69ab4400e7..dc7ee453f7 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -239,6 +239,7 @@ Depósitos en curso Proveedor Spark mediante Breez + Sin límite Este saldo se guarda en Spark, gestionado por Lightspark, Breez y Flashnet. Está seguro mientras al menos uno de ellos actúe con honestidad. Pueden pausar el acceso, y salir sin ellos requiere una salida on-chain. Gastable en cadena Gasto diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index d94ac9e729..86775136d3 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -171,6 +171,7 @@ Depósitos en curso Proveedor Spark mediante Breez + Sin límite Este saldo se guarda en Spark, operado por Lightspark, Breez y Flashnet. Está seguro mientras al menos uno de ellos actúe con honestidad. Pueden pausar el acceso, y salir sin ellos requiere una salida on-chain. Gastable onchain Comisión de liquidez diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 2fa6d6f9ff..fd8c75d8ce 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -108,6 +108,7 @@ Dépôts en cours Fournisseur Spark via Breez + Sans limite Ce solde est détenu sur Spark, géré par Lightspark, Breez et Flashnet. Il est en sécurité tant qu’au moins l’un d’eux agit honnêtement. Ils peuvent suspendre l’accès, et en sortir sans eux nécessite une sortie on-chain. Dépenses \n<accent>Solde</accent> Alimentez votre solde de dépenses pour profiter de transactions instantanées et bon marché avec vos amis, votre famille et les commerçants. diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index c3e71226c8..cd923b94b9 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -238,6 +238,7 @@ Depositi in corso Fornitore Spark tramite Breez + Nessun limite Questo saldo è custodito su Spark, gestito da Lightspark, Breez e Flashnet. È al sicuro finché almeno uno di loro agisce onestamente. Possono sospendere l’accesso e uscire senza di loro richiede un’uscita on-chain. Spendibile Onchain Spesa diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 17b7558ea3..7ecdeab3b3 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -237,6 +237,7 @@ Lopende stortingen Aanbieder Spark via Breez + Geen limiet Dit saldo staat op Spark, beheerd door Lightspark, Breez en Flashnet. Het is veilig zolang ten minste één van hen eerlijk handelt. Zij kunnen de toegang pauzeren, en vertrekken zonder hen vereist een on-chain exit. Besteedbaar onchain Bestedingssaldo diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index bfb6442611..3b82d42533 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -110,6 +110,7 @@ Wpłaty w toku Dostawca Spark przez Breez + Bez limitu To saldo jest przechowywane na Spark, prowadzonym przez Lightspark, Breez i Flashnet. Jest bezpieczne, dopóki co najmniej jeden z nich działa uczciwie. Mogą wstrzymać dostęp, a wyjście bez nich wymaga wyjścia on-chain. Saldo \n<accent>Wydatków</accent> Zasil swoje saldo wydatków, aby cieszyć się natychmiastowymi i tanimi transakcjami z przyjaciółmi, rodziną i sprzedawcami. diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 83670f117f..a96b43d686 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -241,6 +241,7 @@ Depósitos em andamento Provedor Spark via Breez + Sem limite Este saldo fica na Spark, operada pela Lightspark, Breez e Flashnet. Ele está seguro enquanto pelo menos um deles agir com honestidade. Eles podem pausar o acesso, e sair sem eles exige uma saída on-chain. Disponível Onchain Gastos diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 6f8166350d..4dc6ca45ca 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -105,6 +105,7 @@ Depósitos em curso Fornecedor Spark via Breez + Sem limite Este saldo é mantido na Spark, operada pela Lightspark, Breez e Flashnet. Está seguro enquanto pelo menos um deles agir com honestidade. Podem suspender o acesso, e sair sem eles exige uma saída on-chain. Saldo de\n<accent>Gastos</accent> Use seu saldo de gastos para desfrutar de transações instantâneas e baratas com amigos, familiares e comerciantes. diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 5ce533477b..260810d04a 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -253,6 +253,7 @@ Депозиты в обработке Провайдер Spark через Breez + Без ограничений Этот баланс хранится в Spark, которым управляют Lightspark, Breez и Flashnet. Он в безопасности, пока хотя бы один из них действует честно. Они могут приостановить доступ, а выход без них требует ончейн-вывода. Доступно Onchain Расходы diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 66dd5c5de7..1943cb205b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -395,6 +395,7 @@ Deposits in progress Provider Spark via Breez + No limit This balance is held on Spark, run by Lightspark, Breez and Flashnet. It is safe while at least one of them acts honestly. They can pause access, and leaving without them takes an on-chain exit. Spendable Onchain Spending From c32cd542ac06efe06d52d8a467dff4ef9cb4e8d3 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 26 Oct 2026 19:15:15 +0100 Subject: [PATCH 22/47] feat: publish a spark lightning endpoint on paykit --- app/src/main/java/to/bitkit/repositories/LightningRepo.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index c2bbf2cb2f..404bcf046d 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -2309,9 +2309,13 @@ class LightningRepo @Inject constructor( fun getChannels(): List? = if (_lightningState.value.nodeLifecycleState.isRunning()) lightningService.channels else null + /** + * Whether Lightning can be received, through a usable channel or Spark; Paykit publishes a Lightning endpoint + * only then. + */ fun canReceive(): Boolean { val isRunning = _lightningState.value.nodeLifecycleState.isRunning() - return isRunning && lightningService.canReceive() + return isRunning && (lightningService.canReceive() || sparkRepo.canReceive()) } suspend fun bumpFeeByRbf( From bfca63ee9f16b509f63ce05a2bdefc44a459b168 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 26 Oct 2026 23:41:23 +0100 Subject: [PATCH 23/47] ci: pass the breez api key to release builds --- .github/workflows/release-internal.yml | 2 ++ .github/workflows/release.yml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.github/workflows/release-internal.yml b/.github/workflows/release-internal.yml index 8099065f11..1a042d86ca 100644 --- a/.github/workflows/release-internal.yml +++ b/.github/workflows/release-internal.yml @@ -73,6 +73,8 @@ jobs: KEYSTORE_PASSWORD: ${{ secrets.INTERNAL_KEYSTORE_PASSWORD }} KEY_ALIAS: ${{ secrets.INTERNAL_KEY_ALIAS }} KEY_PASSWORD: ${{ secrets.INTERNAL_KEY_PASSWORD }} + # Optional: without it the build passes and Spark (Lightning without channels) stays off. + BREEZ_API_KEY: ${{ secrets.BREEZ_API_KEY }} run: | set -euo pipefail ./gradlew assembleMainnetRelease --no-daemon --stacktrace diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index edcca33abc..5242fcd479 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -74,6 +74,8 @@ jobs: KEYSTORE_PASSWORD: ${{ secrets.BITKIT_KEYSTORE_PASSWORD }} KEY_ALIAS: ${{ secrets.BITKIT_KEY_ALIAS }} KEY_PASSWORD: ${{ secrets.BITKIT_KEY_PASSWORD }} + # Optional: without it the build passes and Spark (Lightning without channels) stays off. + BREEZ_API_KEY: ${{ secrets.BREEZ_API_KEY }} run: | set -euo pipefail ./gradlew assembleMainnetRelease bundleMainnetRelease --no-daemon --stacktrace From 3591356e2236208b84de25e52700cfa75003a00f Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 26 Oct 2026 23:52:17 +0100 Subject: [PATCH 24/47] ci: require the breez api key for release builds --- .github/workflows/release.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5242fcd479..a957db809d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -66,6 +66,15 @@ jobs: printf '%s' "$BITKIT_KEYSTORE_BASE64" | base64 --decode > "$keystore_path" echo "KEYSTORE_FILE=$keystore_path" >> "$GITHUB_ENV" + - name: Check Breez API key + env: + BREEZ_API_KEY: ${{ secrets.BREEZ_API_KEY }} + run: | + if [ -z "${BREEZ_API_KEY// /}" ]; then + echo "::error::BREEZ_API_KEY is missing from the release environment; a release without it ships with Spark (Lightning without channels) off." + exit 1 + fi + - name: Build release artifacts env: GPR_USER: ${{ secrets.GPR_USER || github.actor }} @@ -74,7 +83,6 @@ jobs: KEYSTORE_PASSWORD: ${{ secrets.BITKIT_KEYSTORE_PASSWORD }} KEY_ALIAS: ${{ secrets.BITKIT_KEY_ALIAS }} KEY_PASSWORD: ${{ secrets.BITKIT_KEY_PASSWORD }} - # Optional: without it the build passes and Spark (Lightning without channels) stays off. BREEZ_API_KEY: ${{ secrets.BREEZ_API_KEY }} run: | set -euo pipefail From bb4fa07d7634433be422a3dee16b1c9c7d451bd4 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 27 Oct 2026 00:03:05 +0100 Subject: [PATCH 25/47] fix: list spark deposits at once and quiet the receiving limit --- .../java/to/bitkit/repositories/SparkRepo.kt | 34 +++++++++++++++++- .../java/to/bitkit/services/SparkService.kt | 6 ++++ .../lightning/LightningConnectionsScreen.kt | 6 +++- .../to/bitkit/viewmodels/TransferViewModel.kt | 1 + .../to/bitkit/repositories/SparkRepoTest.kt | 35 +++++++++++++++++++ 5 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 app/src/test/java/to/bitkit/repositories/SparkRepoTest.kt diff --git a/app/src/main/java/to/bitkit/repositories/SparkRepo.kt b/app/src/main/java/to/bitkit/repositories/SparkRepo.kt index e6146792bf..4ddff73fe4 100644 --- a/app/src/main/java/to/bitkit/repositories/SparkRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/SparkRepo.kt @@ -35,6 +35,7 @@ import to.bitkit.ext.create import to.bitkit.ext.runSuspendCatching import to.bitkit.services.SparkService import to.bitkit.utils.Logger +import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject import javax.inject.Provider import javax.inject.Singleton @@ -68,6 +69,9 @@ class SparkRepo @Inject constructor( } private val scope = appScope(bgDispatcher, TAG) + + /** Deposits this app broadcast that Spark hasn't listed yet, by txid, so they show as pending at once. */ + private val broadcastDeposits = ConcurrentHashMap() private var eventsJob: Job? = null private val _state = MutableStateFlow(SparkState(isSupported = sparkService.isSupported)) @@ -151,6 +155,33 @@ class SparkRepo @Inject constructor( } } + /** + * Lists a deposit the app just broadcast as pending right away, then syncs so Spark sees it sooner than its + * minute-long background sync; Spark's own deposit state replaces it once listed. + */ + fun onDepositBroadcast(txid: String, amountSats: ULong) { + broadcastDeposits[txid] = amountSats + _state.update { it.withBroadcastDeposits() } + scope.launch { + runSuspendCatching { + sparkService.syncWallet() + refresh() + }.onFailure { Logger.warn("Failed to sync Spark after deposit '$txid'", it, context = TAG) } + } + } + + private fun SparkState.withBroadcastDeposits(): SparkState { + val known = deposits.map { it.txid }.toSet() + val local = broadcastDeposits.filterKeys { it !in known } + .map { (txid, amount) -> SparkDeposit(txid = txid, amountSats = amount, isClaimed = false) } + if (local.isEmpty()) return this + val all = local + deposits + return copy( + deposits = all.toImmutableList(), + pendingDepositSats = all.filterNot { it.isClaimed }.sumOf { it.amountSats }, + ) + } + suspend fun refreshState(): Result = withContext(bgDispatcher) { if (!sparkService.isConnected) return@withContext Result.success(Unit) runSuspendCatching { refresh() }.onFailure { Logger.warn("Failed to refresh Spark state", it, context = TAG) } @@ -173,6 +204,7 @@ class SparkRepo @Inject constructor( claimedAt = payment.timestamp, ) } + (pending + claimed).forEach { broadcastDeposits.remove(it.txid) } _state.update { it.copy( isReady = true, @@ -180,7 +212,7 @@ class SparkRepo @Inject constructor( pendingDepositSats = pending.sumOf { deposit -> deposit.amountSats }, deposits = (pending + claimed).toImmutableList(), recoverableFundsSats = info.recoverableFundsSats, - ) + ).withBroadcastDeposits() } } diff --git a/app/src/main/java/to/bitkit/services/SparkService.kt b/app/src/main/java/to/bitkit/services/SparkService.kt index db7a7f5f48..9d85877f26 100644 --- a/app/src/main/java/to/bitkit/services/SparkService.kt +++ b/app/src/main/java/to/bitkit/services/SparkService.kt @@ -23,6 +23,7 @@ import breez_sdk_spark.Seed import breez_sdk_spark.SendPaymentMethod import breez_sdk_spark.SendPaymentOptions import breez_sdk_spark.SendPaymentRequest +import breez_sdk_spark.SyncWalletRequest import breez_sdk_spark.connect import breez_sdk_spark.defaultConfig import kotlinx.coroutines.flow.MutableSharedFlow @@ -125,6 +126,11 @@ class SparkService @Inject constructor( File(Env.sparkStoragePath(walletIndex)).deleteRecursively() } + /** Syncs with Spark now instead of waiting for the SDK's next background sync. */ + suspend fun syncWallet() { + requireSdk().syncWallet(SyncWalletRequest) + } + suspend fun getInfo(): GetInfoResponse = requireSdk().getInfo(GetInfoRequest(ensureSynced = false)) suspend fun receiveBolt11(amountSats: ULong?, description: String, expirySecs: UInt): 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 0776f294f8..d849a56b35 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 @@ -328,6 +328,10 @@ private fun BalanceColumn( Column { Caption13Up(text = label, color = Colors.White64) VerticalSpacer(8.dp) + if (valueText != null) { + BodyM(text = valueText, color = Colors.White64) + return@Column + } Row( verticalAlignment = Alignment.CenterVertically, ) { @@ -337,7 +341,7 @@ private fun BalanceColumn( tint = color, modifier = Modifier.size(22.dp) ) - Title(text = valueText ?: balance.toLong().formatToModernDisplay(), color = color) + Title(text = balance.toLong().formatToModernDisplay(), color = color) } } } diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 005598c2e9..e39130bb5b 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -410,6 +410,7 @@ class TransferViewModel @Inject constructor( runSuspendCatching { lightningRepo.completeAcceptedTransferFollowup(orderId = null, txid = outcome.txid) } .onFailure { Logger.warn("Failed to finish accepted Spark funding locally", it, context = TAG) } } + sparkRepo.onDepositBroadcast(outcome.txid, shown.clientBalanceSat) settingsStore.update { it.copy(lightningSetupStep = LN_SETUP_STEP_2) } Logger.info("Funded Spark wallet with '${shown.clientBalanceSat}' sats in '${outcome.txid}'", context = TAG) return true diff --git a/app/src/test/java/to/bitkit/repositories/SparkRepoTest.kt b/app/src/test/java/to/bitkit/repositories/SparkRepoTest.kt new file mode 100644 index 0000000000..42aa545296 --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/SparkRepoTest.kt @@ -0,0 +1,35 @@ +package to.bitkit.repositories + +import breez_sdk_spark.GetInfoResponse +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import to.bitkit.services.SparkService +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertEquals + +class SparkRepoTest : BaseUnitTest() { + private val sparkService = mock() + + private fun createSut() = SparkRepo( + bgDispatcher = testDispatcher, + sparkService = sparkService, + activityRepo = { mock() }, + settingsStore = mock(), + ) + + @Test + fun `lists a broadcast deposit as pending until Spark lists it`() = test { + whenever { sparkService.getInfo() }.thenReturn(GetInfoResponse("pubkey", 0uL, emptyMap(), 0uL)) + whenever { sparkService.listUnclaimedDeposits() }.thenReturn(emptyList()) + whenever { sparkService.listPayments() }.thenReturn(emptyList()) + val sut = createSut() + + sut.onDepositBroadcast("txid", 2_500uL) + + val deposit = sut.state.value.deposits.single() + assertEquals("txid", deposit.txid) + assertEquals(false, deposit.isClaimed) + assertEquals(2_500uL, sut.state.value.pendingDepositSats) + } +} From f542aaaf3dfafb686e73ded7c96f8073c346cbca Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 27 Oct 2026 00:22:54 +0100 Subject: [PATCH 26/47] fix: show no limit while spark can receive --- .../java/to/bitkit/repositories/WalletRepo.kt | 3 +++ .../screens/wallets/receive/EditInvoiceVM.kt | 6 ++++++ .../lightning/LightningConnectionsScreen.kt | 14 ++++++------- .../LightningConnectionsViewModel.kt | 2 ++ .../wallets/receive/EditInvoiceVMTest.kt | 20 +++++++++++++++++++ 5 files changed, 38 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index 1be27f436c..5166c896b2 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -974,6 +974,9 @@ class WalletRepo @Inject constructor( } } + /** Spark receives any amount without inbound liquidity, so no channel or CJIT is needed. */ + fun canReceiveWithSpark(): Boolean = sparkRepo.canReceive() + suspend fun inboundLiquiditySats(): ULong = withContext(bgDispatcher) { return@withContext currentReadyChannels().calculateRemoteBalance() } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceVM.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceVM.kt index 654a0dcf43..f1b1a4cbae 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceVM.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceVM.kt @@ -41,6 +41,12 @@ class EditInvoiceVM @Inject constructor( isGeoBlocked: Boolean, ) { viewModelScope.launch { + if (walletRepo.canReceiveWithSpark()) { + editInvoiceEffect( + EditInvoiceScreenEffects.ApplyReceiveLiquidityAction(ReceiveAdditionalLiquidityAction.None) + ) + return@launch + } _isLoading.update { true } val inboundCapacitySats = walletRepo.inboundLiquiditySats() val maxCjitAmountSats = maxCjitAmountSats(source, amountSats, inboundCapacitySats, isGeoBlocked) 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 d849a56b35..5fe58e1f7a 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 @@ -169,7 +169,11 @@ private fun Content( .fillMaxSize() ) { VerticalSpacer(16.dp) - LightningBalancesSection(uiState.localBalance, uiState.remoteBalance) + LightningBalancesSection( + uiState.localBalance, + uiState.remoteBalance, + isReceivingUnlimited = uiState.isSparkReceiving + ) HorizontalDivider(modifier = Modifier.padding(top = 16.dp)) EmptyStateContent(modifier = Modifier.weight(1f)) @@ -194,7 +198,7 @@ private fun Content( LightningBalancesSection( uiState.localBalance, uiState.remoteBalance, - isReceivingUnlimited = uiState.spark != null + isReceivingUnlimited = uiState.isSparkReceiving ) HorizontalDivider(modifier = Modifier.padding(top = 16.dp)) @@ -328,10 +332,6 @@ private fun BalanceColumn( Column { Caption13Up(text = label, color = Colors.White64) VerticalSpacer(8.dp) - if (valueText != null) { - BodyM(text = valueText, color = Colors.White64) - return@Column - } Row( verticalAlignment = Alignment.CenterVertically, ) { @@ -341,7 +341,7 @@ private fun BalanceColumn( tint = color, modifier = Modifier.size(22.dp) ) - Title(text = balance.toLong().formatToModernDisplay(), color = color) + Title(text = valueText ?: balance.toLong().formatToModernDisplay(), color = color) } } } 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 0a6e0094d1..07565a746f 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 @@ -124,6 +124,7 @@ class LightningConnectionsViewModel @Inject constructor( localBalance = calculateLocalBalance(channels).safe() + sparkState.balanceSats.safe(), remoteBalance = channels.calculateRemoteBalance(), spark = sparkState.toUi(lightningRepo), + isSparkReceiving = sparkState.isReady, ) } } @@ -310,6 +311,7 @@ data class LightningConnectionsUiState( val localBalance: ULong = 0u, val remoteBalance: ULong = 0u, val spark: SparkUi? = null, + val isSparkReceiving: Boolean = false, ) /** The Spark wallet shown as a connection, present once it holds a balance or a deposit. */ diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceVMTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceVMTest.kt index 9bacae9389..7bb264c466 100644 --- a/app/src/test/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceVMTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceVMTest.kt @@ -49,6 +49,26 @@ class EditInvoiceVMTest : BaseUnitTest() { verify(walletRepo, times(1)).inboundLiquiditySats() } + @Test + fun `onClickContinue should emit none for spending above inbound when Spark can receive`() = test { + whenever(walletRepo.canReceiveWithSpark()).thenReturn(true) + + sut.editInvoiceEffect.test { + sut.onClickContinue( + source = ReceiveLiquiditySource.SPENDING, + amountSats = 10_000u, + isGeoBlocked = true, + ) + + assertEquals( + EditInvoiceScreenEffects.ApplyReceiveLiquidityAction(ReceiveAdditionalLiquidityAction.None), + awaitItem(), + ) + cancelAndIgnoreRemainingEvents() + } + verify(walletRepo, times(0)).inboundLiquiditySats() + } + @Test fun `onClickContinue should emit choose amount for spending below CJIT minimum`() = test { whenever(blocktankRepo.maxCjitAmountSats()).thenReturn(Result.success(100_000u)) From e6b6f855c4e66e453e027cf8117c0680b6ee5093 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 27 Oct 2026 00:34:31 +0100 Subject: [PATCH 27/47] feat: back up spark exit data to vss --- app/src/main/java/to/bitkit/env/Env.kt | 4 ++ .../java/to/bitkit/models/BackupCategory.kt | 4 ++ .../java/to/bitkit/models/BackupPayloads.kt | 12 +++++ .../java/to/bitkit/repositories/BackupRepo.kt | 33 ++++++++++++ .../java/to/bitkit/repositories/SparkRepo.kt | 52 ++++++++++++++++++ .../java/to/bitkit/services/SparkService.kt | 9 ++++ .../to/bitkit/viewmodels/BackupsViewModel.kt | 7 ++- app/src/main/res/values-ar/strings.xml | 1 + app/src/main/res/values-b+es+419/strings.xml | 1 + app/src/main/res/values-ca/strings.xml | 1 + app/src/main/res/values-cs/strings.xml | 1 + app/src/main/res/values-de/strings.xml | 1 + app/src/main/res/values-el/strings.xml | 1 + app/src/main/res/values-es-rES/strings.xml | 1 + app/src/main/res/values-es/strings.xml | 1 + app/src/main/res/values-fr/strings.xml | 1 + app/src/main/res/values-it/strings.xml | 1 + app/src/main/res/values-nl/strings.xml | 1 + app/src/main/res/values-pl/strings.xml | 1 + app/src/main/res/values-pt-rBR/strings.xml | 1 + app/src/main/res/values-pt/strings.xml | 1 + app/src/main/res/values-ru/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + .../to/bitkit/repositories/BackupRepoTest.kt | 32 +++++++++++ .../to/bitkit/repositories/SparkRepoTest.kt | 53 ++++++++++++++++++- 25 files changed, 219 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/to/bitkit/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt index 56fe9e83e7..ad85c263a8 100644 --- a/app/src/main/java/to/bitkit/env/Env.kt +++ b/app/src/main/java/to/bitkit/env/Env.kt @@ -229,6 +229,10 @@ internal object Env { fun sparkStoragePath(walletIndex: Int) = storagePathOf(walletIndex, network.name.lowercase(), "spark") + /** A restored Spark exit data backup waiting to be imported once Spark connects; outside Spark's storage. */ + fun sparkExitRestorePath(walletIndex: Int) = + Path(storagePathOf(walletIndex, network.name.lowercase(), "spark-restore"), "exit-state.json").toString() + fun bitkitCoreStoragePath(walletIndex: Int): String { return storagePathOf(walletIndex, network.name.lowercase(), "core") } diff --git a/app/src/main/java/to/bitkit/models/BackupCategory.kt b/app/src/main/java/to/bitkit/models/BackupCategory.kt index 0181b711e7..6b5df079bd 100644 --- a/app/src/main/java/to/bitkit/models/BackupCategory.kt +++ b/app/src/main/java/to/bitkit/models/BackupCategory.kt @@ -38,6 +38,10 @@ enum class BackupCategory( icon = R.drawable.ic_tag, title = R.string.settings__backup__category_tags, ), + SPARK_EXIT( + icon = R.drawable.ic_lightning, + title = R.string.settings__backup__category_spark_exit, + ), // Descoped in v1, will return in v2: // PROFILE( // icon = R.drawable.ic_user, diff --git a/app/src/main/java/to/bitkit/models/BackupPayloads.kt b/app/src/main/java/to/bitkit/models/BackupPayloads.kt index c52781198c..5c150a5eb1 100644 --- a/app/src/main/java/to/bitkit/models/BackupPayloads.kt +++ b/app/src/main/java/to/bitkit/models/BackupPayloads.kt @@ -86,3 +86,15 @@ data class WidgetsBackupV1( val createdAt: Long, val widgets: WidgetsData, ) + +/** + * The Spark wallet's unilateral exit data, as the SDK exports it, kept so a wallet restored on another device can + * still exit on-chain while the Spark operators are unreachable. + */ +@Serializable +data class SparkExitBackupV1( + val version: Int = 1, + val createdAt: Long, + val network: String, + val exitState: String, +) diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index cf99346ba0..2120606ef8 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -62,6 +62,7 @@ import to.bitkit.models.BlocktankBackupV1 import to.bitkit.models.MetadataBackupV1 import to.bitkit.models.PaykitPaymentStateBackup import to.bitkit.models.SettingsBackupV1 +import to.bitkit.models.SparkExitBackupV1 import to.bitkit.models.Toast import to.bitkit.models.WalletBackupV1 import to.bitkit.models.WalletScope @@ -128,6 +129,7 @@ class BackupRepo @Inject constructor( private val onchainSendAttemptStore: OnchainSendAttemptStore, private val transferRepo: Provider, private val lightningRepo: Provider, + private val sparkRepo: SparkRepo, private val vssStoreIdProvider: VssStoreIdProvider, private val preActivityMetadataRepo: PreActivityMetadataRepo, private val lightningService: LightningService, @@ -405,6 +407,16 @@ class BackupRepo @Inject constructor( // payment and sync traffic does not re-upload the whole metadata envelope. dataListenerJobs.add(observeBackupChanges(activityRepo.activityTagsChanged, BackupCategory.METADATA)) + // SPARK_EXIT - Spark announces when its unilateral exit data changes + dataListenerJobs.add( + scope.launch { + sparkRepo.exitStateChanged.collect { + if (shouldSkipBackup()) return@collect + markBackupRequired(BackupCategory.SPARK_EXIT) + } + } + ) + // LIGHTNING_CONNECTIONS - Only display sync timestamp, ldk-node manages its own backups @OptIn(FlowPreview::class) val lightningConnectionsJob = scope.launch { @@ -543,6 +555,8 @@ class BackupRepo @Inject constructor( val upload = if (category == BackupCategory.WALLET) { uploadWalletBackup(null) + } else if (category == BackupCategory.SPARK_EXIT) { + uploadSparkExitBackup() } else { runSuspendCatching { vssBackupClient.putObject(key = category.name, data = getBackupDataBytes(category)).getOrThrow() @@ -572,6 +586,15 @@ class BackupRepo @Inject constructor( } } + /** Nothing to back up until Spark connects, which marks [BackupCategory.SPARK_EXIT] required again. */ + private suspend fun uploadSparkExitBackup(): Result = runSuspendCatching { + val backup = sparkRepo.exitBackup().getOrThrow() ?: return@runSuspendCatching + vssBackupClient.putObject( + key = BackupCategory.SPARK_EXIT.name, + data = json.encodeToString(backup).toByteArray(), + ).getOrThrow() + } + suspend fun persistWalletBackup(usdt: String? = null): Result = uploadWalletBackup(usdt).onFailure { markBackupRequired(BackupCategory.WALLET) } @@ -672,6 +695,11 @@ class BackupRepo @Inject constructor( } BackupCategory.LIGHTNING_CONNECTIONS -> throw NotImplementedError("LIGHTNING backup is managed by ldk-node") + + BackupCategory.SPARK_EXIT -> { + val backup = checkNotNull(sparkRepo.exitBackup().getOrThrow()) { "Spark is not connected" } + json.encodeToString(backup).toByteArray() + } } private suspend fun getMetadataBackupDataBytes(): ByteArray = withContext(ioDispatcher) { @@ -843,6 +871,11 @@ class BackupRepo @Inject constructor( if (restored.needsRewrite) categoriesNeedingRewrite += BackupCategory.ACTIVITY restored.createdAt } + performRestore(BackupCategory.SPARK_EXIT) { dataBytes -> + val parsed = json.decodeFromString(String(dataBytes)) + sparkRepo.restoreExitBackup(parsed, backupWalletIndex()).getOrThrow() + parsed.createdAt + } Logger.info("Full restore success", context = TAG) }.onSuccess { diff --git a/app/src/main/java/to/bitkit/repositories/SparkRepo.kt b/app/src/main/java/to/bitkit/repositories/SparkRepo.kt index 4ddff73fe4..b5cf839834 100644 --- a/app/src/main/java/to/bitkit/repositories/SparkRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/SparkRepo.kt @@ -31,10 +31,14 @@ import org.lightningdevkit.ldknode.PaymentId import to.bitkit.async.appScope import to.bitkit.data.SettingsStore import to.bitkit.di.BgDispatcher +import to.bitkit.env.Env import to.bitkit.ext.create +import to.bitkit.ext.nowMillis import to.bitkit.ext.runSuspendCatching +import to.bitkit.models.SparkExitBackupV1 import to.bitkit.services.SparkService import to.bitkit.utils.Logger +import java.io.File import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject import javax.inject.Provider @@ -80,11 +84,18 @@ class SparkRepo @Inject constructor( private val _nodeEvents = MutableSharedFlow(extraBufferCapacity = 64) val nodeEvents: SharedFlow = _nodeEvents.asSharedFlow() + private val _exitStateChanged = MutableSharedFlow(extraBufferCapacity = 1) + + /** Fires once Spark connects and whenever its unilateral exit data changes, so the exit data backup is redone. */ + val exitStateChanged: SharedFlow = _exitStateChanged.asSharedFlow() + suspend fun start(walletIndex: Int): Result = withContext(bgDispatcher) { if (!sparkService.isSupported) return@withContext Result.success(Unit) runSuspendCatching { if (eventsJob == null) eventsJob = scope.launch { sparkService.events.collect { onSdkEvent(it) } } sparkService.connect(walletIndex) + importRestoredExitState(walletIndex) + _exitStateChanged.emit(Unit) refresh() syncActivities() }.onFailure { @@ -182,6 +193,46 @@ class SparkRepo @Inject constructor( ) } + /** The exit data backup, or null while Spark is not connected and there is nothing to back up. */ + suspend fun exitBackup(): Result = withContext(bgDispatcher) { + runSuspendCatching { + val network = sparkService.networkName + if (!sparkService.isConnected || network == null) return@runSuspendCatching null + SparkExitBackupV1(createdAt = nowMillis(), network = network, exitState = sparkService.exportExitState()) + } + } + + /** + * Puts a restored exit data backup back: at once while Spark is connected, otherwise right after it connects, + * before it is used, so a restored wallet can exit on-chain even while the Spark operators are unreachable. + */ + suspend fun restoreExitBackup(backup: SparkExitBackupV1, walletIndex: Int): Result = + withContext(bgDispatcher) { + runSuspendCatching { + if (backup.network != sparkService.networkName) { + Logger.warn("Skipped Spark exit data from network '${backup.network}'", context = TAG) + return@runSuspendCatching + } + File(Env.sparkExitRestorePath(walletIndex)).writeText(backup.exitState) + if (sparkService.isConnected) importRestoredExitState(walletIndex) + } + } + + private suspend fun importRestoredExitState(walletIndex: Int) { + val file = File(Env.sparkExitRestorePath(walletIndex)) + if (!file.exists()) return + runSuspendCatching { sparkService.importExitState(file.readText()) } + .onSuccess { + file.delete() + Logger.info( + "Imported Spark exit data: '${it.importedLeaves}' leaves, '${it.skippedForeignLeaves}' foreign, " + + "'${it.skippedConflictingLeaves}' conflicting", + context = TAG, + ) + } + .onFailure { Logger.error("Failed to import Spark exit data", it, context = TAG) } + } + suspend fun refreshState(): Result = withContext(bgDispatcher) { if (!sparkService.isConnected) return@withContext Result.success(Unit) runSuspendCatching { refresh() }.onFailure { Logger.warn("Failed to refresh Spark state", it, context = TAG) } @@ -232,6 +283,7 @@ class SparkRepo @Inject constructor( context = TAG, ) is SdkEvent.NewDeposits -> refresh() + is SdkEvent.UnilateralExitStateChanged -> _exitStateChanged.emit(Unit) is SdkEvent.ClaimedDeposits -> { refresh() settingsStore.update { diff --git a/app/src/main/java/to/bitkit/services/SparkService.kt b/app/src/main/java/to/bitkit/services/SparkService.kt index 9d85877f26..799dcb0067 100644 --- a/app/src/main/java/to/bitkit/services/SparkService.kt +++ b/app/src/main/java/to/bitkit/services/SparkService.kt @@ -7,6 +7,8 @@ import breez_sdk_spark.EventListener import breez_sdk_spark.FeePolicy import breez_sdk_spark.GetInfoRequest import breez_sdk_spark.GetInfoResponse +import breez_sdk_spark.ImportUnilateralExitStateRequest +import breez_sdk_spark.ImportUnilateralExitStateResponse import breez_sdk_spark.ListPaymentsRequest import breez_sdk_spark.ListUnclaimedDepositsRequest import breez_sdk_spark.MaxFee @@ -182,6 +184,13 @@ class SparkService @Inject constructor( /** Exports what a unilateral exit needs if the Spark operators stop cooperating; it can reach several MB. */ suspend fun exportExitState(): String = requireSdk().exportUnilateralExitState().exitState + /** Merges an exported exit state back in, without contacting the Spark operators. */ + suspend fun importExitState(exitState: String): ImportUnilateralExitStateResponse = + requireSdk().importUnilateralExitState(ImportUnilateralExitStateRequest(exitState)) + + /** The Spark network this build runs on, as exit data backups record it. */ + val networkName: String? get() = sparkNetwork()?.name?.lowercase() + private fun requireSdk(): BreezSdk = sdk ?: throw SparkError.NotConnected() private fun sparkNetwork(): SparkNetwork? = when (Env.network) { diff --git a/app/src/main/java/to/bitkit/viewmodels/BackupsViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/BackupsViewModel.kt index 350c2c716b..04605d199f 100644 --- a/app/src/main/java/to/bitkit/viewmodels/BackupsViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/BackupsViewModel.kt @@ -18,6 +18,7 @@ import to.bitkit.models.BackupItemStatus import to.bitkit.models.NodeLifecycleState import to.bitkit.repositories.BackupRepo import to.bitkit.repositories.LightningRepo +import to.bitkit.repositories.SparkRepo import javax.inject.Inject @HiltViewModel @@ -25,6 +26,7 @@ class BackupsViewModel @Inject constructor( private val cacheStore: CacheStore, private val backupRepo: BackupRepo, private val lightningRepo: LightningRepo, + private val sparkRepo: SparkRepo, ) : ViewModel() { private val _uiState = MutableStateFlow(BackupStatusUiState()) @@ -37,7 +39,10 @@ class BackupsViewModel @Inject constructor( private fun collectState() { viewModelScope.launch { cacheStore.backupStatuses.collect { cachedStatuses -> - val categories = BackupCategory.entries.map { category -> + // Spark's exit data shows only where Spark runs. + val categories = BackupCategory.entries.filter { + it != BackupCategory.SPARK_EXIT || sparkRepo.state.value.isSupported + }.map { category -> val cachedStatus = cachedStatuses[category] ?: BackupItemStatus(synced = 0, required = 1) category.toUiState(cachedStatus) } diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 11f574043c..a1c9abdbdb 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -574,6 +574,7 @@ جهات الاتصال الملف الشخصي الإعدادات + بيانات الخروج من Spark العلامات سجل المعاملات التعزيزات والتحويلات diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml index a570509551..22bba6b9a7 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -596,6 +596,7 @@ Contactos Perfil Ajustes + Datos de salida de Spark Etiquetas Registro de transacciones Impulsos y transferencias diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 911e5d3491..28c43d4208 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -589,6 +589,7 @@ Contactes Perfil Configuracions + Dades de sortida de Spark Etiquetes Registre de transaccions Impulsos i transferències diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 44fd8767c1..174882164b 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -594,6 +594,7 @@ Kontakty Profil Nastavení + Data pro výstup ze Spark Tagy Transakční protokol Posílení a převody diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index aba47a6243..6cd4b61191 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -647,6 +647,7 @@ Transaktionshistorie Boosts & Übertragungen Einstellungen + Spark-Ausstiegsdaten Widgets Tags Profil diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 4f04853903..32dec19f4d 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -575,6 +575,7 @@ Επαφές Προφίλ Ρυθμίσεις + Δεδομένα εξόδου Spark Ετικέτες Αρχείο συναλλαγών Ενισχύσεις & Μεταφορές diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index dc7ee453f7..b0d60f6f12 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -593,6 +593,7 @@ Contactos Perfil Ajustes + Datos de salida de Spark Etiquetas Registro de Transacciones Impulsos y transferencias diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 86775136d3..a16f7a4b08 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -620,6 +620,7 @@ Registro de Conexiones Registro de Transacciones Ajustes + Datos de salida de Spark Widgets Etiquetas Perfil diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index fd8c75d8ce..b3ccab472b 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -634,6 +634,7 @@ Journal des transactions Boosts et transferts Réglages + Données de sortie Spark Widgets Tags Profil diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index cd923b94b9..bc7c869d1e 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -592,6 +592,7 @@ Contatti Profilo Impostazioni + Dati di uscita Spark Tag Log delle transazioni Boost e Trasferimenti diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 7ecdeab3b3..06f9dab5d1 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -590,6 +590,7 @@ Contacten Profiel Instellingen + Spark-exitgegevens Tags Transactielog Boosts & overboekingen diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 3b82d42533..634530d48b 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -633,6 +633,7 @@ Log transakcji Przyśpieszenia i transfery Ustawienia + Dane wyjścia ze Spark Widgety Tagi Profil diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index a96b43d686..497238db0c 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -597,6 +597,7 @@ Contatos Perfil Ajustes + Dados de saída do Spark Tags Histórico de transações Boosts e transferências diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 4dc6ca45ca..87ab9569c1 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -610,6 +610,7 @@ Histórico de transações Boosts e transferências Ajustes + Dados de saída do Spark Widgets Tags Perfil diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 260810d04a..4fa4e41107 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -612,6 +612,7 @@ Контакты Профиль Настройки + Данные выхода из Spark Теги Журнал Транзакций Ускорения и Переводы diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1943cb205b..153c1b17fb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -911,6 +911,7 @@ Contacts Profile Settings + Spark exit data Tags Transaction Log Boosts & Transfers diff --git a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt index 5e192dbd06..61728b930e 100644 --- a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt @@ -61,6 +61,7 @@ import to.bitkit.models.KnownDevice import to.bitkit.models.MetadataBackupV1 import to.bitkit.models.PaykitPaymentStateBackup import to.bitkit.models.PaykitUsdtStateBackup +import to.bitkit.models.SparkExitBackupV1 import to.bitkit.models.TransportType import to.bitkit.models.WalletBackupV1 import to.bitkit.models.WalletScope @@ -110,6 +111,7 @@ class BackupRepoTest : BaseUnitTest() { private val keychain = mock() private val onchainSendAttemptStore = mock() private val transferRepo = mock() + private val sparkRepo = mock() private val vssStoreIdProvider = mock() private val preActivityMetadataRepo = mock() private val lightningService = mock() @@ -185,9 +187,38 @@ class BackupRepoTest : BaseUnitTest() { privatePaykitAddressReservationRepo.reconcileReservedIndexesWithLdk() }.thenReturn(Result.success(Unit)) + whenever { sparkRepo.exitBackup() }.thenReturn(Result.success(null)) + whenever { sparkRepo.restoreExitBackup(any(), any()) }.thenReturn(Result.success(Unit)) + whenever(sparkRepo.exitStateChanged).thenReturn(MutableSharedFlow()) + sut = createSut() } + @Test + fun `spark exit backup uploads the exported state and restores it`() = test { + val backup = SparkExitBackupV1(createdAt = 1_000, network = "regtest", exitState = "{\"pedigrees\":[]}") + whenever(sparkRepo.exitBackup()).thenReturn(Result.success(backup)) + + sut.triggerBackup(BackupCategory.SPARK_EXIT).getOrThrow() + + val data = argumentCaptor() + verify(vssBackupClient).putObject(eq(BackupCategory.SPARK_EXIT.name), data.capture()) + whenever(vssBackupClient.getObject(BackupCategory.SPARK_EXIT.name)).thenReturn( + Result.success(VssItem(key = BackupCategory.SPARK_EXIT.name, value = data.firstValue, version = 1)) + ) + + sut.performFullRestoreFromLatestBackup().getOrThrow() + + verify(sparkRepo).restoreExitBackup(backup, 0) + } + + @Test + fun `spark exit backup uploads nothing while Spark is not connected`() = test { + sut.triggerBackup(BackupCategory.SPARK_EXIT).getOrThrow() + + verify(vssBackupClient, never()).putObject(eq(BackupCategory.SPARK_EXIT.name), any()) + } + @Test fun `wallet backup waits for submission and includes the latest pending state`() = test { val statuses = MutableStateFlow( @@ -2102,6 +2133,7 @@ class BackupRepoTest : BaseUnitTest() { onchainSendAttemptStore = onchainSendAttemptStore, transferRepo = Provider { transferRepo }, lightningRepo = Provider { lightningRepo }, + sparkRepo = sparkRepo, vssStoreIdProvider = vssStoreIdProvider, preActivityMetadataRepo = preActivityMetadataRepo, lightningService = lightningService, diff --git a/app/src/test/java/to/bitkit/repositories/SparkRepoTest.kt b/app/src/test/java/to/bitkit/repositories/SparkRepoTest.kt index 42aa545296..85f9514af7 100644 --- a/app/src/test/java/to/bitkit/repositories/SparkRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/SparkRepoTest.kt @@ -1,12 +1,24 @@ package to.bitkit.repositories import breez_sdk_spark.GetInfoResponse +import breez_sdk_spark.ImportUnilateralExitStateResponse +import kotlinx.coroutines.flow.MutableSharedFlow +import org.junit.Before +import org.junit.Rule import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.mockito.kotlin.any import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +import to.bitkit.env.Env +import to.bitkit.models.SparkExitBackupV1 import to.bitkit.services.SparkService import to.bitkit.test.BaseUnitTest +import java.io.File import kotlin.test.assertEquals +import kotlin.test.assertFalse class SparkRepoTest : BaseUnitTest() { private val sparkService = mock() @@ -18,11 +30,48 @@ class SparkRepoTest : BaseUnitTest() { settingsStore = mock(), ) - @Test - fun `lists a broadcast deposit as pending until Spark lists it`() = test { + @get:Rule + val tempFolder = TemporaryFolder() + + @Before + fun setUp() { + Env.initAppStoragePath(tempFolder.root.absolutePath) whenever { sparkService.getInfo() }.thenReturn(GetInfoResponse("pubkey", 0uL, emptyMap(), 0uL)) whenever { sparkService.listUnclaimedDeposits() }.thenReturn(emptyList()) whenever { sparkService.listPayments() }.thenReturn(emptyList()) + whenever(sparkService.events).thenReturn(MutableSharedFlow()) + whenever(sparkService.isSupported).thenReturn(true) + whenever(sparkService.networkName).thenReturn("regtest") + } + + @Test + fun `imports a restored exit backup once Spark connects`() = test { + whenever { sparkService.importExitState(any()) }.thenReturn(ImportUnilateralExitStateResponse(2u, 0u, 0u, 0u)) + val sut = createSut() + + sut.restoreExitBackup(SparkExitBackupV1(createdAt = 1, network = "regtest", exitState = "state"), 0) + .getOrThrow() + verify(sparkService, never()).importExitState(any()) + + whenever(sparkService.isConnected).thenReturn(true) + sut.start(0).getOrThrow() + + verify(sparkService).importExitState("state") + assertFalse(File(Env.sparkExitRestorePath(0)).exists()) + } + + @Test + fun `skips an exit backup from another network`() = test { + val sut = createSut() + + sut.restoreExitBackup(SparkExitBackupV1(createdAt = 1, network = "mainnet", exitState = "state"), 0) + .getOrThrow() + + assertFalse(File(Env.sparkExitRestorePath(0)).exists()) + } + + @Test + fun `lists a broadcast deposit as pending until Spark lists it`() = test { val sut = createSut() sut.onDepositBroadcast("txid", 2_500uL) From 53767853d9a076cc6d58e6822629e0a88c112dd9 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 27 Oct 2026 00:42:59 +0100 Subject: [PATCH 28/47] fix: back up spark exit data when spark connected first --- .../java/to/bitkit/repositories/BackupRepo.kt | 10 +++++++-- .../to/bitkit/repositories/BackupRepoTest.kt | 21 +++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index 2120606ef8..a00a640884 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -18,8 +18,10 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @@ -407,10 +409,14 @@ class BackupRepo @Inject constructor( // payment and sync traffic does not re-upload the whole metadata envelope. dataListenerJobs.add(observeBackupChanges(activityRepo.activityTagsChanged, BackupCategory.METADATA)) - // SPARK_EXIT - Spark announces when its unilateral exit data changes + // SPARK_EXIT - back up once Spark is ready, which replays when observing starts after it connected, + // and again whenever Spark announces its unilateral exit data changed dataListenerJobs.add( scope.launch { - sparkRepo.exitStateChanged.collect { + merge( + sparkRepo.state.map { it.isReady }.distinctUntilChanged().filter { it }, + sparkRepo.exitStateChanged, + ).collect { if (shouldSkipBackup()) return@collect markBackupRequired(BackupCategory.SPARK_EXIT) } diff --git a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt index 61728b930e..ee4dc923ee 100644 --- a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt @@ -190,6 +190,7 @@ class BackupRepoTest : BaseUnitTest() { whenever { sparkRepo.exitBackup() }.thenReturn(Result.success(null)) whenever { sparkRepo.restoreExitBackup(any(), any()) }.thenReturn(Result.success(Unit)) whenever(sparkRepo.exitStateChanged).thenReturn(MutableSharedFlow()) + whenever(sparkRepo.state).thenReturn(MutableStateFlow(SparkState())) sut = createSut() } @@ -212,6 +213,26 @@ class BackupRepoTest : BaseUnitTest() { verify(sparkRepo).restoreExitBackup(backup, 0) } + @Test + fun `spark exit backup uploads when Spark was ready before observing started`() = test { + val statuses = MutableStateFlow(mapOf(BackupCategory.SPARK_EXIT to BackupItemStatus(synced = 1, required = 1))) + stubBackupStatuses(statuses, CompletableDeferred(Unit)) {} + stubBackupObservers() + whenever(sparkRepo.state).thenReturn(MutableStateFlow(SparkState(isSupported = true, isReady = true))) + val backup = SparkExitBackupV1(createdAt = 1_000, network = "regtest", exitState = "state") + whenever(sparkRepo.exitBackup()).thenReturn(Result.success(backup)) + try { + sut.startObservingBackups() + runCurrent() + advanceTimeBy(6_000) + runCurrent() + + verify(vssBackupClient).putObject(eq(BackupCategory.SPARK_EXIT.name), any()) + } finally { + sut.stopObservingBackups() + } + } + @Test fun `spark exit backup uploads nothing while Spark is not connected`() = test { sut.triggerBackup(BackupCategory.SPARK_EXIT).getOrThrow() From 567e1a8aca46f275596345d0b208612e1a263af6 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 27 Oct 2026 01:07:38 +0100 Subject: [PATCH 29/47] fix: settle spark payments through their completion event --- .../java/to/bitkit/services/SparkService.kt | 15 ++++++++------- .../java/to/bitkit/services/SparkServiceTest.kt | 17 +++++++++++++++++ 2 files changed, 25 insertions(+), 7 deletions(-) create mode 100644 app/src/test/java/to/bitkit/services/SparkServiceTest.kt diff --git a/app/src/main/java/to/bitkit/services/SparkService.kt b/app/src/main/java/to/bitkit/services/SparkService.kt index 799dcb0067..0d09d9b92b 100644 --- a/app/src/main/java/to/bitkit/services/SparkService.kt +++ b/app/src/main/java/to/bitkit/services/SparkService.kt @@ -56,14 +56,18 @@ class SparkService @Inject constructor( companion object { private const val TAG = "SparkService" - /** Seconds a Lightning send waits for completion before it returns the payment as pending. */ - private const val SEND_COMPLETION_TIMEOUT_SECS = 30u - /** Leeway in sat/vB over the recommended fee that an automatic deposit claim may pay. */ private const val DEPOSIT_CLAIM_FEE_LEEWAY_SAT_PER_VB = 2uL /** Share of a deposit, in parts per million, that an automatic deposit claim may pay on top. */ private const val DEPOSIT_CLAIM_FEE_PPM = 2_000u + + /** + * Returns as soon as the payment starts, like ldk-node, so its completion reaches callers as an event they + * are already watching for; waiting for completion here fired that event before they watched. + */ + fun bolt11SendOptions(prefersSpark: Boolean): SendPaymentOptions = + SendPaymentOptions.Bolt11Invoice(preferSpark = prefersSpark, completionTimeoutSecs = null) } private val mutex = Mutex() @@ -163,10 +167,7 @@ class SparkService @Inject constructor( suspend fun send(prepared: PrepareSendPaymentResponse): Payment { val options = when (prepared.paymentMethod) { - is SendPaymentMethod.Bolt11Invoice -> SendPaymentOptions.Bolt11Invoice( - preferSpark = prefersSpark, - completionTimeoutSecs = SEND_COMPLETION_TIMEOUT_SECS, - ) + is SendPaymentMethod.Bolt11Invoice -> bolt11SendOptions(prefersSpark) is SendPaymentMethod.BitcoinAddress -> SendPaymentOptions.BitcoinAddress(OnchainConfirmationSpeed.MEDIUM) else -> null } diff --git a/app/src/test/java/to/bitkit/services/SparkServiceTest.kt b/app/src/test/java/to/bitkit/services/SparkServiceTest.kt new file mode 100644 index 0000000000..07505a3610 --- /dev/null +++ b/app/src/test/java/to/bitkit/services/SparkServiceTest.kt @@ -0,0 +1,17 @@ +package to.bitkit.services + +import breez_sdk_spark.SendPaymentOptions +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull + +class SparkServiceTest { + @Test + fun `lightning sends return before completion so callers see the completion event`() { + val options = assertIs(SparkService.bolt11SendOptions(prefersSpark = true)) + + assertNull(options.completionTimeoutSecs) + assertEquals(true, options.preferSpark) + } +} From 972e5ec9649c04dd4232d4e8f3495378e385435a Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 27 Oct 2026 12:45:42 +0100 Subject: [PATCH 30/47] fix: stop republishing paid spark invoices and record spark fees --- .../bitkit/repositories/PrivatePaykitRepo.kt | 5 +- .../java/to/bitkit/repositories/SparkRepo.kt | 37 +++++++++++- .../PrivatePaykitAllowancePaymentTest.kt | 4 ++ .../repositories/PrivatePaykitRepoTest.kt | 27 +++++++++ .../to/bitkit/repositories/SparkRepoTest.kt | 56 ++++++++++++++++++- 5 files changed, 123 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index 02dc4fbb55..764a911640 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -81,6 +81,7 @@ class PrivatePaykitRepo @Inject constructor( private val settingsStore: SettingsStore, private val addressReservationRepo: PrivatePaykitAddressReservationRepo, private val lightningRepo: LightningRepo, + private val sparkRepo: SparkRepo, private val walletRepo: WalletRepo, private val publicPaykitRepo: PublicPaykitRepo, private val paymentRequestRepo: PaykitPaymentRequestRepo, @@ -2322,7 +2323,7 @@ class PrivatePaykitRepo @Inject constructor( it.kind is PaymentKind.Bolt11 } .map { it.id.lowercase() } - .toSet() + .toSet() + sparkRepo.attemptedLightningPaymentHashes() private suspend fun isReceivedInvoiceSettled(paymentHash: String): Boolean = paymentHash in receivedSettledPaymentHashes() @@ -2338,7 +2339,7 @@ class PrivatePaykitRepo @Inject constructor( it.kind is PaymentKind.Bolt11 } .map { it.id } - .toSet() + .toSet() + sparkRepo.receivedLightningPaymentHashes() private suspend fun rememberReceivedInvoicePaymentHash(paymentHash: String, publicKey: String) { if (paymentHash.isBlank()) return diff --git a/app/src/main/java/to/bitkit/repositories/SparkRepo.kt b/app/src/main/java/to/bitkit/repositories/SparkRepo.kt index b5cf839834..7b00a0a5d5 100644 --- a/app/src/main/java/to/bitkit/repositories/SparkRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/SparkRepo.kt @@ -76,6 +76,10 @@ class SparkRepo @Inject constructor( /** Deposits this app broadcast that Spark hasn't listed yet, by txid, so they show as pending at once. */ private val broadcastDeposits = ConcurrentHashMap() + + /** Sends recorded before Spark knew their fee; its completion event reports 0 until a later sync. */ + private val sendsRecordedWithoutFee = ConcurrentHashMap.newKeySet() + private var eventsJob: Job? = null private val _state = MutableStateFlow(SparkState(isSupported = sparkService.isSupported)) @@ -243,7 +247,8 @@ class SparkRepo @Inject constructor( val pending = sparkService.listUnclaimedDeposits().map { SparkDeposit(txid = it.txid, amountSats = it.amountSats, isClaimed = false) } - val claimed = sparkService.listPayments() + val payments = sparkService.listPayments() + val claimed = payments .filter { it.method == PaymentMethod.DEPOSIT && it.status == PaymentStatus.COMPLETED } .mapNotNull { payment -> val deposit = payment.details as? PaymentDetails.Deposit ?: return@mapNotNull null @@ -265,6 +270,25 @@ class SparkRepo @Inject constructor( recoverableFundsSats = info.recoverableFundsSats, ).withBroadcastDeposits() } + payments.filter { it.id in sendsRecordedWithoutFee && it.fees.toLong() > 0 }.forEach { recordActivity(it) } + } + + /** Payment hashes of the Lightning payments Spark received. */ + suspend fun receivedLightningPaymentHashes(): Set = + lightningPaymentHashes { it.paymentType == SparkPaymentType.RECEIVE && it.status == PaymentStatus.COMPLETED } + + /** Payment hashes of the Lightning payments Spark sent or is sending. */ + suspend fun attemptedLightningPaymentHashes(): Set = + lightningPaymentHashes { it.paymentType == SparkPaymentType.SEND && it.status != PaymentStatus.FAILED } + + private suspend fun lightningPaymentHashes(filter: (Payment) -> Boolean): Set = withContext(bgDispatcher) { + if (!sparkService.isConnected) return@withContext emptySet() + runSuspendCatching { sparkService.listPayments() } + .onFailure { Logger.warn("Failed to list Spark payments", it, context = TAG) } + .getOrDefault(emptyList()) + .filter(filter) + .mapNotNull { (it.details as? PaymentDetails.Lightning)?.htlcDetails?.paymentHash?.lowercase() } + .toSet() } private suspend fun syncActivities() { @@ -308,8 +332,10 @@ class SparkRepo @Inject constructor( if (payment.status == PaymentStatus.PENDING && payment.paymentType == SparkPaymentType.RECEIVE) { return } + val id = lightning.htlcDetails.paymentHash + val existing = (activityRepo.get().getActivity(id).getOrNull() as? Activity.Lightning)?.v1 val activity = LightningActivity.create( - id = lightning.htlcDetails.paymentHash, + id = id, txType = when (payment.paymentType) { SparkPaymentType.SEND -> PaymentType.SENT SparkPaymentType.RECEIVE -> PaymentType.RECEIVED @@ -325,8 +351,15 @@ class SparkRepo @Inject constructor( fee = payment.fees.toLong().toULong(), message = lightning.description.orEmpty(), preimage = lightning.htlcDetails.preimage, + contact = existing?.contact, + seenAt = existing?.seenAt, ) activityRepo.get().upsertActivity(Activity.Lightning(activity)) + if (payment.paymentType == SparkPaymentType.SEND && payment.fees.toLong() == 0L) { + sendsRecordedWithoutFee.add(payment.id) + } else { + sendsRecordedWithoutFee.remove(payment.id) + } } private fun Payment.toNodeEvent(): Event? { diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitAllowancePaymentTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitAllowancePaymentTest.kt index 076c74f7c4..755bef6919 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitAllowancePaymentTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitAllowancePaymentTest.kt @@ -53,6 +53,7 @@ class PrivatePaykitAllowancePaymentTest : BaseUnitTest(StandardTestDispatcher()) private val cacheStore = mock() private val settingsStore = mock() private val lightningRepo = mock() + private val sparkRepo = mock() private val publicPaykitRepo = mock() private val coreService = mock() private val clock = mock() @@ -69,6 +70,8 @@ class PrivatePaykitAllowancePaymentTest : BaseUnitTest(StandardTestDispatcher()) whenever(publicPaykitRepo.payableEndpoints(any())).thenAnswer { it.getArgument>(0) } whenever(coreService.isAddressUsed(any())).thenReturn(false) PublicPaykitRepo.lightningRouteHintsValidator = { true } + whenever { sparkRepo.receivedLightningPaymentHashes() }.thenReturn(emptySet()) + whenever { sparkRepo.attemptedLightningPaymentHashes() }.thenReturn(emptySet()) sut = PrivatePaykitRepo( ioDispatcher = testDispatcher, @@ -78,6 +81,7 @@ class PrivatePaykitAllowancePaymentTest : BaseUnitTest(StandardTestDispatcher()) settingsStore = settingsStore, addressReservationRepo = mock(), lightningRepo = lightningRepo, + sparkRepo = sparkRepo, walletRepo = mock(), publicPaykitRepo = publicPaykitRepo, paymentRequestRepo = mock(), diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index 5e0aa48fea..5854280acd 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -108,6 +108,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { private val settingsStore = mock() private val addressReservationRepo = mock() private val lightningRepo = mock() + private val sparkRepo = mock() private val walletRepo = mock() private val publicPaykitRepo = mock() private val paymentRequestRepo = mock() @@ -189,6 +190,8 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever { paymentRequestRepo.refreshEligibleTarget(any()) } .thenReturn(Result.success(PaykitPaymentRequestTargetCheck(null, isComplete = true))) whenever(lightningRepo.getPayments()).thenReturn(Result.success(emptyList())) + whenever { sparkRepo.receivedLightningPaymentHashes() }.thenReturn(emptySet()) + whenever { sparkRepo.attemptedLightningPaymentHashes() }.thenReturn(emptySet()) PublicPaykitRepo.lightningRouteHintsValidator = { true } App.currentActivity = CurrentActivity().also { it.onActivityStarted(mock()) } @@ -2933,6 +2936,29 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.closeAndClear() } + @Test + fun `private Lightning invoice paid through Spark is replaced`() = test { + preparePrivateLightningContact() + val refreshedInvoice = "lnbcrt1refreshed" + whenever(lightningRepo.createInvoice(null, "", PRIVATE_BOLT11_EXPIRY_SECONDS)) + .thenReturn(Result.success(refreshedInvoice)) + whenever(coreService.decode(refreshedInvoice)) + .thenReturn(Scanner.Lightning(lightningInvoice(refreshedInvoice, byteArrayOf(8, 8, 8)))) + whenever(sparkRepo.receivedLightningPaymentHashes()).thenReturn(setOf("090909")) + clearInvocations(paykitSdkService) + + sut.refreshKnownSavedContactEndpoints("foreground").getOrThrow() + runCurrent() + + val publication = argumentCaptor>() + verify(paykitSdkService).syncPrivatePaymentListsWithReservations(publication.capture(), eq(false)) + assertEquals( + PublicPaykitRepo.serializePayload(refreshedInvoice), + publication.firstValue.single().reservations.single().payload, + ) + sut.closeAndClear() + } + @Test fun `forced private Lightning refresh includes contacts loaded later`() = test { preparePrivateLightningContact() @@ -4099,6 +4125,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { settingsStore = settingsStore, addressReservationRepo = addressReservationRepo, lightningRepo = lightningRepo, + sparkRepo = sparkRepo, walletRepo = walletRepo, publicPaykitRepo = publicPaykitRepo, paymentRequestRepo = paymentRequestRepo, diff --git a/app/src/test/java/to/bitkit/repositories/SparkRepoTest.kt b/app/src/test/java/to/bitkit/repositories/SparkRepoTest.kt index 85f9514af7..c71384d3b7 100644 --- a/app/src/test/java/to/bitkit/repositories/SparkRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/SparkRepoTest.kt @@ -2,14 +2,25 @@ package to.bitkit.repositories import breez_sdk_spark.GetInfoResponse import breez_sdk_spark.ImportUnilateralExitStateResponse +import breez_sdk_spark.Payment +import breez_sdk_spark.PaymentDetails +import breez_sdk_spark.PaymentMethod +import breez_sdk_spark.PaymentStatus +import breez_sdk_spark.PaymentType +import breez_sdk_spark.SdkEvent +import breez_sdk_spark.SparkHtlcDetails +import breez_sdk_spark.SparkHtlcStatus +import com.synonym.bitkitcore.Activity import kotlinx.coroutines.flow.MutableSharedFlow import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.mock import org.mockito.kotlin.never +import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.env.Env @@ -17,16 +28,19 @@ import to.bitkit.models.SparkExitBackupV1 import to.bitkit.services.SparkService import to.bitkit.test.BaseUnitTest import java.io.File +import java.math.BigInteger import kotlin.test.assertEquals import kotlin.test.assertFalse class SparkRepoTest : BaseUnitTest() { private val sparkService = mock() + private val activityRepo = mock() + private val sparkEvents = MutableSharedFlow() private fun createSut() = SparkRepo( bgDispatcher = testDispatcher, sparkService = sparkService, - activityRepo = { mock() }, + activityRepo = { activityRepo }, settingsStore = mock(), ) @@ -39,7 +53,9 @@ class SparkRepoTest : BaseUnitTest() { whenever { sparkService.getInfo() }.thenReturn(GetInfoResponse("pubkey", 0uL, emptyMap(), 0uL)) whenever { sparkService.listUnclaimedDeposits() }.thenReturn(emptyList()) whenever { sparkService.listPayments() }.thenReturn(emptyList()) - whenever(sparkService.events).thenReturn(MutableSharedFlow()) + whenever(sparkService.events).thenReturn(sparkEvents) + whenever { activityRepo.getActivity(any(), any()) }.thenReturn(Result.success(null)) + whenever { activityRepo.upsertActivity(any()) }.thenReturn(Result.success(Unit)) whenever(sparkService.isSupported).thenReturn(true) whenever(sparkService.networkName).thenReturn("regtest") } @@ -60,6 +76,21 @@ class SparkRepoTest : BaseUnitTest() { assertFalse(File(Env.sparkExitRestorePath(0)).exists()) } + @Test + fun `records a send's fee once a sync reports it`() = test { + whenever(sparkService.isConnected).thenReturn(true) + val sut = createSut() + sut.start(0).getOrThrow() + + sparkEvents.emit(SdkEvent.PaymentSucceeded(sentPayment(fees = 0))) + whenever { sparkService.listPayments() }.thenReturn(listOf(sentPayment(fees = 2))) + sparkEvents.emit(SdkEvent.Synced) + + val activities = argumentCaptor() + verify(activityRepo, times(2)).upsertActivity(activities.capture()) + assertEquals(listOf(0uL, 2uL), activities.allValues.map { (it as Activity.Lightning).v1.fee }) + } + @Test fun `skips an exit backup from another network`() = test { val sut = createSut() @@ -81,4 +112,25 @@ class SparkRepoTest : BaseUnitTest() { assertEquals(false, deposit.isClaimed) assertEquals(2_500uL, sut.state.value.pendingDepositSats) } + + private fun sentPayment(fees: Int) = Payment( + id = "send", + paymentType = PaymentType.SEND, + status = PaymentStatus.COMPLETED, + amount = BigInteger.valueOf(128), + fees = BigInteger.valueOf(fees.toLong()), + timestamp = 1uL, + method = PaymentMethod.LIGHTNING, + details = PaymentDetails.Lightning( + description = null, + invoice = "lnbc1280n1send", + destinationPubkey = "pubkey", + htlcDetails = SparkHtlcDetails("hash", "preimage", 1uL, SparkHtlcStatus.PREIMAGE_SHARED), + lnurlPayInfo = null, + lnurlWithdrawInfo = null, + lnurlReceiveMetadata = null, + conversionInfo = null, + ), + conversionDetails = null, + ) } From 9726d18cf36263358899d9073a58c20c501c30dc Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 27 Oct 2026 13:00:16 +0100 Subject: [PATCH 31/47] fix: show spark withdrawals as transfers with their fee --- .../java/to/bitkit/repositories/SparkRepo.kt | 31 +++++++++++++++++ .../bitkit/ui/components/LightningChannel.kt | 3 +- .../lightning/LightningConnectionsScreen.kt | 30 ++++++++++++---- .../LightningConnectionsViewModel.kt | 3 ++ .../settings/lightning/SparkDetailScreen.kt | 19 +++++++++++ .../to/bitkit/repositories/SparkRepoTest.kt | 34 +++++++++++++++++++ 6 files changed, 113 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/SparkRepo.kt b/app/src/main/java/to/bitkit/repositories/SparkRepo.kt index 7b00a0a5d5..de342cacb5 100644 --- a/app/src/main/java/to/bitkit/repositories/SparkRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/SparkRepo.kt @@ -267,10 +267,33 @@ class SparkRepo @Inject constructor( balanceSats = info.balanceSats, pendingDepositSats = pending.sumOf { deposit -> deposit.amountSats }, deposits = (pending + claimed).toImmutableList(), + withdrawals = payments.toWithdrawals(), recoverableFundsSats = info.recoverableFundsSats, ).withBroadcastDeposits() } payments.filter { it.id in sendsRecordedWithoutFee && it.fees.toLong() > 0 }.forEach { recordActivity(it) } + markTransfers(payments) + } + + private fun List.toWithdrawals() = filter { it.status != PaymentStatus.FAILED } + .mapNotNull { payment -> + val withdraw = payment.details as? PaymentDetails.Withdraw ?: return@mapNotNull null + SparkWithdrawal( + txid = withdraw.txId, + amountSats = payment.amount.toLong().toULong(), + feeSats = payment.fees.toLong().toULong(), + ) + } + .toImmutableList() + + private suspend fun markTransfers(payments: List) { + val activities = activityRepo.get() + payments.filter { it.status != PaymentStatus.FAILED } + .mapNotNull { (it.details as? PaymentDetails.Withdraw)?.txId } + .forEach { txid -> + val activity = activities.getOnchainActivityByTxId(txid)?.takeUnless { it.isTransfer } ?: return@forEach + activities.updateActivity(activity.id, Activity.Onchain(activity.copy(isTransfer = true))) + } } /** Payment hashes of the Lightning payments Spark received. */ @@ -390,9 +413,17 @@ data class SparkState( /** Deposits seen on-chain that Spark claims into [balanceSats] after 3 confirmations. */ val pendingDepositSats: ULong = 0uL, val deposits: ImmutableList = persistentListOf(), + val withdrawals: ImmutableList = persistentListOf(), val recoverableFundsSats: ULong = 0uL, ) +/** A Transfer to Savings out of Spark, with what Spark kept for it. */ +data class SparkWithdrawal( + val txid: String, + val amountSats: ULong, + val feeSats: ULong, +) + /** An on-chain deposit into Spark: pending until Spark claims it after 3 confirmations. */ data class SparkDeposit( val txid: String, 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 e2da9f207f..bdcb5d046b 100644 --- a/app/src/main/java/to/bitkit/ui/components/LightningChannel.kt +++ b/app/src/main/java/to/bitkit/ui/components/LightningChannel.kt @@ -41,7 +41,8 @@ fun LightningChannel( showLabels: Boolean = false, showReceiving: Boolean = true, ) { - val spendingColor = if (status == ChannelStatusUi.CLOSED) Colors.Gray5 else Colors.Purple50 + val isEmptySpendingBar = !showReceiving && localBalance == 0L + val spendingColor = if (status == ChannelStatusUi.CLOSED || isEmptySpendingBar) Colors.Gray5 else Colors.Purple50 val spendingAvailableColor = if (status == ChannelStatusUi.CLOSED) Colors.Gray3 else Colors.Purple val receivingColor = if (status == ChannelStatusUi.CLOSED) Colors.Gray5 else Colors.White64 val receivingAvailableColor = if (status == ChannelStatusUi.CLOSED) Colors.Gray3 else Colors.White 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 5fe58e1f7a..0e09cbe271 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 @@ -460,6 +460,25 @@ internal fun SparkDepositRow( depositUi: SparkDepositUi, onOpenTx: (String) -> Unit, modifier: Modifier = Modifier, +) { + SparkTransactionRow( + title = stringResource(R.string.lightning__spark_deposit), + txid = depositUi.deposit.txid, + amountSats = depositUi.deposit.amountSats, + confirmations = depositUi.confirmations, + onOpenTx = onOpenTx, + modifier = modifier.testTag("SparkDeposit") + ) +} + +@Composable +internal fun SparkTransactionRow( + title: String, + txid: String, + amountSats: ULong, + onOpenTx: (String) -> Unit, + modifier: Modifier = Modifier, + confirmations: UInt? = null, ) { Row( horizontalArrangement = Arrangement.SpaceBetween, @@ -467,13 +486,12 @@ internal fun SparkDepositRow( modifier = modifier .fillMaxWidth() .padding(top = 12.dp) - .clickableAlpha { onOpenTx(depositUi.deposit.txid) } - .testTag("SparkDeposit") + .clickableAlpha { onOpenTx(txid) } ) { Column(modifier = Modifier.weight(1f)) { - CaptionB(text = stringResource(R.string.lightning__spark_deposit)) + CaptionB(text = title) CaptionB( - text = depositUi.deposit.txid, + text = txid, color = Colors.White64, maxLines = 1, overflow = TextOverflow.MiddleEllipsis, @@ -483,8 +501,8 @@ internal fun SparkDepositRow( horizontalAlignment = Alignment.End, modifier = Modifier.padding(start = 16.dp) ) { - MoneyCaptionB(sats = depositUi.deposit.amountSats.toLong(), symbol = true) - depositUi.confirmations?.let { + MoneyCaptionB(sats = amountSats.toLong(), symbol = true) + confirmations?.let { CaptionB( text = stringResource(R.string.lightning__spark_confirmations, minOf(it.toInt(), 3)), color = Colors.Purple, 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 07565a746f..6da1b919c1 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 @@ -42,6 +42,7 @@ import to.bitkit.repositories.LogsRepo import to.bitkit.repositories.SparkDeposit import to.bitkit.repositories.SparkRepo import to.bitkit.repositories.SparkState +import to.bitkit.repositories.SparkWithdrawal import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.Logger import javax.inject.Inject @@ -318,6 +319,7 @@ data class LightningConnectionsUiState( data class SparkUi( val balanceSats: ULong, val deposits: ImmutableList = persistentListOf(), + val withdrawals: ImmutableList = persistentListOf(), ) { val pendingDeposits get() = deposits.filterNot { it.deposit.isClaimed } val claimedDeposits get() = deposits.filter { it.deposit.isClaimed } @@ -338,6 +340,7 @@ internal suspend fun SparkState.toUi(lightningRepo: LightningRepo): SparkUi? { deposits = deposits.map { SparkDepositUi(it, if (it.isClaimed) null else lightningRepo.onchainConfirmations(it.txid)) }.toImmutableList(), + withdrawals = withdrawals, ) } diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/SparkDetailScreen.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/SparkDetailScreen.kt index 6663e8dbc6..c3f9dc13bc 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/SparkDetailScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/SparkDetailScreen.kt @@ -28,6 +28,7 @@ import kotlinx.collections.immutable.persistentListOf import to.bitkit.R import to.bitkit.ext.setClipboardText import to.bitkit.repositories.SparkDeposit +import to.bitkit.repositories.SparkWithdrawal import to.bitkit.ui.components.BodyS import to.bitkit.ui.components.CaptionB import to.bitkit.ui.components.ChannelStatusUi @@ -151,6 +152,23 @@ private fun Content( } } + if (spark.withdrawals.isNotEmpty()) { + SectionTitle(stringResource(R.string.lightning__savings_confirm__label)) + spark.withdrawals.forEach { withdrawal -> + SparkTransactionRow( + title = stringResource(R.string.wallet__activity_transfer_to_savings), + txid = withdrawal.txid, + amountSats = withdrawal.amountSats, + onOpenTx = onOpenTx, + modifier = Modifier.testTag("SparkWithdrawal") + ) + SectionRow( + name = stringResource(R.string.wallet__activity_fee), + valueContent = { MoneyCaptionB(sats = withdrawal.feeSats.toLong(), symbol = true) }, + ) + } + } + FillHeight() VerticalSpacer(32.dp) PrimaryButton( @@ -179,6 +197,7 @@ private fun Preview() { SparkDepositUi(SparkDeposit("6c8706b8a9cbd18f", 15_000uL, isClaimed = false), 1u), SparkDepositUi(SparkDeposit("d0067b2497c7b905", 35_000uL, isClaimed = true, feeSats = 312uL)), ), + withdrawals = persistentListOf(SparkWithdrawal("4f1d0c55e2a9b713", 4_917uL, feeSats = 1_470uL)), ), ), ) diff --git a/app/src/test/java/to/bitkit/repositories/SparkRepoTest.kt b/app/src/test/java/to/bitkit/repositories/SparkRepoTest.kt index c71384d3b7..ae2b1f3999 100644 --- a/app/src/test/java/to/bitkit/repositories/SparkRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/SparkRepoTest.kt @@ -11,6 +11,7 @@ import breez_sdk_spark.SdkEvent import breez_sdk_spark.SparkHtlcDetails import breez_sdk_spark.SparkHtlcStatus import com.synonym.bitkitcore.Activity +import com.synonym.bitkitcore.OnchainActivity import kotlinx.coroutines.flow.MutableSharedFlow import org.junit.Before import org.junit.Rule @@ -18,12 +19,14 @@ import org.junit.Test import org.junit.rules.TemporaryFolder import org.mockito.kotlin.any import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.env.Env +import to.bitkit.ext.create import to.bitkit.models.SparkExitBackupV1 import to.bitkit.services.SparkService import to.bitkit.test.BaseUnitTest @@ -31,6 +34,8 @@ import java.io.File import java.math.BigInteger import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertTrue +import com.synonym.bitkitcore.PaymentType as ActivityPaymentType class SparkRepoTest : BaseUnitTest() { private val sparkService = mock() @@ -91,6 +96,35 @@ class SparkRepoTest : BaseUnitTest() { assertEquals(listOf(0uL, 2uL), activities.allValues.map { (it as Activity.Lightning).v1.fee }) } + @Test + fun `lists a withdrawal with its fee and marks its arrival in Savings as a transfer`() = test { + val arrival = OnchainActivity.create( + id = "arrival", + txType = ActivityPaymentType.RECEIVED, + txId = "withdrawal", + value = 4_917uL, + fee = 0uL, + address = "bc1q", + timestamp = 1uL, + ) + whenever { activityRepo.getOnchainActivityByTxId(eq("withdrawal"), any()) }.thenReturn(arrival) + whenever { activityRepo.updateActivity(any(), any(), any()) }.thenReturn(Result.success(Unit)) + val withdrawal = sentPayment(fees = 1_470).copy( + method = PaymentMethod.WITHDRAW, + details = PaymentDetails.Withdraw("withdrawal"), + ) + whenever { sparkService.listPayments() }.thenReturn(listOf(withdrawal)) + whenever(sparkService.isConnected).thenReturn(true) + + val sut = createSut() + sut.start(0).getOrThrow() + + val updated = argumentCaptor() + verify(activityRepo).updateActivity(eq("arrival"), updated.capture(), any()) + assertTrue((updated.firstValue as Activity.Onchain).v1.isTransfer) + assertEquals(listOf(SparkWithdrawal("withdrawal", 128uL, 1_470uL)), sut.state.value.withdrawals) + } + @Test fun `skips an exit backup from another network`() = test { val sut = createSut() From f96bae939430ff08d28f2066e6b08968b6649036 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 27 Oct 2026 13:20:46 +0100 Subject: [PATCH 32/47] fix: restore spark exit data before spark connects --- .../main/java/to/bitkit/repositories/BackupRepo.kt | 11 ++++++----- app/src/main/java/to/bitkit/repositories/SparkRepo.kt | 2 +- .../java/to/bitkit/repositories/BackupRepoTest.kt | 8 ++++++-- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index a00a640884..076b8bcb41 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -850,6 +850,12 @@ class BackupRepo @Inject constructor( widgetsStore.restoreFromBackup(parsed) parsed.createdAt } + // Before the slower categories, so the exit data is staged before Spark connects and syncs. + performRestore(BackupCategory.SPARK_EXIT) { dataBytes -> + val parsed = json.decodeFromString(String(dataBytes)) + sparkRepo.restoreExitBackup(parsed, backupWalletIndex()).getOrThrow() + parsed.createdAt + } val retainedWalletBackup = keychain.loadString(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name) if (retainedWalletBackup.isNullOrEmpty()) { var didRestoreWalletBackup = false @@ -877,11 +883,6 @@ class BackupRepo @Inject constructor( if (restored.needsRewrite) categoriesNeedingRewrite += BackupCategory.ACTIVITY restored.createdAt } - performRestore(BackupCategory.SPARK_EXIT) { dataBytes -> - val parsed = json.decodeFromString(String(dataBytes)) - sparkRepo.restoreExitBackup(parsed, backupWalletIndex()).getOrThrow() - parsed.createdAt - } Logger.info("Full restore success", context = TAG) }.onSuccess { diff --git a/app/src/main/java/to/bitkit/repositories/SparkRepo.kt b/app/src/main/java/to/bitkit/repositories/SparkRepo.kt index de342cacb5..da444e6c4a 100644 --- a/app/src/main/java/to/bitkit/repositories/SparkRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/SparkRepo.kt @@ -230,7 +230,7 @@ class SparkRepo @Inject constructor( file.delete() Logger.info( "Imported Spark exit data: '${it.importedLeaves}' leaves, '${it.skippedForeignLeaves}' foreign, " + - "'${it.skippedConflictingLeaves}' conflicting", + "'${it.skippedConflictingLeaves}' conflicting, '${it.skippedChains}' already held", context = TAG, ) } diff --git a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt index ee4dc923ee..2049dbfd03 100644 --- a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt @@ -27,6 +27,7 @@ import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.atLeastOnce import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.eq +import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.times @@ -196,7 +197,7 @@ class BackupRepoTest : BaseUnitTest() { } @Test - fun `spark exit backup uploads the exported state and restores it`() = test { + fun `spark exit backup uploads the exported state and restores it before the wallet`() = test { val backup = SparkExitBackupV1(createdAt = 1_000, network = "regtest", exitState = "{\"pedigrees\":[]}") whenever(sparkRepo.exitBackup()).thenReturn(Result.success(backup)) @@ -210,7 +211,10 @@ class BackupRepoTest : BaseUnitTest() { sut.performFullRestoreFromLatestBackup().getOrThrow() - verify(sparkRepo).restoreExitBackup(backup, 0) + inOrder(sparkRepo, vssBackupClient) { + verify(sparkRepo).restoreExitBackup(backup, 0) + verify(vssBackupClient).getObject(BackupCategory.WALLET.name) + } } @Test From 0eda717be8f944d29dc0d9e695f1a241457e70e5 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 27 Oct 2026 13:47:19 +0100 Subject: [PATCH 33/47] fix: show the most spark may keep to claim a deposit --- .../java/to/bitkit/repositories/SparkRepo.kt | 10 ++-------- .../java/to/bitkit/services/SparkService.kt | 17 +++++++++++++++++ .../java/to/bitkit/services/SparkServiceTest.kt | 7 +++++++ 3 files changed, 26 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/SparkRepo.kt b/app/src/main/java/to/bitkit/repositories/SparkRepo.kt index da444e6c4a..71516401dd 100644 --- a/app/src/main/java/to/bitkit/repositories/SparkRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/SparkRepo.kt @@ -62,12 +62,6 @@ class SparkRepo @Inject constructor( private const val TAG = "SparkRepo" private const val MSATS_PER_SAT = 1_000uL - /** - * Size of Spark's deposit claim transaction; the SDK prices a standard claim as the fastest recommended fee - * rate times this size before the provider quotes it (breez/spark-sdk `CLAIM_TX_SIZE_VBYTES`). - */ - private const val CLAIM_TX_SIZE_VBYTES = 99uL - /** Last step of the Spending setup progress, shown once a funding deposit is claimed. */ private const val SETUP_STEP_DONE = 3 } @@ -151,8 +145,8 @@ class SparkRepo @Inject constructor( } /** Estimates what Spark keeps from a deposit to claim it, the way the SDK does before the provider quotes it. */ - suspend fun estimateDepositClaimFeeSats(): Result = withContext(bgDispatcher) { - runSuspendCatching { sparkService.fastestFeeRate() * CLAIM_TX_SIZE_VBYTES } + suspend fun estimateDepositClaimFeeSats(amountSats: ULong): Result = withContext(bgDispatcher) { + runSuspendCatching { SparkService.maxDepositClaimFeeSats(sparkService.fastestFeeRate(), amountSats) } } suspend fun depositAddress(): Result = withContext(bgDispatcher) { diff --git a/app/src/main/java/to/bitkit/services/SparkService.kt b/app/src/main/java/to/bitkit/services/SparkService.kt index 0d09d9b92b..3b37a8441f 100644 --- a/app/src/main/java/to/bitkit/services/SparkService.kt +++ b/app/src/main/java/to/bitkit/services/SparkService.kt @@ -36,6 +36,7 @@ import kotlinx.coroutines.sync.withLock import org.lightningdevkit.ldknode.Network import to.bitkit.data.keychain.Keychain import to.bitkit.env.Env +import to.bitkit.models.safe import to.bitkit.utils.AppError import to.bitkit.utils.Logger import to.bitkit.utils.ServiceError @@ -62,6 +63,22 @@ class SparkService @Inject constructor( /** Share of a deposit, in parts per million, that an automatic deposit claim may pay on top. */ private const val DEPOSIT_CLAIM_FEE_PPM = 2_000u + private const val PPM_DIVISOR = 1_000_000uL + + /** Size of the transaction that claims a deposit, which prices the on-chain part of its fee. */ + private const val CLAIM_TX_SIZE_VBYTES = 99uL + + /** + * The most Spark may keep to claim a deposit of [amountSats] under this wallet's fee cap, at the fastest + * recommended fee rate [fastestFeeRate]. The provider quotes the real fee only once the deposit exists. + */ + fun maxDepositClaimFeeSats(fastestFeeRate: ULong, amountSats: ULong): ULong { + val rate = fastestFeeRate.safe() + DEPOSIT_CLAIM_FEE_LEEWAY_SAT_PER_VB.safe() + val onchain = rate.safe() * CLAIM_TX_SIZE_VBYTES.safe() + val proportional = amountSats.safe() * DEPOSIT_CLAIM_FEE_PPM.toULong().safe() / PPM_DIVISOR + return onchain.safe() + proportional.safe() + } + /** * Returns as soon as the payment starts, like ldk-node, so its completion reaches callers as an event they * are already watching for; waiting for completion here fired that event before they watched. diff --git a/app/src/test/java/to/bitkit/services/SparkServiceTest.kt b/app/src/test/java/to/bitkit/services/SparkServiceTest.kt index 07505a3610..edd29ebbfa 100644 --- a/app/src/test/java/to/bitkit/services/SparkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/SparkServiceTest.kt @@ -14,4 +14,11 @@ class SparkServiceTest { assertNull(options.completionTimeoutSecs) assertEquals(true, options.preferSpark) } + + @Test + fun `claim fee estimate covers the claims seen on mainnet`() { + // Mainnet claims so far took 198 and 297 sats; the cap at 1 sat/vB stays above both. + assertEquals(302uL, SparkService.maxDepositClaimFeeSats(fastestFeeRate = 1uL, amountSats = 2_500uL)) + assertEquals(327uL, SparkService.maxDepositClaimFeeSats(fastestFeeRate = 1uL, amountSats = 15_000uL)) + } } From bfc43b53303b8839c9e1d8268636a26262f0fc02 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 27 Oct 2026 13:47:20 +0100 Subject: [PATCH 34/47] fix: finish spark-only transfer to savings without waiting --- .../bitkit/ui/screens/transfer/SavingsProgressScreen.kt | 3 ++- .../main/java/to/bitkit/viewmodels/TransferViewModel.kt | 6 +++++- .../java/to/bitkit/viewmodels/TransferViewModelTest.kt | 9 +++++++++ 3 files changed, 16 insertions(+), 2 deletions(-) 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 cdf45cacdc..b055ff86cb 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 @@ -123,11 +123,12 @@ private suspend fun runChannelClose( onInterrupted: () -> Unit, onGiveUp: () -> Unit, ) { + val closesChannels = transfer.hasChannelsToClose() val channelsFailedToCoopClose = transfer.closeSelectedChannels() if (channelsFailedToCoopClose.isEmpty()) { wallet.refreshState() - delay(5000) + if (closesChannels) delay(5000) onSuccess() return } diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index e39130bb5b..1ba5879a02 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -217,7 +217,7 @@ class TransferViewModel @Inject constructor( return@launch } onEstimateReady(clientBalanceSat = amountSats, lspBalanceSat = 0uL, feeSat = amountSats, isSpark = true) - sparkRepo.estimateDepositClaimFeeSats() + sparkRepo.estimateDepositClaimFeeSats(amountSats) .onSuccess { fee -> _spendingUiState.update { it.copy(sparkClaimFeeSats = fee) } } .onFailure { Logger.warn("Failed to estimate Spark claim fee", it, context = TAG) } } @@ -1950,6 +1950,9 @@ class TransferViewModel @Inject constructor( private fun resolveSavingsTransferMode(): SavingsTransferMode = if (_savingsSwapState.value.quote != null) SavingsTransferMode.SWAP else SavingsTransferMode.CLOSE + /** Whether Transfer to Savings closes channels, rather than only withdrawing Spark's balance. */ + fun hasChannelsToClose(): Boolean = channelsToClose.isNotEmpty() + /** Closes the channels selected earlier, pending closure */ suspend fun closeSelectedChannels(): List { withdrawSparkToSavings() @@ -1967,6 +1970,7 @@ class TransferViewModel @Inject constructor( } private suspend fun closeChannels(channels: List): List { + if (channels.isEmpty()) return emptyList() lightningRepo.awaitPeerConnected() val channelsFailedToClose = coroutineScope { channels.map { channel -> diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index b9d3968ee0..90190df41b 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -212,6 +212,15 @@ class TransferViewModelTest : BaseUnitTest() { ) } + @Test + fun `transfer to savings with only spark doesn't wait for a lightning peer`() = test { + sut.onTransferToSavingsConfirm(channels = emptyList(), mode = SavingsTransferMode.CLOSE) + + assertFalse(sut.hasChannelsToClose()) + assertTrue(sut.closeSelectedChannels().isEmpty()) + verify(lightningRepo, never()).awaitPeerConnected(any()) + } + @Test fun `updateLimits caps spending max at LSP max client balance when on-chain balance exceeds it`() = test { stubSpendableBalances(ON_CHAIN_BALANCE) From d48bfcfd9c75309e43afafed987c6da2730d8062 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 27 Oct 2026 14:26:06 +0100 Subject: [PATCH 35/47] fix: stop loading a payment request whose details fail to resolve --- .../java/to/bitkit/viewmodels/AppViewModel.kt | 27 ++++++++++++ .../viewmodels/AppViewModelSendFlowTest.kt | 42 +++++++++++++++---- 2 files changed, 62 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index f74ec12fc5..a5ea2f593d 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -1854,6 +1854,15 @@ class AppViewModel @Inject constructor( request, IncomingPaykitPaymentRequestFailureReason.EndpointNotPayable, ) + } else if ( + result != PublicPaykitPaymentResult.WaitingForUpdatedPaymentList && + requestedPaymentRequestId.value == request.id + ) { + finishFailedRequestedPaymentRequestPresentation( + request = request, + reason = result?.incomingPaymentRequestFailureReason + ?: IncomingPaykitPaymentRequestFailureReason.ResolutionFailed, + ) } else { deferPaymentRequestPresentation( request = request, @@ -1875,6 +1884,24 @@ class AppViewModel @Inject constructor( return true } + private fun finishFailedRequestedPaymentRequestPresentation( + request: PaykitPaymentRequest, + reason: IncomingPaykitPaymentRequestFailureReason, + ) { + paykitPaymentRequestDiagnostics.logPresentationRejection(request.counterparty, reason) + val restorePaymentRequestSheet = shouldRestorePaymentRequestSheet + paymentRequestPresentationGeneration++ + clearRequestedPaymentRequest() + toast( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.wallet__payment_request), + description = context.getString(R.string.slashtags__error_pay_waiting_msg), + testTag = "PaymentRequestWaitingForDetailsToast", + ) + if (restorePaymentRequestSheet) showSheet(Sheet.PaymentRequests) + completePaymentRequestPresentation(request) + } + private fun finishPrivateLinkPendingPaymentRequestPresentation(request: PaykitPaymentRequest) { paykitPaymentRequestDiagnostics.logPresentationRejection( request.counterparty, diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index c816598e0c..4e8dabafba 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -2588,6 +2588,36 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals("The payment request is no longer available.", terminalToast.description) } + @Test + fun `manual request whose resolution fails stops loading at once`() = test { + sut.setIsAuthenticated(true) + val request = paymentRequest() + whenever(context.getString(R.string.wallet__payment_request)).thenReturn("Payment Request") + whenever(context.getString(R.string.slashtags__error_pay_waiting_msg)).thenReturn("Try again") + whenever(privatePaykitRepo.beginPaymentRequest(request)).thenReturn( + Result.failure(AppError("Pubky shared state remains locked; retry later")) + ) + pendingPaykitPaymentRequests.value = listOf(request) + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + runCurrent() + clearInvocations(toastManager) + + sut.showPaymentRequests() + sut.openIncomingPaymentRequest(request.id) + advanceTimeBy(TRANSITION_SCREEN_MS) + runCurrent() + advanceTimeBy(30.seconds.inWholeMilliseconds) + runCurrent() + + assertEquals(Sheet.PaymentRequests, sut.currentSheet.value) + verify(privatePaykitRepo, times(1)).beginPaymentRequest(request) + assertNull(sut.requestedPaymentRequestId.value) + val toast = argumentCaptor() + verify(toastManager).enqueue(toast.capture()) + assertEquals("Try again", toast.firstValue.description) + } + @Test fun `opened request with an invalid target returns to the request sheet`() = test { sut.setIsAuthenticated(true) @@ -3200,7 +3230,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `failed request opened from the full screen does not replace it with the request sheet`() = test { + fun `failed request opened from the full screen stops loading without replacing it with the request sheet`() = test { sut.setIsAuthenticated(true) val request = paymentRequest() whenever(context.getString(R.string.wallet__payment_request)).thenReturn("Payment Request") @@ -3223,16 +3253,14 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertNull(sut.currentSheet.value) verify(paykitPaymentRequestRepo).markPresented(request) - verify(privatePaykitRepo, times(15)).beginPaymentRequest(request) - verify(paykitPaymentRequestDiagnostics, times(15)).logPresentationRejection( + verify(privatePaykitRepo, times(1)).beginPaymentRequest(request) + verify(paykitPaymentRequestDiagnostics).logPresentationRejection( request.counterparty, IncomingPaykitPaymentRequestFailureReason.NoSupportedEndpoint, ) val toastCaptor = argumentCaptor() - verify(toastManager, times(2)).enqueue(toastCaptor.capture()) - val (waitingToast, terminalToast) = toastCaptor.allValues - assertNull(waitingToast.testTag) - assertEquals("PaymentRequestUnavailableToast", terminalToast.testTag) + verify(toastManager).enqueue(toastCaptor.capture()) + assertEquals("PaymentRequestWaitingForDetailsToast", toastCaptor.firstValue.testTag) } @Test From ae55b36e118a502459377ac103c20e85cef19f10 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 27 Oct 2026 14:29:30 +0100 Subject: [PATCH 36/47] test: shorten the failed request presentation test name --- .../test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 4e8dabafba..97fae0959d 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -3230,7 +3230,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `failed request opened from the full screen stops loading without replacing it with the request sheet`() = test { + fun `failed request opened from the full screen stops loading in place`() = test { sut.setIsAuthenticated(true) val request = paymentRequest() whenever(context.getString(R.string.wallet__payment_request)).thenReturn("Payment Request") From 024b6bb8554a544c0851c2300a45a4e616b5e3f1 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 27 Oct 2026 15:48:52 +0100 Subject: [PATCH 37/47] fix: settle a spark send that finished before it was watched --- .../to/bitkit/repositories/LightningRepo.kt | 3 ++ .../java/to/bitkit/repositories/SparkRepo.kt | 16 ++++++++++ .../java/to/bitkit/viewmodels/AppViewModel.kt | 24 ++++++++------- .../viewmodels/AppViewModelSendFlowTest.kt | 30 +++++++++++++++++++ 4 files changed, 63 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 404bcf046d..0b5d94a386 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -2309,6 +2309,9 @@ class LightningRepo @Inject constructor( fun getChannels(): List? = if (_lightningState.value.nodeLifecycleState.isRunning()) lightningService.channels else null + /** The completion event of a Spark send that settled before its caller started watching for it. */ + suspend fun settledSparkSendEvent(paymentHash: String): Event? = sparkRepo.settledSendEvent(paymentHash) + /** * Whether Lightning can be received, through a usable channel or Spark; Paykit publishes a Lightning endpoint * only then. diff --git a/app/src/main/java/to/bitkit/repositories/SparkRepo.kt b/app/src/main/java/to/bitkit/repositories/SparkRepo.kt index 71516401dd..ae0fb88ec3 100644 --- a/app/src/main/java/to/bitkit/repositories/SparkRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/SparkRepo.kt @@ -290,6 +290,22 @@ class SparkRepo @Inject constructor( } } + /** + * The completion event of a Spark Lightning send that has already settled. A Spark-to-Spark payment settles inside + * the send call, so its event can fire before the caller starts watching for it. + */ + suspend fun settledSendEvent(paymentHash: String): Event? = withContext(bgDispatcher) { + if (!sparkService.isConnected) return@withContext null + runSuspendCatching { sparkService.listPayments() } + .onFailure { Logger.warn("Failed to list Spark payments", it, context = TAG) } + .getOrNull() + ?.firstOrNull { + val hash = (it.details as? PaymentDetails.Lightning)?.htlcDetails?.paymentHash + it.paymentType == SparkPaymentType.SEND && hash.equals(paymentHash, ignoreCase = true) + } + ?.toNodeEvent() + } + /** Payment hashes of the Lightning payments Spark received. */ suspend fun receivedLightningPaymentHashes(): Set = lightningPaymentHashes { it.paymentType == SparkPaymentType.RECEIVE && it.status == PaymentStatus.COMPLETED } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index a5ea2f593d..c3b3febd9d 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -6682,18 +6682,22 @@ class AppViewModel @Inject constructor( paymentDeadlineAt = paymentDeadlineAt, onBeforeSend = onBeforeSend, ).onSuccess { hash -> - // Wait until matching payment event is received (with timeout for hold invoices) - val result = lightningRepo.nodeEvents.watchUntil(LightningRepo.SEND_LN_TIMEOUT) { - when (it) { - is Event.PaymentSuccessful if it.paymentHash == hash -> WatchResult.Complete(Result.success(hash)) - is Event.PaymentFailed if it.paymentHash == hash -> WatchResult.Complete( - Result.failure( - LightningPaymentFailedError(reason = it.reason, paymentRequest = bolt11) - ) - ) + fun outcome(event: Event): Result? = when (event) { + is Event.PaymentSuccessful if event.paymentHash == hash -> Result.success(hash) + is Event.PaymentFailed if event.paymentHash == hash -> Result.failure( + LightningPaymentFailedError(reason = event.reason, paymentRequest = bolt11) + ) - else -> WatchResult.Continue() + else -> null + } + // Wait until matching payment event is received (with timeout for hold invoices) + val result = coroutineScope { + val watch = async(start = CoroutineStart.UNDISPATCHED) { + lightningRepo.nodeEvents.watchUntil(LightningRepo.SEND_LN_TIMEOUT) { + outcome(it)?.let { result -> WatchResult.Complete(result) } ?: WatchResult.Continue() + } } + lightningRepo.settledSparkSendEvent(hash)?.let(::outcome)?.also { watch.cancel() } ?: watch.await() } return result ?: Result.failure(PaymentPendingException(hash)) } diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 97fae0959d..ae5124095e 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -13094,6 +13094,36 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(privatePaykitRepo).consumePrivatePaymentList(contactKey, privateContext) } + @Test + fun `spark send that settled before the watch started completes without pending`() = test { + val bolt11 = "lnbcrt1sparktospark" + val paymentHash = "spark_hash" + balanceState.value = BalanceState(maxSendLightningSats = 100_000u) + whenever(lightningRepo.payInvoice(bolt11 = bolt11, sats = null)).thenReturn(Result.success(paymentHash)) + whenever(lightningRepo.settledSparkSendEvent(paymentHash)).thenReturn( + Event.PaymentSuccessful( + paymentId = paymentHash, + paymentHash = paymentHash, + paymentPreimage = "preimage", + feePaidMsat = 0uL, + ), + ) + setSendState( + SendUiState( + address = bolt11, + amount = 1000u, + payMethod = SendMethod.LIGHTNING, + decodedInvoice = lightningInvoice(bolt11, amountSats = 1000u), + ), + ) + + sut.setSendEvent(SendEvent.PayConfirmed) + advanceTimeBy(LightningRepo.SEND_LN_TIMEOUT.inWholeMilliseconds + 1_000) + runCurrent() + + verify(pendingPaymentRepo, never()).track(paymentHash) + } + @Test fun `private lightning pending payment consumes private list`() = test { val bolt11 = "lnbcrt1pending" From 3cbe1ff1fb3977ddc2d54b282d0bde73f6d8fa69 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 27 Oct 2026 12:23:44 +0100 Subject: [PATCH 38/47] feat: add usdt on spark via breez sdk --- app/src/main/java/to/bitkit/env/Env.kt | 10 + .../java/to/bitkit/repositories/SparkRepo.kt | 132 +++++ .../java/to/bitkit/services/SparkService.kt | 39 ++ app/src/main/java/to/bitkit/ui/ContentView.kt | 10 + .../bitkit/ui/screens/wallets/HomeScreen.kt | 28 +- .../screens/wallets/usdt/SparkUsdViewModel.kt | 159 ++++++ .../wallets/usdt/SparkUsdWalletScreen.kt | 515 ++++++++++++++++++ .../java/to/bitkit/viewmodels/AppViewModel.kt | 7 + .../to/bitkit/repositories/SparkRepoTest.kt | 119 ++++ docs/screens-map.md | 1 + journeys/spark-usd-bitkit-to-bitkit.xml | 12 + 11 files changed, 1028 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/to/bitkit/ui/screens/wallets/usdt/SparkUsdViewModel.kt create mode 100644 app/src/main/java/to/bitkit/ui/screens/wallets/usdt/SparkUsdWalletScreen.kt create mode 100644 journeys/spark-usd-bitkit-to-bitkit.xml diff --git a/app/src/main/java/to/bitkit/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt index ad85c263a8..10d636212d 100644 --- a/app/src/main/java/to/bitkit/env/Env.kt +++ b/app/src/main/java/to/bitkit/env/Env.kt @@ -33,6 +33,16 @@ internal object Env { val breezApiKey: String? get() = BuildConfig.BREEZ_API_KEY.takeIf { it.isNotBlank() } + /** + * The Spark token held as the dollar balance on Spark, matched by identifier since anyone can issue a token + * with any ticker. Regtest uses a 6-decimal test token issued on Lightspark's regtest; mainnet has none yet. + */ + val sparkUsdTokenId: String? + get() = when (network) { + Network.REGTEST -> "btknrt1e2h2l3jwna0dzwfjsf3dcgck8zjuug3qprnuvl7hs23a68xvz38q7966fl" + else -> null + } + /** * Esplora endpoint for the node's chain source on regtest builds, set to put the wallet on Spark's regtest chain * through `scripts/spark-regtest-esplora.py`; Electrum is used when unset. diff --git a/app/src/main/java/to/bitkit/repositories/SparkRepo.kt b/app/src/main/java/to/bitkit/repositories/SparkRepo.kt index ae0fb88ec3..1116b15aee 100644 --- a/app/src/main/java/to/bitkit/repositories/SparkRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/SparkRepo.kt @@ -1,12 +1,15 @@ package to.bitkit.repositories import breez_sdk_spark.FeePolicy +import breez_sdk_spark.InputType import breez_sdk_spark.Payment import breez_sdk_spark.PaymentDetails import breez_sdk_spark.PaymentMethod import breez_sdk_spark.PaymentStatus import breez_sdk_spark.SdkEvent import breez_sdk_spark.SendPaymentMethod +import breez_sdk_spark.TokenMetadata +import breez_sdk_spark.TokenTransactionType import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.LightningActivity import com.synonym.bitkitcore.PaymentState @@ -37,8 +40,10 @@ import to.bitkit.ext.nowMillis import to.bitkit.ext.runSuspendCatching import to.bitkit.models.SparkExitBackupV1 import to.bitkit.services.SparkService +import to.bitkit.utils.AppError import to.bitkit.utils.Logger import java.io.File +import java.math.BigInteger import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject import javax.inject.Provider @@ -64,6 +69,30 @@ class SparkRepo @Inject constructor( /** Last step of the Spending setup progress, shown once a funding deposit is claimed. */ private const val SETUP_STEP_DONE = 3 + + /** Decimals of Bitkit's dollar amounts, shared with Arbitrum USDT so both format alike. */ + private const val USD_DECIMALS = 6 + + private val SPARK_INPUT_PREFIXES = listOf("spark1", "sparkrt1", "spark:") + + /** Whether [input] looks like a Spark address or invoice, before the SDK parses it. */ + fun isSparkInput(input: String): Boolean = input.trim().lowercase().let { text -> + SPARK_INPUT_PREFIXES.any { text.startsWith(it) } + } + + /** Converts token base units to Bitkit's 6-decimal dollar units. */ + fun toUsdUnits(amount: BigInteger, decimals: Int): ULong = + amount.scaleDecimals(decimals, USD_DECIMALS).toLong().toULong() + + /** Converts Bitkit's 6-decimal dollar units to token base units. */ + fun fromUsdUnits(amount: ULong, decimals: Int): BigInteger = + BigInteger.valueOf(amount.toLong()).scaleDecimals(USD_DECIMALS, decimals) + + private fun BigInteger.scaleDecimals(from: Int, to: Int): BigInteger = when { + to > from -> multiply(BigInteger.TEN.pow(to - from)) + to < from -> divide(BigInteger.TEN.pow(from - to)) + else -> this + } } private val scope = appScope(bgDispatcher, TAG) @@ -73,6 +102,7 @@ class SparkRepo @Inject constructor( /** Sends recorded before Spark knew their fee; its completion event reports 0 until a later sync. */ private val sendsRecordedWithoutFee = ConcurrentHashMap.newKeySet() + private var usdTokenMetadata: TokenMetadata? = null private var eventsJob: Job? = null @@ -144,6 +174,52 @@ class SparkRepo @Inject constructor( } } + /** A Spark invoice for the dollar token, with [amount] in 6-decimal dollar units when set. */ + suspend fun createUsdRequest(amount: ULong?): Result = withContext(bgDispatcher) { + runSuspendCatching { + val tokenId = requireNotNull(Env.sparkUsdTokenId) { "No Spark dollar token on this network" } + val decimals = usdDecimals(tokenId) + sparkService.receiveTokenInvoice(tokenId, amount?.let { fromUsdUnits(it, decimals) }) + } + } + + /** + * Reads a scanned or pasted Spark address or invoice as a dollar payment. An invoice for another token or for + * sats fails, so it never reaches the dollar send flow. + */ + suspend fun parseUsdRecipient(input: String): Result = withContext(bgDispatcher) { + runSuspendCatching { + val tokenId = requireNotNull(Env.sparkUsdTokenId) { "No Spark dollar token on this network" } + when (val parsed = sparkService.parse(input.trim())) { + is InputType.SparkAddress -> SparkUsdRecipient(parsed.v1.address, amount = null) + is InputType.SparkInvoice if parsed.v1.tokenIdentifier == tokenId -> SparkUsdRecipient( + request = parsed.v1.invoice, + amount = parsed.v1.amount?.let { toUsdUnits(it, usdDecimals(tokenId)) }, + ) + else -> throw SparkUsdError.UnsupportedRecipient() + } + } + } + + /** Pays [amount] dollar units of the dollar token to [request], a Spark address or invoice. */ + suspend fun sendUsd(request: String, amount: ULong): Result = withContext(bgDispatcher) { + runSuspendCatching { + val tokenId = requireNotNull(Env.sparkUsdTokenId) { "No Spark dollar token on this network" } + val units = fromUsdUnits(amount, usdDecimals(tokenId)) + val payment = sparkService.send(sparkService.prepareTokenSend(request, units, tokenId)) + Logger.info("Sent Spark dollar payment '${payment.id}' with status '${payment.status}'", context = TAG) + refresh() + }.onFailure { + Logger.error("Failed to send Spark dollar payment", it, context = TAG) + } + } + + private suspend fun usdDecimals(tokenId: String): Int = usdMetadata(tokenId).decimals.toInt() + + private suspend fun usdMetadata(tokenId: String): TokenMetadata = usdTokenMetadata + ?: requireNotNull(sparkService.tokenMetadata(tokenId)) { "Unknown Spark token '$tokenId'" } + .also { usdTokenMetadata = it } + /** Estimates what Spark keeps from a deposit to claim it, the way the SDK does before the provider quotes it. */ suspend fun estimateDepositClaimFeeSats(amountSats: ULong): Result = withContext(bgDispatcher) { runSuspendCatching { SparkService.maxDepositClaimFeeSats(sparkService.fastestFeeRate(), amountSats) } @@ -255,6 +331,11 @@ class SparkRepo @Inject constructor( ) } (pending + claimed).forEach { broadcastDeposits.remove(it.txid) } + val usd = Env.sparkUsdTokenId?.let { tokenId -> + runSuspendCatching { usdState(tokenId, info.tokenBalances[tokenId]?.balance) } + .onFailure { Logger.warn("Failed to refresh Spark dollar balance", it, context = TAG) } + .getOrElse { _state.value.usd } + } _state.update { it.copy( isReady = true, @@ -263,6 +344,7 @@ class SparkRepo @Inject constructor( deposits = (pending + claimed).toImmutableList(), withdrawals = payments.toWithdrawals(), recoverableFundsSats = info.recoverableFundsSats, + usd = usd, ).withBroadcastDeposits() } payments.filter { it.id in sendsRecordedWithoutFee && it.fees.toLong() > 0 }.forEach { recordActivity(it) } @@ -324,6 +406,29 @@ class SparkRepo @Inject constructor( .toSet() } + private suspend fun usdState(tokenId: String, balance: BigInteger?): SparkUsdState { + val metadata = usdMetadata(tokenId) + val decimals = metadata.decimals.toInt() + val transfers = sparkService.listTokenPayments(tokenId).mapNotNull { payment -> + val token = payment.details as? PaymentDetails.Token ?: return@mapNotNull null + if (token.txType != TokenTransactionType.TRANSFER) return@mapNotNull null + SparkUsdTransfer( + id = payment.id, + isIncoming = payment.paymentType == SparkPaymentType.RECEIVE, + status = payment.status, + amount = toUsdUnits(payment.amount, decimals), + fee = toUsdUnits(payment.fees, decimals), + timestamp = payment.timestamp, + txHash = token.txHash, + ) + } + return SparkUsdState( + ticker = metadata.ticker.trim(), + balance = toUsdUnits(balance ?: BigInteger.ZERO, decimals), + transfers = transfers.sortedByDescending { it.timestamp }.toImmutableList(), + ) + } + private suspend fun syncActivities() { sparkService.listPayments().forEach { recordActivity(it) } } @@ -334,6 +439,7 @@ class SparkRepo @Inject constructor( is SdkEvent.Synced -> refresh() is SdkEvent.PaymentSucceeded -> onPayment(event.payment) is SdkEvent.PaymentFailed -> onPayment(event.payment) + is SdkEvent.PaymentPending if event.payment.method == PaymentMethod.TOKEN -> refresh() is SdkEvent.PaymentPending -> recordActivity(event.payment) is SdkEvent.UnclaimedDeposits -> Logger.warn( "Found '${event.unclaimedDeposits.size}' unclaimed Spark deposits", @@ -425,6 +531,8 @@ data class SparkState( val deposits: ImmutableList = persistentListOf(), val withdrawals: ImmutableList = persistentListOf(), val recoverableFundsSats: ULong = 0uL, + /** The dollar token balance, or null where this network has no dollar token or Spark hasn't loaded it. */ + val usd: SparkUsdState? = null, ) /** A Transfer to Savings out of Spark, with what Spark kept for it. */ @@ -434,6 +542,30 @@ data class SparkWithdrawal( val feeSats: ULong, ) +/** The dollar token held on Spark, in 6-decimal dollar units like Arbitrum USDT. */ +data class SparkUsdState( + val ticker: String, + val balance: ULong, + val transfers: ImmutableList = persistentListOf(), +) + +data class SparkUsdTransfer( + val id: String, + val isIncoming: Boolean, + val status: PaymentStatus, + val amount: ULong, + val fee: ULong, + val timestamp: ULong, + val txHash: String, +) + +/** Where a dollar payment goes: a Spark address or invoice, with the amount an invoice fixes. */ +data class SparkUsdRecipient(val request: String, val amount: ULong?) + +sealed class SparkUsdError(message: String) : AppError(message) { + class UnsupportedRecipient : SparkUsdError("Not a Spark address or an invoice for the dollar token") +} + /** An on-chain deposit into Spark: pending until Spark claims it after 3 confirmations. */ data class SparkDeposit( val txid: String, diff --git a/app/src/main/java/to/bitkit/services/SparkService.kt b/app/src/main/java/to/bitkit/services/SparkService.kt index 3b37a8441f..8b0b3c2f90 100644 --- a/app/src/main/java/to/bitkit/services/SparkService.kt +++ b/app/src/main/java/to/bitkit/services/SparkService.kt @@ -1,5 +1,6 @@ package to.bitkit.services +import breez_sdk_spark.AssetFilter import breez_sdk_spark.BreezSdk import breez_sdk_spark.ConnectRequest import breez_sdk_spark.DepositInfo @@ -7,8 +8,10 @@ import breez_sdk_spark.EventListener import breez_sdk_spark.FeePolicy import breez_sdk_spark.GetInfoRequest import breez_sdk_spark.GetInfoResponse +import breez_sdk_spark.GetTokensMetadataRequest import breez_sdk_spark.ImportUnilateralExitStateRequest import breez_sdk_spark.ImportUnilateralExitStateResponse +import breez_sdk_spark.InputType import breez_sdk_spark.ListPaymentsRequest import breez_sdk_spark.ListUnclaimedDepositsRequest import breez_sdk_spark.MaxFee @@ -26,6 +29,7 @@ import breez_sdk_spark.SendPaymentMethod import breez_sdk_spark.SendPaymentOptions import breez_sdk_spark.SendPaymentRequest import breez_sdk_spark.SyncWalletRequest +import breez_sdk_spark.TokenMetadata import breez_sdk_spark.connect import breez_sdk_spark.defaultConfig import kotlinx.coroutines.flow.MutableSharedFlow @@ -199,6 +203,41 @@ class SparkService @Inject constructor( suspend fun listPayments(): List = requireSdk().listPayments(ListPaymentsRequest()).payments + suspend fun listTokenPayments(tokenId: String): List = + requireSdk().listPayments(ListPaymentsRequest(assetFilter = AssetFilter.Token(tokenId))).payments + + /** + * A Spark invoice for [tokenId], so a payer scanning it knows which token to send; the wallet's plain Spark + * address receives tokens too but doesn't name one. + */ + suspend fun receiveTokenInvoice(tokenId: String, amount: BigInteger?): String { + val method = ReceivePaymentMethod.SparkInvoice( + amount = amount, + tokenIdentifier = tokenId, + expiryTime = null, + description = null, + senderPublicKey = null, + ) + return requireSdk().receivePayment(ReceivePaymentRequest(method)).paymentRequest + } + + suspend fun tokenMetadata(tokenId: String): TokenMetadata? = + requireSdk().getTokensMetadata(GetTokensMetadataRequest(listOf(tokenId))).tokensMetadata.firstOrNull() + + suspend fun parse(input: String): InputType = requireSdk().parse(input) + + /** Prepares a [tokenId] payment; [amount] in token base units, ignored by the SDK when an invoice sets one. */ + suspend fun prepareTokenSend(paymentRequest: String, amount: BigInteger?, tokenId: String) = + requireSdk().prepareSendPayment( + PrepareSendPaymentRequest( + paymentRequest = PaymentRequest.Input(input = paymentRequest), + amount = amount, + tokenIdentifier = tokenId, + conversionOptions = null, + feePolicy = null, + ), + ) + /** Exports what a unilateral exit needs if the Spark operators stop cooperating; it can reach several MB. */ suspend fun exportExitState(): String = requireSdk().exportUnilateralExitState().exitState diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 8d0da8b68e..65b802510f 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -170,6 +170,7 @@ import to.bitkit.ui.screens.wallets.receive.ReceiveRoute import to.bitkit.ui.screens.wallets.receive.ReceiveSheet import to.bitkit.ui.screens.wallets.send.HwSendViewModel import to.bitkit.ui.screens.wallets.suggestion.BuyIntroScreen +import to.bitkit.ui.screens.wallets.usdt.SparkUsdWalletScreen import to.bitkit.ui.screens.wallets.usdt.UsdtDepositsScreen import to.bitkit.ui.screens.wallets.usdt.UsdtWalletScreen import to.bitkit.ui.screens.widgets.WidgetsIntroScreen @@ -1295,6 +1296,12 @@ private fun NavGraphBuilder.home( deepLinkableComposable { UsdtWalletScreen(onBack = { navController.popBackStack() }) } + composableWithDefaultTransitions { entry -> + SparkUsdWalletScreen( + onBack = { navController.popBackStack() }, + initialRecipient = entry.toRoute().recipient, + ) + } composableWithDefaultTransitions { entry -> val payment = entry.toRoute() UsdtWalletScreen( @@ -2366,6 +2373,9 @@ sealed interface Routes { @Serializable data class UsdtPayment(val recipient: String, val amount: String) : Routes.InternalOnly + @Serializable + data class SparkUsdWallet(val recipient: String = "") : Routes.InternalOnly + @Serializable data object Settings : Routes.DeepLinkable 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 5c75d59a64..ba48eb30f8 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 @@ -174,6 +174,7 @@ import to.bitkit.ui.navigateToTransferIntro import to.bitkit.ui.scaffold.AppAlertDialog import to.bitkit.ui.screens.wallets.activity.components.ActivityListSimple import to.bitkit.ui.screens.wallets.activity.utils.previewActivityItems +import to.bitkit.ui.screens.wallets.usdt.SparkUsdWalletBalance import to.bitkit.ui.screens.wallets.usdt.UsdtRefreshEffect import to.bitkit.ui.screens.wallets.usdt.UsdtViewModel import to.bitkit.ui.screens.wallets.usdt.UsdtWalletBalance @@ -208,6 +209,7 @@ import to.bitkit.ui.theme.Colors import to.bitkit.ui.theme.Insets import to.bitkit.ui.theme.TopBarGradient import to.bitkit.ui.utils.withAccent +import to.bitkit.ui.walletViewModel import to.bitkit.viewmodels.ActivityListViewModel import to.bitkit.viewmodels.AppViewModel import to.bitkit.viewmodels.SettingsViewModel @@ -409,6 +411,7 @@ fun HomeScreen( onNavigateToSpending = { walletNavController.navigate(Routes.Spending) }, onClickHardwareWallet = { walletNavController.navigateTo(Routes.HardwareWallet(it)) }, onClickUsdt = { walletNavController.navigateTo(Routes.UsdtWallet) }, + onClickSparkUsd = { walletNavController.navigateTo(Routes.SparkUsdWallet()) }, onCalculatorInputActiveChanged = onCalculatorInputActiveChanged, ) } @@ -450,6 +453,7 @@ private fun Content( onNavigateToSpending: () -> Unit = {}, onClickHardwareWallet: (String) -> Unit = {}, onClickUsdt: () -> Unit = {}, + onClickSparkUsd: () -> Unit = {}, onCalculatorInputActiveChanged: (Boolean) -> Unit = {}, hazeState: HazeState = rememberHazeState(), balances: BalanceState = LocalBalances.current, @@ -588,6 +592,7 @@ private fun Content( onNavigateToSpending = onNavigateToSpending, onClickHardwareWallet = onClickHardwareWallet, onClickUsdt = onClickUsdt, + onClickSparkUsd = onClickSparkUsd, ) 1 -> WidgetsPage( @@ -643,6 +648,7 @@ private fun WalletPage( onNavigateToSpending: () -> Unit, onClickHardwareWallet: (String) -> Unit, onClickUsdt: () -> Unit, + onClickSparkUsd: () -> Unit, ) { val usdt by appViewModel?.usdtWallet?.collectAsStateWithLifecycle() ?: remember { mutableStateOf(UsdtWalletState()) } @@ -650,9 +656,12 @@ private fun WalletPage( val showUsdt = appViewModel?.isUsdtVisible?.collectAsStateWithLifecycle()?.value == true val rate = LocalCurrencies.current.paykitRate val usdtSats = usdtHeadlineSats(usdt.balance, showUsdt, rate) + val sparkUsd = walletViewModel?.sparkState?.collectAsStateWithLifecycle()?.value?.usd + val sparkUsdSats = usdtHeadlineSats(sparkUsd?.balance, sparkUsd != null, rate) val headlineSats = BigInteger( balances.totalWithHardwareSats.toString() - ).add(BigInteger.valueOf(usdtSats ?: 0L)).min(BigInteger.valueOf(Long.MAX_VALUE)).toLong() + ).add(BigInteger.valueOf(usdtSats ?: 0L)).add(BigInteger.valueOf(sparkUsdSats ?: 0L)) + .min(BigInteger.valueOf(Long.MAX_VALUE)).toLong() val heightStatusBar = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() val pullToRefreshState = rememberPullToRefreshState() val hasActivity = !latestActivities.isNullOrEmpty() || usdt.transfers.isNotEmpty() @@ -685,7 +694,7 @@ private fun WalletPage( BalanceHeaderView( sats = headlineSats, - isUnavailable = usdtSats == null, + isUnavailable = usdtSats == null || sparkUsdSats == null, showEyeIcon = true, testTag = "TotalBalance", modifier = Modifier @@ -700,6 +709,8 @@ private fun WalletPage( onNavigateToSpending = onNavigateToSpending, onClickHardwareWallet = onClickHardwareWallet, onClickUsdt = onClickUsdt, + showSparkUsd = sparkUsd != null, + onClickSparkUsd = onClickSparkUsd, ) VerticalSpacer(32.dp) @@ -768,6 +779,8 @@ private fun BalancesSection( onNavigateToSpending: () -> Unit, onClickHardwareWallet: (String) -> Unit, onClickUsdt: () -> Unit, + showSparkUsd: Boolean = false, + onClickSparkUsd: () -> Unit = {}, ) { Column(modifier = Modifier.fillMaxWidth()) { Row( @@ -803,6 +816,8 @@ private fun BalancesSection( (appViewModel?.isUsdtVisible?.collectAsStateWithLifecycle()?.value == true), onClickHardwareWallet = onClickHardwareWallet, onClickUsdt = onClickUsdt, + showSparkUsd = showSparkUsd, + onClickSparkUsd = onClickSparkUsd, ) } } @@ -813,8 +828,12 @@ private fun AdditionalWallets( showUsdt: Boolean, onClickHardwareWallet: (String) -> Unit, onClickUsdt: () -> Unit, + showSparkUsd: Boolean = false, + onClickSparkUsd: () -> Unit = {}, ) { - val count = wallets.size + if (showUsdt) 1 else 0 + val usdtIndex = if (showUsdt) wallets.size else -1 + val sparkUsdIndex = if (showSparkUsd) wallets.size + if (showUsdt) 1 else 0 else -1 + val count = wallets.size + (if (showUsdt) 1 else 0) + (if (showSparkUsd) 1 else 0) for (rowStart in 0 until count step 2) { VerticalSpacer(if (wallets.isEmpty()) 24.dp else 16.dp) Row( @@ -828,7 +847,8 @@ private fun AdditionalWallets( val wallet = wallets.getOrNull(index) when { wallet != null -> HwDeviceCell(wallet = wallet, onClick = onClickHardwareWallet) - showUsdt && index == wallets.size -> UsdtWalletBalance(onClick = onClickUsdt) + index == usdtIndex -> UsdtWalletBalance(onClick = onClickUsdt) + index == sparkUsdIndex -> SparkUsdWalletBalance(onClick = onClickSparkUsd) else -> FillWidth() } } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/SparkUsdViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/SparkUsdViewModel.kt new file mode 100644 index 0000000000..6cd6b16857 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/SparkUsdViewModel.kt @@ -0,0 +1,159 @@ +package to.bitkit.ui.screens.wallets.usdt + +import androidx.compose.runtime.Immutable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.synonym.bitkitcore.usdtFormatAmount +import com.synonym.bitkitcore.usdtParseAmount +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import to.bitkit.R +import to.bitkit.data.SettingsData +import to.bitkit.data.SettingsStore +import to.bitkit.ext.runSuspendCatching +import to.bitkit.repositories.SparkRepo +import to.bitkit.repositories.SparkUsdError +import javax.inject.Inject + +@HiltViewModel +class SparkUsdViewModel @Inject constructor( + private val sparkRepo: SparkRepo, + private val settingsStore: SettingsStore, +) : ViewModel() { + val wallet = sparkRepo.state.map { it.usd } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), sparkRepo.state.value.usd) + + val settings = settingsStore.data + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), SettingsData(hideBalance = true)) + + private val _state = MutableStateFlow(SparkUsdSendState()) + val state = _state.asStateFlow() + + fun refresh() = viewModelScope.launch { sparkRepo.refreshState() } + + fun loadReceiveRequest() = viewModelScope.launch { + _state.update { it.copy(receiveRequest = null, error = null) } + sparkRepo.createUsdRequest(amount = null) + .onSuccess { request -> _state.update { it.copy(receiveRequest = request) } } + .onFailure { _state.update { it.copy(error = R.string.usdt__error_network) } } + } + + fun startSend(input: String = "") = _state.update { + SparkUsdSendState(page = SparkUsdPage.RECIPIENT, recipientInput = input) + } + + fun showReceive() { + _state.update { SparkUsdSendState(page = SparkUsdPage.RECEIVE) } + loadReceiveRequest() + } + + fun dismiss() { + if (_state.value.busy) return + _state.update { SparkUsdSendState() } + } + + fun back() = _state.update { + when { + it.busy -> it + it.authenticationRequired -> it.copy(authenticationRequired = false) + it.page == SparkUsdPage.REVIEW -> it.copy(page = SparkUsdPage.AMOUNT, error = null) + it.page == SparkUsdPage.AMOUNT -> it.copy(page = SparkUsdPage.RECIPIENT, error = null) + else -> SparkUsdSendState() + } + } + + fun setRecipientInput(input: String) = _state.update { it.copy(recipientInput = input, error = null) } + + fun continueWithRecipient() = viewModelScope.launch { + val input = _state.value.recipientInput.trim() + if (input.isEmpty()) return@launch + _state.update { it.copy(busy = true, error = null) } + sparkRepo.parseUsdRecipient(input) + .onSuccess { recipient -> + _state.update { + it.copy( + busy = false, + recipient = recipient.request, + amount = recipient.amount, + amountInput = recipient.amount?.let(::usdtFormatAmount).orEmpty(), + isAmountFixed = recipient.amount != null, + page = if (recipient.amount != null) SparkUsdPage.REVIEW else SparkUsdPage.AMOUNT, + ) + } + } + .onFailure { error -> + val message = if (error is SparkUsdError) R.string.usdt__error_address else R.string.usdt__error_network + _state.update { it.copy(busy = false, error = message) } + } + } + + fun setAmountInput(input: String) = _state.update { it.copy(amountInput = input, error = null) } + + fun setMaximum() = _state.update { state -> + wallet.value?.balance?.let { state.copy(amountInput = usdtFormatAmount(it), error = null) } ?: state + } + + fun review() = _state.update { state -> + val amount = runCatching { usdtParseAmount(state.amountInput) }.getOrNull()?.takeIf { it > 0uL } + ?: return@update state.copy(error = R.string.usdt__error_amount) + val balance = wallet.value?.balance ?: 0uL + if (amount > balance) return@update state.copy(error = R.string.usdt__error_balance) + state.copy(amount = amount, page = SparkUsdPage.REVIEW, error = null) + } + + fun confirm() = viewModelScope.launch { + val state = _state.value + if (state.busy || state.completed || state.authenticationRequired) return@launch + val amount = state.amount ?: return@launch + if (amount > (wallet.value?.balance ?: 0uL)) { + _state.update { it.copy(error = R.string.usdt__error_balance) } + return@launch + } + val settings = runSuspendCatching { settingsStore.data.first() }.getOrNull() ?: return@launch + if (settings.isPinEnabled && settings.isPinForPaymentsEnabled) { + _state.update { it.copy(authenticationRequired = true) } + } else { + send() + } + } + + fun authenticationVerified() { + if (!_state.value.authenticationRequired) return + _state.update { it.copy(authenticationRequired = false) } + viewModelScope.launch { send() } + } + + private suspend fun send() { + val state = _state.value + val recipient = state.recipient ?: return + val amount = state.amount ?: return + _state.update { it.copy(busy = true, error = null) } + sparkRepo.sendUsd(recipient, amount) + .onSuccess { _state.update { it.copy(busy = false, completed = true) } } + .onFailure { _state.update { it.copy(busy = false, error = R.string.usdt__send_failed_description) } } + } +} + +enum class SparkUsdPage { WALLET, RECEIVE, RECIPIENT, AMOUNT, REVIEW } + +@Immutable +data class SparkUsdSendState( + val page: SparkUsdPage = SparkUsdPage.WALLET, + val receiveRequest: String? = null, + val recipientInput: String = "", + val recipient: String? = null, + val amountInput: String = "", + val amount: ULong? = null, + val isAmountFixed: Boolean = false, + val busy: Boolean = false, + val completed: Boolean = false, + val authenticationRequired: Boolean = false, + val error: Int? = null, +) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/SparkUsdWalletScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/SparkUsdWalletScreen.kt new file mode 100644 index 0000000000..dcf36e8d18 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/SparkUsdWalletScreen.kt @@ -0,0 +1,515 @@ +package to.bitkit.ui.screens.wallets.usdt + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.SheetValue +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.platform.testTag +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.compose.ui.unit.sp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import breez_sdk_spark.PaymentStatus +import com.synonym.bitkitcore.usdtFormatAmount +import kotlinx.collections.immutable.persistentListOf +import to.bitkit.R +import to.bitkit.ext.uiDateStyleFor +import to.bitkit.repositories.SparkUsdState +import to.bitkit.repositories.SparkUsdTransfer +import to.bitkit.ui.appViewModel +import to.bitkit.ui.components.BodyMSB +import to.bitkit.ui.components.BodyS +import to.bitkit.ui.components.BodySSB +import to.bitkit.ui.components.BottomSheet +import to.bitkit.ui.components.ButtonSize +import to.bitkit.ui.components.Caption13Up +import to.bitkit.ui.components.CaptionB +import to.bitkit.ui.components.FillHeight +import to.bitkit.ui.components.GradientCircularProgressIndicator +import to.bitkit.ui.components.HorizontalSpacer +import to.bitkit.ui.components.NumberPad +import to.bitkit.ui.components.NumberPadActionButton +import to.bitkit.ui.components.NumberPadType +import to.bitkit.ui.components.PaymentAddressInput +import to.bitkit.ui.components.PaymentReviewIllustration +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.SwipeToConfirm +import to.bitkit.ui.components.TabBar +import to.bitkit.ui.components.UsdtAmountHeader +import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.components.WalletBalanceContent +import to.bitkit.ui.components.usdtOverviewAmount +import to.bitkit.ui.scaffold.AppTopBar +import to.bitkit.ui.scaffold.DrawerNavIcon +import to.bitkit.ui.scaffold.SheetTopBar +import to.bitkit.ui.screens.wallets.activity.components.ActivityRowSurface +import to.bitkit.ui.screens.wallets.activity.components.AmountViewContent +import to.bitkit.ui.screens.wallets.activity.components.CircularIcon +import to.bitkit.ui.screens.wallets.activity.components.activityGroupTitleResource +import to.bitkit.ui.screens.wallets.receive.ReceiveQrView +import to.bitkit.ui.settingsViewModel +import to.bitkit.ui.shared.UiConstants +import to.bitkit.ui.shared.modifiers.clickableAlpha +import to.bitkit.ui.shared.modifiers.sheetHeight +import to.bitkit.ui.shared.util.blockPointerInputPassthrough +import to.bitkit.ui.theme.AppTextStyles +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.NumberPadInputHandler +import to.bitkit.ui.utils.uiDateText + +/** Home tile for the dollar balance on Spark, next to the Arbitrum USDT one. */ +@Composable +fun RowScope.SparkUsdWalletBalance(onClick: () -> Unit, viewModel: SparkUsdViewModel = hiltViewModel()) { + val wallet by viewModel.wallet.collectAsStateWithLifecycle() + val settings by viewModel.settings.collectAsStateWithLifecycle() + WalletBalanceContent( + title = wallet?.ticker ?: "USDT", + titleStyle = UsdtDetailStyles.Caption, + icon = { + Image( + painterResource(R.drawable.tether_circle), + contentDescription = null, + modifier = Modifier.padding(end = 4.dp).size(24.dp) + ) + }, + titleTrailing = { + HorizontalSpacer(4.dp) + CaptionB(stringResource(R.string.lightning__spark), color = Colors.White64) + }, + modifier = Modifier.clickableAlpha(onClick = onClick).padding(vertical = 4.dp).testTag("SparkUsdWallet") + ) { + BodyMSB( + when { + settings.hideBalance -> UiConstants.HIDE_BALANCE_SHORT + else -> wallet?.let { "$ " + usdtOverviewAmount(it.balance) } ?: "—" + }, + style = UsdtDetailStyles.HistoryTitle + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SparkUsdWalletScreen( + onBack: () -> Unit, + initialRecipient: String = "", + viewModel: SparkUsdViewModel = hiltViewModel(), +) { + val wallet by viewModel.wallet.collectAsStateWithLifecycle() + val state by viewModel.state.collectAsStateWithLifecycle() + val settings by viewModel.settings.collectAsStateWithLifecycle() + LaunchedEffect(viewModel, initialRecipient) { + viewModel.refresh() + if (initialRecipient.isNotEmpty()) { + viewModel.startSend(initialRecipient) + viewModel.continueWithRecipient() + } + } + BackHandler(enabled = state.page == SparkUsdPage.WALLET, onBack = onBack) + SparkUsdWalletContent( + wallet = wallet, + hideBalance = settings.hideBalance, + onReceive = viewModel::showReceive, + onSend = { viewModel.startSend() }, + onBack = onBack, + ) + if (state.page == SparkUsdPage.WALLET) return + val blocked by rememberUpdatedState(state.busy) + val sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true, + confirmValueChange = { it != SheetValue.Hidden || !blocked }, + ) + BottomSheet( + onDismissRequest = viewModel::dismiss, + sheetState = sheetState, + containerColor = Colors.Gray7, + dragHandleTopPadding = 8.dp, + ) { + BackHandler(onBack = viewModel::back) + Column( + modifier = Modifier.sheetHeight(isModal = true).background(Colors.Gray7) + .navigationBarsPadding().imePadding() + .padding(bottom = 16.dp) + ) { + if (state.authenticationRequired) { + Box(modifier = Modifier.padding(horizontal = 16.dp)) { + UsdtAuthentication( + useBiometrics = settings.isBiometricEnabled, + onCancel = viewModel::back, + onVerified = viewModel::authenticationVerified, + ) + } + } else { + SparkUsdSheetContent( + wallet = wallet, + state = state, + hideBalance = settings.hideBalance, + onBack = viewModel::back, + onRecipientChange = viewModel::setRecipientInput, + onRecipientContinue = viewModel::continueWithRecipient, + onAmountChange = viewModel::setAmountInput, + onMaximum = viewModel::setMaximum, + onReview = viewModel::review, + onConfirm = viewModel::confirm, + onDone = viewModel::dismiss, + ) + } + } + } +} + +@Composable +private fun SparkUsdWalletContent( + wallet: SparkUsdState?, + hideBalance: Boolean, + onReceive: () -> Unit, + onSend: () -> Unit, + onBack: () -> Unit, +) { + val settings = settingsViewModel + val app = appViewModel + val allowSwipe by settings?.enableSwipeToHideBalance?.collectAsStateWithLifecycle() ?: remember { + mutableStateOf(false) + } + val transfers = wallet?.transfers ?: persistentListOf() + Box(modifier = Modifier.fillMaxSize().background(Colors.Black).blockPointerInputPassthrough()) { + UsdtCoinIllustration( + modifier = Modifier.align(Alignment.TopEnd) + .offset(x = 81.dp, y = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 4.dp) + .scale(scaleX = -1f, scaleY = 1f) + ) + Column { + AppTopBar( + titleText = (wallet?.ticker ?: "USDT") + " · " + stringResource(R.string.lightning__spark), + onBackClick = onBack, + icon = R.drawable.tether_circle, + actions = { DrawerNavIcon() } + ) + LazyColumn(modifier = Modifier.padding(horizontal = 16.dp)) { + item { + VerticalSpacer(16.dp) + UsdtAmountHeader( + amount = wallet?.let { usdtFormatAmount(it.balance) } ?: "—", + displayAmount = wallet?.let { usdtOverviewAmount(it.balance) }, + hideBalance = hideBalance, + onToggleHide = if (allowSwipe) ({ settings?.setHideBalance(!hideBalance) }) else null, + modifier = Modifier.testTag("SparkUsdBalance") + ) + VerticalSpacer(32.dp) + } + itemsIndexed(transfers, key = { _, transfer -> transfer.id }) { index, transfer -> + val heading = activityGroupTitleResource(transfer.timestamp) + if (index == 0 || activityGroupTitleResource(transfers[index - 1].timestamp) != heading) { + Caption13Up( + stringResource(heading), + color = Colors.White64, + modifier = Modifier.padding(top = if (index == 0) 0.dp else 16.dp, bottom = 16.dp) + ) + } + SparkUsdActivityRow(transfer, hideBalance) + VerticalSpacer(16.dp) + } + item { VerticalSpacer(120.dp) } + } + } + TabBar(onSendClick = onSend, onReceiveClick = onReceive, onScanClick = { + app?.showScannerSheet(showBackButton = false) + }) + } +} + +@Composable +private fun SparkUsdActivityRow(transfer: SparkUsdTransfer, hideBalance: Boolean) { + val color = when (transfer.status) { + PaymentStatus.FAILED -> Colors.Red + PaymentStatus.PENDING -> Colors.Yellow + PaymentStatus.COMPLETED -> Colors.Usdt + } + val icon = when (transfer.status) { + PaymentStatus.FAILED -> R.drawable.ic_x_circle + PaymentStatus.PENDING -> R.drawable.ic_hourglass_simple + PaymentStatus.COMPLETED -> if (transfer.isIncoming) R.drawable.ic_received else R.drawable.ic_sent + } + val title = when (transfer.status) { + PaymentStatus.FAILED -> R.string.usdt__failed + PaymentStatus.PENDING -> R.string.usdt__pending + PaymentStatus.COMPLETED -> if (transfer.isIncoming) R.string.usdt__received else R.string.usdt__sent + } + ActivityRowSurface(onClick = {}, modifier = Modifier.testTag("SparkUsdActivity-" + transfer.id)) { + CircularIcon( + icon = painterResource(icon), + iconColor = color, + backgroundColor = color.copy(alpha = 0.16f), + size = 40.dp + ) + HorizontalSpacer(16.dp) + Column(verticalArrangement = Arrangement.spacedBy(2.dp), modifier = Modifier.weight(1f)) { + BodyMSB(stringResource(title), maxLines = 1) + CaptionB( + uiDateText(transfer.timestamp, uiDateStyleFor(transfer.timestamp)), + color = Colors.White64, + maxLines = 1 + ) + } + HorizontalSpacer(16.dp) + AmountViewContent( + title = usdtOverviewAmount(transfer.amount), + titlePrefix = if (transfer.isIncoming) "+" else "−", + subtitle = "$ " + usdtOverviewAmount(transfer.amount), + hideBalance = hideBalance + ) + } +} + +@Composable +private fun SparkUsdSheetContent( + wallet: SparkUsdState?, + state: SparkUsdSendState, + hideBalance: Boolean, + onBack: () -> Unit, + onRecipientChange: (String) -> Unit, + onRecipientContinue: () -> Unit, + onAmountChange: (String) -> Unit, + onMaximum: () -> Unit, + onReview: () -> Unit, + onConfirm: () -> Unit, + onDone: () -> Unit, +) { + val title = when { + state.page == SparkUsdPage.RECEIVE -> R.string.usdt__receive_title + state.completed -> R.string.usdt__sent_title + state.page == SparkUsdPage.REVIEW -> R.string.wallet__send_review + state.page == SparkUsdPage.AMOUNT -> R.string.usdt__amount + else -> R.string.usdt__send_title + } + Column(modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp)) { + SheetTopBar(stringResource(title), onBack = onBack.takeUnless { state.busy || state.completed }) + VerticalSpacer(16.dp) + when (state.page) { + SparkUsdPage.RECEIVE -> SparkUsdReceiveContent(state) + SparkUsdPage.AMOUNT -> SparkUsdAmountContent( + wallet, + state, + hideBalance, + onAmountChange, + onMaximum, + onReview + ) + SparkUsdPage.REVIEW -> SparkUsdReviewContent(state, onConfirm, onDone) + else -> SparkUsdRecipientContent(state, onRecipientChange, onRecipientContinue) + } + } +} + +@Composable +private fun ColumnScope.SparkUsdReceiveContent(state: SparkUsdSendState) { + val request = state.receiveRequest + BoxWithConstraints(modifier = Modifier.weight(1f).fillMaxWidth()) { + when { + request != null -> ReceiveQrView( + uri = request, + copyText = request, + qrLogoPainter = painterResource(R.drawable.tether_circle), + onClickEditInvoice = null, + accentColor = Colors.Usdt, + copyMessage = stringResource(R.string.usdt__receive_copied), + modifier = Modifier.fillMaxWidth().height(minOf(maxHeight, maxWidth + ButtonSize.Small.height + 32.dp)) + .testTag("SparkUsdReceiveQr") + ) + state.error != null -> BodyS(stringResource(state.error), color = Colors.Brand) + else -> GradientCircularProgressIndicator(modifier = Modifier.size(24.dp)) + } + } + BodyS( + stringResource(R.string.usdt__receive_network, stringResource(R.string.lightning__spark)), + color = Colors.White64, + ) +} + +@Composable +private fun ColumnScope.SparkUsdRecipientContent( + state: SparkUsdSendState, + onRecipientChange: (String) -> Unit, + onRecipientContinue: () -> Unit, +) { + Caption13Up(stringResource(R.string.wallet__send_to), color = Colors.White64) + VerticalSpacer(8.dp) + PaymentAddressInput( + value = state.recipientInput, + onValueChange = onRecipientChange, + placeholder = stringResource(R.string.usdt__address), + modifier = Modifier.fillMaxWidth().weight(1f).testTag("SparkUsdRecipient") + ) + VerticalSpacer(16.dp) + state.error?.let { + BodyS(stringResource(it), color = Colors.Brand, modifier = Modifier.testTag("SparkUsdError")) + VerticalSpacer(16.dp) + } + PrimaryButton( + text = stringResource(R.string.common__continue), + enabled = state.recipientInput.isNotBlank() && !state.busy, + isLoading = state.busy, + onClick = onRecipientContinue, + modifier = Modifier.testTag("SparkUsdRecipientContinue") + ) +} + +@Composable +private fun SparkUsdAmountContent( + wallet: SparkUsdState?, + state: SparkUsdSendState, + hideBalance: Boolean, + onAmountChange: (String) -> Unit, + onMaximum: () -> Unit, + onReview: () -> Unit, +) { + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val maxHeight = this.maxHeight + Column(modifier = Modifier.fillMaxWidth()) { + UsdtAmountHeader(state.amountInput.ifEmpty { "0" }, modifier = Modifier.testTag("SparkUsdAmount")) + FillHeight(min = 12.dp) + state.error?.let { + BodyS(stringResource(it), color = Colors.Brand, modifier = Modifier.testTag("SparkUsdError")) + VerticalSpacer(16.dp) + } + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.weight(1f)) { + Caption13Up(stringResource(R.string.wallet__send_available), color = Colors.White64) + VerticalSpacer(4.dp) + BodySSB( + when { + hideBalance -> UiConstants.HIDE_BALANCE_SHORT + else -> wallet?.let { "$ " + usdtOverviewAmount(it.balance) } ?: "—" + } + ) + } + NumberPadActionButton( + text = stringResource(R.string.common__max), + color = Colors.Usdt, + onClick = onMaximum, + textStyle = AppTextStyles.CaptionM.copy(letterSpacing = 1.sp), + modifier = Modifier.testTag("SparkUsdMax") + ) + } + VerticalSpacer(16.dp) + HorizontalDivider(color = Colors.White10) + NumberPad( + type = NumberPadType.DECIMAL, + availableHeight = maxHeight, + onDeleteLongPress = { onAmountChange("") }, + onPress = { key -> + onAmountChange( + NumberPadInputHandler.handleInput(key, state.amountInput, maxLength = 21, maxDecimals = 6) + ) + }, + ) + PrimaryButton( + text = stringResource(R.string.common__continue), + onClick = onReview, + enabled = state.amountInput.isNotEmpty(), + modifier = Modifier.testTag("SparkUsdReview") + ) + } + } +} + +@Composable +private fun ColumnScope.SparkUsdReviewContent(state: SparkUsdSendState, onConfirm: () -> Unit, onDone: () -> Unit) { + val amount = state.amount ?: 0uL + val swipeProgress = remember { mutableFloatStateOf(0f) } + UsdtAmountHeader(usdtFormatAmount(amount), displayAmount = usdtOverviewAmount(amount)) + VerticalSpacer(24.dp) + Caption13Up(stringResource(R.string.wallet__send_to), color = Colors.White64) + VerticalSpacer(8.dp) + BodySSB(state.recipient.orEmpty(), maxLines = 2, modifier = Modifier.testTag("SparkUsdReviewRecipient")) + FillHeight() + if (!state.completed) { + PaymentReviewIllustration( + swipeProgress = { swipeProgress.floatValue }, + isUsdt = true, + modifier = Modifier.height(200.dp) + ) + } + state.error?.let { + BodyS(stringResource(it), color = Colors.Brand, modifier = Modifier.testTag("SparkUsdError")) + VerticalSpacer(16.dp) + } + if (state.completed) { + BodyS(stringResource(R.string.usdt__submitted_description), color = Colors.White64) + VerticalSpacer(16.dp) + PrimaryButton( + text = stringResource(R.string.common__close), + onClick = onDone, + modifier = Modifier.testTag("SparkUsdDone") + ) + } else { + SwipeToConfirm( + text = stringResource(R.string.wallet__send_swipe), + color = Colors.Usdt, + onConfirm = onConfirm, + enabled = !state.busy, + loading = state.busy, + confirmed = state.busy || state.authenticationRequired, + progress = swipeProgress, + textStyle = AppTextStyles.BodySSB.copy(letterSpacing = 0.sp), + modifier = Modifier.fillMaxWidth().testTag("SparkUsdConfirm") + ) + } +} + +@Preview(showSystemUi = true) +@Composable +private fun Preview() { + AppThemeSurface { + SparkUsdWalletContent( + wallet = SparkUsdState( + ticker = "USDT", + balance = 12_340_000uL, + transfers = persistentListOf( + SparkUsdTransfer("1", true, PaymentStatus.COMPLETED, 5_000_000uL, 0uL, 1_793_098_272uL, "ab"), + ), + ), + hideBalance = false, + onReceive = {}, + onSend = {}, + onBack = {}, + ) + } +} diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index c3b3febd9d..5663db60f6 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -215,6 +215,7 @@ import to.bitkit.repositories.PublicPaykitRepo import to.bitkit.repositories.QuickPayPaymentFailedError import to.bitkit.repositories.QuickPayRepo import to.bitkit.repositories.SamRockRepo +import to.bitkit.repositories.SparkRepo import to.bitkit.repositories.TransferRepo import to.bitkit.repositories.UsdtPreferencesRepo import to.bitkit.repositories.UsdtRepo @@ -4462,6 +4463,12 @@ class AppViewModel @Inject constructor( return@withContext } + if (contactPaymentContext == null && Env.sparkUsdTokenId != null && SparkRepo.isSparkInput(input)) { + hideSheet() + mainScreenEffect(MainScreenEffect.Navigate(Routes.SparkUsdWallet(input.trim()))) + return@withContext + } + val contactPaymentProfile = activeContactPaymentProfile() val incomingPaymentRequest = activeIncomingPaymentRequest() val isPaymentRequest = incomingPaymentRequest != null diff --git a/app/src/test/java/to/bitkit/repositories/SparkRepoTest.kt b/app/src/test/java/to/bitkit/repositories/SparkRepoTest.kt index ae2b1f3999..6af4674c15 100644 --- a/app/src/test/java/to/bitkit/repositories/SparkRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/SparkRepoTest.kt @@ -1,7 +1,9 @@ package to.bitkit.repositories +import breez_sdk_spark.BitcoinNetwork import breez_sdk_spark.GetInfoResponse import breez_sdk_spark.ImportUnilateralExitStateResponse +import breez_sdk_spark.InputType import breez_sdk_spark.Payment import breez_sdk_spark.PaymentDetails import breez_sdk_spark.PaymentMethod @@ -10,6 +12,10 @@ import breez_sdk_spark.PaymentType import breez_sdk_spark.SdkEvent import breez_sdk_spark.SparkHtlcDetails import breez_sdk_spark.SparkHtlcStatus +import breez_sdk_spark.SparkInvoiceDetails +import breez_sdk_spark.TokenBalance +import breez_sdk_spark.TokenMetadata +import breez_sdk_spark.TokenTransactionType import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.OnchainActivity import kotlinx.coroutines.flow.MutableSharedFlow @@ -34,6 +40,8 @@ import java.io.File import java.math.BigInteger import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull import kotlin.test.assertTrue import com.synonym.bitkitcore.PaymentType as ActivityPaymentType @@ -167,4 +175,115 @@ class SparkRepoTest : BaseUnitTest() { ), conversionDetails = null, ) + + @Test + fun `recognizes spark addresses and invoices before parsing`() { + assertTrue(SparkRepo.isSparkInput("sparkrt1pgssyuuuhnrrdjswal5c3s3rafw9w3y5dd4cuy3whr3f0zhs8hu")) + assertTrue(SparkRepo.isSparkInput(" SPARK1abc")) + assertFalse(SparkRepo.isSparkInput("lnbcrt1abc")) + assertFalse(SparkRepo.isSparkInput("0x2d8a9f6c4c37a2f8b7f3a3b1d6c3c8f8d1a2b3c4")) + } + + @Test + fun `scales token units to dollar units and back`() { + assertEquals(1_500_000uL, SparkRepo.toUsdUnits(BigInteger.valueOf(1_500_000), 6)) + assertEquals(1_500_000uL, SparkRepo.toUsdUnits(BigInteger.valueOf(150_000_000), 8)) + assertEquals(BigInteger.valueOf(150_000_000), SparkRepo.fromUsdUnits(1_500_000uL, 8)) + assertEquals(BigInteger.valueOf(1_500_000), SparkRepo.fromUsdUnits(1_500_000uL, 6)) + } + + @Test + fun `loads the dollar token balance and transfers`() = test { + val tokenId = requireNotNull(Env.sparkUsdTokenId) + val metadata = usdMetadata(tokenId) + whenever { sparkService.getInfo() }.thenReturn( + GetInfoResponse( + "pubkey", + 0uL, + mapOf(tokenId to TokenBalance(BigInteger.valueOf(7_000_000), metadata)), + 0uL + ), + ) + whenever { sparkService.tokenMetadata(tokenId) }.thenReturn(metadata) + whenever { sparkService.listTokenPayments(tokenId) }.thenReturn( + listOf( + tokenPayment("mint", metadata, TokenTransactionType.MINT, 1uL), + tokenPayment("in", metadata, TokenTransactionType.TRANSFER, 2uL), + ), + ) + whenever(sparkService.isConnected).thenReturn(true) + val sut = createSut() + + sut.start(0).getOrThrow() + + val usd = requireNotNull(sut.state.value.usd) + assertEquals("USDT", usd.ticker) + assertEquals(7_000_000uL, usd.balance) + val transfer = usd.transfers.single() + assertEquals("in", transfer.id) + assertTrue(transfer.isIncoming) + assertEquals(2_000_000uL, transfer.amount) + } + + @Test + fun `rejects a spark invoice for another token`() = test { + whenever { sparkService.parse("sparkrt1invoice") }.thenReturn( + InputType.SparkInvoice(invoice(tokenId = "btknrt1other", amount = null)), + ) + val sut = createSut() + + val result = sut.parseUsdRecipient("sparkrt1invoice") + + assertIs(result.exceptionOrNull()) + } + + @Test + fun `reads the amount a dollar invoice fixes`() = test { + val tokenId = requireNotNull(Env.sparkUsdTokenId) + whenever { sparkService.tokenMetadata(tokenId) }.thenReturn(usdMetadata(tokenId)) + whenever { sparkService.parse("sparkrt1invoice") }.thenReturn( + InputType.SparkInvoice(invoice(tokenId = tokenId, amount = BigInteger.valueOf(2_500_000))), + ) + val sut = createSut() + + val recipient = sut.parseUsdRecipient("sparkrt1invoice").getOrThrow() + + assertEquals("sparkrt1invoice", recipient.request) + assertEquals(2_500_000uL, recipient.amount) + assertNull(sut.parseUsdRecipient("sparkrt1invoice").exceptionOrNull()) + } + + private fun usdMetadata(tokenId: String) = TokenMetadata( + identifier = tokenId, + issuerPublicKey = "issuer", + name = "Test Tether USD", + ticker = "USDT", + decimals = 6u, + maxSupply = BigInteger.valueOf(Long.MAX_VALUE), + isFreezable = true, + ) + + private fun tokenPayment(id: String, metadata: TokenMetadata, txType: TokenTransactionType, dollars: ULong) = + Payment( + id = id, + paymentType = PaymentType.RECEIVE, + status = PaymentStatus.COMPLETED, + amount = BigInteger.valueOf((dollars * 1_000_000uL).toLong()), + fees = BigInteger.ZERO, + timestamp = dollars, + method = PaymentMethod.TOKEN, + details = PaymentDetails.Token(metadata, "hash-$id", txType, null, null), + conversionDetails = null, + ) + + private fun invoice(tokenId: String, amount: BigInteger?) = SparkInvoiceDetails( + invoice = "sparkrt1invoice", + identityPublicKey = "pubkey", + network = BitcoinNetwork.REGTEST, + amount = amount, + tokenIdentifier = tokenId, + expiryTime = null, + description = null, + senderPublicKey = null, + ) } diff --git a/docs/screens-map.md b/docs/screens-map.md index 496eec1985..fe1bc908c8 100644 --- a/docs/screens-map.md +++ b/docs/screens-map.md @@ -226,6 +226,7 @@ Frame names are stable across handoff iterations; node ids are not, so the map l | Android | Figma | | - | - | +| SparkUsdWalletScreen.kt | `todo` | | UsdtDepositReceiveScreen.kt | Receive USDT › HW QR / Recipient / Specify Amount (populated) / Fee To Add Liquidity | | UsdtDepositsScreen.kt | Activity › Transaction Cross Network USDT / Transaction Receiving USDT Tech details | | UsdtWalletScreen.kt | Savings, Spending, Hardware, USDT Balances › Wallet USDT (funds & activity) / Wallet USDT (empty) | diff --git a/journeys/spark-usd-bitkit-to-bitkit.xml b/journeys/spark-usd-bitkit-to-bitkit.xml new file mode 100644 index 0000000000..7a9d09d891 --- /dev/null +++ b/journeys/spark-usd-bitkit-to-bitkit.xml @@ -0,0 +1,12 @@ + + Use two onboarded dev builds (regtest), A and B, with Spark connected. Fund A with the Spark test dollar token first (see the PR's manual test: the token's issuer wallet is not part of the journey environment). The dollar balance on Spark is separate from the Arbitrum USDT wallet. + + On Home of A, verify a tile (testTag "SparkUsdWallet") shows the token ticker, "Spark" and its dollar balance, next to Savings and Spending, and that the total balance includes it. + Open the tile. Verify the title reads "USDT · Spark", the balance header (testTag "SparkUsdBalance") matches the tile, and the funding payment is listed as Received. + On B, open the tile and tap Receive. Verify a QR (testTag "SparkUsdReceiveQr") holding a Spark invoice (sparkrt1…) and "Network: Spark" below it. + Hand B's invoice to A as a URI (lightning:<invoice>). Verify A opens the dollar send flow on the amount step with its available balance, without a recipient screen. + Enter 10, tap Continue (testTag "SparkUsdReview"), and swipe to pay. Verify the sheet shows Sent USDT, then close it (testTag "SparkUsdDone"). + Verify A's balance dropped by exactly 10.00 with a Sent row, and B's balance rose by 10.00 with a Received row, with no fee on either side. + On A, start a send and enter a Lightning invoice or a Spark invoice for sats as the recipient. Verify the recipient is rejected with "Enter a valid address for the selected network." and nothing is sent. + + From 446cdfd0f52d5de352094f1bbe51cead98855e2d Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 27 Oct 2026 12:24:54 +0100 Subject: [PATCH 39/47] chore: add changelog fragment --- changelog.d/next/1710.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/next/1710.added.md diff --git a/changelog.d/next/1710.added.md b/changelog.d/next/1710.added.md new file mode 100644 index 0000000000..c93f7af1f0 --- /dev/null +++ b/changelog.d/next/1710.added.md @@ -0,0 +1 @@ +Added a dollar balance on Spark that Bitkit wallets can send to each other for free. From 8ae8ffc9f6017a6bd694c94afd9624f367265aa2 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 27 Oct 2026 12:53:31 +0100 Subject: [PATCH 40/47] fix: pay fixed-amount spark invoices as they stand --- .../java/to/bitkit/repositories/SparkRepo.kt | 26 +++++++++++-------- .../screens/wallets/usdt/SparkUsdViewModel.kt | 2 +- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/SparkRepo.kt b/app/src/main/java/to/bitkit/repositories/SparkRepo.kt index 1116b15aee..aa81e7c4ae 100644 --- a/app/src/main/java/to/bitkit/repositories/SparkRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/SparkRepo.kt @@ -201,18 +201,22 @@ class SparkRepo @Inject constructor( } } - /** Pays [amount] dollar units of the dollar token to [request], a Spark address or invoice. */ - suspend fun sendUsd(request: String, amount: ULong): Result = withContext(bgDispatcher) { - runSuspendCatching { - val tokenId = requireNotNull(Env.sparkUsdTokenId) { "No Spark dollar token on this network" } - val units = fromUsdUnits(amount, usdDecimals(tokenId)) - val payment = sparkService.send(sparkService.prepareTokenSend(request, units, tokenId)) - Logger.info("Sent Spark dollar payment '${payment.id}' with status '${payment.status}'", context = TAG) - refresh() - }.onFailure { - Logger.error("Failed to send Spark dollar payment", it, context = TAG) + /** + * Pays [amount] dollar units of the dollar token to [request], a Spark address or invoice. An invoice that fixes + * its amount is paid as it stands, so no rescaled amount can mismatch it. + */ + suspend fun sendUsd(request: String, amount: ULong, isAmountFixed: Boolean = false): Result = + withContext(bgDispatcher) { + runSuspendCatching { + val tokenId = requireNotNull(Env.sparkUsdTokenId) { "No Spark dollar token on this network" } + val units = if (isAmountFixed) null else fromUsdUnits(amount, usdDecimals(tokenId)) + val payment = sparkService.send(sparkService.prepareTokenSend(request, units, tokenId)) + Logger.info("Sent Spark dollar payment '${payment.id}' with status '${payment.status}'", context = TAG) + refresh() + }.onFailure { + Logger.error("Failed to send Spark dollar payment", it, context = TAG) + } } - } private suspend fun usdDecimals(tokenId: String): Int = usdMetadata(tokenId).decimals.toInt() diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/SparkUsdViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/SparkUsdViewModel.kt index 6cd6b16857..b4b1aeafb8 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/SparkUsdViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/SparkUsdViewModel.kt @@ -135,7 +135,7 @@ class SparkUsdViewModel @Inject constructor( val recipient = state.recipient ?: return val amount = state.amount ?: return _state.update { it.copy(busy = true, error = null) } - sparkRepo.sendUsd(recipient, amount) + sparkRepo.sendUsd(recipient, amount, state.isAmountFixed) .onSuccess { _state.update { it.copy(busy = false, completed = true) } } .onFailure { _state.update { it.copy(busy = false, error = R.string.usdt__send_failed_description) } } } From fb1ebbc88ca7c0dd629a93f42bd46fb9e55f45fc Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 27 Oct 2026 13:07:05 +0100 Subject: [PATCH 41/47] feat: pin a test dollar token on mainnet spark --- app/src/main/java/to/bitkit/env/Env.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt index 10d636212d..edb343fdad 100644 --- a/app/src/main/java/to/bitkit/env/Env.kt +++ b/app/src/main/java/to/bitkit/env/Env.kt @@ -35,11 +35,13 @@ internal object Env { /** * The Spark token held as the dollar balance on Spark, matched by identifier since anyone can issue a token - * with any ticker. Regtest uses a 6-decimal test token issued on Lightspark's regtest; mainnet has none yet. + * with any ticker. No Tether-issued USDT exists on Spark yet, so both networks use a 6-decimal test token issued for + * testing: "Test Tether USD" on Lightspark's regtest and "Bitkit Test Dollar" (tUSD) on mainnet. */ val sparkUsdTokenId: String? get() = when (network) { Network.REGTEST -> "btknrt1e2h2l3jwna0dzwfjsf3dcgck8zjuug3qprnuvl7hs23a68xvz38q7966fl" + Network.BITCOIN -> "btkn1nhdmkalm7wwh5pylhwacwwxeghv2q9mf0f3km44rruv0jf7vhm7stwkdzs" else -> null } From 9d5c4bf77d813bddfc89a21c18b25113cbd4eeb5 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 27 Oct 2026 13:13:51 +0100 Subject: [PATCH 42/47] chore: wrap the spark dollar token note --- app/src/main/java/to/bitkit/env/Env.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt index edb343fdad..084100f7c9 100644 --- a/app/src/main/java/to/bitkit/env/Env.kt +++ b/app/src/main/java/to/bitkit/env/Env.kt @@ -35,8 +35,8 @@ internal object Env { /** * The Spark token held as the dollar balance on Spark, matched by identifier since anyone can issue a token - * with any ticker. No Tether-issued USDT exists on Spark yet, so both networks use a 6-decimal test token issued for - * testing: "Test Tether USD" on Lightspark's regtest and "Bitkit Test Dollar" (tUSD) on mainnet. + * with any ticker. No Tether-issued USDT exists on Spark yet, so both networks use a 6-decimal test token: + * "Test Tether USD" on Lightspark's regtest and "Bitkit Test Dollar" (tUSD) on mainnet. */ val sparkUsdTokenId: String? get() = when (network) { From a7456a2dd31bb13fa2a31dc767fd115448d65b8d Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 27 Oct 2026 13:44:46 +0100 Subject: [PATCH 43/47] feat: hold usdb on spark and convert it with bitcoin --- app/src/main/java/to/bitkit/env/Env.kt | 11 +- .../java/to/bitkit/repositories/SparkRepo.kt | 96 ++++++++++++ .../java/to/bitkit/services/SparkService.kt | 61 ++++++++ .../screens/wallets/usdt/SparkUsdViewModel.kt | 65 +++++++- .../wallets/usdt/SparkUsdWalletScreen.kt | 145 +++++++++++++++++- app/src/main/res/values-ar/strings.xml | 14 ++ app/src/main/res/values-b+es+419/strings.xml | 14 ++ app/src/main/res/values-ca/strings.xml | 14 ++ app/src/main/res/values-cs/strings.xml | 14 ++ app/src/main/res/values-de/strings.xml | 14 ++ app/src/main/res/values-el/strings.xml | 14 ++ app/src/main/res/values-es-rES/strings.xml | 14 ++ app/src/main/res/values-es/strings.xml | 14 ++ app/src/main/res/values-fr/strings.xml | 14 ++ app/src/main/res/values-it/strings.xml | 14 ++ app/src/main/res/values-nl/strings.xml | 14 ++ app/src/main/res/values-pl/strings.xml | 14 ++ app/src/main/res/values-pt-rBR/strings.xml | 14 ++ app/src/main/res/values-pt/strings.xml | 14 ++ app/src/main/res/values-ru/strings.xml | 14 ++ app/src/main/res/values/strings.xml | 14 ++ 21 files changed, 589 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/to/bitkit/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt index 084100f7c9..3bea2b3e82 100644 --- a/app/src/main/java/to/bitkit/env/Env.kt +++ b/app/src/main/java/to/bitkit/env/Env.kt @@ -35,13 +35,16 @@ internal object Env { /** * The Spark token held as the dollar balance on Spark, matched by identifier since anyone can issue a token - * with any ticker. No Tether-issued USDT exists on Spark yet, so both networks use a 6-decimal test token: - * "Test Tether USD" on Lightspark's regtest and "Bitkit Test Dollar" (tUSD) on mainnet. + * with any ticker. Mainnet holds USDB, Brale's 6-decimal dollar token on Spark; regtest holds a 6-decimal + * "Test USDB" token issued on Lightspark's regtest. */ + /** Orchestra, which bridges Spark dollars to Arbitrum USDT, serves mainnet only. */ + val isSparkBridgeAvailable: Boolean get() = network == Network.BITCOIN + val sparkUsdTokenId: String? get() = when (network) { - Network.REGTEST -> "btknrt1e2h2l3jwna0dzwfjsf3dcgck8zjuug3qprnuvl7hs23a68xvz38q7966fl" - Network.BITCOIN -> "btkn1nhdmkalm7wwh5pylhwacwwxeghv2q9mf0f3km44rruv0jf7vhm7stwkdzs" + Network.REGTEST -> "btknrt1wrr0qtshpyuzrx5p0mvd605dnwvfv3dcpfexqgfzpgl3rykdacsq6p8z6l" + Network.BITCOIN -> "btkn1xgrvjwey5ngcagvap2dzzvsy4uk8ua9x69k82dwvt5e7ef9drm9qztux87" else -> null } diff --git a/app/src/main/java/to/bitkit/repositories/SparkRepo.kt b/app/src/main/java/to/bitkit/repositories/SparkRepo.kt index aa81e7c4ae..1e369c8a74 100644 --- a/app/src/main/java/to/bitkit/repositories/SparkRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/SparkRepo.kt @@ -1,13 +1,16 @@ package to.bitkit.repositories +import androidx.compose.runtime.Stable import breez_sdk_spark.FeePolicy import breez_sdk_spark.InputType import breez_sdk_spark.Payment import breez_sdk_spark.PaymentDetails import breez_sdk_spark.PaymentMethod import breez_sdk_spark.PaymentStatus +import breez_sdk_spark.PrepareSendPaymentResponse import breez_sdk_spark.SdkEvent import breez_sdk_spark.SendPaymentMethod +import breez_sdk_spark.SparkAsset import breez_sdk_spark.TokenMetadata import breez_sdk_spark.TokenTransactionType import com.synonym.bitkitcore.Activity @@ -70,6 +73,9 @@ class SparkRepo @Inject constructor( /** Last step of the Spending setup progress, shown once a funding deposit is claimed. */ private const val SETUP_STEP_DONE = 3 + /** Arbitrum One, where Bitkit's own USDT wallet lives. */ + private const val ARBITRUM_CHAIN_ID = "42161" + /** Decimals of Bitkit's dollar amounts, shared with Arbitrum USDT so both format alike. */ private const val USD_DECIMALS = 6 @@ -218,6 +224,80 @@ class SparkRepo @Inject constructor( } } + /** + * Quotes converting within the wallet through Flashnet: [amount] dollar units to get when [toUsd], or to give + * when converting to bitcoin. + */ + suspend fun quoteConversion(toUsd: Boolean, amount: ULong): Result = withContext(bgDispatcher) { + runSuspendCatching { + val tokenId = requireNotNull(Env.sparkUsdTokenId) { "No Spark dollar token on this network" } + val decimals = usdDecimals(tokenId) + val prepared = sparkService.prepareConversion(toUsd, fromUsdUnits(amount, decimals), tokenId) + val estimate = requireNotNull(prepared.conversionEstimate) { "No conversion estimate" } + val sats = { value: BigInteger -> value.toLong().toULong() } + val usd = { value: BigInteger -> toUsdUnits(value, decimals) } + if (toUsd) { + SparkUsdQuote( + SparkUsdQuote.Kind.TO_USD, + sats(estimate.amountIn), + usd(estimate.amountOut), + sats(estimate.fee), + prepared + ) + } else { + SparkUsdQuote( + SparkUsdQuote.Kind.TO_BTC, + usd(estimate.amountIn), + sats(estimate.amountOut), + usd(estimate.fee), + prepared + ) + } + } + } + + /** Quotes moving [amount] dollar units to Bitkit's USDT wallet at [arbitrumAddress] through Orchestra. */ + suspend fun quoteArbitrumBridge(arbitrumAddress: String, amount: ULong): Result = + withContext(bgDispatcher) { + runSuspendCatching { + val tokenId = requireNotNull(Env.sparkUsdTokenId) { "No Spark dollar token on this network" } + val route = sparkService.crossChainRoutes(arbitrumAddress) + .filter { it.chainId?.removePrefix("eip155:") == ARBITRUM_CHAIN_ID } + .filter { route -> + route.acceptedAssets.any { (it.asset as? SparkAsset.Token)?.tokenIdentifier == tokenId } + } + .sortedBy { if (it.asset.uppercase() == "USDT0") 0 else 1 } + .firstOrNull { it.asset.uppercase().startsWith("USDT") } + ?: throw SparkUsdError.NoRoute() + val prepared = sparkService.prepareCrossChain( + arbitrumAddress, + route, + fromUsdUnits(amount, usdDecimals(tokenId)), + tokenId, + ) + val method = prepared.paymentMethod as SendPaymentMethod.CrossChainAddress + val decimals = route.decimals.toInt() + SparkUsdQuote( + kind = SparkUsdQuote.Kind.TO_ARBITRUM, + pay = amount, + receive = toUsdUnits(method.estimatedOut, decimals), + fee = toUsdUnits(method.feeAmount, decimals), + prepared = prepared, + ) + } + } + + /** Runs a conversion or bridge [quote] and refreshes the balances. */ + suspend fun execute(quote: SparkUsdQuote): Result = withContext(bgDispatcher) { + runSuspendCatching { + val payment = sparkService.send(quote.prepared) + Logger.info("Ran Spark dollar '${quote.kind}' with status '${payment.status}'", context = TAG) + refresh() + }.onFailure { + Logger.error("Failed to run Spark dollar '${quote.kind}'", it, context = TAG) + } + } + private suspend fun usdDecimals(tokenId: String): Int = usdMetadata(tokenId).decimals.toInt() private suspend fun usdMetadata(tokenId: String): TokenMetadata = usdTokenMetadata @@ -566,8 +646,24 @@ data class SparkUsdTransfer( /** Where a dollar payment goes: a Spark address or invoice, with the amount an invoice fixes. */ data class SparkUsdRecipient(val request: String, val amount: ULong?) +/** + * A priced conversion or bridge, ready to run. [pay] and [receive] are in sats for the bitcoin side and in dollar + * units otherwise; [fee] is in the unit of what is paid, except for the bridge, where it is in dollar units. + */ +@Stable +data class SparkUsdQuote( + val kind: Kind, + val pay: ULong, + val receive: ULong, + val fee: ULong, + val prepared: PrepareSendPaymentResponse, +) { + enum class Kind { TO_USD, TO_BTC, TO_ARBITRUM } +} + sealed class SparkUsdError(message: String) : AppError(message) { class UnsupportedRecipient : SparkUsdError("Not a Spark address or an invoice for the dollar token") + class NoRoute : SparkUsdError("No cross-chain route pays Arbitrum USDT from the dollar token") } /** An on-chain deposit into Spark: pending until Spark claims it after 3 confirmations. */ diff --git a/app/src/main/java/to/bitkit/services/SparkService.kt b/app/src/main/java/to/bitkit/services/SparkService.kt index 8b0b3c2f90..6084487a4d 100644 --- a/app/src/main/java/to/bitkit/services/SparkService.kt +++ b/app/src/main/java/to/bitkit/services/SparkService.kt @@ -3,9 +3,16 @@ package to.bitkit.services import breez_sdk_spark.AssetFilter import breez_sdk_spark.BreezSdk import breez_sdk_spark.ConnectRequest +import breez_sdk_spark.ConversionOptions +import breez_sdk_spark.ConversionType +import breez_sdk_spark.CrossChainConfig +import breez_sdk_spark.CrossChainRouteFilter +import breez_sdk_spark.CrossChainRoutePair import breez_sdk_spark.DepositInfo import breez_sdk_spark.EventListener import breez_sdk_spark.FeePolicy +import breez_sdk_spark.FetchConversionLimitsRequest +import breez_sdk_spark.FetchConversionLimitsResponse import breez_sdk_spark.GetInfoRequest import breez_sdk_spark.GetInfoResponse import breez_sdk_spark.GetTokensMetadataRequest @@ -125,6 +132,7 @@ class SparkService @Inject constructor( onchain = MaxOnchainFee.NetworkRecommended(DEPOSIT_CLAIM_FEE_LEEWAY_SAT_PER_VB), proportionalPpm = DEPOSIT_CLAIM_FEE_PPM, ) + if (network == SparkNetwork.MAINNET) crossChainConfig = CrossChainConfig() } val storageDir = File(Env.sparkStoragePath(walletIndex)).apply { mkdirs() }.path val connected = connect(ConnectRequest(config, Seed.Mnemonic(mnemonic, passphrase), storageDir)) @@ -224,6 +232,58 @@ class SparkService @Inject constructor( suspend fun tokenMetadata(tokenId: String): TokenMetadata? = requireSdk().getTokensMetadata(GetTokensMetadataRequest(listOf(tokenId))).tokensMetadata.firstOrNull() + /** The minimum amounts Flashnet converts, for [type] and the dollar token [tokenId]. */ + suspend fun conversionLimits(type: ConversionType, tokenId: String): FetchConversionLimitsResponse = + requireSdk().fetchConversionLimits(FetchConversionLimitsRequest(type, tokenId)) + + /** + * Prepares a conversion within this wallet: a payment to its own Spark address, which the SDK runs as a + * self-transfer through Flashnet. [amount] is in token units both ways: what to get when converting to the token, + * what to give when converting to bitcoin. + */ + suspend fun prepareConversion(toToken: Boolean, amount: BigInteger, tokenId: String): PrepareSendPaymentResponse { + val sdk = requireSdk() + val ownAddress = sdk.receivePayment(ReceivePaymentRequest(ReceivePaymentMethod.SparkAddress)).paymentRequest + val type = if (toToken) ConversionType.FromBitcoin else ConversionType.ToBitcoin(tokenId) + return sdk.prepareSendPayment( + PrepareSendPaymentRequest( + paymentRequest = PaymentRequest.Input(input = ownAddress), + amount = amount, + tokenIdentifier = tokenId, + conversionOptions = ConversionOptions(conversionType = type), + feePolicy = FeePolicy.FEES_INCLUDED.takeIf { !toToken }, + ), + ) + } + + /** Routes that pay [address], an address on another chain, through a cross-chain provider. */ + suspend fun crossChainRoutes(address: String): List { + val details = (requireSdk().parse(address) as? InputType.CrossChainAddress)?.v1 + ?: throw SparkError.NotCrossChainAddress() + return requireSdk().getCrossChainRoutes(CrossChainRouteFilter.Send(details, null)) + } + + /** Prepares paying [amount] token units of [tokenId] to [address] on another chain through [route]. */ + suspend fun prepareCrossChain( + address: String, + route: CrossChainRoutePair, + amount: BigInteger, + tokenId: String, + ): PrepareSendPaymentResponse = requireSdk().prepareSendPayment( + PrepareSendPaymentRequest( + paymentRequest = PaymentRequest.CrossChain( + address = address, + route = route, + maxSlippageBps = null, + targetOverpayBps = null + ), + amount = amount, + tokenIdentifier = tokenId, + conversionOptions = null, + feePolicy = null, + ), + ) + suspend fun parse(input: String): InputType = requireSdk().parse(input) /** Prepares a [tokenId] payment; [amount] in token base units, ignored by the SDK when an invoice sets one. */ @@ -260,4 +320,5 @@ class SparkService @Inject constructor( sealed class SparkError(message: String) : AppError(message) { class Unsupported : SparkError("Spark is not available on this network or build") class NotConnected : SparkError("Spark wallet is not connected") + class NotCrossChainAddress : SparkError("Not an address on a chain the cross-chain providers serve") } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/SparkUsdViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/SparkUsdViewModel.kt index b4b1aeafb8..d17dce6679 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/SparkUsdViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/SparkUsdViewModel.kt @@ -1,6 +1,6 @@ package to.bitkit.ui.screens.wallets.usdt -import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.synonym.bitkitcore.usdtFormatAmount @@ -9,6 +9,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn @@ -17,15 +18,19 @@ import kotlinx.coroutines.launch import to.bitkit.R import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore +import to.bitkit.env.Env import to.bitkit.ext.runSuspendCatching import to.bitkit.repositories.SparkRepo import to.bitkit.repositories.SparkUsdError +import to.bitkit.repositories.SparkUsdQuote +import to.bitkit.repositories.UsdtRepo import javax.inject.Inject @HiltViewModel class SparkUsdViewModel @Inject constructor( private val sparkRepo: SparkRepo, private val settingsStore: SettingsStore, + private val usdtRepo: UsdtRepo, ) : ViewModel() { val wallet = sparkRepo.state.map { it.usd } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), sparkRepo.state.value.usd) @@ -33,6 +38,11 @@ class SparkUsdViewModel @Inject constructor( val settings = settingsStore.data .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), SettingsData(hideBalance = true)) + /** Bitkit's own Arbitrum USDT address, the bridge's destination; Orchestra only runs on mainnet. */ + val bridgeAddress = combine(usdtRepo.state, sparkRepo.state) { usdt, _ -> usdt.address } + .map { address -> address.takeIf { Env.isSparkBridgeAvailable && it.isNotEmpty() } } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) + private val _state = MutableStateFlow(SparkUsdSendState()) val state = _state.asStateFlow() @@ -63,12 +73,50 @@ class SparkUsdViewModel @Inject constructor( when { it.busy -> it it.authenticationRequired -> it.copy(authenticationRequired = false) + it.quote != null && !it.completed -> it.copy(quote = null, error = null) it.page == SparkUsdPage.REVIEW -> it.copy(page = SparkUsdPage.AMOUNT, error = null) it.page == SparkUsdPage.AMOUNT -> it.copy(page = SparkUsdPage.RECIPIENT, error = null) else -> SparkUsdSendState() } } + fun startConvert() = _state.update { SparkUsdSendState(page = SparkUsdPage.CONVERT) } + + fun startBridge() = _state.update { SparkUsdSendState(page = SparkUsdPage.BRIDGE) } + + fun setConvertToUsd(toUsd: Boolean) = _state.update { it.copy(convertToUsd = toUsd, quote = null, error = null) } + + fun quote() = viewModelScope.launch { + val state = _state.value + val amount = runCatching { usdtParseAmount(state.amountInput) }.getOrNull()?.takeIf { it > 0uL } + if (amount == null) { + _state.update { it.copy(error = R.string.usdt__error_amount) } + return@launch + } + val spendsDollars = state.page == SparkUsdPage.BRIDGE || !state.convertToUsd + if (spendsDollars && amount > (wallet.value?.balance ?: 0uL)) { + _state.update { it.copy(error = R.string.usdt__error_balance_spark) } + return@launch + } + _state.update { it.copy(busy = true, error = null) } + val result = when (state.page) { + SparkUsdPage.BRIDGE -> sparkRepo.quoteArbitrumBridge(bridgeAddress.value.orEmpty(), amount) + else -> sparkRepo.quoteConversion(state.convertToUsd, amount) + } + result + .onSuccess { quote -> _state.update { it.copy(busy = false, quote = quote) } } + .onFailure { _state.update { it.copy(busy = false, error = R.string.usdt__convert_failed_spark) } } + } + + fun confirmQuote() = viewModelScope.launch { + val quote = _state.value.quote ?: return@launch + if (_state.value.busy || _state.value.completed) return@launch + _state.update { it.copy(busy = true, error = null) } + sparkRepo.execute(quote) + .onSuccess { _state.update { it.copy(busy = false, completed = true) } } + .onFailure { _state.update { it.copy(busy = false, error = R.string.usdt__convert_failed_spark) } } + } + fun setRecipientInput(input: String) = _state.update { it.copy(recipientInput = input, error = null) } fun continueWithRecipient() = viewModelScope.launch { @@ -89,7 +137,10 @@ class SparkUsdViewModel @Inject constructor( } } .onFailure { error -> - val message = if (error is SparkUsdError) R.string.usdt__error_address else R.string.usdt__error_network + val message = when (error) { + is SparkUsdError -> R.string.usdt__error_address_spark + else -> R.string.usdt__error_network + } _state.update { it.copy(busy = false, error = message) } } } @@ -104,7 +155,7 @@ class SparkUsdViewModel @Inject constructor( val amount = runCatching { usdtParseAmount(state.amountInput) }.getOrNull()?.takeIf { it > 0uL } ?: return@update state.copy(error = R.string.usdt__error_amount) val balance = wallet.value?.balance ?: 0uL - if (amount > balance) return@update state.copy(error = R.string.usdt__error_balance) + if (amount > balance) return@update state.copy(error = R.string.usdt__error_balance_spark) state.copy(amount = amount, page = SparkUsdPage.REVIEW, error = null) } @@ -113,7 +164,7 @@ class SparkUsdViewModel @Inject constructor( if (state.busy || state.completed || state.authenticationRequired) return@launch val amount = state.amount ?: return@launch if (amount > (wallet.value?.balance ?: 0uL)) { - _state.update { it.copy(error = R.string.usdt__error_balance) } + _state.update { it.copy(error = R.string.usdt__error_balance_spark) } return@launch } val settings = runSuspendCatching { settingsStore.data.first() }.getOrNull() ?: return@launch @@ -141,9 +192,9 @@ class SparkUsdViewModel @Inject constructor( } } -enum class SparkUsdPage { WALLET, RECEIVE, RECIPIENT, AMOUNT, REVIEW } +enum class SparkUsdPage { WALLET, RECEIVE, RECIPIENT, AMOUNT, REVIEW, CONVERT, BRIDGE } -@Immutable +@Stable data class SparkUsdSendState( val page: SparkUsdPage = SparkUsdPage.WALLET, val receiveRequest: String? = null, @@ -152,6 +203,8 @@ data class SparkUsdSendState( val amountInput: String = "", val amount: ULong? = null, val isAmountFixed: Boolean = false, + val convertToUsd: Boolean = true, + val quote: SparkUsdQuote? = null, val busy: Boolean = false, val completed: Boolean = false, val authenticationRequired: Boolean = false, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/SparkUsdWalletScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/SparkUsdWalletScreen.kt index dcf36e8d18..51742c75b1 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/SparkUsdWalletScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/SparkUsdWalletScreen.kt @@ -50,6 +50,8 @@ import com.synonym.bitkitcore.usdtFormatAmount import kotlinx.collections.immutable.persistentListOf import to.bitkit.R import to.bitkit.ext.uiDateStyleFor +import to.bitkit.models.formatToModernDisplay +import to.bitkit.repositories.SparkUsdQuote import to.bitkit.repositories.SparkUsdState import to.bitkit.repositories.SparkUsdTransfer import to.bitkit.ui.appViewModel @@ -69,6 +71,7 @@ import to.bitkit.ui.components.NumberPadType import to.bitkit.ui.components.PaymentAddressInput import to.bitkit.ui.components.PaymentReviewIllustration import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.SecondaryButton import to.bitkit.ui.components.SwipeToConfirm import to.bitkit.ui.components.TabBar import to.bitkit.ui.components.UsdtAmountHeader @@ -135,6 +138,7 @@ fun SparkUsdWalletScreen( val wallet by viewModel.wallet.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle() val settings by viewModel.settings.collectAsStateWithLifecycle() + val bridgeAddress by viewModel.bridgeAddress.collectAsStateWithLifecycle() LaunchedEffect(viewModel, initialRecipient) { viewModel.refresh() if (initialRecipient.isNotEmpty()) { @@ -149,6 +153,8 @@ fun SparkUsdWalletScreen( onReceive = viewModel::showReceive, onSend = { viewModel.startSend() }, onBack = onBack, + onConvert = viewModel::startConvert, + onBridge = viewModel::startBridge.takeIf { bridgeAddress != null }, ) if (state.page == SparkUsdPage.WALLET) return val blocked by rememberUpdatedState(state.busy) @@ -189,6 +195,9 @@ fun SparkUsdWalletScreen( onReview = viewModel::review, onConfirm = viewModel::confirm, onDone = viewModel::dismiss, + onConvertDirection = viewModel::setConvertToUsd, + onQuote = viewModel::quote, + onConfirmQuote = viewModel::confirmQuote, ) } } @@ -202,6 +211,8 @@ private fun SparkUsdWalletContent( onReceive: () -> Unit, onSend: () -> Unit, onBack: () -> Unit, + onConvert: () -> Unit = {}, + onBridge: (() -> Unit)? = null, ) { val settings = settingsViewModel val app = appViewModel @@ -232,6 +243,25 @@ private fun SparkUsdWalletContent( onToggleHide = if (allowSwipe) ({ settings?.setHideBalance(!hideBalance) }) else null, modifier = Modifier.testTag("SparkUsdBalance") ) + VerticalSpacer(24.dp) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + SecondaryButton( + text = stringResource(R.string.usdt__convert_spark), + onClick = onConvert, + size = ButtonSize.Small, + fullWidth = false, + testTag = "SparkUsdConvert", + ) + onBridge?.let { + SecondaryButton( + text = stringResource(R.string.usdt__bridge_spark), + onClick = it, + size = ButtonSize.Small, + fullWidth = false, + testTag = "SparkUsdBridge", + ) + } + } VerticalSpacer(32.dp) } itemsIndexed(transfers, key = { _, transfer -> transfer.id }) { index, transfer -> @@ -311,13 +341,18 @@ private fun SparkUsdSheetContent( onReview: () -> Unit, onConfirm: () -> Unit, onDone: () -> Unit, + onConvertDirection: (Boolean) -> Unit = {}, + onQuote: () -> Unit = {}, + onConfirmQuote: () -> Unit = {}, ) { val title = when { state.page == SparkUsdPage.RECEIVE -> R.string.usdt__receive_title - state.completed -> R.string.usdt__sent_title + state.page == SparkUsdPage.CONVERT -> R.string.usdt__convert_spark + state.page == SparkUsdPage.BRIDGE -> R.string.usdt__bridge_spark + state.completed -> R.string.usdt__sent_title_spark state.page == SparkUsdPage.REVIEW -> R.string.wallet__send_review state.page == SparkUsdPage.AMOUNT -> R.string.usdt__amount - else -> R.string.usdt__send_title + else -> R.string.usdt__send_title_spark } Column(modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp)) { SheetTopBar(stringResource(title), onBack = onBack.takeUnless { state.busy || state.completed }) @@ -333,6 +368,14 @@ private fun SparkUsdSheetContent( onReview ) SparkUsdPage.REVIEW -> SparkUsdReviewContent(state, onConfirm, onDone) + SparkUsdPage.CONVERT, SparkUsdPage.BRIDGE -> SparkUsdConvertContent( + state, + onAmountChange, + onConvertDirection, + onQuote, + onConfirmQuote, + onDone, + ) else -> SparkUsdRecipientContent(state, onRecipientChange, onRecipientContinue) } } @@ -349,7 +392,7 @@ private fun ColumnScope.SparkUsdReceiveContent(state: SparkUsdSendState) { qrLogoPainter = painterResource(R.drawable.tether_circle), onClickEditInvoice = null, accentColor = Colors.Usdt, - copyMessage = stringResource(R.string.usdt__receive_copied), + copyMessage = stringResource(R.string.usdt__receive_copied_spark), modifier = Modifier.fillMaxWidth().height(minOf(maxHeight, maxWidth + ButtonSize.Small.height + 32.dp)) .testTag("SparkUsdReceiveQr") ) @@ -494,6 +537,102 @@ private fun ColumnScope.SparkUsdReviewContent(state: SparkUsdSendState, onConfir } } +@Composable +private fun ColumnScope.SparkUsdConvertContent( + state: SparkUsdSendState, + onAmountChange: (String) -> Unit, + onConvertDirection: (Boolean) -> Unit, + onQuote: () -> Unit, + onConfirmQuote: () -> Unit, + onDone: () -> Unit, +) { + val quote = state.quote + if (quote == null) { + if (state.page == SparkUsdPage.CONVERT) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + listOf(true to R.string.usdt__convert_to_usd_spark, false to R.string.usdt__convert_to_btc_spark) + .forEach { (toUsd, label) -> + NumberPadActionButton( + text = stringResource(label), + color = if (state.convertToUsd == toUsd) Colors.Usdt else Colors.White64, + onClick = { onConvertDirection(toUsd) }, + textStyle = AppTextStyles.CaptionM.copy(letterSpacing = 1.sp), + modifier = Modifier.testTag(if (toUsd) "SparkUsdConvertToUsd" else "SparkUsdConvertToBtc") + ) + } + } + } else { + BodyS(stringResource(R.string.usdt__bridge_note_spark), color = Colors.White64) + } + VerticalSpacer(16.dp) + UsdtAmountHeader(state.amountInput.ifEmpty { "0" }, modifier = Modifier.testTag("SparkUsdConvertAmount")) + FillHeight(min = 12.dp) + state.error?.let { + BodyS(stringResource(it), color = Colors.Brand, modifier = Modifier.testTag("SparkUsdError")) + VerticalSpacer(16.dp) + } + HorizontalDivider(color = Colors.White10) + NumberPad( + type = NumberPadType.DECIMAL, + availableHeight = 360.dp, + onDeleteLongPress = { onAmountChange("") }, + onPress = { key -> + onAmountChange( + NumberPadInputHandler.handleInput(key, state.amountInput, maxLength = 21, maxDecimals = 6) + ) + }, + ) + PrimaryButton( + text = stringResource(R.string.common__continue), + onClick = onQuote, + enabled = state.amountInput.isNotEmpty() && !state.busy, + isLoading = state.busy, + modifier = Modifier.testTag("SparkUsdQuote") + ) + return + } + val bitcoinIn = quote.kind == SparkUsdQuote.Kind.TO_USD + val bitcoinOut = quote.kind == SparkUsdQuote.Kind.TO_BTC + SparkUsdQuoteRow(R.string.usdt__convert_pay_spark, quote.pay.display(bitcoinIn)) + SparkUsdQuoteRow(R.string.usdt__convert_receive_spark, quote.receive.display(bitcoinOut)) + SparkUsdQuoteRow(R.string.usdt__convert_fee_spark, quote.fee.display(bitcoinIn)) + FillHeight() + state.error?.let { + BodyS(stringResource(it), color = Colors.Brand, modifier = Modifier.testTag("SparkUsdError")) + VerticalSpacer(16.dp) + } + if (state.completed) { + PrimaryButton( + text = stringResource(R.string.common__close), + onClick = onDone, + modifier = Modifier.testTag("SparkUsdDone") + ) + } else { + SwipeToConfirm( + text = stringResource(R.string.other__swipe), + color = Colors.Usdt, + onConfirm = onConfirmQuote, + enabled = !state.busy, + loading = state.busy, + confirmed = state.busy, + textStyle = AppTextStyles.BodySSB.copy(letterSpacing = 0.sp), + modifier = Modifier.fillMaxWidth().testTag("SparkUsdConfirmQuote") + ) + } +} + +@Composable +private fun SparkUsdQuoteRow(label: Int, value: String) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp)) { + Caption13Up(stringResource(label), color = Colors.White64, modifier = Modifier.weight(1f)) + BodySSB(value) + } + HorizontalDivider(color = Colors.White10) +} + +private fun ULong.display(isSats: Boolean): String = + if (isSats) "₿ " + formatToModernDisplay() else "$ " + usdtOverviewAmount(this) + @Preview(showSystemUi = true) @Composable private fun Preview() { diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index a1c9abdbdb..37d3858e56 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -745,12 +745,21 @@ عنوان المستلم المبلغ الجسر بحاجة إلى متابعة + ينقل الدولارات من Spark إلى محفظة USDT الخاصة بك على Arbitrum. يحدد مزود الجسر الرسوم. الجسر مرجع الجسر أُعيدت USDT إلى محفظتك. اطّلع على التفاصيل لمعرفة المبلغ المسترد. + إلى USDT دفعتك معلقة. يمكنك تتبّع التأكيد والتسليم عبر الشبكات في النشاط. اسمح بالوصول إلى الكاميرا لمسح رموز QR لمدفوعات USDT. مؤكد + لم يكتمل التحويل. حاول مرة أخرى بعد قليل. + الرسوم + تدفع + تستلم (تقديري) + تحويل + احصل على بيتكوين + احصل على دولارات نسخ التفاصيل جارٍ التسليم جارٍ تسليم USDT @@ -791,9 +800,11 @@ فعّل USDT لمتابعة هذا الدفع أو الطلب. تفعيل USDT؟ أدخل عنوانًا صالحًا للشبكة المحددة. + أدخل عنوان Spark أو فاتورة Spark لهذا الرمز. أدخل مبلغًا صالحًا لا يتجاوز 6 منازل عشرية. تعذر نسخ هذا الدفع احتياطيًا. ستُعاد المحاولة عند توفر النسخ الاحتياطي. رصيد USDT غير كافٍ لتغطية المبلغ والرسوم. + لا توجد دولارات كافية على Spark لهذا المبلغ. المبلغ صغير جدًا لتغطية تكاليف الجسر. جرّب مبلغًا أكبر. اضبط تاريخ جهازك ووقته تلقائيًا، ثم حاول مرة أخرى. USDT غير متاح حاليًا. يرجى المحاولة مرة أخرى لاحقًا. @@ -827,6 +838,7 @@ معلق المبلغ تم نسخ العنوان إلى الحافظة.\nتأكد من إرسال USDT على الشبكة المحددة فقط. + تم نسخ الفاتورة إلى الحافظة. الاستلام (تقديري) الرسوم الرسوم المقدّرة @@ -844,9 +856,11 @@ لم يكتمل دفعك. تحقق من النشاط لمعرفة التفاصيل. اختر الشبكة التي يستخدمها المستلم. إرسال USDT + إرسال دولارات شبكة المرسل مُرسل تم إرسال USDT + تم إرسال الدولارات USDT (Tether) تم تقديم الدفع تتم معالجة دفعك. يمكنك تتبّع حالته في النشاط. diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml index 22bba6b9a7..134609dd3a 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -769,12 +769,21 @@ Dirección de destino Monto El puente requiere atención + Mueve dólares de Spark a tu billetera USDT en Arbitrum. El proveedor del puente fija la comisión. Puente Referencia del puente Los USDT se devolvieron a su billetera. Consulte los detalles para ver el monto reembolsado. + A USDT Su pago está pendiente. Puede seguir la confirmación y la entrega entre redes en Actividad. Permita el acceso a la cámara para escanear códigos QR de pago USDT. Confirmado + La conversión no se completó. Inténtalo de nuevo en un momento. + Comisión + Pagas + Recibes (est.) + Convertir + Obtener bitcoin + Obtener dólares Copiar detalles Entregando Entregando USDT @@ -815,9 +824,11 @@ Active USDT para continuar con este pago o solicitud. ¿Activar USDT? Ingrese una dirección válida para la red seleccionada. + Ingresa una dirección de Spark o una factura de Spark para este token. Ingrese un monto válido con hasta 6 decimales. No se pudo hacer una copia de seguridad de este pago. Se reintentará cuando la copia de seguridad esté disponible. No tiene suficientes USDT para cubrir el monto y las comisiones. + No tienes suficientes dólares en Spark para este monto. Monto demasiado bajo para cubrir los costos del puente. Pruebe con un monto mayor. Configure la fecha y la hora de su dispositivo en automático y vuelva a intentarlo. USDT no está disponible en este momento. Vuelva a intentarlo más tarde. @@ -851,6 +862,7 @@ Pendiente Monto Dirección copiada al portapapeles.\nAsegúrate de enviar USDT solo a través de la red seleccionada. + Factura copiada al portapapeles. A recibir (est.) Comisiones Comisiones estimadas @@ -868,9 +880,11 @@ Su pago no se completó. Consulte Actividad para ver los detalles. Elija la red que usa su destinatario. Enviar USDT + Enviar dólares Red del remitente Enviado USDT enviados + Dólares enviados USDT (Tether) Pago enviado Su pago se está procesando. Puede seguir su estado en Actividad. diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 28c43d4208..2810a9a067 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -762,12 +762,21 @@ Adreça del destinatari Import El pont requereix atenció + Mou dòlars de Spark a la teva cartera USDT a Arbitrum. El proveïdor del pont fixa la comissió. Pont Referència del pont Els USDT s\'han retornat a la teva cartera. Consulta els detalls per veure l\'import retornat. + A USDT El teu pagament està pendent. Pots seguir la confirmació i el lliurament entre xarxes a Activitat. Permet l\'accés a la càmera per escanejar codis QR de pagament USDT. Confirmat + La conversió no s\'ha completat. Torna-ho a provar d\'aquí a un moment. + Comissió + Pagues + Reps (est.) + Convertir + Obtenir bitcoin + Obtenir dòlars Copiar detalls En lliurament Lliurant USDT @@ -808,9 +817,11 @@ Activa USDT per continuar amb aquest pagament o sol·licitud. Activar USDT? Introdueix una adreça vàlida per a la xarxa seleccionada. + Introdueix una adreça de Spark o una factura de Spark per a aquest token. Introdueix un import vàlid amb un màxim de 6 decimals. No es pot fer la còpia de seguretat d\'aquest pagament. Es tornarà a intentar quan la còpia de seguretat estigui disponible. No tens prou USDT per cobrir l\'import i les tarifes. + No tens prou dòlars a Spark per a aquest import. L\'import és massa petit per cobrir els costos del pont. Prova amb un import més gran. Configura la data i l\'hora del dispositiu en mode automàtic i torna-ho a provar. USDT no està disponible ara mateix. Torna-ho a provar més tard. @@ -844,6 +855,7 @@ Pendent Import Adreça copiada al porta-retalls.\nAssegura’t que l’USDT només s’enviï a través de la xarxa seleccionada. + Factura copiada al porta-retalls. Rebràs (est.) Tarifes Tarifes estimades @@ -861,9 +873,11 @@ El teu pagament no s\'ha completat. Consulta Activitat per veure\'n els detalls. Tria la xarxa que utilitza el teu destinatari. Enviar USDT + Envia dòlars Xarxa del remitent Enviat USDT enviats + Dòlars enviats USDT (Tether) Pagament enviat El teu pagament s\'està processant. Pots seguir-ne l\'estat a Activitat. diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 174882164b..7f24656115 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -766,12 +766,21 @@ Adresa příjemce Částka Most vyžaduje pozornost + Přesune dolary ze Sparku do tvé peněženky USDT na Arbitru. Poplatek určuje poskytovatel mostu. Most Reference mostu USDT byly vráceny do vaší peněženky. Vrácenou částku najdete v podrobnostech. + Do USDT Vaše platba čeká na vyřízení. Potvrzení a doručení mezi sítěmi můžete sledovat v Aktivitě. Povolte přístup k fotoaparátu pro skenování platebních QR kódů USDT. Potvrzeno + Převod se nedokončil. Zkus to za chvíli znovu. + Poplatek + Zaplatíš + Dostaneš (odhad) + Převést + Získat bitcoin + Získat dolary Kopírovat údaje Doručování Doručování USDT @@ -812,9 +821,11 @@ Pro pokračování v této platbě nebo žádosti zapněte USDT. Zapnout USDT? Zadejte platnou adresu pro vybranou síť. + Zadej adresu Spark nebo fakturu Spark pro tento token. Zadejte platnou částku s nejvýše 6 desetinnými místy. Tuto platbu nelze zálohovat. Záloha se zopakuje, jakmile bude zálohování dostupné. Nemáte dostatek USDT na pokrytí částky a poplatků. + Na Sparku nemáš na tuto částku dost dolarů. Částka je příliš malá na pokrytí nákladů mostu. Zkuste vyšší částku. Nastavte automatické datum a čas zařízení a zkuste to znovu. USDT momentálně není k dispozici. Zkuste to prosím později. @@ -848,6 +859,7 @@ Čekající Částka Adresa byla zkopírována do schránky.\nUjistěte se, že USDT posíláte pouze prostřednictvím vybrané sítě. + Faktura zkopírována do schránky. Obdržíte (odhad) Poplatky Odhadované poplatky @@ -865,9 +877,11 @@ Vaše platba nebyla dokončena. Podrobnosti najdete v Aktivitě. Vyberte síť, kterou používá váš příjemce. Odeslat USDT + Poslat dolary Síť odesílatele Odesláno Odeslané USDT + Dolary odeslány USDT (Tether) Platba odeslána Vaše platba se zpracovává. Její stav můžete sledovat v Aktivitě. diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 6cd4b61191..b274f0db33 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -764,12 +764,21 @@ Empfängeradresse Betrag Bridge erfordert Prüfung + Verschiebt Dollar von Spark in deine USDT-Wallet auf Arbitrum. Die Gebühr legt der Bridge-Anbieter fest. Bridge Bridge-Referenz Die USDT wurden an dein Wallet zurückgegeben. Den erstatteten Betrag findest du in den Details. + Zu USDT Deine Zahlung ist ausstehend. Bestätigung und netzwerkübergreifende Zustellung kannst du unter Aktivität verfolgen. Erlaube Kamerazugriff, um QR-Codes für USDT-Zahlungen zu scannen. Bestätigt + Die Umwandlung wurde nicht abgeschlossen. Versuche es gleich noch einmal. + Gebühr + Du zahlst + Du erhältst (ca.) + Umwandeln + Bitcoin erhalten + Dollar erhalten Details kopieren Wird zugestellt USDT wird zugestellt @@ -810,9 +819,11 @@ Aktiviere USDT, um mit dieser Zahlung oder Anforderung fortzufahren. USDT aktivieren? Gib eine gültige Adresse für das ausgewählte Netzwerk ein. + Gib eine Spark-Adresse oder eine Spark-Rechnung für diesen Token ein. Gib einen gültigen Betrag mit bis zu 6 Dezimalstellen ein. Diese Zahlung kann nicht gesichert werden. Die Sicherung wird wiederholt, sobald sie verfügbar ist. Nicht genug USDT für Betrag und Gebühren. + Nicht genug Dollar auf Spark für diesen Betrag. Der Betrag ist zu klein, um die Bridge-Kosten zu decken. Versuche einen höheren Betrag. Stelle Datum und Uhrzeit deines Geräts auf automatisch und versuche es dann erneut. USDT ist derzeit nicht verfügbar. Bitte versuche es später erneut. @@ -846,6 +857,7 @@ Ausstehend Betrag Adresse in die Zwischenablage kopiert.\nStelle sicher, dass USDT nur über das ausgewählte Netzwerk gesendet wird. + Rechnung in die Zwischenablage kopiert. Empfang (ca.) Gebühren Geschätzte Gebühren @@ -863,9 +875,11 @@ Deine Zahlung wurde nicht abgeschlossen. Details findest du unter Aktivität. Wähle das Netzwerk, das dein Empfänger verwendet. USDT senden + Dollar senden Absendernetzwerk Gesendet USDT gesendet + Dollar gesendet USDT (Tether) Zahlung übermittelt Deine Zahlung wird verarbeitet. Den Status kannst du unter Aktivität verfolgen. diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 32dec19f4d..ef6fd96203 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -747,12 +747,21 @@ Διεύθυνση παραλήπτη Ποσό Η γέφυρα χρειάζεται προσοχή + Μεταφέρει δολάρια από το Spark στο πορτοφόλι USDT σου στο Arbitrum. Τη χρέωση ορίζει ο πάροχος της γέφυρας. Γέφυρα Αναφορά γέφυρας Τα USDT επιστράφηκαν στο πορτοφόλι σου. Δες τις λεπτομέρειες για το ποσό που επιστράφηκε. + Σε USDT Η πληρωμή σου εκκρεμεί. Μπορείς να παρακολουθείς την επιβεβαίωση και την παράδοση μεταξύ δικτύων στη Δραστηριότητα. Επίτρεψε την πρόσβαση στην κάμερα για σάρωση κωδικών QR πληρωμών USDT. Επιβεβαιώθηκε + Η μετατροπή δεν ολοκληρώθηκε. Δοκίμασε ξανά σε λίγο. + Χρέωση + Πληρώνεις + Λαμβάνεις (εκτ.) + Μετατροπή + Λήψη bitcoin + Λήψη δολαρίων Αντιγραφή λεπτομερειών Σε παράδοση Παράδοση USDT @@ -793,9 +802,11 @@ Ενεργοποίησε το USDT για να συνεχίσεις με αυτή την πληρωμή ή το αίτημα. Ενεργοποίηση USDT; Εισάγαγε έγκυρη διεύθυνση για το επιλεγμένο δίκτυο. + Εισάγε μια διεύθυνση Spark ή ένα τιμολόγιο Spark για αυτό το token. Εισάγαγε έγκυρο ποσό με έως 6 δεκαδικά ψηφία. Δεν ήταν δυνατή η δημιουργία αντιγράφου ασφαλείας αυτής της πληρωμής. Θα γίνει νέα προσπάθεια όταν είναι διαθέσιμο το αντίγραφο ασφαλείας. Δεν υπάρχουν αρκετά USDT για την κάλυψη του ποσού και των τελών. + Δεν υπάρχουν αρκετά δολάρια στο Spark για αυτό το ποσό. Το ποσό είναι πολύ μικρό για να καλύψει τα κόστη της γέφυρας. Δοκίμασε μεγαλύτερο ποσό. Ρύθμισε την ημερομηνία και την ώρα της συσκευής σου σε αυτόματη και δοκίμασε ξανά. Το USDT δεν είναι διαθέσιμο αυτή τη στιγμή. Δοκίμασε ξανά αργότερα. @@ -829,6 +840,7 @@ Εκκρεμεί Ποσό Η διεύθυνση αντιγράφηκε στο πρόχειρο.\nΒεβαιώσου ότι το USDT αποστέλλεται μόνο μέσω του επιλεγμένου δικτύου. + Το τιμολόγιο αντιγράφηκε στο πρόχειρο. Λήψη (εκτίμ.) Τέλη Εκτιμώμενα τέλη @@ -846,9 +858,11 @@ Η πληρωμή σου δεν ολοκληρώθηκε. Δες τη Δραστηριότητα για λεπτομέρειες. Επίλεξε το δίκτυο που χρησιμοποιεί ο παραλήπτης σου. Αποστολή USDT + Αποστολή δολαρίων Δίκτυο αποστολέα Στάλθηκε Στάλθηκαν USDT + Τα δολάρια στάλθηκαν USDT (Tether) Η πληρωμή υποβλήθηκε Η πληρωμή σου βρίσκεται σε επεξεργασία. Μπορείς να παρακολουθείς την κατάστασή της στη Δραστηριότητα. diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index b0d60f6f12..4f691f4308 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -765,12 +765,21 @@ Dirección de destino Cantidad El puente requiere atención + Mueve dólares de Spark a tu monedero USDT en Arbitrum. El proveedor del puente fija la comisión. Puente Referencia del puente Los USDT se han devuelto a tu monedero. Consulta los detalles para ver la cantidad reembolsada. + A USDT Tu pago está pendiente. Puedes seguir la confirmación y la entrega entre redes en Actividad. Permite el acceso a la cámara para escanear códigos QR de pago USDT. Confirmado + La conversión no se ha completado. Vuelve a intentarlo en un momento. + Comisión + Pagas + Recibes (est.) + Convertir + Obtener bitcoin + Obtener dólares Copiar detalles Entregando Entregando USDT @@ -811,9 +820,11 @@ Activa USDT para continuar con este pago o solicitud. ¿Activar USDT? Introduce una dirección válida para la red seleccionada. + Introduce una dirección de Spark o una factura de Spark para este token. Introduce una cantidad válida con hasta 6 decimales. No se pudo hacer una copia de seguridad de este pago. Se reintentará cuando la copia de seguridad esté disponible. No tienes suficientes USDT para cubrir la cantidad y las comisiones. + No tienes suficientes dólares en Spark para este importe. Cantidad demasiado pequeña para cubrir los costes del puente. Prueba con una cantidad mayor. Configura la fecha y la hora de tu dispositivo en automático e inténtalo de nuevo. USDT no está disponible en este momento. Inténtalo de nuevo más tarde. @@ -847,6 +858,7 @@ Pendiente Cantidad Dirección copiada al portapapeles.\nAsegúrate de enviar USDT solo a través de la red seleccionada. + Factura copiada al portapapeles. A recibir (est.) Comisiones Comisiones estimadas @@ -864,9 +876,11 @@ Tu pago no se completó. Consulta Actividad para ver los detalles. Elige la red que usa tu destinatario. Enviar USDT + Enviar dólares Red del remitente Enviado USDT enviados + Dólares enviados USDT (Tether) Pago enviado Tu pago se está procesando. Puedes seguir su estado en Actividad. diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index a16f7a4b08..7b39b73d4e 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -762,12 +762,21 @@ Dirección de destino Cantidad El puente requiere atención + Mueve dólares de Spark a tu billetera USDT en Arbitrum. El proveedor del puente fija la comisión. Puente Referencia del puente Los USDT se han devuelto a su monedero. Consulte los detalles para ver la cantidad reembolsada. + A USDT Su pago está pendiente. Puede seguir la confirmación y la entrega entre redes en Actividad. Permita el acceso a la cámara para escanear códigos QR de pago USDT. Confirmado + La conversión no se completó. Inténtalo de nuevo en un momento. + Comisión + Pagas + Recibes (est.) + Convertir + Obtener bitcoin + Obtener dólares Copiar detalles Entregando Entregando USDT @@ -808,9 +817,11 @@ Active USDT para continuar con este pago o solicitud. ¿Activar USDT? Introduzca una dirección válida para la red seleccionada. + Introduce una dirección de Spark o una factura de Spark para este token. Introduzca una cantidad válida con hasta 6 decimales. No se pudo hacer una copia de seguridad de este pago. Se reintentará cuando la copia de seguridad esté disponible. No tiene suficientes USDT para cubrir la cantidad y las comisiones. + No tienes suficientes dólares en Spark para este importe. Cantidad demasiado pequeña para cubrir los costes del puente. Pruebe con una cantidad mayor. Configure la fecha y la hora de su dispositivo en automático e inténtelo de nuevo. USDT no está disponible en este momento. Inténtelo de nuevo más tarde. @@ -844,6 +855,7 @@ Pendiente Cantidad Dirección copiada al portapapeles.\nAsegúrate de enviar USDT solo a través de la red seleccionada. + Factura copiada al portapapeles. A recibir (est.) Comisiones Comisiones estimadas @@ -861,9 +873,11 @@ Su pago no se completó. Consulte Actividad para ver los detalles. Elija la red que usa su destinatario. Enviar USDT + Enviar dólares Red del remitente Enviado USDT enviados + Dólares enviados USDT (Tether) Pago enviado Su pago se está procesando. Puede seguir su estado en Actividad. diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index b3ccab472b..ee4e184519 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -766,12 +766,21 @@ Adresse du destinataire Montant Le pont requiert une action + Transfère des dollars de Spark vers ton portefeuille USDT sur Arbitrum. Le fournisseur du pont fixe les frais. Pont Référence du pont Les USDT ont été restitués à votre portefeuille. Consultez les détails pour le montant remboursé. + Vers USDT Votre paiement est en attente. Vous pouvez suivre la confirmation et la livraison inter-réseaux dans Activité. Autorisez l\'accès à la caméra pour scanner les QR codes de paiement USDT. Confirmé + La conversion n\'a pas abouti. Réessaie dans un instant. + Frais + Tu paies + Tu reçois (est.) + Convertir + Obtenir du bitcoin + Obtenir des dollars Copier les détails En livraison Livraison des USDT @@ -812,9 +821,11 @@ Activez USDT pour poursuivre ce paiement ou cette demande. Activer USDT ? Saisissez une adresse valide pour le réseau sélectionné. + Saisis une adresse Spark ou une facture Spark pour ce jeton. Saisissez un montant valide avec 6 décimales au maximum. Impossible de sauvegarder ce paiement. Une nouvelle tentative aura lieu dès que la sauvegarde sera disponible. USDT insuffisants pour couvrir le montant et les frais. + Pas assez de dollars sur Spark pour ce montant. Montant trop faible pour couvrir les coûts du pont. Essayez un montant plus élevé. Réglez la date et l\'heure de votre appareil en automatique, puis réessayez. USDT est actuellement indisponible. Veuillez réessayer plus tard. @@ -848,6 +859,7 @@ En attente Montant Adresse copiée dans le presse-papiers.\nAssurez-vous que les USDT sont envoyés uniquement sur le réseau sélectionné. + Facture copiée dans le presse-papiers. Reçu (est.) Frais Frais estimés @@ -865,9 +877,11 @@ Votre paiement n\'a pas abouti. Consultez Activité pour plus de détails. Choisissez le réseau utilisé par votre destinataire. Envoyer des USDT + Envoyer des dollars Réseau d\'envoi Envoyé USDT envoyés + Dollars envoyés USDT (Tether) Paiement soumis Votre paiement est en cours de traitement. Vous pouvez suivre son statut dans Activité. diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index bc7c869d1e..a934487b0c 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -765,12 +765,21 @@ Indirizzo destinatario Importo Il bridge richiede attenzione + Sposta dollari da Spark al tuo wallet USDT su Arbitrum. La commissione è fissata dal fornitore del bridge. Bridge Riferimento bridge Gli USDT sono stati restituiti al tuo wallet. Vedi i dettagli per l\'importo rimborsato. + A USDT Il tuo pagamento è in attesa. Puoi seguire la conferma e la consegna tra reti in Attività. Consenti l\'accesso alla fotocamera per scansionare i codici QR di pagamento USDT. Confermato + La conversione non è stata completata. Riprova tra poco. + Commissione + Paghi + Ricevi (stima) + Converti + Ottieni bitcoin + Ottieni dollari Copia Dettagli In consegna Consegna USDT @@ -811,9 +820,11 @@ Attiva USDT per continuare con questo pagamento o richiesta. Attivare USDT? Inserisci un indirizzo valido per la rete selezionata. + Inserisci un indirizzo Spark o una fattura Spark per questo token. Inserisci un importo valido con al massimo 6 decimali. Impossibile eseguire il backup di questo pagamento. Verrà ritentato quando il backup sarà disponibile. USDT insufficienti per coprire importo e commissioni. + Non hai abbastanza dollari su Spark per questo importo. Importo troppo basso per coprire i costi del bridge. Prova con un importo maggiore. Imposta data e ora del dispositivo in automatico, poi riprova. USDT non è al momento disponibile. Riprova più tardi. @@ -847,6 +858,7 @@ In attesa Importo Indirizzo copiato negli appunti.\nAssicurati che USDT venga inviato solo sulla rete selezionata. + Fattura copiata negli appunti. Ricevi (stima) Commissioni Commissioni Stimate @@ -864,9 +876,11 @@ Il tuo pagamento non è stato completato. Controlla Attività per i dettagli. Scegli la rete usata dal destinatario. Invia USDT + Invia dollari Rete mittente Inviato USDT Inviati + Dollari inviati USDT (Tether) Pagamento inviato Il tuo pagamento è in elaborazione. Puoi seguirne lo stato in Attività. diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 06f9dab5d1..da5ff9361a 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -761,12 +761,21 @@ Adres ontvanger Bedrag Bridge vereist aandacht + Verplaatst dollars van Spark naar je USDT-wallet op Arbitrum. De bridge-aanbieder bepaalt de kosten. Bridge Bridge-referentie De USDT is teruggestort naar uw wallet. Bekijk de details voor het terugbetaalde bedrag. + Naar USDT Uw betaling is in behandeling. U kunt de bevestiging en de levering naar het andere netwerk volgen in Activiteit. Sta cameratoegang toe om QR-codes van USDT-betalingen te scannen. Bevestigd + De omzetting is niet voltooid. Probeer het zo opnieuw. + Kosten + Je betaalt + Je ontvangt (ca.) + Omzetten + Bitcoin krijgen + Dollars krijgen Details kopiëren Wordt geleverd USDT wordt geleverd @@ -807,9 +816,11 @@ Schakel USDT in om door te gaan met deze betaling of dit verzoek. USDT inschakelen? Voer een geldig adres in voor het geselecteerde netwerk. + Voer een Spark-adres of een Spark-factuur voor deze token in. Voer een geldig bedrag in met maximaal 6 decimalen. Kan deze betaling niet back-uppen. Dit wordt opnieuw geprobeerd zodra de back-up beschikbaar is. Niet genoeg USDT voor het bedrag en de vergoedingen. + Niet genoeg dollars op Spark voor dit bedrag. Bedrag te klein om de bridgekosten te dekken. Probeer een hoger bedrag. Stel de datum en tijd van uw apparaat in op automatisch en probeer het opnieuw. USDT is momenteel niet beschikbaar. Probeer het later opnieuw. @@ -843,6 +854,7 @@ In behandeling Bedrag Adres gekopieerd naar klembord.\nZorg ervoor dat USDT alleen via het geselecteerde netwerk wordt verstuurd. + Factuur gekopieerd naar het klembord. Ontvangst (ca.) Vergoedingen Geschatte vergoedingen @@ -860,9 +872,11 @@ Uw betaling is niet voltooid. Bekijk Activiteit voor details. Kies het netwerk dat uw ontvanger gebruikt. USDT verzenden + Dollars verzenden Netwerk afzender Verzonden USDT verzonden + Dollars verzonden USDT (Tether) Betaling ingediend Uw betaling wordt verwerkt. U kunt de status volgen in Activiteit. diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 634530d48b..4b0104f0d8 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -768,12 +768,21 @@ Adres odbiorcy Kwota Most wymaga uwagi + Przenosi dolary ze Sparka do Twojego portfela USDT na Arbitrum. Opłatę ustala dostawca mostu. Most Identyfikator mostu USDT zostało zwrócone do Twojego portfela. Zwróconą kwotę znajdziesz w szczegółach. + Do USDT Twoja płatność oczekuje. Potwierdzenie i dostarczenie między sieciami możesz śledzić w Aktywności. Zezwól na dostęp do aparatu, aby skanować kody QR płatności USDT. Potwierdzono + Wymiana nie została ukończona. Spróbuj ponownie za chwilę. + Opłata + Płacisz + Otrzymasz (szac.) + Wymień + Otrzymaj bitcoin + Otrzymaj dolary Kopiuj szczegóły Dostarczanie Dostarczanie USDT @@ -814,9 +823,11 @@ Włącz USDT, aby kontynuować tę płatność lub żądanie. Włączyć USDT? Wprowadź prawidłowy adres dla wybranej sieci. + Wpisz adres Spark lub fakturę Spark dla tego tokena. Wprowadź prawidłową kwotę z maksymalnie 6 miejscami po przecinku. Nie można utworzyć kopii zapasowej tej płatności. Ponowna próba nastąpi, gdy kopia zapasowa będzie dostępna. Za mało USDT na pokrycie kwoty i opłat. + Za mało dolarów na Sparku na tę kwotę. Kwota jest za mała, aby pokryć koszty mostu. Spróbuj większej kwoty. Ustaw automatyczną datę i godzinę na urządzeniu, a następnie spróbuj ponownie. USDT jest obecnie niedostępne. Spróbuj ponownie później. @@ -850,6 +861,7 @@ W toku Kwota Adres skopiowany do schowka.\nUpewnij się, że USDT jest wysyłany tylko przez wybraną sieć. + Faktura skopiowana do schowka. Otrzymasz (szac.) Opłaty Szacowane opłaty @@ -867,9 +879,11 @@ Twoja płatność nie została zrealizowana. Szczegóły znajdziesz w Aktywności. Wybierz sieć, której używa odbiorca. Wyślij USDT + Wyślij dolary Sieć nadawcy Wysłane Wysłano USDT + Dolary wysłane USDT (Tether) Płatność wysłana Twoja płatność jest przetwarzana. Jej status możesz śledzić w Aktywności. diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 497238db0c..c6a4dec917 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -769,12 +769,21 @@ Endereço do destinatário Valor A ponte precisa de atenção + Move dólares do Spark para sua carteira USDT na Arbitrum. O provedor da ponte define a taxa. Ponte Referência da ponte O USDT foi devolvido à sua carteira. Veja os detalhes para conferir o valor reembolsado. + Para USDT Seu pagamento está pendente. Você pode acompanhar a confirmação e a entrega entre redes em Atividade. Permita o acesso à câmera para escanear códigos QR de pagamento USDT. Confirmado + A conversão não foi concluída. Tente novamente em instantes. + Taxa + Você paga + Você recebe (est.) + Converter + Obter bitcoin + Obter dólares Copiar Detalhes Entregando Entregando USDT @@ -815,9 +824,11 @@ Ative o USDT para continuar com este pagamento ou solicitação. Ativar USDT? Digite um endereço válido para a rede selecionada. + Insira um endereço Spark ou uma fatura Spark para este token. Digite um valor válido com até 6 casas decimais. Não foi possível fazer backup deste pagamento. Uma nova tentativa será feita quando o backup estiver disponível. USDT insuficiente para cobrir o valor e as taxas. + Dólares insuficientes no Spark para este valor. Valor muito baixo para cobrir os custos da ponte. Tente um valor maior. Configure a data e a hora do dispositivo como automáticas e tente novamente. O USDT está indisponível no momento. Tente novamente mais tarde. @@ -851,6 +862,7 @@ Pendente Valor Endereço copiado para a área de transferência.\nCertifique-se de enviar USDT apenas pela rede selecionada. + Fatura copiada para a área de transferência. Receber (est.) Taxas Taxas Estimadas @@ -868,9 +880,11 @@ Seu pagamento não foi concluído. Confira os detalhes em Atividade. Escolha a rede que o seu destinatário usa. Enviar USDT + Enviar dólares Rede do remetente Enviado USDT Enviado + Dólares enviados USDT (Tether) Pagamento iniciado Seu pagamento está sendo processado. Você pode acompanhar o status em Atividade. diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 87ab9569c1..646d4e5f4c 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -741,6 +741,20 @@ Single Random Draw Configurações do sistema Idioma + Move dólares do Spark para a tua carteira USDT na Arbitrum. O fornecedor da ponte define a taxa. + Para USDT + A conversão não foi concluída. Tenta novamente daqui a pouco. + Taxa + Pagas + Recebes (est.) + Converter + Obter bitcoin + Obter dólares + Introduz um endereço Spark ou uma fatura Spark para este token. + Dólares insuficientes no Spark para este montante. + Fatura copiada para a área de transferência. + Enviar dólares + Dólares enviados Carteira Atividade Contatos diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 4fa4e41107..afa12e6fdd 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -786,12 +786,21 @@ Адрес получателя Сумма Мост требует внимания + Переводит доллары из Spark в ваш кошелёк USDT в Arbitrum. Комиссию устанавливает провайдер моста. Мост ID операции моста USDT возвращены в ваш кошелёк. Сумма возврата указана в деталях. + В USDT Ваш платёж в ожидании. Подтверждение и доставку между сетями можно отслеживать в Активности. Разрешите доступ к камере, чтобы сканировать QR-коды платежей USDT. Подтверждено + Обмен не завершился. Попробуйте ещё раз чуть позже. + Комиссия + Вы платите + Вы получите (≈) + Обменять + Получить биткоин + Получить доллары Копировать Детали Доставка Доставка USDT @@ -832,9 +841,11 @@ Включите USDT, чтобы продолжить этот платёж или запрос. Включить USDT? Введите действительный адрес для выбранной сети. + Введите адрес Spark или счёт Spark для этого токена. Введите корректную сумму, не более 6 знаков после запятой. Не удалось создать резервную копию этого платежа. Попытка повторится, когда резервное копирование станет доступно. Недостаточно USDT для оплаты суммы и комиссий. + Недостаточно долларов в Spark для этой суммы. Сумма слишком мала для покрытия расходов моста. Попробуйте сумму побольше. Включите автоматическую установку даты и времени на устройстве и попробуйте ещё раз. USDT сейчас недоступен. Попробуйте позже. @@ -868,6 +879,7 @@ В ожидании Сумма Адрес скопирован в буфер обмена.\nОтправляйте USDT только через выбранную сеть. + Счёт скопирован в буфер обмена. К получению (≈) Комиссии Примерные Комиссии @@ -885,9 +897,11 @@ Ваш платёж не выполнен. Подробности смотрите в Активности. Выберите сеть, которую использует получатель. Отправить USDT + Отправить доллары Сеть отправителя Отправлено Отправлено USDT + Доллары отправлены USDT (Tether) Платёж отправлен Ваш платёж обрабатывается. Отслеживать его статус можно в Активности. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 153c1b17fb..4b2b514bd5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1223,12 +1223,21 @@ Recipient address Amount Bridge needs attention + Moves dollars from Spark to your USDT wallet on Arbitrum. The bridge provider sets the fee. Bridge Bridge reference USDT was returned to your wallet. See details for the refunded amount. + To USDT Your payment is pending. You can track confirmation and cross-network delivery in Activity. Allow camera access to scan USDT payment QR codes. Confirmed + The conversion did not complete. Try again in a moment. + Fee + You pay + You receive (est.) + Convert + Get bitcoin + Get dollars Copy Details Delivering Delivering USDT @@ -1269,9 +1278,11 @@ Enable USDT to continue with this payment or request. Enable USDT? Enter a valid address for the selected network. + Enter a Spark address or a Spark invoice for this token. Enter a valid amount with up to 6 decimal places. Unable to back up this payment. It will be retried when backup is available. Not enough USDT to cover the amount and fees. + Not enough dollars on Spark for this amount. Amount too small to cover bridge costs. Try a larger amount. This route cannot complete the transfer right now. Try another network or try again later. Amount is above this route’s maximum. Try a smaller amount. @@ -1308,6 +1319,7 @@ Pending Amount Address copied to clipboard.\nMake sure USDT is only sent on the selected network. + Invoice copied to clipboard. Receive (est) Fees Estimated Fees @@ -1325,9 +1337,11 @@ Your payment was not completed. Check Activity for details. Choose the network your recipient uses. Send USDT + Send Dollars Sender network Sent Sent USDT + Sent Dollars USDT (Tether) Payment submitted Your payment is being processed. You can track its status in Activity. From 88e240dd56028fb52a998bee393713e8c85ac6e4 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 27 Oct 2026 13:53:54 +0100 Subject: [PATCH 44/47] feat: make spark dollars payable by pubky over paykit --- .../bitkit/repositories/PrivatePaykitRepo.kt | 3 +++ .../bitkit/repositories/PublicPaykitRepo.kt | 16 ++++++++++++++ .../java/to/bitkit/repositories/SparkRepo.kt | 5 +++++ .../java/to/bitkit/services/SparkService.kt | 4 ++++ .../java/to/bitkit/viewmodels/AppViewModel.kt | 22 +++++++++++++++++-- .../repositories/PrivatePaykitRepoTest.kt | 4 +++- .../repositories/PublicPaykitRepoTest.kt | 2 ++ .../PublicPaykitSharingAdmissionTest.kt | 2 ++ .../viewmodels/AppViewModelSendFlowTest.kt | 2 ++ 9 files changed, 57 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index 764a911640..5b717f2294 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -1826,6 +1826,9 @@ class PrivatePaykitRepo @Inject constructor( ) } } + if (PublicPaykitRepo.isLightningPaymentOptionEnabled(settings)) { + sparkUsdEndpoint(sparkRepo)?.let { endpoints += it } + } endpoints } diff --git a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt index 1873af4a86..4145078a95 100644 --- a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt @@ -118,6 +118,7 @@ class PublicPaykitRepo @Inject constructor( private val settingsStore: SettingsStore, private val clock: Clock, private val usdtRepo: UsdtRepo, + private val sparkRepo: SparkRepo, ) { companion object { private val methodIdPattern = Regex("^[a-z0-9]+-[a-z0-9]+-[a-z0-9]+$") @@ -130,6 +131,7 @@ class PublicPaykitRepo @Inject constructor( MethodId.P2sh, MethodId.P2pkh, MethodId.UsdtArbitrum, + MethodId.UsdbSpark, ) private val publicBolt11Expiry = 24.hours @@ -452,6 +454,8 @@ class PublicPaykitRepo @Inject constructor( } } + if (includeLightning) sparkUsdEndpoint(sparkRepo)?.let { endpoints += it } + if (endpoints.isEmpty() && requireEndpoint) throw PublicPaykitError.NoSupportedEndpoint return endpoints @@ -542,6 +546,7 @@ class PublicPaykitRepo @Inject constructor( private suspend fun isPayable(endpoint: Endpoint): Boolean = runSuspendCatching { when (endpoint.methodId) { MethodId.UsdtArbitrum -> Env.isUsdtEnabled && PaykitUsdt.address(endpoint.rawPayload) != null + MethodId.UsdbSpark -> Env.sparkUsdTokenId != null && SparkRepo.isSparkInput(endpoint.value) MethodId.Bolt11 -> { val scan = coreService.decode(endpoint.paymentRequest) as? Scanner.Lightning ?: return@runSuspendCatching false @@ -563,6 +568,16 @@ class PublicPaykitRepo @Inject constructor( }.getOrDefault(false) } +/** + * The Spark dollar endpoint: the wallet's Spark address, published with the Spending endpoints while Spark holds a + * dollar token. Payers send the token to it by identifier. + */ +internal suspend fun sparkUsdEndpoint(sparkRepo: SparkRepo): Endpoint? { + if (Env.sparkUsdTokenId == null || sparkRepo.state.value.usd == null) return null + val address = sparkRepo.sparkAddress().getOrNull() ?: return null + return Endpoint(MethodId.UsdbSpark, value = address, rawPayload = PublicPaykitRepo.serializePayload(address)) +} + data class Endpoint( val methodId: MethodId, val value: String, @@ -582,6 +597,7 @@ enum class MethodId( val isBitkitManaged: Boolean = false, ) { UsdtArbitrum(fixedRawValue = "usdt-arbitrum-address", isBitkitManaged = true), + UsdbSpark(fixedRawValue = "usdb-spark-address", isBitkitManaged = true), Bolt11(fixedRawValue = "btc-lightning-bolt11", isBitkitManaged = true), Lnurl(fixedRawValue = "btc-lightning-lnurl"), P2tr(onchainEndpoint = "p2tr", isOnchain = true, isBitkitManaged = true), diff --git a/app/src/main/java/to/bitkit/repositories/SparkRepo.kt b/app/src/main/java/to/bitkit/repositories/SparkRepo.kt index 1e369c8a74..6c93ec5a3d 100644 --- a/app/src/main/java/to/bitkit/repositories/SparkRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/SparkRepo.kt @@ -189,6 +189,11 @@ class SparkRepo @Inject constructor( } } + /** The wallet's static Spark address, published on Paykit as its dollar endpoint. */ + suspend fun sparkAddress(): Result = withContext(bgDispatcher) { + runSuspendCatching { sparkService.sparkAddress() } + } + /** * Reads a scanned or pasted Spark address or invoice as a dollar payment. An invoice for another token or for * sats fails, so it never reaches the dollar send flow. diff --git a/app/src/main/java/to/bitkit/services/SparkService.kt b/app/src/main/java/to/bitkit/services/SparkService.kt index 6084487a4d..66da5faa3a 100644 --- a/app/src/main/java/to/bitkit/services/SparkService.kt +++ b/app/src/main/java/to/bitkit/services/SparkService.kt @@ -284,6 +284,10 @@ class SparkService @Inject constructor( ), ) + /** The wallet's static Spark address; it receives sats and tokens alike. */ + suspend fun sparkAddress(): String = + requireSdk().receivePayment(ReceivePaymentRequest(ReceivePaymentMethod.SparkAddress)).paymentRequest + suspend fun parse(input: String): InputType = requireSdk().parse(input) /** Prepares a [tokenId] payment; [amount] in token base units, ignored by the SDK when an invoice sets one. */ diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 5663db60f6..6797a76ba7 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -312,6 +312,7 @@ class AppViewModel @Inject constructor( private val paykitPaymentRequestDiagnostics: PaykitPaymentRequestDiagnostics, private val refreshContactPaykitLink: RefreshContactPaykitLinkUseCase, private val usdtRepo: UsdtRepo, + private val sparkRepo: SparkRepo, val paykitUsdtPayments: PaykitUsdtPaymentRepo, private val samRockRepo: SamRockRepo, private val appUpdateSheet: AppUpdateTimedSheet, @@ -625,6 +626,14 @@ class AppViewModel @Inject constructor( .stateIn(viewModelScope, SharingStarted.Eagerly, false) init { + viewModelScope.launch { + sparkRepo.state.map { it.usd != null }.distinctUntilChanged().collect { + if (it && isPaykitEnabled.value) { + refreshPublicPaykitEndpointsIfEnabled() + refreshPrivatePaykitEndpointsIfEnabled("Spark dollar availability", requeueActive = true) + } + } + } viewModelScope.launch { usdtPreferences.enabled.distinctUntilChanged().collect { updateCanSwitchWallet() @@ -3763,7 +3772,16 @@ class AppViewModel @Inject constructor( } private suspend fun openPaykitEndpoints(payment: ContactPaymentContext, fromMainScanner: Boolean) { - val decoded = payment.endpoints.filter { it.methodId != MethodId.UsdtArbitrum } + val sparkUsdEndpoint = payment.endpoints.firstOrNull { it.methodId == MethodId.UsdbSpark } + val holdsSparkUsd = (sparkRepo.state.value.usd?.balance ?: 0uL) > 0uL + if (sparkUsdEndpoint != null && holdsSparkUsd && payment.incomingPaymentRequest == null) { + clearActiveContactPaymentContext() + hideSheet() + mainScreenEffect(MainScreenEffect.Navigate(Routes.SparkUsdWallet(sparkUsdEndpoint.value))) + return + } + val decoded = payment.endpoints + .filter { it.methodId != MethodId.UsdtArbitrum && it.methodId != MethodId.UsdbSpark } .map { coreService.decode(it.paymentRequest) } val address = decoded.filterIsInstance().lastOrNull()?.invoice?.address.orEmpty() val invoice = decoded.filterIsInstance().lastOrNull()?.invoice @@ -4611,7 +4629,7 @@ class AppViewModel @Inject constructor( private fun requiresPaykitAssetSelection(payment: ContactPaymentContext): Boolean = payment.endpoints.isNotEmpty() && ( - payment.endpoints.any { it.methodId == MethodId.UsdtArbitrum } || + payment.endpoints.any { it.methodId == MethodId.UsdtArbitrum || it.methodId == MethodId.UsdbSpark } || payment.incomingPaymentRequest?.let { it.amount.asset != PaykitAsset.BTC || it.pricing.conversion != null } == true diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index 5854280acd..d4eb3670ee 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -76,6 +76,7 @@ import to.bitkit.services.PaykitSdkOperationLock.Priority import to.bitkit.services.PaykitSdkService import to.bitkit.services.PubkyService import to.bitkit.test.BaseUnitTest +import to.bitkit.test.idleSparkRepo import to.bitkit.utils.AppError import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -108,7 +109,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { private val settingsStore = mock() private val addressReservationRepo = mock() private val lightningRepo = mock() - private val sparkRepo = mock() + private val sparkRepo = idleSparkRepo() private val walletRepo = mock() private val publicPaykitRepo = mock() private val paymentRequestRepo = mock() @@ -2227,6 +2228,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { paykitSdkService = paykitSdkService, settingsStore = settingsStore, usdtRepo = mock { on { state }.thenReturn(MutableStateFlow(UsdtWalletState())) }, + sparkRepo = idleSparkRepo(), clock = clock, ) for (failureStage in listOf("withdraw", "disable capability")) { diff --git a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt index 7ccbe10aa6..550f5b8d7c 100644 --- a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt @@ -33,6 +33,7 @@ import to.bitkit.services.PaykitResolvedPaymentEndpoint import to.bitkit.services.PaykitSdkOperationLock.Priority import to.bitkit.services.PaykitSdkService import to.bitkit.test.BaseUnitTest +import to.bitkit.test.idleSparkRepo import to.bitkit.utils.AppError import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -616,6 +617,7 @@ class PublicPaykitRepoTest : BaseUnitTest() { lightningRepo = lightningRepo, coreService = coreService, usdtRepo = usdtRepo, + sparkRepo = idleSparkRepo(), paykitSdkService = paykitSdkService, settingsStore = settingsStore, clock = clock, diff --git a/app/src/test/java/to/bitkit/repositories/PublicPaykitSharingAdmissionTest.kt b/app/src/test/java/to/bitkit/repositories/PublicPaykitSharingAdmissionTest.kt index ab5fe5a8e8..bcb63ea09a 100644 --- a/app/src/test/java/to/bitkit/repositories/PublicPaykitSharingAdmissionTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PublicPaykitSharingAdmissionTest.kt @@ -29,6 +29,7 @@ import to.bitkit.data.UsdtPreference import to.bitkit.services.CoreService import to.bitkit.services.PaykitSdkService import to.bitkit.test.BaseUnitTest +import to.bitkit.test.idleSparkRepo import to.bitkit.utils.AppError import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -64,6 +65,7 @@ class PublicPaykitSharingAdmissionTest : BaseUnitTest() { settingsStore = settingsStore, clock = Clock.System, usdtRepo = usdtRepo, + sparkRepo = idleSparkRepo(), ) whenever(pubkyRepo.publicKey).thenReturn(MutableStateFlow("pubkyself")) whenever(pubkyRepo.isRestoringSession).thenReturn(isRestoringSession) diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index ae5124095e..c107ea3f41 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -215,6 +215,7 @@ import to.bitkit.services.NodeServiceFgState import to.bitkit.services.PaykitSdkOperationLock.Priority import to.bitkit.services.PendingChannelMigration import to.bitkit.test.BaseUnitTest +import to.bitkit.test.idleSparkRepo import to.bitkit.ui.Routes import to.bitkit.ui.components.Sheet import to.bitkit.ui.components.SubscriptionRoute @@ -925,6 +926,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { paykitPaymentRequestDiagnostics = paykitPaymentRequestDiagnostics, refreshContactPaykitLink = refreshContactPaykitLink, usdtRepo = usdtRepo, + sparkRepo = idleSparkRepo(), paykitUsdtPayments = paykitUsdtPayments, samRockRepo = samRockRepo, appUpdateSheet = mock { on { type } doReturn TimedSheetType.APP_UPDATE }, From d0e1207c1cffc76022f9f0494c937ec1275429ff Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 27 Oct 2026 13:57:12 +0100 Subject: [PATCH 45/47] chore: describe the usdb balance in the changelog --- changelog.d/next/1710.added.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/next/1710.added.md b/changelog.d/next/1710.added.md index c93f7af1f0..eb3fc213a3 100644 --- a/changelog.d/next/1710.added.md +++ b/changelog.d/next/1710.added.md @@ -1 +1 @@ -Added a dollar balance on Spark that Bitkit wallets can send to each other for free. +Added a USDB dollar balance on Spark that you can convert with bitcoin, move to USDT, and send to other Bitkit wallets for free. From fce7ddb389f6fe9e8f27e6f0fe4c71e9d39d4a05 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 27 Oct 2026 14:42:34 +0100 Subject: [PATCH 46/47] fix: stop dollar conversions spending can't cover and show fees in dollars --- .../java/to/bitkit/repositories/SparkRepo.kt | 5 ++-- .../screens/wallets/usdt/SparkUsdViewModel.kt | 28 +++++++++++++++++-- .../wallets/usdt/SparkUsdWalletScreen.kt | 2 +- 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/SparkRepo.kt b/app/src/main/java/to/bitkit/repositories/SparkRepo.kt index 6c93ec5a3d..f538444ea2 100644 --- a/app/src/main/java/to/bitkit/repositories/SparkRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/SparkRepo.kt @@ -241,12 +241,13 @@ class SparkRepo @Inject constructor( val estimate = requireNotNull(prepared.conversionEstimate) { "No conversion estimate" } val sats = { value: BigInteger -> value.toLong().toULong() } val usd = { value: BigInteger -> toUsdUnits(value, decimals) } + // Flashnet reports the swap fee in USDB units both ways (its simulate endpoint, 27 Oct), not in sats. if (toUsd) { SparkUsdQuote( SparkUsdQuote.Kind.TO_USD, sats(estimate.amountIn), usd(estimate.amountOut), - sats(estimate.fee), + usd(estimate.fee), prepared ) } else { @@ -653,7 +654,7 @@ data class SparkUsdRecipient(val request: String, val amount: ULong?) /** * A priced conversion or bridge, ready to run. [pay] and [receive] are in sats for the bitcoin side and in dollar - * units otherwise; [fee] is in the unit of what is paid, except for the bridge, where it is in dollar units. + * units otherwise; [fee] is always in dollar units. */ @Stable data class SparkUsdQuote( diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/SparkUsdViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/SparkUsdViewModel.kt index d17dce6679..edc5f686dc 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/SparkUsdViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/SparkUsdViewModel.kt @@ -3,6 +3,7 @@ package to.bitkit.ui.screens.wallets.usdt import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import breez_sdk_spark.SdkException import com.synonym.bitkitcore.usdtFormatAmount import com.synonym.bitkitcore.usdtParseAmount import dagger.hilt.android.lifecycle.HiltViewModel @@ -98,14 +99,29 @@ class SparkUsdViewModel @Inject constructor( _state.update { it.copy(error = R.string.usdt__error_balance_spark) } return@launch } + val spendsSats = state.page == SparkUsdPage.CONVERT && state.convertToUsd + if (spendsSats && sparkRepo.state.value.balanceSats == 0uL) { + _state.update { it.copy(error = R.string.other__pay_insufficient_spending) } + return@launch + } _state.update { it.copy(busy = true, error = null) } val result = when (state.page) { SparkUsdPage.BRIDGE -> sparkRepo.quoteArbitrumBridge(bridgeAddress.value.orEmpty(), amount) else -> sparkRepo.quoteConversion(state.convertToUsd, amount) } result - .onSuccess { quote -> _state.update { it.copy(busy = false, quote = quote) } } - .onFailure { _state.update { it.copy(busy = false, error = R.string.usdt__convert_failed_spark) } } + .onSuccess { quote -> + val paysSats = quote.kind == SparkUsdQuote.Kind.TO_USD + val affordable = !paysSats || quote.pay <= sparkRepo.state.value.balanceSats + _state.update { + if (affordable) { + it.copy(busy = false, quote = quote) + } else { + it.copy(busy = false, error = R.string.other__pay_insufficient_spending) + } + } + } + .onFailure { error -> _state.update { it.copy(busy = false, error = conversionError(error)) } } } fun confirmQuote() = viewModelScope.launch { @@ -114,7 +130,13 @@ class SparkUsdViewModel @Inject constructor( _state.update { it.copy(busy = true, error = null) } sparkRepo.execute(quote) .onSuccess { _state.update { it.copy(busy = false, completed = true) } } - .onFailure { _state.update { it.copy(busy = false, error = R.string.usdt__convert_failed_spark) } } + .onFailure { error -> _state.update { it.copy(busy = false, error = conversionError(error)) } } + } + + private fun conversionError(error: Throwable): Int = when { + error is SdkException.InsufficientFunds -> R.string.other__pay_insufficient_spending + error.message?.contains("insufficient", ignoreCase = true) == true -> R.string.other__pay_insufficient_spending + else -> R.string.usdt__convert_failed_spark } fun setRecipientInput(input: String) = _state.update { it.copy(recipientInput = input, error = null) } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/SparkUsdWalletScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/SparkUsdWalletScreen.kt index 51742c75b1..20d6071cb9 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/SparkUsdWalletScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/SparkUsdWalletScreen.kt @@ -595,7 +595,7 @@ private fun ColumnScope.SparkUsdConvertContent( val bitcoinOut = quote.kind == SparkUsdQuote.Kind.TO_BTC SparkUsdQuoteRow(R.string.usdt__convert_pay_spark, quote.pay.display(bitcoinIn)) SparkUsdQuoteRow(R.string.usdt__convert_receive_spark, quote.receive.display(bitcoinOut)) - SparkUsdQuoteRow(R.string.usdt__convert_fee_spark, quote.fee.display(bitcoinIn)) + SparkUsdQuoteRow(R.string.usdt__convert_fee_spark, quote.fee.display(isSats = false)) FillHeight() state.error?.let { BodyS(stringResource(it), color = Colors.Brand, modifier = Modifier.testTag("SparkUsdError")) From 2616c13b18b9ab42f613ae6e5cb8d582859ad939 Mon Sep 17 00:00:00 2001 From: ovitrif Date: Tue, 27 Oct 2026 15:56:21 +0100 Subject: [PATCH 47/47] feat: show spark dollar activity, receipts and sends like other payments --- .../androidServices/LightningNodeService.kt | 21 ++ .../models/NewTransactionSheetDetails.kt | 2 + .../java/to/bitkit/repositories/SparkRepo.kt | 115 +++++++++-- app/src/main/java/to/bitkit/ui/ContentView.kt | 10 + .../bitkit/ui/screens/wallets/HomeScreen.kt | 4 +- .../wallets/activity/AllActivityScreen.kt | 35 ++++ .../components/ActivityListGrouped.kt | 28 ++- .../activity/components/ActivityListSimple.kt | 18 +- .../wallets/usdt/SparkUsdActivityDetail.kt | 179 ++++++++++++++++++ .../screens/wallets/usdt/SparkUsdViewModel.kt | 5 +- .../wallets/usdt/SparkUsdWalletScreen.kt | 109 +++++------ .../bitkit/ui/sheets/NewTransactionSheet.kt | 12 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 28 +++ app/src/main/res/values-ar/strings.xml | 4 + app/src/main/res/values-b+es+419/strings.xml | 4 + app/src/main/res/values-ca/strings.xml | 4 + app/src/main/res/values-cs/strings.xml | 4 + app/src/main/res/values-de/strings.xml | 4 + app/src/main/res/values-el/strings.xml | 4 + app/src/main/res/values-es-rES/strings.xml | 4 + app/src/main/res/values-es/strings.xml | 4 + app/src/main/res/values-fr/strings.xml | 4 + app/src/main/res/values-it/strings.xml | 4 + app/src/main/res/values-nl/strings.xml | 4 + app/src/main/res/values-pl/strings.xml | 4 + app/src/main/res/values-pt-rBR/strings.xml | 4 + app/src/main/res/values-pt/strings.xml | 4 + app/src/main/res/values-ru/strings.xml | 4 + app/src/main/res/values/strings.xml | 4 + .../to/bitkit/repositories/SparkRepoTest.kt | 50 ++++- .../test/java/to/bitkit/test/IdleSparkRepo.kt | 1 + 31 files changed, 595 insertions(+), 86 deletions(-) create mode 100644 app/src/main/java/to/bitkit/ui/screens/wallets/usdt/SparkUsdActivityDetail.kt diff --git a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt index 1c101a1b07..5bb1c1758d 100644 --- a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt +++ b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt @@ -12,6 +12,7 @@ import androidx.annotation.RequiresApi import androidx.core.app.NotificationCompat import androidx.core.app.ServiceCompat import androidx.core.content.ContextCompat +import com.synonym.bitkitcore.usdtFormatAmount import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.launch @@ -32,8 +33,12 @@ import to.bitkit.domain.commands.NotifyPendingPaymentResolved import to.bitkit.domain.commands.NotifyPendingPaymentResolvedHandler import to.bitkit.ext.activityManager import to.bitkit.models.NewTransactionSheetDetails +import to.bitkit.models.NewTransactionSheetDirection +import to.bitkit.models.NewTransactionSheetType import to.bitkit.models.NotificationDetails import to.bitkit.repositories.LightningRepo +import to.bitkit.repositories.SparkRepo +import to.bitkit.repositories.SparkUsdTransfer import to.bitkit.repositories.WalletRepo import to.bitkit.services.NodeEventHandler import to.bitkit.services.NodeServiceFgState @@ -79,6 +84,9 @@ class LightningNodeService : Service() { @Inject lateinit var nodeServiceFgState: NodeServiceFgState + @Inject + lateinit var sparkRepo: SparkRepo + private var hasStartedNode = false private val nodeEventHandler: NodeEventHandler = { event -> @@ -91,6 +99,7 @@ class LightningNodeService : Service() { private fun setupService() { if (hasStartedNode) return hasStartedNode = true + serviceScope.launch { sparkRepo.usdReceived.collect { notifySparkUsdReceived(it) } } serviceScope.launch { lightningRepo.start( @@ -144,6 +153,18 @@ class LightningNodeService : Service() { pushNotification(notification.title, notification.body) } + private suspend fun notifySparkUsdReceived(transfer: SparkUsdTransfer) { + if (App.currentActivity?.value != null) return + val sheet = NewTransactionSheetDetails( + type = NewTransactionSheetType.LIGHTNING, + direction = NewTransactionSheetDirection.RECEIVED, + usdtAmount = transfer.amount, + sparkUsdTransferId = transfer.id, + ) + cacheStore.setBackgroundReceive(sheet) + pushNotification(getString(R.string.usdt__received_title_spark), "$ " + usdtFormatAmount(transfer.amount)) + } + private suspend fun handlePendingPaymentResolved(event: Event) { val command = NotifyPendingPaymentResolved.Command.from(event) ?: return diff --git a/app/src/main/java/to/bitkit/models/NewTransactionSheetDetails.kt b/app/src/main/java/to/bitkit/models/NewTransactionSheetDetails.kt index 3963b4b524..d7595d448a 100644 --- a/app/src/main/java/to/bitkit/models/NewTransactionSheetDetails.kt +++ b/app/src/main/java/to/bitkit/models/NewTransactionSheetDetails.kt @@ -14,6 +14,8 @@ data class NewTransactionSheetDetails( val sats: Long = 0, val usdtAmount: ULong? = null, val usdtTransferId: String? = null, + /** A Spark dollar activity, shown with [usdtAmount] as its dollar amount. */ + val sparkUsdTransferId: String? = null, val isLoadingDetails: Boolean = false, ) { companion object { diff --git a/app/src/main/java/to/bitkit/repositories/SparkRepo.kt b/app/src/main/java/to/bitkit/repositories/SparkRepo.kt index f538444ea2..33cad28a4b 100644 --- a/app/src/main/java/to/bitkit/repositories/SparkRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/SparkRepo.kt @@ -1,6 +1,7 @@ package to.bitkit.repositories import androidx.compose.runtime.Stable +import breez_sdk_spark.ConversionChain import breez_sdk_spark.FeePolicy import breez_sdk_spark.InputType import breez_sdk_spark.Payment @@ -118,6 +119,11 @@ class SparkRepo @Inject constructor( private val _nodeEvents = MutableSharedFlow(extraBufferCapacity = 64) val nodeEvents: SharedFlow = _nodeEvents.asSharedFlow() + private val _usdReceived = MutableSharedFlow(extraBufferCapacity = 8) + + /** Fires for each completed incoming dollar transfer, so the app can show its received sheet or notification. */ + val usdReceived: SharedFlow = _usdReceived.asSharedFlow() + private val _exitStateChanged = MutableSharedFlow(extraBufferCapacity = 1) /** Fires once Spark connects and whenever its unilateral exit data changes, so the exit data backup is redone. */ @@ -201,7 +207,7 @@ class SparkRepo @Inject constructor( suspend fun parseUsdRecipient(input: String): Result = withContext(bgDispatcher) { runSuspendCatching { val tokenId = requireNotNull(Env.sparkUsdTokenId) { "No Spark dollar token on this network" } - when (val parsed = sparkService.parse(input.trim())) { + when (val parsed = sparkService.parse(input.trim().let { if (isSparkInput(it)) it.lowercase() else it })) { is InputType.SparkAddress -> SparkUsdRecipient(parsed.v1.address, amount = null) is InputType.SparkInvoice if parsed.v1.tokenIdentifier == tokenId -> SparkUsdRecipient( request = parsed.v1.invoice, @@ -214,9 +220,10 @@ class SparkRepo @Inject constructor( /** * Pays [amount] dollar units of the dollar token to [request], a Spark address or invoice. An invoice that fixes - * its amount is paid as it stands, so no rescaled amount can mismatch it. + * its amount is paid as it stands, so no rescaled amount can mismatch it. Returns the payment's id, which its + * activity carries. */ - suspend fun sendUsd(request: String, amount: ULong, isAmountFixed: Boolean = false): Result = + suspend fun sendUsd(request: String, amount: ULong, isAmountFixed: Boolean = false): Result = withContext(bgDispatcher) { runSuspendCatching { val tokenId = requireNotNull(Env.sparkUsdTokenId) { "No Spark dollar token on this network" } @@ -224,6 +231,7 @@ class SparkRepo @Inject constructor( val payment = sparkService.send(sparkService.prepareTokenSend(request, units, tokenId)) Logger.info("Sent Spark dollar payment '${payment.id}' with status '${payment.status}'", context = TAG) refresh() + payment.id }.onFailure { Logger.error("Failed to send Spark dollar payment", it, context = TAG) } @@ -499,19 +507,7 @@ class SparkRepo @Inject constructor( private suspend fun usdState(tokenId: String, balance: BigInteger?): SparkUsdState { val metadata = usdMetadata(tokenId) val decimals = metadata.decimals.toInt() - val transfers = sparkService.listTokenPayments(tokenId).mapNotNull { payment -> - val token = payment.details as? PaymentDetails.Token ?: return@mapNotNull null - if (token.txType != TokenTransactionType.TRANSFER) return@mapNotNull null - SparkUsdTransfer( - id = payment.id, - isIncoming = payment.paymentType == SparkPaymentType.RECEIVE, - status = payment.status, - amount = toUsdUnits(payment.amount, decimals), - fee = toUsdUnits(payment.fees, decimals), - timestamp = payment.timestamp, - txHash = token.txHash, - ) - } + val transfers = sparkService.listPayments().mapNotNull { it.toUsdTransfer(tokenId, decimals) } return SparkUsdState( ticker = metadata.ticker.trim(), balance = toUsdUnits(balance ?: BigInteger.ZERO, decimals), @@ -519,6 +515,63 @@ class SparkRepo @Inject constructor( ) } + /** + * Reads a Spark payment as dollar activity: a token transfer, or a conversion or bridge with the token on one + * side. Conversion fees come in USDB units on both sides (Flashnet's fee plus a zero Spark transfer fee). + */ + private fun Payment.toUsdTransfer(tokenId: String, decimals: Int): SparkUsdTransfer? { + val usd = { value: BigInteger -> toUsdUnits(value, decimals) } + val conversion = conversionDetails?.conversions?.firstOrNull { + it.from.asset.identifier == tokenId || it.to.asset.identifier == tokenId + } + if (conversion != null) { + val fee = usd(conversion.from.fee + conversion.to.fee) + return when { + conversion.to.asset.identifier == tokenId -> SparkUsdTransfer( + id = id, + kind = SparkUsdTransfer.Kind.TO_USD, + status = status, + amount = usd(conversion.to.amount), + fee = fee, + timestamp = timestamp, + sats = conversion.from.amount.toLong().toULong(), + ) + conversion.to.chain is ConversionChain.External -> SparkUsdTransfer( + id = id, + kind = SparkUsdTransfer.Kind.TO_ARBITRUM, + status = status, + amount = usd(conversion.from.amount), + fee = fee, + timestamp = timestamp, + ) + else -> SparkUsdTransfer( + id = id, + kind = SparkUsdTransfer.Kind.TO_BTC, + status = status, + amount = usd(conversion.from.amount), + fee = fee, + timestamp = timestamp, + sats = conversion.to.amount.toLong().toULong(), + ) + } + } + val token = details as? PaymentDetails.Token ?: return null + if (token.metadata.identifier != tokenId || token.txType != TokenTransactionType.TRANSFER) return null + return SparkUsdTransfer( + id = id, + kind = when (paymentType) { + SparkPaymentType.RECEIVE -> SparkUsdTransfer.Kind.RECEIVED + SparkPaymentType.SEND -> SparkUsdTransfer.Kind.SENT + }, + status = status, + amount = usd(amount), + fee = usd(fees), + timestamp = timestamp, + txHash = token.txHash, + counterparty = token.invoiceDetails?.invoice, + ) + } + private suspend fun syncActivities() { sparkService.listPayments().forEach { recordActivity(it) } } @@ -554,6 +607,19 @@ class SparkRepo @Inject constructor( refresh() recordActivity(payment) payment.toNodeEvent()?.let { _nodeEvents.emit(it) } + val received = _state.value.usd?.transfers?.firstOrNull { it.id == payment.id } + if (received?.kind == SparkUsdTransfer.Kind.RECEIVED && received.status == PaymentStatus.COMPLETED) { + _usdReceived.emit(received) + } + } + + /** Syncs with Spark now, so a payment that arrived while the app was away shows without waiting for its sync. */ + suspend fun syncNow(): Result = withContext(bgDispatcher) { + if (!sparkService.isConnected) return@withContext Result.success(Unit) + runSuspendCatching { + sparkService.syncWallet() + refresh() + } } private suspend fun recordActivity(payment: Payment) { @@ -639,15 +705,26 @@ data class SparkUsdState( val transfers: ImmutableList = persistentListOf(), ) +/** + * Dollar activity on Spark. [amount] and [fee] are in dollar units; [sats] is the bitcoin side of a conversion, and + * [counterparty] the Spark invoice a transfer paid or was paid through, when known. + */ data class SparkUsdTransfer( val id: String, - val isIncoming: Boolean, + val kind: Kind, val status: PaymentStatus, val amount: ULong, val fee: ULong, val timestamp: ULong, - val txHash: String, -) + val txHash: String? = null, + val counterparty: String? = null, + val sats: ULong? = null, +) { + enum class Kind { SENT, RECEIVED, TO_USD, TO_BTC, TO_ARBITRUM } + + /** Whether dollars came in: a receive or a conversion to dollars. */ + val isIncoming: Boolean get() = kind == Kind.RECEIVED || kind == Kind.TO_USD +} /** Where a dollar payment goes: a Spark address or invoice, with the amount an invoice fixes. */ data class SparkUsdRecipient(val request: String, val amount: ULong?) diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 65b802510f..e20729cd8d 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -170,6 +170,7 @@ import to.bitkit.ui.screens.wallets.receive.ReceiveRoute import to.bitkit.ui.screens.wallets.receive.ReceiveSheet import to.bitkit.ui.screens.wallets.send.HwSendViewModel import to.bitkit.ui.screens.wallets.suggestion.BuyIntroScreen +import to.bitkit.ui.screens.wallets.usdt.SparkUsdActivityScreen import to.bitkit.ui.screens.wallets.usdt.SparkUsdWalletScreen import to.bitkit.ui.screens.wallets.usdt.UsdtDepositsScreen import to.bitkit.ui.screens.wallets.usdt.UsdtWalletScreen @@ -1296,6 +1297,12 @@ private fun NavGraphBuilder.home( deepLinkableComposable { UsdtWalletScreen(onBack = { navController.popBackStack() }) } + composableWithDefaultTransitions { entry -> + SparkUsdActivityScreen( + transferId = entry.toRoute().transferId, + onBack = { navController.popBackStack() }, + ) + } composableWithDefaultTransitions { entry -> SparkUsdWalletScreen( onBack = { navController.popBackStack() }, @@ -2376,6 +2383,9 @@ sealed interface Routes { @Serializable data class SparkUsdWallet(val recipient: String = "") : Routes.InternalOnly + @Serializable + data class SparkUsdActivity(val transferId: String) : Routes.InternalOnly + @Serializable data object Settings : Routes.DeepLinkable 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 ba48eb30f8..9c1177c2c2 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 @@ -664,7 +664,8 @@ private fun WalletPage( .min(BigInteger.valueOf(Long.MAX_VALUE)).toLong() val heightStatusBar = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() val pullToRefreshState = rememberPullToRefreshState() - val hasActivity = !latestActivities.isNullOrEmpty() || usdt.transfers.isNotEmpty() + val hasActivity = !latestActivities.isNullOrEmpty() || usdt.transfers.isNotEmpty() || + sparkUsd?.transfers?.isNotEmpty() == true PullToRefreshBox( state = pullToRefreshState, @@ -744,6 +745,7 @@ private fun WalletPage( items = latestActivities, maxItems = (slotCapacity - countNonItemSlots(homeUiState)).coerceAtLeast(0), usdtItems = usdt.transfers, + sparkUsdItems = sparkUsd?.transfers ?: persistentListOf(), onAllActivityClick = onNavigateToAllActivity, onActivityItemClick = onNavigateToActivityItem, hardwareIds = hardwareIds, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/AllActivityScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/AllActivityScreen.kt index 9c06a55ff2..c1addb2229 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/AllActivityScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/AllActivityScreen.kt @@ -8,6 +8,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag @@ -23,6 +24,8 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.toImmutableList import to.bitkit.R +import to.bitkit.repositories.SparkState +import to.bitkit.repositories.SparkUsdTransfer import to.bitkit.ui.appViewModel import to.bitkit.ui.components.Sheet import to.bitkit.ui.scaffold.AppTopBar @@ -35,6 +38,7 @@ import to.bitkit.ui.screens.wallets.activity.utils.previewActivityItems import to.bitkit.ui.shared.modifiers.swipeToChangeTab import to.bitkit.ui.shared.util.screen import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.walletViewModel import to.bitkit.viewmodels.ActivityListViewModel internal fun UsdtTransfer.matchesActivityFilter( @@ -57,6 +61,26 @@ internal fun UsdtTransfer.matchesActivityFilter( (end?.let { timestamp <= (it / 1000).toULong() } != false) } +internal fun SparkUsdTransfer.matchesActivityFilter( + tab: ActivityTab, + noTagFilter: Boolean, + search: String, + start: Long?, + end: Long?, +): Boolean { + val tabMatches = when (tab) { + ActivityTab.ALL -> true + ActivityTab.SENT -> kind == SparkUsdTransfer.Kind.SENT + ActivityTab.RECEIVED -> kind == SparkUsdTransfer.Kind.RECEIVED + ActivityTab.OTHER -> kind !in listOf(SparkUsdTransfer.Kind.SENT, SparkUsdTransfer.Kind.RECEIVED) + } + val searchMatches = search.isEmpty() || counterparty?.contains(search, ignoreCase = true) == true || + txHash?.contains(search, ignoreCase = true) == true || search.equals("USDB", ignoreCase = true) + return tabMatches && noTagFilter && searchMatches && + (start?.let { timestamp >= (it / 1000).toULong() } != false) && + (end?.let { timestamp <= (it / 1000).toULong() } != false) +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun AllActivityScreen( @@ -80,12 +104,21 @@ fun AllActivityScreen( it.matchesActivityFilter(selectedTab, selectedTags.isEmpty(), searchText, startDate, endDate) }.toImmutableList() } + val sparkState by walletViewModel?.sparkState?.collectAsStateWithLifecycle() ?: remember { + mutableStateOf(SparkState()) + } + val sparkUsdItems = remember(sparkState.usd, selectedTab, searchText, selectedTags, startDate, endDate) { + sparkState.usd?.transfers.orEmpty().filter { + it.matchesActivityFilter(selectedTab, selectedTags.isEmpty(), searchText, startDate, endDate) + }.toImmutableList() + } val tabs = activityTabs val currentTabIndex = tabs.indexOf(selectedTab) AllActivityScreenContent( filteredActivities = filteredActivities, usdtItems = usdtItems, + sparkUsdItems = sparkUsdItems, hardwareIds = hardwareIds, searchText = searchText, onSearchTextChange = { viewModel.setSearchText(it) }, @@ -110,6 +143,7 @@ private fun AllActivityScreenContent( searchText: String, hardwareIds: ImmutableSet = persistentSetOf(), usdtItems: ImmutableList = persistentListOf(), + sparkUsdItems: ImmutableList = persistentListOf(), onSearchTextChange: (String) -> Unit, hasTagFilter: Boolean, selectedTags: ImmutableSet, @@ -162,6 +196,7 @@ private fun AllActivityScreenContent( ActivityListGrouped( items = filteredActivities, usdtItems = usdtItems, + sparkUsdItems = sparkUsdItems, onActivityItemClick = onActivityItemClick, onEmptyActivityRowClick = onEmptyActivityRowClick, hardwareIds = hardwareIds, 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 d1c6dfa722..7d5af21dd3 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 @@ -30,6 +30,7 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentSetOf import to.bitkit.R import to.bitkit.ext.scopedId +import to.bitkit.repositories.SparkUsdTransfer import to.bitkit.ui.activityListViewModel import to.bitkit.ui.appViewModel import to.bitkit.ui.components.BodyM @@ -37,6 +38,7 @@ import to.bitkit.ui.components.Caption13Up import to.bitkit.ui.components.TertiaryButton import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.screens.wallets.activity.utils.previewActivityItems +import to.bitkit.ui.screens.wallets.usdt.SparkUsdActivityRow import to.bitkit.ui.screens.wallets.usdt.UsdtActivityRow import to.bitkit.ui.settingsViewModel import to.bitkit.ui.theme.AppThemeSurface @@ -63,6 +65,7 @@ fun ActivityListGrouped( hardwareIds: ImmutableSet = persistentSetOf(), titleProvider: @Composable (Activity) -> String? = { null }, usdtItems: ImmutableList = persistentListOf(), + sparkUsdItems: ImmutableList = persistentListOf(), ) { val app = appViewModel val settings = settingsViewModel @@ -72,8 +75,10 @@ fun ActivityListGrouped( horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier.fillMaxSize() ) { - if (!items.isNullOrEmpty() || usdtItems.isNotEmpty()) { - val groupedItems = remember(items, usdtItems) { groupActivityItems(items.orEmpty(), usdtItems) } + if (!items.isNullOrEmpty() || usdtItems.isNotEmpty() || sparkUsdItems.isNotEmpty()) { + val groupedItems = remember(items, usdtItems, sparkUsdItems) { + groupActivityItems(items.orEmpty(), usdtItems, sparkUsdItems) + } LazyColumn( state = listState, @@ -93,6 +98,15 @@ fun ActivityListGrouped( if (index < groupedItems.lastIndex) VerticalSpacer(16.dp) } + is SparkUsdTransfer -> { + SparkUsdActivityRow( + item, + hideBalance, + onClick = { app?.navigateToSparkUsdActivity(item.id) } + ) + if (index < groupedItems.lastIndex) VerticalSpacer(16.dp) + } + is String -> { Caption13Up( text = item, @@ -188,6 +202,7 @@ private fun ActivityContactRow( private fun groupedActivityKey(index: Int, item: Any): String = when (item) { is UsdtTransfer -> "usdt_${item.id}" + is SparkUsdTransfer -> "sparkusd_${item.id}" is String -> "header_$item" is Activity.Lightning -> "lightning_${item.scopedId()}" is Activity.Onchain -> "onchain_${item.scopedId()}" @@ -286,7 +301,11 @@ fun LazyListScope.activityListGroupedItems( } // region utils -private fun groupActivityItems(activityItems: List, usdtItems: List = emptyList()): List { +private fun groupActivityItems( + activityItems: List, + usdtItems: List = emptyList(), + sparkUsdItems: List = emptyList(), +): List { val now = Instant.now() val zoneId = ZoneId.systemDefault() val today = now.atZone(zoneId).truncatedTo(ChronoUnit.DAYS) @@ -308,12 +327,13 @@ private fun groupActivityItems(activityItems: List, usdtItems: List entry.item is WalletActivity.Usdt -> entry.item + is WalletActivity.SparkUsd -> entry.item } val timestamp = entry.timestamp.toLong() when { diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListSimple.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListSimple.kt index f5737c79f4..1add15c7d3 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListSimple.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListSimple.kt @@ -23,11 +23,13 @@ import kotlinx.collections.immutable.persistentSetOf import to.bitkit.R import to.bitkit.ext.scopedId import to.bitkit.ext.timestamp +import to.bitkit.repositories.SparkUsdTransfer import to.bitkit.ui.activityListViewModel import to.bitkit.ui.appViewModel import to.bitkit.ui.components.TertiaryButton import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.screens.wallets.activity.utils.previewActivityItems +import to.bitkit.ui.screens.wallets.usdt.SparkUsdActivityRow import to.bitkit.ui.screens.wallets.usdt.UsdtActivityRow import to.bitkit.ui.settingsViewModel import to.bitkit.ui.theme.AppThemeSurface @@ -39,14 +41,18 @@ fun ActivityListSimple( onActivityItemClick: (Activity) -> Unit, hardwareIds: ImmutableSet = persistentSetOf(), usdtItems: ImmutableList = persistentListOf(), + sparkUsdItems: ImmutableList = persistentListOf(), maxItems: Int = 4, ) { - if (items.isNullOrEmpty() && usdtItems.isEmpty()) return + if (items.isNullOrEmpty() && usdtItems.isEmpty() && sparkUsdItems.isEmpty()) return val app = appViewModel val settings = settingsViewModel val hideBalance by settings?.hideBalance?.collectAsStateWithLifecycle() ?: remember { mutableStateOf(false) } - val rows = remember(items, usdtItems, maxItems) { - (items.orEmpty().map { WalletActivity.Bitcoin(it) } + usdtItems.map { WalletActivity.Usdt(it) }) + val rows = remember(items, usdtItems, sparkUsdItems, maxItems) { + ( + items.orEmpty().map { WalletActivity.Bitcoin(it) } + usdtItems.map { WalletActivity.Usdt(it) } + + sparkUsdItems.map { WalletActivity.SparkUsd(it) } + ) .sortedByDescending { it.timestamp }.take(maxItems) } @@ -73,6 +79,11 @@ fun ActivityListSimple( hideBalance, onClick = { app?.navigateToUsdtActivity(entry.item.id) } ) + is WalletActivity.SparkUsd -> SparkUsdActivityRow( + entry.item, + hideBalance, + onClick = { app?.navigateToSparkUsdActivity(entry.item.id) } + ) } if (index < rows.lastIndex) VerticalSpacer(16.dp) } @@ -114,4 +125,5 @@ internal sealed interface WalletActivity { val timestamp: ULong data class Bitcoin(val item: Activity) : WalletActivity { override val timestamp get() = item.timestamp() } data class Usdt(val item: UsdtTransfer) : WalletActivity { override val timestamp get() = item.timestamp } + data class SparkUsd(val item: SparkUsdTransfer) : WalletActivity { override val timestamp get() = item.timestamp } } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/SparkUsdActivityDetail.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/SparkUsdActivityDetail.kt new file mode 100644 index 0000000000..22f2de11c1 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/SparkUsdActivityDetail.kt @@ -0,0 +1,179 @@ +package to.bitkit.ui.screens.wallets.usdt + +import androidx.annotation.StringRes +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import breez_sdk_spark.PaymentStatus +import com.synonym.bitkitcore.usdtFormatAmount +import to.bitkit.R +import to.bitkit.ext.uiDateStyleFor +import to.bitkit.models.formatToModernDisplay +import to.bitkit.repositories.SparkUsdTransfer +import to.bitkit.ui.components.BodyMSB +import to.bitkit.ui.components.BodySSB +import to.bitkit.ui.components.Caption13Up +import to.bitkit.ui.components.CaptionB +import to.bitkit.ui.components.HorizontalSpacer +import to.bitkit.ui.components.UsdtAmountHeader +import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.components.usdtOverviewAmount +import to.bitkit.ui.scaffold.AppTopBar +import to.bitkit.ui.scaffold.DrawerNavIcon +import to.bitkit.ui.screens.wallets.activity.components.ActivityRowSurface +import to.bitkit.ui.screens.wallets.activity.components.AmountViewContent +import to.bitkit.ui.screens.wallets.activity.components.CircularIcon +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.uiDateText + +@Composable +fun SparkUsdActivityRow(transfer: SparkUsdTransfer, hideBalance: Boolean, onClick: () -> Unit) { + ActivityRowSurface(onClick = onClick, modifier = Modifier.testTag("SparkUsdActivity-" + transfer.id)) { + SparkUsdActivityIcon(transfer) + HorizontalSpacer(16.dp) + Column(verticalArrangement = Arrangement.spacedBy(2.dp), modifier = Modifier.weight(1f)) { + BodyMSB(stringResource(transfer.titleResource), maxLines = 1) + CaptionB( + uiDateText(transfer.timestamp, uiDateStyleFor(transfer.timestamp)), + color = Colors.White64, + maxLines = 1 + ) + } + HorizontalSpacer(16.dp) + AmountViewContent( + title = usdtOverviewAmount(transfer.amount), + titlePrefix = if (transfer.isIncoming) "+" else "−", + subtitle = "$ " + usdtOverviewAmount(transfer.amount), + hideBalance = hideBalance + ) + } +} + +/** Opens one Spark dollar activity by id, from the Spark dollar balance. */ +@Composable +fun SparkUsdActivityScreen(transferId: String, onBack: () -> Unit, viewModel: SparkUsdViewModel = hiltViewModel()) { + val wallet by viewModel.wallet.collectAsStateWithLifecycle() + val settings by viewModel.settings.collectAsStateWithLifecycle() + val transfer = wallet?.transfers?.firstOrNull { it.id == transferId } + if (transfer == null) { + Column(modifier = Modifier.fillMaxSize().background(Colors.Black)) { + AppTopBar(titleText = null, onBackClick = onBack, actions = { DrawerNavIcon() }) + } + return + } + SparkUsdActivityDetail(transfer, settings.hideBalance, onBack) +} + +@Composable +private fun SparkUsdActivityDetail(transfer: SparkUsdTransfer, hideBalance: Boolean, onBack: () -> Unit) { + Column(modifier = Modifier.fillMaxSize().background(Colors.Black)) { + AppTopBar( + titleText = stringResource(transfer.titleResource), + onBackClick = onBack, + actions = { DrawerNavIcon() } + ) + Column(modifier = Modifier.verticalScroll(rememberScrollState()).padding(horizontal = 16.dp)) { + VerticalSpacer(16.dp) + Row(verticalAlignment = Alignment.CenterVertically) { + UsdtAmountHeader( + amount = usdtFormatAmount(transfer.amount), + displayAmount = usdtOverviewAmount(transfer.amount), + hideBalance = hideBalance, + prefix = if (transfer.isIncoming) "+" else "−", + modifier = Modifier.weight(1f).testTag("SparkUsdActivityAmount") + ) + SparkUsdActivityIcon(transfer) + } + VerticalSpacer(24.dp) + DetailRow(R.string.wallet__activity_status, stringResource(transfer.statusResource), transfer.color) + DetailRow( + R.string.wallet__activity_date, + uiDateText(transfer.timestamp, uiDateStyleFor(transfer.timestamp)) + ) + transfer.sats?.let { + val label = when { + transfer.isIncoming -> R.string.wallet__activity_sent + else -> R.string.wallet__activity_received + } + DetailRow(label, "₿ " + it.formatToModernDisplay()) + } + DetailRow(R.string.wallet__activity_fee, "$ " + usdtOverviewAmount(transfer.fee)) + transfer.counterparty?.let { DetailRow(R.string.wallet__activity_invoice, it) } + transfer.txHash?.let { DetailRow(R.string.wallet__activity_tx_id, it) } + VerticalSpacer(32.dp) + } + } +} + +@Composable +private fun DetailRow(@StringRes label: Int, value: String, valueColor: Color = Colors.White) { + Column(modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp)) { + Caption13Up(stringResource(label), color = Colors.White64) + VerticalSpacer(8.dp) + BodySSB(value, color = valueColor, maxLines = 3, overflow = TextOverflow.Ellipsis) + } + HorizontalDivider(color = Colors.White10) +} + +@Composable +private fun SparkUsdActivityIcon(transfer: SparkUsdTransfer) { + CircularIcon( + icon = painterResource(transfer.iconResource), + iconColor = transfer.color, + backgroundColor = transfer.color.copy(alpha = 0.16f), + size = 40.dp + ) +} + +private val SparkUsdTransfer.color: Color get() = when (status) { + PaymentStatus.FAILED -> Colors.Red + PaymentStatus.PENDING -> Colors.Yellow + PaymentStatus.COMPLETED -> Colors.Usdt +} + +private val SparkUsdTransfer.iconResource: Int get() = when (status) { + PaymentStatus.FAILED -> R.drawable.ic_x_circle + PaymentStatus.PENDING -> R.drawable.ic_hourglass_simple + PaymentStatus.COMPLETED -> when (kind) { + SparkUsdTransfer.Kind.TO_USD, + SparkUsdTransfer.Kind.TO_BTC, + SparkUsdTransfer.Kind.TO_ARBITRUM, + -> R.drawable.ic_transfer + else -> if (isIncoming) R.drawable.ic_received else R.drawable.ic_sent + } +} + +private val SparkUsdTransfer.statusResource: Int get() = when (status) { + PaymentStatus.FAILED -> R.string.usdt__failed + PaymentStatus.PENDING -> R.string.usdt__pending + PaymentStatus.COMPLETED -> R.string.usdt__confirmed +} + +private val SparkUsdTransfer.titleResource: Int get() = when { + status == PaymentStatus.FAILED -> R.string.usdt__failed + status == PaymentStatus.PENDING -> R.string.usdt__pending + kind == SparkUsdTransfer.Kind.TO_USD -> R.string.usdt__activity_to_usd_spark + kind == SparkUsdTransfer.Kind.TO_BTC -> R.string.usdt__activity_to_btc_spark + kind == SparkUsdTransfer.Kind.TO_ARBITRUM -> R.string.usdt__activity_bridge_spark + isIncoming -> R.string.usdt__received + else -> R.string.usdt__sent +} diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/SparkUsdViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/SparkUsdViewModel.kt index edc5f686dc..a5647dfc81 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/SparkUsdViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/SparkUsdViewModel.kt @@ -49,6 +49,8 @@ class SparkUsdViewModel @Inject constructor( fun refresh() = viewModelScope.launch { sparkRepo.refreshState() } + fun syncNow() = viewModelScope.launch { sparkRepo.syncNow() } + fun loadReceiveRequest() = viewModelScope.launch { _state.update { it.copy(receiveRequest = null, error = null) } sparkRepo.createUsdRequest(amount = null) @@ -209,7 +211,7 @@ class SparkUsdViewModel @Inject constructor( val amount = state.amount ?: return _state.update { it.copy(busy = true, error = null) } sparkRepo.sendUsd(recipient, amount, state.isAmountFixed) - .onSuccess { _state.update { it.copy(busy = false, completed = true) } } + .onSuccess { id -> _state.update { it.copy(busy = false, completed = true, sentTransferId = id) } } .onFailure { _state.update { it.copy(busy = false, error = R.string.usdt__send_failed_description) } } } } @@ -227,6 +229,7 @@ data class SparkUsdSendState( val isAmountFixed: Boolean = false, val convertToUsd: Boolean = true, val quote: SparkUsdQuote? = null, + val sentTransferId: String? = null, val busy: Boolean = false, val completed: Boolean = false, val authenticationRequired: Boolean = false, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/SparkUsdWalletScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/SparkUsdWalletScreen.kt index 20d6071cb9..1dd1ce4f21 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/SparkUsdWalletScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/SparkUsdWalletScreen.kt @@ -48,8 +48,11 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import breez_sdk_spark.PaymentStatus import com.synonym.bitkitcore.usdtFormatAmount import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.delay import to.bitkit.R -import to.bitkit.ext.uiDateStyleFor +import to.bitkit.models.NewTransactionSheetDetails +import to.bitkit.models.NewTransactionSheetDirection +import to.bitkit.models.NewTransactionSheetType import to.bitkit.models.formatToModernDisplay import to.bitkit.repositories.SparkUsdQuote import to.bitkit.repositories.SparkUsdState @@ -81,9 +84,6 @@ import to.bitkit.ui.components.usdtOverviewAmount import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.DrawerNavIcon import to.bitkit.ui.scaffold.SheetTopBar -import to.bitkit.ui.screens.wallets.activity.components.ActivityRowSurface -import to.bitkit.ui.screens.wallets.activity.components.AmountViewContent -import to.bitkit.ui.screens.wallets.activity.components.CircularIcon import to.bitkit.ui.screens.wallets.activity.components.activityGroupTitleResource import to.bitkit.ui.screens.wallets.receive.ReceiveQrView import to.bitkit.ui.settingsViewModel @@ -91,11 +91,15 @@ import to.bitkit.ui.shared.UiConstants import to.bitkit.ui.shared.modifiers.clickableAlpha import to.bitkit.ui.shared.modifiers.sheetHeight import to.bitkit.ui.shared.util.blockPointerInputPassthrough +import to.bitkit.ui.sheets.NewTransactionSheetView import to.bitkit.ui.theme.AppTextStyles import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.NumberPadInputHandler -import to.bitkit.ui.utils.uiDateText +import kotlin.time.Duration.Companion.seconds + +/** How often the receive screen syncs with Spark, so an incoming payment shows within seconds. */ +private val RECEIVE_POLL_INTERVAL = 5.seconds /** Home tile for the dollar balance on Spark, next to the Arbitrum USDT one. */ @Composable @@ -198,6 +202,7 @@ fun SparkUsdWalletScreen( onConvertDirection = viewModel::setConvertToUsd, onQuote = viewModel::quote, onConfirmQuote = viewModel::confirmQuote, + onPoll = viewModel::syncNow, ) } } @@ -273,7 +278,11 @@ private fun SparkUsdWalletContent( modifier = Modifier.padding(top = if (index == 0) 0.dp else 16.dp, bottom = 16.dp) ) } - SparkUsdActivityRow(transfer, hideBalance) + SparkUsdActivityRow( + transfer, + hideBalance, + onClick = { app?.navigateToSparkUsdActivity(transfer.id) } + ) VerticalSpacer(16.dp) } item { VerticalSpacer(120.dp) } @@ -285,49 +294,6 @@ private fun SparkUsdWalletContent( } } -@Composable -private fun SparkUsdActivityRow(transfer: SparkUsdTransfer, hideBalance: Boolean) { - val color = when (transfer.status) { - PaymentStatus.FAILED -> Colors.Red - PaymentStatus.PENDING -> Colors.Yellow - PaymentStatus.COMPLETED -> Colors.Usdt - } - val icon = when (transfer.status) { - PaymentStatus.FAILED -> R.drawable.ic_x_circle - PaymentStatus.PENDING -> R.drawable.ic_hourglass_simple - PaymentStatus.COMPLETED -> if (transfer.isIncoming) R.drawable.ic_received else R.drawable.ic_sent - } - val title = when (transfer.status) { - PaymentStatus.FAILED -> R.string.usdt__failed - PaymentStatus.PENDING -> R.string.usdt__pending - PaymentStatus.COMPLETED -> if (transfer.isIncoming) R.string.usdt__received else R.string.usdt__sent - } - ActivityRowSurface(onClick = {}, modifier = Modifier.testTag("SparkUsdActivity-" + transfer.id)) { - CircularIcon( - icon = painterResource(icon), - iconColor = color, - backgroundColor = color.copy(alpha = 0.16f), - size = 40.dp - ) - HorizontalSpacer(16.dp) - Column(verticalArrangement = Arrangement.spacedBy(2.dp), modifier = Modifier.weight(1f)) { - BodyMSB(stringResource(title), maxLines = 1) - CaptionB( - uiDateText(transfer.timestamp, uiDateStyleFor(transfer.timestamp)), - color = Colors.White64, - maxLines = 1 - ) - } - HorizontalSpacer(16.dp) - AmountViewContent( - title = usdtOverviewAmount(transfer.amount), - titlePrefix = if (transfer.isIncoming) "+" else "−", - subtitle = "$ " + usdtOverviewAmount(transfer.amount), - hideBalance = hideBalance - ) - } -} - @Composable private fun SparkUsdSheetContent( wallet: SparkUsdState?, @@ -344,7 +310,28 @@ private fun SparkUsdSheetContent( onConvertDirection: (Boolean) -> Unit = {}, onQuote: () -> Unit = {}, onConfirmQuote: () -> Unit = {}, + onPoll: () -> Unit = {}, ) { + val sentTransferId = state.sentTransferId + if (state.page == SparkUsdPage.REVIEW && state.completed && sentTransferId != null) { + val app = appViewModel + NewTransactionSheetView( + details = NewTransactionSheetDetails( + type = NewTransactionSheetType.LIGHTNING, + direction = NewTransactionSheetDirection.SENT, + usdtAmount = state.amount, + sparkUsdTransferId = sentTransferId, + ), + onCloseClick = onDone, + onDetailClick = { + onDone() + app?.navigateToSparkUsdActivity(sentTransferId) + }, + hideBalance = hideBalance, + modifier = Modifier.fillMaxSize().testTag("SparkUsdSendSuccess") + ) + return + } val title = when { state.page == SparkUsdPage.RECEIVE -> R.string.usdt__receive_title state.page == SparkUsdPage.CONVERT -> R.string.usdt__convert_spark @@ -358,7 +345,7 @@ private fun SparkUsdSheetContent( SheetTopBar(stringResource(title), onBack = onBack.takeUnless { state.busy || state.completed }) VerticalSpacer(16.dp) when (state.page) { - SparkUsdPage.RECEIVE -> SparkUsdReceiveContent(state) + SparkUsdPage.RECEIVE -> SparkUsdReceiveContent(state, onPoll) SparkUsdPage.AMOUNT -> SparkUsdAmountContent( wallet, state, @@ -382,12 +369,19 @@ private fun SparkUsdSheetContent( } @Composable -private fun ColumnScope.SparkUsdReceiveContent(state: SparkUsdSendState) { +private fun ColumnScope.SparkUsdReceiveContent(state: SparkUsdSendState, onPoll: () -> Unit = {}) { val request = state.receiveRequest + LaunchedEffect(Unit) { + while (true) { + delay(RECEIVE_POLL_INTERVAL) + onPoll() + } + } BoxWithConstraints(modifier = Modifier.weight(1f).fillMaxWidth()) { when { request != null -> ReceiveQrView( - uri = request, + // Bech32 is case-insensitive; uppercase fits QR's alphanumeric mode, a less dense code. + uri = request.uppercase(), copyText = request, qrLogoPainter = painterResource(R.drawable.tether_circle), onClickEditInvoice = null, @@ -639,10 +633,17 @@ private fun Preview() { AppThemeSurface { SparkUsdWalletContent( wallet = SparkUsdState( - ticker = "USDT", + ticker = "USDB", balance = 12_340_000uL, transfers = persistentListOf( - SparkUsdTransfer("1", true, PaymentStatus.COMPLETED, 5_000_000uL, 0uL, 1_793_098_272uL, "ab"), + SparkUsdTransfer( + id = "1", + kind = SparkUsdTransfer.Kind.RECEIVED, + status = PaymentStatus.COMPLETED, + amount = 5_000_000uL, + fee = 0uL, + timestamp = 1_793_098_272uL, + ), ), ), hideBalance = false, diff --git a/app/src/main/java/to/bitkit/ui/sheets/NewTransactionSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/NewTransactionSheet.kt index 65c708d047..61cb33fe7a 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/NewTransactionSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/NewTransactionSheet.kt @@ -155,7 +155,7 @@ fun NewTransactionSheetView( hideBalance = hideBalance, onClick = { val hasDetails = details.activityId != null || details.paymentHashOrTxId != null || - details.usdtTransferId != null + (details.usdtTransferId != null || details.sparkUsdTransferId != null) if (!details.isLoadingDetails && hasDetails) onDetailClick() }, modifier = Modifier.testTag("ReceivedTransaction") @@ -179,6 +179,11 @@ fun NewTransactionSheetView( private fun TransactionHeader(details: NewTransactionSheetDetails) { val titleText = stringResource( when { + details.sparkUsdTransferId != null -> if (details.direction == NewTransactionSheetDirection.SENT) { + R.string.usdt__sent_title_spark + } else { + R.string.usdt__received_title_spark + } details.usdtAmount != null -> if (details.direction == NewTransactionSheetDirection.SENT) { R.string.usdt__payment_sent } else { @@ -252,7 +257,8 @@ private fun TransactionButtons( } else { null } - if (details.direction == NewTransactionSheetDirection.SENT || details.usdtTransferId != null) { + val hasDollarDetails = details.usdtTransferId != null || details.sparkUsdTransferId != null + if (details.direction == NewTransactionSheetDirection.SENT || hasDollarDetails) { Row( horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier @@ -264,7 +270,7 @@ private fun TransactionButtons( onClick = onDetailClick, textStyle = buttonStyle, enabled = !details.isLoadingDetails && ( - details.activityId != null || details.paymentHashOrTxId != null || details.usdtTransferId != null + details.activityId != null || details.paymentHashOrTxId != null || hasDollarDetails ), modifier = Modifier .weight(1f) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 6797a76ba7..ab95f78b1a 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -626,6 +626,18 @@ class AppViewModel @Inject constructor( .stateIn(viewModelScope, SharingStarted.Eagerly, false) init { + viewModelScope.launch { + sparkRepo.usdReceived.collect { + showTransactionSheet( + NewTransactionSheetDetails( + type = NewTransactionSheetType.LIGHTNING, + direction = NewTransactionSheetDirection.RECEIVED, + usdtAmount = it.amount, + sparkUsdTransferId = it.id, + ) + ) + } + } viewModelScope.launch { sparkRepo.state.map { it.usd != null }.distinctUntilChanged().collect { if (it && isPaykitEnabled.value) { @@ -3087,6 +3099,8 @@ class AppViewModel @Inject constructor( return@withContext } + if (Env.sparkUsdTokenId != null && SparkRepo.isSparkInput(input)) return@withContext + val scanResult = runCatching { coreService.decode(input.removeLightningSchemes()) } if (scanResult.isFailure) { @@ -6603,6 +6617,11 @@ class AppViewModel @Inject constructor( fun onClickActivityDetail() { val details = _transactionSheet.value + details.sparkUsdTransferId?.let { + hideNewTransactionSheet() + navigateToSparkUsdActivity(it) + return + } details.usdtTransferId?.let { hideNewTransactionSheet() navigateToUsdtActivity(it) @@ -6746,6 +6765,13 @@ class AppViewModel @Inject constructor( } } + fun navigateToSparkUsdActivity(transferId: String) { + viewModelScope.launch { + hideSheet() + mainScreenEffect(MainScreenEffect.Navigate(Routes.SparkUsdActivity(transferId))) + } + } + fun navigateToUsdtActivity(transferId: String) { viewModelScope.launch { hideSheet() @@ -7353,6 +7379,7 @@ class AppViewModel @Inject constructor( when { isUsdtPaymentInput(text.trim()) -> isUsdtEnabled.value && !hasActiveUsdtSend() && parseUsdtPaymentRequest(text.trim()).isSuccess + Env.sparkUsdTokenId != null && SparkRepo.isSparkInput(text) -> true else -> SamRockSetupRequest.isProtocolUrl(text) || runSuspendCatching { coreService.decode(text) }.isSuccess } } @@ -8280,6 +8307,7 @@ class AppViewModel @Inject constructor( viewModelScope.launch { if (isOnline.value == ConnectivityState.CONNECTED) pubkyRepo.restoreSessionIfNeeded() } + viewModelScope.launch { sparkRepo.syncNow() } } fun onHomeResumed() { diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 37d3858e56..8eb033ed42 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -742,6 +742,9 @@ المدفوعات معلق الحالة + تم النقل إلى USDT + تم التحويل إلى بيتكوين + تم التحويل إلى دولارات عنوان المستلم المبلغ الجسر بحاجة إلى متابعة @@ -847,6 +850,7 @@ شبكة USDT استلام الدولارات مستلم + تم استلام الدولارات شبكة الاستلام جارٍ استلام USDT يحصل المستلم على diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml index 134609dd3a..2ec19c5056 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -766,6 +766,9 @@ Pendiente En cola Estatus + Movido a USDT + Convertido a bitcoin + Convertido a dólares Dirección de destino Monto El puente requiere atención @@ -871,6 +874,7 @@ Red USDT Recibir dólares Recibido + Dólares recibidos Red de recepción Recibiendo USDT Destinatario recibe diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 2810a9a067..0c9e190f0a 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -759,6 +759,9 @@ Pendent A la cua Estat + Mogut a USDT + Convertit a bitcoin + Convertit a dòlars Adreça del destinatari Import El pont requereix atenció @@ -864,6 +867,7 @@ Xarxa USDT Rebre dòlars Rebut + Dòlars rebuts Xarxa de recepció Rebent USDT El destinatari rep diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 7f24656115..4a47404337 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -763,6 +763,9 @@ Platby Čekající Ve frontě + Přesunuto do USDT + Převedeno na bitcoin + Převedeno na dolary Adresa příjemce Částka Most vyžaduje pozornost @@ -868,6 +871,7 @@ Síť USDT Přijmout dolary Přijato + Dolary přijaty Přijímající síť Přijímání USDT Příjemce obdrží diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index b274f0db33..da4fb31d71 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -761,6 +761,9 @@ Verbinden Rapid-Gossip-Sync-Server aktualisiert Möglicherweise musst du die App ein- oder zweimal neu starten, damit diese Änderung wirksam wird. + Zu USDT verschoben + In Bitcoin umgewandelt + In Dollar umgewandelt Empfängeradresse Betrag Bridge erfordert Prüfung @@ -866,6 +869,7 @@ USDT-Netzwerk Dollar empfangen Empfangen + Dollar erhalten Empfangsnetzwerk USDT wird empfangen Empfänger erhält diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index ef6fd96203..3d6ff4e00a 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -744,6 +744,9 @@ Εκκρεμεί Σε αναμονή Κατάσταση + Μεταφέρθηκε σε USDT + Μετατράπηκε σε bitcoin + Μετατράπηκε σε δολάρια Διεύθυνση παραλήπτη Ποσό Η γέφυρα χρειάζεται προσοχή @@ -849,6 +852,7 @@ Δίκτυο USDT Λήψη δολαρίων Ελήφθη + Τα δολάρια ελήφθησαν Δίκτυο λήψης Λήψη USDT Ο παραλήπτης λαμβάνει diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index 4f691f4308..dcbae7bc6d 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -762,6 +762,9 @@ Pendiente En cola Estatus + Movido a USDT + Convertido a bitcoin + Convertido a dólares Dirección de destino Cantidad El puente requiere atención @@ -867,6 +870,7 @@ Red USDT Recibir dólares Recibido + Dólares recibidos Red de recepción Recibiendo USDT Destinatario recibe diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 7b39b73d4e..7b23e255f1 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -759,6 +759,9 @@ Conectar Bitkit se ha conectado correctamente al servidor Rapid-Gossip-Sync especificado. Servidor Rapid-Gossip-Sync Actualizado + Movido a USDT + Convertido a bitcoin + Convertido a dólares Dirección de destino Cantidad El puente requiere atención @@ -864,6 +867,7 @@ Red USDT Recibir dólares Recibido + Dólares recibidos Red de recepción Recibiendo USDT Destinatario recibe diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index ee4e184519..df3e6baee5 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -763,6 +763,9 @@ Connecter Mise à jour du serveur Rapid-Gossip-Sync Il se peut que vous deviez redémarrer l\'application une ou deux fois pour que cette modification prenne effet. + Transféré vers USDT + Converti en bitcoin + Converti en dollars Adresse du destinataire Montant Le pont requiert une action @@ -868,6 +871,7 @@ Réseau USDT Recevoir des dollars Reçu + Dollars reçus Réseau de réception Réception d\'USDT Le destinataire reçoit diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index a934487b0c..dd86366715 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -762,6 +762,9 @@ In attesa In coda Stato + Spostato in USDT + Convertito in bitcoin + Convertito in dollari Indirizzo destinatario Importo Il bridge richiede attenzione @@ -867,6 +870,7 @@ Rete USDT Ricevi Dollari Ricevuto + Dollari ricevuti Rete di ricezione Ricezione USDT Destinatario riceve diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index da5ff9361a..e177e52a4c 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -758,6 +758,9 @@ Betalingen In behandeling In de wacht + Verplaatst naar USDT + Omgezet naar bitcoin + Omgezet naar dollars Adres ontvanger Bedrag Bridge vereist aandacht @@ -863,6 +866,7 @@ USDT-netwerk Dollars ontvangen Ontvangen + Dollars ontvangen Ontvangstnetwerk USDT wordt ontvangen Ontvanger krijgt diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 4b0104f0d8..5d11299514 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -765,6 +765,9 @@ Połącz Rapid-Gossip-Sync zaktualizowany Zmiana może wymagać ponownego uruchomienia aplikacji raz lub dwa razy. + Przeniesiono do USDT + Wymieniono na bitcoin + Wymieniono na dolary Adres odbiorcy Kwota Most wymaga uwagi @@ -870,6 +873,7 @@ Sieć USDT Otrzymaj dolary Otrzymano + Dolary otrzymane Sieć odbiorcza Odbieranie USDT Odbiorca otrzyma diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index c6a4dec917..c87d19637c 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -766,6 +766,9 @@ Pagamentos Pendente Na Fila + Movido para USDT + Convertido em bitcoin + Convertido em dólares Endereço do destinatário Valor A ponte precisa de atenção @@ -871,6 +874,7 @@ Rede USDT Receber Dólares Recebido + Dólares recebidos Rede de recebimento Recebendo USDT Destinatário recebe diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 646d4e5f4c..59b33927c5 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -741,6 +741,9 @@ Single Random Draw Configurações do sistema Idioma + Movido para USDT + Convertido em bitcoin + Convertido em dólares Move dólares do Spark para a tua carteira USDT na Arbitrum. O fornecedor da ponte define a taxa. Para USDT A conversão não foi concluída. Tenta novamente daqui a pouco. @@ -753,6 +756,7 @@ Introduz um endereço Spark ou uma fatura Spark para este token. Dólares insuficientes no Spark para este montante. Fatura copiada para a área de transferência. + Dólares recebidos Enviar dólares Dólares enviados Carteira diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index afa12e6fdd..61a5a0669d 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -783,6 +783,9 @@ В ожидании В очереди Статус + Переведено в USDT + Обменено на биткоин + Обменено на доллары Адрес получателя Сумма Мост требует внимания @@ -888,6 +891,7 @@ Сеть USDT Получить Доллары Получено + Доллары получены Сеть получения Получение USDT Получатель получит diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4b2b514bd5..c3f40f719b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1220,6 +1220,9 @@ Weekly Subscription Yearly Yearly Subscription + Moved to USDT + Converted to bitcoin + Converted to dollars Recipient address Amount Bridge needs attention @@ -1328,6 +1331,7 @@ USDT Network Receive Dollars Received + Received Dollars Receiving network Receiving USDT Recipient gets diff --git a/app/src/test/java/to/bitkit/repositories/SparkRepoTest.kt b/app/src/test/java/to/bitkit/repositories/SparkRepoTest.kt index 6af4674c15..5081ef0f8d 100644 --- a/app/src/test/java/to/bitkit/repositories/SparkRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/SparkRepoTest.kt @@ -1,6 +1,13 @@ package to.bitkit.repositories import breez_sdk_spark.BitcoinNetwork +import breez_sdk_spark.Conversion +import breez_sdk_spark.ConversionAsset +import breez_sdk_spark.ConversionChain +import breez_sdk_spark.ConversionDetails +import breez_sdk_spark.ConversionProvider +import breez_sdk_spark.ConversionSide +import breez_sdk_spark.ConversionStatus import breez_sdk_spark.GetInfoResponse import breez_sdk_spark.ImportUnilateralExitStateResponse import breez_sdk_spark.InputType @@ -205,7 +212,7 @@ class SparkRepoTest : BaseUnitTest() { ), ) whenever { sparkService.tokenMetadata(tokenId) }.thenReturn(metadata) - whenever { sparkService.listTokenPayments(tokenId) }.thenReturn( + whenever { sparkService.listPayments() }.thenReturn( listOf( tokenPayment("mint", metadata, TokenTransactionType.MINT, 1uL), tokenPayment("in", metadata, TokenTransactionType.TRANSFER, 2uL), @@ -221,10 +228,51 @@ class SparkRepoTest : BaseUnitTest() { assertEquals(7_000_000uL, usd.balance) val transfer = usd.transfers.single() assertEquals("in", transfer.id) + assertEquals(SparkUsdTransfer.Kind.RECEIVED, transfer.kind) assertTrue(transfer.isIncoming) assertEquals(2_000_000uL, transfer.amount) } + @Test + fun `lists a conversion to dollars with its bitcoin side and fee`() = test { + val tokenId = requireNotNull(Env.sparkUsdTokenId) + val metadata = usdMetadata(tokenId) + whenever { sparkService.tokenMetadata(tokenId) }.thenReturn(metadata) + val conversion = Conversion( + provider = ConversionProvider.AMM, + status = ConversionStatus.COMPLETED, + from = ConversionSide( + chain = ConversionChain.Spark, + asset = ConversionAsset("BTC", "btc", 8u), + amount = BigInteger.valueOf(2_504), + fee = BigInteger.valueOf(801), + ), + to = ConversionSide( + chain = ConversionChain.Spark, + asset = ConversionAsset("USDB", tokenId, 6u), + amount = BigInteger.valueOf(2_000_000), + fee = BigInteger.ZERO, + ), + amountAdjustment = null, + ) + whenever { sparkService.listPayments() }.thenReturn( + listOf( + tokenPayment("convert", metadata, TokenTransactionType.TRANSFER, 2uL) + .copy(conversionDetails = ConversionDetails(ConversionStatus.COMPLETED, listOf(conversion))), + ), + ) + whenever(sparkService.isConnected).thenReturn(true) + val sut = createSut() + + sut.start(0).getOrThrow() + + val transfer = requireNotNull(sut.state.value.usd).transfers.single() + assertEquals(SparkUsdTransfer.Kind.TO_USD, transfer.kind) + assertEquals(2_000_000uL, transfer.amount) + assertEquals(2_504uL, transfer.sats) + assertEquals(801uL, transfer.fee) + } + @Test fun `rejects a spark invoice for another token`() = test { whenever { sparkService.parse("sparkrt1invoice") }.thenReturn( diff --git a/app/src/test/java/to/bitkit/test/IdleSparkRepo.kt b/app/src/test/java/to/bitkit/test/IdleSparkRepo.kt index e77e84ceb7..34a3f30ca8 100644 --- a/app/src/test/java/to/bitkit/test/IdleSparkRepo.kt +++ b/app/src/test/java/to/bitkit/test/IdleSparkRepo.kt @@ -11,4 +11,5 @@ import to.bitkit.repositories.SparkState fun idleSparkRepo(): SparkRepo = mock { on { state } doReturn MutableStateFlow(SparkState()) on { nodeEvents } doReturn MutableSharedFlow() + on { usdReceived } doReturn MutableSharedFlow() }