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/BlocktankRepo.kt b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt index 49a5326d9..3bc282d04 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 @@ -43,8 +47,11 @@ 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") class BlocktankRepo @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, private val coreService: CoreService, @@ -52,6 +59,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 +407,74 @@ class BlocktankRepo @Inject constructor( } } + suspend fun claimGiftCode( + code: String, + amount: ULong, + waitTimeout: Duration = TIMEOUT_GIFT_CODE, + ): 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, + ) { + delay(PEER_CONNECTION_DELAY_MS) + + val channels = lightningRepo.getChannelsAsync().getOrThrow() + val maxInboundCapacity = channels.calculateRemoteBalance() + + 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) + } + } + + 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 null" } + + 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 + private val TIMEOUT_GIFT_CODE = 30.seconds } } @@ -413,3 +485,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 8d32de835..af03fdfd2 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -102,7 +102,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, @@ -823,6 +823,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/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 4402f8978..ff177fc3b 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..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 @@ -39,6 +41,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/settings/lightning/LightningConnectionsViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt index 408c9570a..e484e3174 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,6 +23,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 @@ -113,7 +114,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 } @@ -329,12 +330,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/GiftErrorSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/GiftErrorSheet.kt new file mode 100644 index 000000000..daa85e6dc --- /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.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.FillHeight +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, + ) + + FillHeight() + + Image( + painter = painterResource(R.drawable.exclamation_mark), + contentDescription = null, + modifier = Modifier + .fillMaxWidth(IMAGE_WIDTH_FRACTION) + .aspectRatio(1.0f) + .align(Alignment.CenterHorizontally) + ) + + FillHeight() + + 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..23ce441e0 --- /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.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.FillHeight +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, + ) + + FillHeight() + + 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..342a5a110 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/sheets/GiftViewModel.kt @@ -0,0 +1,146 @@ +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 +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import to.bitkit.di.BgDispatcher +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.GiftClaimResult +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 blocktankRepo: BlocktankRepo, + private val activityRepo: ActivityRepo, +) : 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 + + @Volatile + 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 + viewModelScope.launch(bgDispatcher) { + claimGift() + } + } + + private suspend fun claimGift() = withContext(bgDispatcher) { + if (isClaiming) return@withContext + isClaiming = true + + try { + blocktankRepo.claimGiftCode( + code = code, + amount = amount, + waitTimeout = NODE_STARTUP_TIMEOUT_MS.milliseconds, + ).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 insertGiftActivity(result: GiftClaimResult.SuccessWithoutLiquidity) { + val nowTimestamp = nowTimestamp().epochSecond.toULong() + + val lightningActivity = 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) { + 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 + } +} 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 1a3a0ffb6..c9546614e 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -536,6 +536,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( @@ -807,6 +808,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 8417fe519..171d3437e 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 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, ) }