From 972363b33be54bbede7cecc88e2f703e15f3ff04 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 19 Nov 2025 21:38:49 -0500 Subject: [PATCH 1/9] Add gift codes support --- app/src/main/java/to/bitkit/ui/ContentView.kt | 2 + .../java/to/bitkit/ui/components/SheetHost.kt | 1 + .../to/bitkit/ui/sheets/GiftErrorSheet.kt | 88 ++++++++ .../java/to/bitkit/ui/sheets/GiftLoading.kt | 150 ++++++++++++++ .../java/to/bitkit/ui/sheets/GiftRoute.kt | 23 +++ .../java/to/bitkit/ui/sheets/GiftSheet.kt | 102 +++++++++ .../java/to/bitkit/ui/sheets/GiftViewModel.kt | 195 ++++++++++++++++++ .../bitkit/ui/sheets/NewTransactionSheet.kt | 2 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 6 + app/src/main/res/values/strings.xml | 2 + 10 files changed, 570 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/to/bitkit/ui/sheets/GiftErrorSheet.kt create mode 100644 app/src/main/java/to/bitkit/ui/sheets/GiftLoading.kt create mode 100644 app/src/main/java/to/bitkit/ui/sheets/GiftRoute.kt create mode 100644 app/src/main/java/to/bitkit/ui/sheets/GiftSheet.kt create mode 100644 app/src/main/java/to/bitkit/ui/sheets/GiftViewModel.kt diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index f7d234cc6..cfd339360 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -147,6 +147,7 @@ import to.bitkit.ui.sheets.BackgroundPaymentsIntroSheet import to.bitkit.ui.sheets.BackupRoute import to.bitkit.ui.sheets.BackupSheet import to.bitkit.ui.sheets.ForceTransferSheet +import to.bitkit.ui.sheets.GiftSheet import to.bitkit.ui.sheets.HighBalanceWarningSheet import to.bitkit.ui.sheets.LnurlAuthSheet import to.bitkit.ui.sheets.PinSheet @@ -363,6 +364,7 @@ fun ContentView( is Sheet.Backup -> BackupSheet(sheet, onDismiss = { appViewModel.hideSheet() }) is Sheet.LnurlAuth -> LnurlAuthSheet(sheet, appViewModel) Sheet.ForceTransfer -> ForceTransferSheet(appViewModel, transferViewModel) + is Sheet.Gift -> GiftSheet(sheet, appViewModel) is Sheet.TimedSheet -> { when (sheet.type) { TimedSheetType.APP_UPDATE -> { 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 c0ffe470b..993db03cf 100644 --- a/app/src/main/java/to/bitkit/ui/components/SheetHost.kt +++ b/app/src/main/java/to/bitkit/ui/components/SheetHost.kt @@ -39,6 +39,7 @@ sealed interface Sheet { data object ActivityTagSelector : Sheet data class LnurlAuth(val domain: String, val lnurl: String, val k1: String) : Sheet data object ForceTransfer : Sheet + data class Gift(val code: String, val amount: ULong) : Sheet data class TimedSheet(val type: TimedSheetType) : Sheet } diff --git a/app/src/main/java/to/bitkit/ui/sheets/GiftErrorSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/GiftErrorSheet.kt new file mode 100644 index 000000000..2944236f0 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/sheets/GiftErrorSheet.kt @@ -0,0 +1,88 @@ +package to.bitkit.ui.sheets + +import androidx.annotation.StringRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +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.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import to.bitkit.R +import to.bitkit.ui.components.BodyM +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.SheetSize +import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.scaffold.SheetTopBar +import to.bitkit.ui.shared.modifiers.sheetHeight +import to.bitkit.ui.shared.util.gradientBackground +import to.bitkit.ui.theme.Colors + +@Composable +fun GiftErrorSheet( + @StringRes titleRes: Int, + @StringRes textRes: Int, + testTag: String, + onDismiss: () -> Unit, +) { + Content( + titleRes = titleRes, + textRes = textRes, + testTag = testTag, + onDismiss = onDismiss, + ) +} + +@Composable +private fun Content( + @StringRes titleRes: Int, + @StringRes textRes: Int, + testTag: String, + modifier: Modifier = Modifier, + onDismiss: () -> Unit = {}, +) { + Column( + modifier = modifier + .sheetHeight(SheetSize.LARGE) + .gradientBackground() + .navigationBarsPadding() + .padding(horizontal = 16.dp) + ) { + SheetTopBar(titleText = stringResource(titleRes)) + VerticalSpacer(16.dp) + + BodyM( + text = stringResource(textRes), + color = Colors.White64, + ) + + Spacer(modifier = Modifier.weight(1f)) + + Image( + painter = painterResource(R.drawable.exclamation_mark), + contentDescription = null, + modifier = Modifier + .fillMaxWidth(IMAGE_WIDTH_FRACTION) + .aspectRatio(1.0f) + .align(Alignment.CenterHorizontally) + ) + + Spacer(modifier = Modifier.weight(1f)) + + PrimaryButton( + text = stringResource(R.string.common__ok), + onClick = onDismiss, + modifier = Modifier + .fillMaxWidth() + .testTag(testTag), + ) + VerticalSpacer(16.dp) + } +} diff --git a/app/src/main/java/to/bitkit/ui/sheets/GiftLoading.kt b/app/src/main/java/to/bitkit/ui/sheets/GiftLoading.kt new file mode 100644 index 000000000..712988c9d --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/sheets/GiftLoading.kt @@ -0,0 +1,150 @@ +package to.bitkit.ui.sheets + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import to.bitkit.R +import to.bitkit.models.BITCOIN_SYMBOL +import to.bitkit.models.PrimaryDisplay +import to.bitkit.models.formatToModernDisplay +import to.bitkit.ui.LocalCurrencies +import to.bitkit.ui.components.BodyM +import to.bitkit.ui.components.Display +import to.bitkit.ui.components.MoneySSB +import to.bitkit.ui.components.SheetSize +import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.currencyViewModel +import to.bitkit.ui.scaffold.SheetTopBar +import to.bitkit.ui.shared.modifiers.sheetHeight +import to.bitkit.ui.shared.util.gradientBackground +import to.bitkit.ui.theme.Colors + +@Composable +fun GiftLoading( + viewModel: GiftViewModel, +) { + Content( + amount = viewModel.amount, + ) +} + +@Composable +private fun Content( + amount: ULong, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .sheetHeight(SheetSize.LARGE) + .gradientBackground() + .navigationBarsPadding() + .padding(horizontal = 16.dp) + ) { + SheetTopBar(titleText = stringResource(R.string.other__gift__claiming__title)) + VerticalSpacer(16.dp) + + val currencies = LocalCurrencies.current + val currency = currencyViewModel + val primaryDisplay = currencies.primaryDisplay + + if (primaryDisplay == PrimaryDisplay.BITCOIN) { + MoneySSB( + sats = amount.toLong(), + unit = PrimaryDisplay.FIAT, + color = Colors.White64, + showSymbol = true, + modifier = Modifier.align(Alignment.Start), + ) + VerticalSpacer(16.dp) + val bitcoinAmount = remember(amount, currency, currencies) { + currency?.convert(amount.toLong())?.bitcoinDisplay(currencies.displayUnit)?.value + ?: amount.formatToModernDisplay() + } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.align(Alignment.Start), + ) { + Display( + text = BITCOIN_SYMBOL, + color = Colors.White, + modifier = Modifier.padding(end = 6.dp), + ) + Display( + text = bitcoinAmount, + color = Colors.White, + ) + } + } else { + MoneySSB( + sats = amount.toLong(), + unit = PrimaryDisplay.BITCOIN, + color = Colors.White64, + showSymbol = true, + modifier = Modifier.align(Alignment.Start), + ) + VerticalSpacer(16.dp) + val fiatAmount = remember(amount, currency) { + currency?.convert(amount.toLong())?.formatted ?: "" + } + val fiatSymbol = remember(amount, currency) { + currency?.convert(amount.toLong())?.symbol ?: "" + } + if (fiatAmount.isNotEmpty()) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.align(Alignment.Start), + ) { + Display( + text = fiatSymbol, + color = Colors.White, + modifier = Modifier.padding(end = 6.dp), + ) + Display( + text = fiatAmount, + color = Colors.White, + ) + } + } + } + VerticalSpacer(32.dp) + + BodyM( + text = stringResource(R.string.other__gift__claiming__text), + color = Colors.White64, + ) + + Spacer(modifier = Modifier.weight(1f)) + + Image( + painter = painterResource(R.drawable.gift), + contentDescription = null, + modifier = Modifier + .fillMaxWidth(IMAGE_WIDTH_FRACTION) + .aspectRatio(1.0f) + .align(Alignment.CenterHorizontally) + ) + + VerticalSpacer(32.dp) + + CircularProgressIndicator( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(bottom = 32.dp) + .testTag("GiftLoading") + ) + } +} diff --git a/app/src/main/java/to/bitkit/ui/sheets/GiftRoute.kt b/app/src/main/java/to/bitkit/ui/sheets/GiftRoute.kt new file mode 100644 index 000000000..060bb8fbb --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/sheets/GiftRoute.kt @@ -0,0 +1,23 @@ +package to.bitkit.ui.sheets + +import kotlinx.serialization.Serializable + +internal const val IMAGE_WIDTH_FRACTION = 0.8f + +@Serializable +sealed interface GiftRoute { + @Serializable + data object Loading : GiftRoute + + @Serializable + data object Used : GiftRoute + + @Serializable + data object UsedUp : GiftRoute + + @Serializable + data object Error : GiftRoute + + @Serializable + data object Success : GiftRoute +} diff --git a/app/src/main/java/to/bitkit/ui/sheets/GiftSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/GiftSheet.kt new file mode 100644 index 000000000..614678b18 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/sheets/GiftSheet.kt @@ -0,0 +1,102 @@ +package to.bitkit.ui.sheets + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.rememberNavController +import to.bitkit.R +import to.bitkit.models.NewTransactionSheetDetails +import to.bitkit.ui.components.Sheet +import to.bitkit.ui.shared.modifiers.sheetHeight +import to.bitkit.ui.utils.composableWithDefaultTransitions +import to.bitkit.viewmodels.AppViewModel + +@Composable +fun GiftSheet( + sheet: Sheet.Gift, + appViewModel: AppViewModel, + modifier: Modifier = Modifier, + viewModel: GiftViewModel = hiltViewModel(), +) { + val navController = rememberNavController() + + LaunchedEffect(sheet.code, sheet.amount) { + viewModel.initialize(sheet.code, sheet.amount) + } + + val onSuccessState = rememberUpdatedState { details: NewTransactionSheetDetails -> + appViewModel.hideSheet() + appViewModel.showNewTransactionSheet(details = details, event = null) + } + + LaunchedEffect(Unit) { + viewModel.successEvent.collect { details -> + onSuccessState.value(details) + } + } + + LaunchedEffect(Unit) { + viewModel.navigationEvent.collect { route -> + when (route) { + is GiftRoute.Success -> { + appViewModel.hideSheet() + } + else -> { + navController.navigate(route) { + popUpTo(GiftRoute.Loading) { inclusive = false } + } + } + } + } + } + + Column( + modifier = modifier + .fillMaxWidth() + .sheetHeight() + .imePadding() + .testTag("GiftSheet") + ) { + NavHost( + navController = navController, + startDestination = GiftRoute.Loading, + ) { + composableWithDefaultTransitions { + GiftLoading( + viewModel = viewModel, + ) + } + composableWithDefaultTransitions { + GiftErrorSheet( + titleRes = R.string.other__gift__used__title, + textRes = R.string.other__gift__used__text, + testTag = "GiftUsed", + onDismiss = { appViewModel.hideSheet() }, + ) + } + composableWithDefaultTransitions { + GiftErrorSheet( + titleRes = R.string.other__gift__used_up__title, + textRes = R.string.other__gift__used_up__text, + testTag = "GiftUsedUp", + onDismiss = { appViewModel.hideSheet() }, + ) + } + composableWithDefaultTransitions { + GiftErrorSheet( + titleRes = R.string.other__gift__error__title, + textRes = R.string.other__gift__error__text, + testTag = "GiftError", + onDismiss = { appViewModel.hideSheet() }, + ) + } + } + } +} diff --git a/app/src/main/java/to/bitkit/ui/sheets/GiftViewModel.kt b/app/src/main/java/to/bitkit/ui/sheets/GiftViewModel.kt new file mode 100644 index 000000000..4528d886a --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/sheets/GiftViewModel.kt @@ -0,0 +1,195 @@ +package to.bitkit.ui.sheets + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.synonym.bitkitcore.Activity +import com.synonym.bitkitcore.PaymentType +import com.synonym.bitkitcore.giftOrder +import com.synonym.bitkitcore.giftPay +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch +import to.bitkit.async.ServiceQueue +import to.bitkit.di.BgDispatcher +import to.bitkit.models.NewTransactionSheetDetails +import to.bitkit.models.NewTransactionSheetDirection +import to.bitkit.models.NewTransactionSheetType +import to.bitkit.repositories.BlocktankRepo +import to.bitkit.repositories.LightningRepo +import to.bitkit.services.CoreService +import to.bitkit.services.LightningService +import to.bitkit.utils.Logger +import javax.inject.Inject + +@HiltViewModel +class GiftViewModel @Inject constructor( + @BgDispatcher private val bgDispatcher: CoroutineDispatcher, + private val lightningRepo: LightningRepo, + private val lightningService: LightningService, + private val coreService: CoreService, + private val blocktankRepo: BlocktankRepo, +) : ViewModel() { + + private val _navigationEvent = MutableSharedFlow(extraBufferCapacity = 1) + val navigationEvent = _navigationEvent.asSharedFlow() + + private val _successEvent = MutableSharedFlow(extraBufferCapacity = 1) + val successEvent = _successEvent.asSharedFlow() + + private var code: String = "" + var amount: ULong = 0uL + private set + private var isClaiming: Boolean = false + + fun initialize(code: String, amount: ULong) { + if (!isClaiming) { + viewModelScope.launch { + _navigationEvent.emit(GiftRoute.Loading) + } + } + this.code = code + this.amount = amount + claimGift() + } + + private fun claimGift() = viewModelScope.launch(bgDispatcher) { + if (isClaiming) { + return@launch + } + + isClaiming = true + runCatching { + waitForPeers() + + val channels = lightningRepo.lightningState.value.channels + val maxInboundCapacity = channels.sumOf { it.inboundCapacityMsat / 1000u } + + if (maxInboundCapacity >= amount) { + claimWithLiquidity() + } else { + claimWithoutLiquidity() + } + }.onFailure { e -> + isClaiming = false + handleGiftClaimError(e) + } + } + + private suspend fun waitForPeers() { + val nodeLifecycleState = lightningRepo.lightningState.value.nodeLifecycleState + if (!nodeLifecycleState.isRunning()) { + val startTime = System.currentTimeMillis() + while (!lightningRepo.lightningState.value.nodeLifecycleState.isRunning()) { + if (System.currentTimeMillis() - startTime > NODE_STARTUP_TIMEOUT_MS) { + Logger.warn("Timeout waiting for node to be running", context = TAG) + error("Timeout waiting for node to be running") + } + delay(NODE_STATE_CHECK_INTERVAL_MS) + } + } + + delay(PEER_CONNECTION_DELAY_MS) + } + + private suspend fun claimWithLiquidity() { + runCatching { + val invoice = lightningService.receive( + sat = null, + description = "blocktank-gift-code:$code", + expirySecs = 3600u, + ) + + ServiceQueue.CORE.background { + giftPay(invoice = invoice) + } + + isClaiming = false + _navigationEvent.emit(GiftRoute.Success) + }.onFailure { e -> + isClaiming = false + handleGiftClaimError(e) + } + } + + private suspend fun claimWithoutLiquidity() { + runCatching { + check(lightningService.nodeId != null) { "Node not started" } + val nodeId = lightningService.nodeId!! + + val order = ServiceQueue.CORE.background { + giftOrder(clientNodeId = nodeId, code = "blocktank-gift-code:$code") + } + + check(order.orderId != null) { "Order ID is nil" } + val orderId = order.orderId!! + + val openedOrder = blocktankRepo.openChannel(orderId).getOrThrow() + + val nowTimestamp = (System.currentTimeMillis() / 1000).toULong() + + val lightningActivity = com.synonym.bitkitcore.LightningActivity( + id = openedOrder.channel?.fundingTx?.id ?: orderId, + txType = PaymentType.RECEIVED, + status = com.synonym.bitkitcore.PaymentState.SUCCEEDED, + value = amount, + fee = 0u, + invoice = openedOrder.payment?.bolt11Invoice?.request ?: "", + message = code, + timestamp = nowTimestamp, + preimage = null, + createdAt = nowTimestamp, + updatedAt = null, + ) + + coreService.activity.insert(Activity.Lightning(lightningActivity)) + + isClaiming = false + _successEvent.emit( + NewTransactionSheetDetails( + type = NewTransactionSheetType.LIGHTNING, + direction = NewTransactionSheetDirection.RECEIVED, + paymentHashOrTxId = openedOrder.channel?.fundingTx?.id ?: orderId, + sats = amount.toLong(), + ) + ) + _navigationEvent.emit(GiftRoute.Success) + }.onFailure { e -> + isClaiming = false + handleGiftClaimError(e) + } + } + + private suspend fun handleGiftClaimError(error: Throwable) { + Logger.error("Gift claim failed: $error", error, context = TAG) + + val route = when { + errorContains(error, "GIFT_CODE_ALREADY_USED") -> GiftRoute.Used + errorContains(error, "GIFT_CODE_USED_UP") -> GiftRoute.UsedUp + else -> GiftRoute.Error + } + + _navigationEvent.emit(route) + } + + private fun errorContains(error: Throwable, text: String): Boolean { + var currentError: Throwable? = error + while (currentError != null) { + val errorText = currentError.toString() + (currentError.message ?: "") + if (errorText.contains(text, ignoreCase = true)) { + return true + } + currentError = currentError.cause + } + return false + } + + companion object { + private const val TAG = "GiftViewModel" + private const val NODE_STARTUP_TIMEOUT_MS = 30_000L + private const val PEER_CONNECTION_DELAY_MS = 2_000L + private const val NODE_STATE_CHECK_INTERVAL_MS = 500L + } +} 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 6c5c6e925..52963fa70 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/NewTransactionSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/NewTransactionSheet.kt @@ -140,7 +140,7 @@ fun NewTransactionSheetView( val titleText = when (details.type) { NewTransactionSheetType.LIGHTNING -> when (details.direction) { NewTransactionSheetDirection.SENT -> stringResource(R.string.wallet__send_sent) - else -> stringResource(R.string.wallet__payment_received) + else -> stringResource(R.string.wallet__instant_payment_received) } NewTransactionSheetType.ONCHAIN -> when (details.direction) { diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 5cd9f303c..2b0e6ad67 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -533,6 +533,7 @@ class AppViewModel @Inject constructor( is Scanner.LnurlAuth -> onScanLnurlAuth(scan.data) is Scanner.LnurlChannel -> onScanLnurlChannel(scan.data) is Scanner.NodeId -> onScanNodeId(scan) + is Scanner.Gift -> onScanGift(scan.code, scan.amount) else -> { Logger.warn("Unhandled scan data: $scan", context = TAG) toast( @@ -804,6 +805,11 @@ class AppViewModel @Inject constructor( mainScreenEffect(MainScreenEffect.Navigate(nextRoute)) } + private fun onScanGift(code: String, amount: ULong) { + hideSheet() // hide scan sheet if opened + showSheet(Sheet.Gift(code = code, amount = amount)) + } + private suspend fun handleQuickPayIfApplicable( amountSats: ULong, lnurlPay: LnurlPayData? = null, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f1f09c921..81e8bf9f1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -424,6 +424,8 @@ Used Code This Bitkit gift code has already been used, and the funds have been paid out. OK + Out of Gifts + Sorry, you\'re too late! All gifts for this code have already been claimed. Shop Get your life on the Bitcoin standard. Spend your Bitcoin on digital gift cards, eSIMs, phone refills, and more. Get Started From 6ee9d91c300a3799268989ea4f412997a55e4928 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 20 Nov 2025 08:54:33 -0500 Subject: [PATCH 2/9] Update code to reduce duplication --- .../main/java/to/bitkit/ext/ChannelDetails.kt | 7 ++++ .../to/bitkit/repositories/LightningRepo.kt | 2 +- .../LightningConnectionsViewModel.kt | 9 ++--- .../java/to/bitkit/ui/sheets/GiftLoading.kt | 4 +-- .../java/to/bitkit/ui/sheets/GiftViewModel.kt | 36 ++++++++----------- 5 files changed, 26 insertions(+), 32 deletions(-) diff --git a/app/src/main/java/to/bitkit/ext/ChannelDetails.kt b/app/src/main/java/to/bitkit/ext/ChannelDetails.kt index 411fb114e..081acca0e 100644 --- a/app/src/main/java/to/bitkit/ext/ChannelDetails.kt +++ b/app/src/main/java/to/bitkit/ext/ChannelDetails.kt @@ -39,6 +39,13 @@ fun List?.totalNextOutboundHtlcLimitSats(): ULong { ?: 0u } +/** Calculates the total remote balance (inbound capacity) from open channels. */ +fun List.calculateRemoteBalance(): ULong { + return this + .filterOpen() + .sumOf { it.inboundCapacityMsat / 1000u } +} + fun createChannelDetails(): ChannelDetails { return ChannelDetails( channelId = "channelId", diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 881ad6da2..72ede2533 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -94,7 +94,7 @@ class LightningRepo @Inject constructor( * @param operation Lambda to execute when the node is running * @return Result of the operation, or failure if node isn't running or operation fails */ - private suspend fun executeWhenNodeRunning( + suspend fun executeWhenNodeRunning( operationName: String, waitTimeout: Duration = 1.minutes, operation: suspend () -> Result, 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 97b2caaaa..704b81458 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 @@ -21,6 +21,7 @@ import org.lightningdevkit.ldknode.OutPoint import to.bitkit.R import to.bitkit.di.BgDispatcher import to.bitkit.ext.amountOnClose +import to.bitkit.ext.calculateRemoteBalance import to.bitkit.ext.createChannelDetails import to.bitkit.ext.filterOpen import to.bitkit.ext.filterPending @@ -82,7 +83,7 @@ class LightningConnectionsViewModel @Inject constructor( .map { it.mapToUiModel() }, failedOrders = getFailedOrdersAsChannels(blocktankState.paidOrders).map { it.mapToUiModel() }, localBalance = calculateLocalBalance(channels), - remoteBalance = calculateRemoteBalance(channels), + remoteBalance = channels.calculateRemoteBalance(), ) }.collect { newState -> _uiState.update { newState } @@ -263,12 +264,6 @@ class LightningConnectionsViewModel @Inject constructor( .sumOf { it.amountOnClose } } - private fun calculateRemoteBalance(channels: List): ULong { - return channels - .filterOpen() - .sumOf { it.inboundCapacityMsat / 1000u } - } - fun zipLogsForSharing(onReady: (Uri) -> Unit) { viewModelScope.launch { logsRepo.zipLogsForSharing() diff --git a/app/src/main/java/to/bitkit/ui/sheets/GiftLoading.kt b/app/src/main/java/to/bitkit/ui/sheets/GiftLoading.kt index 712988c9d..23ce441e0 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/GiftLoading.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/GiftLoading.kt @@ -3,7 +3,6 @@ package to.bitkit.ui.sheets import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBarsPadding @@ -24,6 +23,7 @@ import to.bitkit.models.formatToModernDisplay import to.bitkit.ui.LocalCurrencies import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.Display +import to.bitkit.ui.components.FillHeight import to.bitkit.ui.components.MoneySSB import to.bitkit.ui.components.SheetSize import to.bitkit.ui.components.VerticalSpacer @@ -127,7 +127,7 @@ private fun Content( color = Colors.White64, ) - Spacer(modifier = Modifier.weight(1f)) + FillHeight() Image( painter = painterResource(R.drawable.gift), diff --git a/app/src/main/java/to/bitkit/ui/sheets/GiftViewModel.kt b/app/src/main/java/to/bitkit/ui/sheets/GiftViewModel.kt index 4528d886a..c7da88619 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/GiftViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/GiftViewModel.kt @@ -14,23 +14,25 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.launch import to.bitkit.async.ServiceQueue import to.bitkit.di.BgDispatcher +import to.bitkit.ext.calculateRemoteBalance import to.bitkit.models.NewTransactionSheetDetails import to.bitkit.models.NewTransactionSheetDirection import to.bitkit.models.NewTransactionSheetType +import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.BlocktankRepo import to.bitkit.repositories.LightningRepo -import to.bitkit.services.CoreService import to.bitkit.services.LightningService import to.bitkit.utils.Logger import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds @HiltViewModel class GiftViewModel @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, private val lightningRepo: LightningRepo, private val lightningService: LightningService, - private val coreService: CoreService, private val blocktankRepo: BlocktankRepo, + private val activityRepo: ActivityRepo, ) : ViewModel() { private val _navigationEvent = MutableSharedFlow(extraBufferCapacity = 1) @@ -62,10 +64,17 @@ class GiftViewModel @Inject constructor( isClaiming = true runCatching { - waitForPeers() + lightningRepo.executeWhenNodeRunning( + operationName = "waitForNodeRunning", + waitTimeout = NODE_STARTUP_TIMEOUT_MS.milliseconds, + ) { + Result.success(Unit) + }.getOrThrow() + + delay(PEER_CONNECTION_DELAY_MS) val channels = lightningRepo.lightningState.value.channels - val maxInboundCapacity = channels.sumOf { it.inboundCapacityMsat / 1000u } + val maxInboundCapacity = channels.calculateRemoteBalance() if (maxInboundCapacity >= amount) { claimWithLiquidity() @@ -78,22 +87,6 @@ class GiftViewModel @Inject constructor( } } - private suspend fun waitForPeers() { - val nodeLifecycleState = lightningRepo.lightningState.value.nodeLifecycleState - if (!nodeLifecycleState.isRunning()) { - val startTime = System.currentTimeMillis() - while (!lightningRepo.lightningState.value.nodeLifecycleState.isRunning()) { - if (System.currentTimeMillis() - startTime > NODE_STARTUP_TIMEOUT_MS) { - Logger.warn("Timeout waiting for node to be running", context = TAG) - error("Timeout waiting for node to be running") - } - delay(NODE_STATE_CHECK_INTERVAL_MS) - } - } - - delay(PEER_CONNECTION_DELAY_MS) - } - private suspend fun claimWithLiquidity() { runCatching { val invoice = lightningService.receive( @@ -144,7 +137,7 @@ class GiftViewModel @Inject constructor( updatedAt = null, ) - coreService.activity.insert(Activity.Lightning(lightningActivity)) + activityRepo.insertActivity(Activity.Lightning(lightningActivity)).getOrThrow() isClaiming = false _successEvent.emit( @@ -190,6 +183,5 @@ class GiftViewModel @Inject constructor( private const val TAG = "GiftViewModel" private const val NODE_STARTUP_TIMEOUT_MS = 30_000L private const val PEER_CONNECTION_DELAY_MS = 2_000L - private const val NODE_STATE_CHECK_INTERVAL_MS = 500L } } From 8f7592584f1f2dee4ccdc3e2ea488ea2dbc392d4 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 20 Nov 2025 10:32:54 -0500 Subject: [PATCH 3/9] Fix comment --- .../java/to/bitkit/ui/sheets/GiftViewModel.kt | 27 ++++++++++--------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/sheets/GiftViewModel.kt b/app/src/main/java/to/bitkit/ui/sheets/GiftViewModel.kt index c7da88619..05bfb27d6 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/GiftViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/GiftViewModel.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import to.bitkit.async.ServiceQueue import to.bitkit.di.BgDispatcher import to.bitkit.ext.calculateRemoteBalance @@ -44,6 +45,8 @@ class GiftViewModel @Inject constructor( private var code: String = "" var amount: ULong = 0uL private set + + @Volatile private var isClaiming: Boolean = false fun initialize(code: String, amount: ULong) { @@ -54,16 +57,17 @@ class GiftViewModel @Inject constructor( } this.code = code this.amount = amount - claimGift() - } - - private fun claimGift() = viewModelScope.launch(bgDispatcher) { - if (isClaiming) { - return@launch + viewModelScope.launch(bgDispatcher) { + claimGift() } + } + @Suppress("TooGenericExceptionCaught") + private suspend fun claimGift() = withContext(bgDispatcher) { + if (isClaiming) return@withContext isClaiming = true - runCatching { + + try { lightningRepo.executeWhenNodeRunning( operationName = "waitForNodeRunning", waitTimeout = NODE_STARTUP_TIMEOUT_MS.milliseconds, @@ -81,9 +85,10 @@ class GiftViewModel @Inject constructor( } else { claimWithoutLiquidity() } - }.onFailure { e -> - isClaiming = false + } catch (e: Exception) { handleGiftClaimError(e) + } finally { + isClaiming = false } } @@ -99,10 +104,8 @@ class GiftViewModel @Inject constructor( giftPay(invoice = invoice) } - isClaiming = false _navigationEvent.emit(GiftRoute.Success) }.onFailure { e -> - isClaiming = false handleGiftClaimError(e) } } @@ -139,7 +142,6 @@ class GiftViewModel @Inject constructor( activityRepo.insertActivity(Activity.Lightning(lightningActivity)).getOrThrow() - isClaiming = false _successEvent.emit( NewTransactionSheetDetails( type = NewTransactionSheetType.LIGHTNING, @@ -150,7 +152,6 @@ class GiftViewModel @Inject constructor( ) _navigationEvent.emit(GiftRoute.Success) }.onFailure { e -> - isClaiming = false handleGiftClaimError(e) } } From b3825073cdb9f3a6230e29500e9fcf61d886d67a Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 20 Nov 2025 13:57:56 -0500 Subject: [PATCH 4/9] Fix input validation --- app/src/main/java/to/bitkit/ui/sheets/GiftViewModel.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/main/java/to/bitkit/ui/sheets/GiftViewModel.kt b/app/src/main/java/to/bitkit/ui/sheets/GiftViewModel.kt index 05bfb27d6..85a599fa8 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/GiftViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/GiftViewModel.kt @@ -50,6 +50,9 @@ class GiftViewModel @Inject constructor( private var isClaiming: Boolean = false fun initialize(code: String, amount: ULong) { + require(code.isNotBlank()) { "Gift code cannot be blank" } + require(amount > 0u) { "Gift amount must be positive" } + if (!isClaiming) { viewModelScope.launch { _navigationEvent.emit(GiftRoute.Loading) From fdf7516755dec9c6625e3bd58114a0da4fe1b075 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 21 Nov 2025 08:24:52 -0500 Subject: [PATCH 5/9] Fix comments --- .../to/bitkit/repositories/BlocktankRepo.kt | 78 ++++++++++ .../to/bitkit/repositories/LightningRepo.kt | 4 + .../to/bitkit/ui/sheets/GiftErrorSheet.kt | 6 +- .../java/to/bitkit/ui/sheets/GiftViewModel.kt | 139 ++++++------------ .../bitkit/repositories/BlocktankRepoTest.kt | 2 + 5 files changed, 135 insertions(+), 94 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt index 49a5326d9..6e2f202c0 100644 --- a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt @@ -7,6 +7,8 @@ import com.synonym.bitkitcore.IBtEstimateFeeResponse2 import com.synonym.bitkitcore.IBtInfo import com.synonym.bitkitcore.IBtOrder import com.synonym.bitkitcore.IcJitEntry +import com.synonym.bitkitcore.giftOrder +import com.synonym.bitkitcore.giftPay import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob @@ -27,9 +29,11 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.lightningdevkit.ldknode.ChannelDetails +import to.bitkit.async.ServiceQueue import to.bitkit.data.CacheStore import to.bitkit.di.BgDispatcher import to.bitkit.env.Env +import to.bitkit.ext.calculateRemoteBalance import to.bitkit.ext.nowTimestamp import to.bitkit.models.BlocktankBackupV1 import to.bitkit.models.EUR_CURRENCY @@ -45,6 +49,7 @@ import kotlin.math.ceil import kotlin.math.min @Singleton +@Suppress("LongParameterList") class BlocktankRepo @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, private val coreService: CoreService, @@ -52,6 +57,7 @@ class BlocktankRepo @Inject constructor( private val currencyRepo: CurrencyRepo, private val cacheStore: CacheStore, @Named("enablePolling") private val enablePolling: Boolean, + private val lightningRepo: LightningRepo, ) { private val repoScope = CoroutineScope(bgDispatcher + SupervisorJob()) @@ -399,10 +405,72 @@ class BlocktankRepo @Inject constructor( } } + suspend fun claimGiftCode( + code: String, + amount: ULong, + waitTimeout: kotlin.time.Duration = kotlin.time.Duration.parse("30s"), + ): Result = withContext(bgDispatcher) { + runCatching { + lightningRepo.executeWhenNodeRunning( + operationName = "claimGiftCode", + waitTimeout = waitTimeout, + ) { + Result.success(Unit) + }.getOrThrow() + + delay(PEER_CONNECTION_DELAY_MS) + + val channels = lightningRepo.getChannelsAsync().getOrThrow() + val maxInboundCapacity = channels.calculateRemoteBalance() + + if (maxInboundCapacity >= amount) { + claimGiftCodeWithLiquidity(code) + } else { + claimGiftCodeWithoutLiquidity(code, amount) + } + }.onFailure { e -> + Logger.error("Failed to claim gift code", e, context = TAG) + } + } + + private suspend fun claimGiftCodeWithLiquidity(code: String): GiftClaimResult { + val invoice = lightningRepo.createInvoice( + amountSats = null, + description = "blocktank-gift-code:$code", + expirySeconds = 3600u, + ).getOrThrow() + + ServiceQueue.CORE.background { + giftPay(invoice = invoice) + } + + return GiftClaimResult.SuccessWithLiquidity + } + + private suspend fun claimGiftCodeWithoutLiquidity(code: String, amount: ULong): GiftClaimResult { + val nodeId = lightningService.nodeId ?: throw ServiceError.NodeNotStarted + + val order = ServiceQueue.CORE.background { + giftOrder(clientNodeId = nodeId, code = "blocktank-gift-code:$code") + } + + val orderId = checkNotNull(order.orderId) { "Order ID is nil" } + + val openedOrder = openChannel(orderId).getOrThrow() + + return GiftClaimResult.SuccessWithoutLiquidity( + paymentHashOrTxId = openedOrder.channel?.fundingTx?.id ?: orderId, + sats = amount.toLong(), + invoice = openedOrder.payment?.bolt11Invoice?.request ?: "", + code = code, + ) + } + companion object { private const val TAG = "BlocktankRepo" private const val DEFAULT_CHANNEL_EXPIRY_WEEKS = 6u private const val DEFAULT_SOURCE = "bitkit-android" + private const val PEER_CONNECTION_DELAY_MS = 2_000L } } @@ -413,3 +481,13 @@ data class BlocktankState( val info: IBtInfo? = null, val minCjitSats: Int? = null, ) + +sealed class GiftClaimResult { + object SuccessWithLiquidity : GiftClaimResult() + data class SuccessWithoutLiquidity( + val paymentHashOrTxId: String, + val sats: Long, + val invoice: String, + val code: String, + ) : GiftClaimResult() +} diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 8f0509a68..32a035b64 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -812,6 +812,10 @@ class LightningRepo @Inject constructor( Result.success(checkNotNull(lightningService.balances)) } + suspend fun getChannelsAsync(): Result> = executeWhenNodeRunning("getChannelsAsync") { + Result.success(checkNotNull(lightningService.channels)) + } + fun getStatus(): NodeStatus? = if (_lightningState.value.nodeLifecycleState.isRunning()) lightningService.status else null diff --git a/app/src/main/java/to/bitkit/ui/sheets/GiftErrorSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/GiftErrorSheet.kt index 2944236f0..daa85e6dc 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/GiftErrorSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/GiftErrorSheet.kt @@ -3,7 +3,6 @@ package to.bitkit.ui.sheets import androidx.annotation.StringRes import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBarsPadding @@ -17,6 +16,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import to.bitkit.R import to.bitkit.ui.components.BodyM +import to.bitkit.ui.components.FillHeight import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SheetSize import to.bitkit.ui.components.VerticalSpacer @@ -63,7 +63,7 @@ private fun Content( color = Colors.White64, ) - Spacer(modifier = Modifier.weight(1f)) + FillHeight() Image( painter = painterResource(R.drawable.exclamation_mark), @@ -74,7 +74,7 @@ private fun Content( .align(Alignment.CenterHorizontally) ) - Spacer(modifier = Modifier.weight(1f)) + FillHeight() PrimaryButton( text = stringResource(R.string.common__ok), diff --git a/app/src/main/java/to/bitkit/ui/sheets/GiftViewModel.kt b/app/src/main/java/to/bitkit/ui/sheets/GiftViewModel.kt index 85a599fa8..bfde2a0f0 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/GiftViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/GiftViewModel.kt @@ -3,26 +3,22 @@ package to.bitkit.ui.sheets import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.synonym.bitkitcore.Activity +import com.synonym.bitkitcore.PaymentState import com.synonym.bitkitcore.PaymentType -import com.synonym.bitkitcore.giftOrder -import com.synonym.bitkitcore.giftPay import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import to.bitkit.async.ServiceQueue import to.bitkit.di.BgDispatcher -import to.bitkit.ext.calculateRemoteBalance +import to.bitkit.ext.nowTimestamp import to.bitkit.models.NewTransactionSheetDetails import to.bitkit.models.NewTransactionSheetDirection import to.bitkit.models.NewTransactionSheetType import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.BlocktankRepo -import to.bitkit.repositories.LightningRepo -import to.bitkit.services.LightningService +import to.bitkit.repositories.GiftClaimResult import to.bitkit.utils.Logger import javax.inject.Inject import kotlin.time.Duration.Companion.milliseconds @@ -30,8 +26,6 @@ import kotlin.time.Duration.Companion.milliseconds @HiltViewModel class GiftViewModel @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, - private val lightningRepo: LightningRepo, - private val lightningService: LightningService, private val blocktankRepo: BlocktankRepo, private val activityRepo: ActivityRepo, ) : ViewModel() { @@ -65,98 +59,62 @@ class GiftViewModel @Inject constructor( } } - @Suppress("TooGenericExceptionCaught") private suspend fun claimGift() = withContext(bgDispatcher) { if (isClaiming) return@withContext isClaiming = true try { - lightningRepo.executeWhenNodeRunning( - operationName = "waitForNodeRunning", + blocktankRepo.claimGiftCode( + code = code, + amount = amount, waitTimeout = NODE_STARTUP_TIMEOUT_MS.milliseconds, - ) { - Result.success(Unit) - }.getOrThrow() - - delay(PEER_CONNECTION_DELAY_MS) - - val channels = lightningRepo.lightningState.value.channels - val maxInboundCapacity = channels.calculateRemoteBalance() - - if (maxInboundCapacity >= amount) { - claimWithLiquidity() - } else { - claimWithoutLiquidity() - } - } catch (e: Exception) { - handleGiftClaimError(e) + ).fold( + onSuccess = { result -> + when (result) { + is GiftClaimResult.SuccessWithLiquidity -> { + _navigationEvent.emit(GiftRoute.Success) + } + is GiftClaimResult.SuccessWithoutLiquidity -> { + insertGiftActivity(result) + _successEvent.emit( + NewTransactionSheetDetails( + type = NewTransactionSheetType.LIGHTNING, + direction = NewTransactionSheetDirection.RECEIVED, + paymentHashOrTxId = result.paymentHashOrTxId, + sats = result.sats, + ) + ) + _navigationEvent.emit(GiftRoute.Success) + } + } + }, + onFailure = { error -> + handleGiftClaimError(error) + } + ) } finally { isClaiming = false } } - private suspend fun claimWithLiquidity() { - runCatching { - val invoice = lightningService.receive( - sat = null, - description = "blocktank-gift-code:$code", - expirySecs = 3600u, - ) - - ServiceQueue.CORE.background { - giftPay(invoice = invoice) - } - - _navigationEvent.emit(GiftRoute.Success) - }.onFailure { e -> - handleGiftClaimError(e) - } - } - - private suspend fun claimWithoutLiquidity() { - runCatching { - check(lightningService.nodeId != null) { "Node not started" } - val nodeId = lightningService.nodeId!! - - val order = ServiceQueue.CORE.background { - giftOrder(clientNodeId = nodeId, code = "blocktank-gift-code:$code") - } - - check(order.orderId != null) { "Order ID is nil" } - val orderId = order.orderId!! - - val openedOrder = blocktankRepo.openChannel(orderId).getOrThrow() - - val nowTimestamp = (System.currentTimeMillis() / 1000).toULong() - - val lightningActivity = com.synonym.bitkitcore.LightningActivity( - id = openedOrder.channel?.fundingTx?.id ?: orderId, - txType = PaymentType.RECEIVED, - status = com.synonym.bitkitcore.PaymentState.SUCCEEDED, - value = amount, - fee = 0u, - invoice = openedOrder.payment?.bolt11Invoice?.request ?: "", - message = code, - timestamp = nowTimestamp, - preimage = null, - createdAt = nowTimestamp, - updatedAt = null, - ) - - activityRepo.insertActivity(Activity.Lightning(lightningActivity)).getOrThrow() - - _successEvent.emit( - NewTransactionSheetDetails( - type = NewTransactionSheetType.LIGHTNING, - direction = NewTransactionSheetDirection.RECEIVED, - paymentHashOrTxId = openedOrder.channel?.fundingTx?.id ?: orderId, - sats = amount.toLong(), - ) - ) - _navigationEvent.emit(GiftRoute.Success) - }.onFailure { e -> - handleGiftClaimError(e) - } + private suspend fun insertGiftActivity(result: GiftClaimResult.SuccessWithoutLiquidity) { + val nowTimestamp = nowTimestamp().epochSecond.toULong() + + val lightningActivity = com.synonym.bitkitcore.LightningActivity( + id = result.paymentHashOrTxId, + txType = PaymentType.RECEIVED, + status = PaymentState.SUCCEEDED, + value = result.sats.toULong(), + fee = 0u, + invoice = result.invoice, + message = result.code, + timestamp = nowTimestamp, + preimage = null, + createdAt = nowTimestamp, + updatedAt = null, + ) + + activityRepo.insertActivity(Activity.Lightning(lightningActivity)).getOrThrow() } private suspend fun handleGiftClaimError(error: Throwable) { @@ -186,6 +144,5 @@ class GiftViewModel @Inject constructor( companion object { private const val TAG = "GiftViewModel" private const val NODE_STARTUP_TIMEOUT_MS = 30_000L - private const val PEER_CONNECTION_DELAY_MS = 2_000L } } diff --git a/app/src/test/java/to/bitkit/repositories/BlocktankRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BlocktankRepoTest.kt index 18a423066..3f5a879b2 100644 --- a/app/src/test/java/to/bitkit/repositories/BlocktankRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BlocktankRepoTest.kt @@ -27,6 +27,7 @@ class BlocktankRepoTest : BaseUnitTest() { private val lightningService: LightningService = mock() private val currencyRepo: CurrencyRepo = mock() private val cacheStore: CacheStore = mock() + private val lightningRepo: LightningRepo = mock() private lateinit var sut: BlocktankRepo @@ -56,6 +57,7 @@ class BlocktankRepoTest : BaseUnitTest() { currencyRepo = currencyRepo, cacheStore = cacheStore, enablePolling = false, + lightningRepo = lightningRepo, ) } From f2497101687e474235f361b9340b971c01e57113 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 21 Nov 2025 12:21:09 -0500 Subject: [PATCH 6/9] Fix comment --- app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt | 3 +++ app/src/main/java/to/bitkit/ui/sheets/GiftViewModel.kt | 3 --- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt index 6e2f202c0..d7e11184c 100644 --- a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt @@ -411,6 +411,9 @@ class BlocktankRepo @Inject constructor( waitTimeout: kotlin.time.Duration = kotlin.time.Duration.parse("30s"), ): Result = withContext(bgDispatcher) { runCatching { + require(code.isNotBlank()) { "Gift code cannot be blank" } + require(amount > 0u) { "Gift amount must be positive" } + lightningRepo.executeWhenNodeRunning( operationName = "claimGiftCode", waitTimeout = waitTimeout, diff --git a/app/src/main/java/to/bitkit/ui/sheets/GiftViewModel.kt b/app/src/main/java/to/bitkit/ui/sheets/GiftViewModel.kt index bfde2a0f0..71f64902e 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/GiftViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/GiftViewModel.kt @@ -44,9 +44,6 @@ class GiftViewModel @Inject constructor( private var isClaiming: Boolean = false fun initialize(code: String, amount: ULong) { - require(code.isNotBlank()) { "Gift code cannot be blank" } - require(amount > 0u) { "Gift amount must be positive" } - if (!isClaiming) { viewModelScope.launch { _navigationEvent.emit(GiftRoute.Loading) From c2dd3db0dfe73f40a89d0f5109cbb4d82fea5c95 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 24 Nov 2025 19:22:36 +0100 Subject: [PATCH 7/9] fix: namespace imports --- app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt | 7 +++++-- app/src/main/java/to/bitkit/ui/sheets/GiftViewModel.kt | 3 ++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt index d7e11184c..45a0e47ed 100644 --- a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt @@ -47,6 +47,8 @@ import javax.inject.Named import javax.inject.Singleton import kotlin.math.ceil import kotlin.math.min +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds @Singleton @Suppress("LongParameterList") @@ -408,7 +410,7 @@ class BlocktankRepo @Inject constructor( suspend fun claimGiftCode( code: String, amount: ULong, - waitTimeout: kotlin.time.Duration = kotlin.time.Duration.parse("30s"), + waitTimeout: Duration = TIMEOUT_GIFT_CODE, ): Result = withContext(bgDispatcher) { runCatching { require(code.isNotBlank()) { "Gift code cannot be blank" } @@ -457,7 +459,7 @@ class BlocktankRepo @Inject constructor( giftOrder(clientNodeId = nodeId, code = "blocktank-gift-code:$code") } - val orderId = checkNotNull(order.orderId) { "Order ID is nil" } + val orderId = checkNotNull(order.orderId) { "Order ID is null" } val openedOrder = openChannel(orderId).getOrThrow() @@ -474,6 +476,7 @@ class BlocktankRepo @Inject constructor( private const val DEFAULT_CHANNEL_EXPIRY_WEEKS = 6u private const val DEFAULT_SOURCE = "bitkit-android" private const val PEER_CONNECTION_DELAY_MS = 2_000L + private val TIMEOUT_GIFT_CODE = 30.seconds } } diff --git a/app/src/main/java/to/bitkit/ui/sheets/GiftViewModel.kt b/app/src/main/java/to/bitkit/ui/sheets/GiftViewModel.kt index 71f64902e..342a5a110 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/GiftViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/GiftViewModel.kt @@ -3,6 +3,7 @@ package to.bitkit.ui.sheets import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.synonym.bitkitcore.Activity +import com.synonym.bitkitcore.LightningActivity import com.synonym.bitkitcore.PaymentState import com.synonym.bitkitcore.PaymentType import dagger.hilt.android.lifecycle.HiltViewModel @@ -97,7 +98,7 @@ class GiftViewModel @Inject constructor( private suspend fun insertGiftActivity(result: GiftClaimResult.SuccessWithoutLiquidity) { val nowTimestamp = nowTimestamp().epochSecond.toULong() - val lightningActivity = com.synonym.bitkitcore.LightningActivity( + val lightningActivity = LightningActivity( id = result.paymentHashOrTxId, txType = PaymentType.RECEIVED, status = PaymentState.SUCCEEDED, From 0445771ddc51b3eafe132ffd37853ab1a45ac046 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 24 Nov 2025 19:22:50 +0100 Subject: [PATCH 8/9] fix: stable Sheet interface --- app/src/main/java/to/bitkit/ui/components/SheetHost.kt | 2 ++ 1 file changed, 2 insertions(+) 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 993db03cf..b9ffba7be 100644 --- a/app/src/main/java/to/bitkit/ui/components/SheetHost.kt +++ b/app/src/main/java/to/bitkit/ui/components/SheetHost.kt @@ -17,6 +17,7 @@ import androidx.compose.material3.rememberBottomSheetScaffoldState import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier @@ -30,6 +31,7 @@ import to.bitkit.ui.theme.Colors enum class SheetSize { LARGE, MEDIUM, SMALL, CALENDAR; } +@Stable sealed interface Sheet { data class Send(val route: SendRoute = SendRoute.Recipient) : Sheet data object Receive : Sheet From 7072c311404fdbedd852055cdde2cb15497e9adb Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 24 Nov 2025 13:38:07 -0500 Subject: [PATCH 9/9] Fix wait for node block --- .../to/bitkit/repositories/BlocktankRepo.kt | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt index 45a0e47ed..3bc282d04 100644 --- a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt @@ -420,19 +420,17 @@ class BlocktankRepo @Inject constructor( operationName = "claimGiftCode", waitTimeout = waitTimeout, ) { - Result.success(Unit) - }.getOrThrow() - - delay(PEER_CONNECTION_DELAY_MS) + delay(PEER_CONNECTION_DELAY_MS) - val channels = lightningRepo.getChannelsAsync().getOrThrow() - val maxInboundCapacity = channels.calculateRemoteBalance() + val channels = lightningRepo.getChannelsAsync().getOrThrow() + val maxInboundCapacity = channels.calculateRemoteBalance() - if (maxInboundCapacity >= amount) { - claimGiftCodeWithLiquidity(code) - } else { - claimGiftCodeWithoutLiquidity(code, amount) - } + if (maxInboundCapacity >= amount) { + Result.success(claimGiftCodeWithLiquidity(code)) + } else { + Result.success(claimGiftCodeWithoutLiquidity(code, amount)) + } + }.getOrThrow() }.onFailure { e -> Logger.error("Failed to claim gift code", e, context = TAG) }