diff --git a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt index d5c7b44d4..2dbffe95d 100644 --- a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt @@ -20,13 +20,17 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn @@ -37,7 +41,10 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import org.lightningdevkit.ldknode.Bolt11Invoice import org.lightningdevkit.ldknode.ChannelDetails +import org.lightningdevkit.ldknode.Event import to.bitkit.async.ServiceQueue import to.bitkit.data.CacheStore import to.bitkit.di.BgDispatcher @@ -47,6 +54,7 @@ import to.bitkit.ext.calculateRemoteBalance import to.bitkit.ext.nowTimestamp import to.bitkit.models.BlocktankBackupV1 import to.bitkit.models.EUR +import to.bitkit.models.msatCeilOf import to.bitkit.services.CoreService import to.bitkit.services.LightningService import to.bitkit.utils.Logger @@ -459,26 +467,52 @@ class BlocktankRepo @Inject constructor( } } - private suspend fun claimGiftCodeWithLiquidity(code: String, amount: ULong): GiftClaimResult { + private suspend fun claimGiftCodeWithLiquidity(code: String, amount: ULong): GiftClaimResult = coroutineScope { val invoice = lightningRepo.createInvoice( amountSats = null, description = "blocktank-gift-code:$code", expirySeconds = Defaults.bolt11InvoiceExpirySeconds, ).getOrThrow() + val expectedPaymentHash = Bolt11Invoice.fromStr(invoice).paymentHash() + Logger.debug("Created invoice for gift code, requesting payment from LSP", context = TAG) + val paymentReceivedDeferred = async(start = CoroutineStart.UNDISPATCHED) { + lightningRepo.nodeEvents + .filterIsInstance() + .first { it.paymentHash == expectedPaymentHash } + } + val giftResponse = ServiceQueue.CORE.background { giftPay(invoice = invoice) } - Logger.debug("Gift payment request completed: id=${giftResponse.id}", context = TAG) + Logger.debug( + "Gift payment request completed: id='${giftResponse.id}', awaiting LDK PaymentReceived", + context = TAG, + ) + + val paymentReceived = withTimeoutOrNull(GIFT_PAYMENT_RECEIVE_TIMEOUT) { + paymentReceivedDeferred.await() + } + + if (paymentReceived == null) { + paymentReceivedDeferred.cancel() + throw ServiceError.GiftClaimPaymentNotReceived() + } + + Logger.debug( + "Gift payment confirmed by LDK: hash='${paymentReceived.paymentHash}', " + + "amountMsat='${paymentReceived.amountMsat}'", + context = TAG, + ) + + val receivedSats = msatCeilOf(paymentReceived.amountMsat).toLong() - return GiftClaimResult.SuccessWithLiquidity( - paymentHashOrTxId = giftResponse.bolt11PaymentId ?: giftResponse.id, - sats = giftResponse.bolt11Payment?.paidSat?.toLong() - ?: giftResponse.appliedGiftCode?.giftSat?.toLong() - ?: amount.toLong(), + GiftClaimResult.SuccessWithLiquidity( + paymentHashOrTxId = paymentReceived.paymentHash, + sats = receivedSats.takeIf { it > 0 } ?: amount.toLong(), invoice = invoice, code = code, ) @@ -518,6 +552,7 @@ class BlocktankRepo @Inject constructor( private const val DEFAULT_SOURCE = "bitkit-android" private const val PEER_CONNECTION_DELAY_MS = 2_000L private val TIMEOUT_GIFT_CODE = 30.seconds + private val GIFT_PAYMENT_RECEIVE_TIMEOUT = 45.seconds } } diff --git a/app/src/main/java/to/bitkit/ui/MainActivity.kt b/app/src/main/java/to/bitkit/ui/MainActivity.kt index 421853b1b..12870a400 100644 --- a/app/src/main/java/to/bitkit/ui/MainActivity.kt +++ b/app/src/main/java/to/bitkit/ui/MainActivity.kt @@ -63,6 +63,10 @@ import to.bitkit.viewmodels.WalletViewModel @AndroidEntryPoint class MainActivity : FragmentActivity() { + private companion object { + const val KEY_CONSUMED_DEEPLINK_URI = "consumed_deeplink_uri" + } + private val appViewModel by viewModels() private val walletViewModel by viewModels() private val blocktankViewModel by viewModels() @@ -83,7 +87,12 @@ class MainActivity : FragmentActivity() { desc = getString(R.string.notification__channel_node__body), importance = NotificationManager.IMPORTANCE_LOW ) - appViewModel.handleDeeplinkIntent(intent) + + val consumedUri = savedInstanceState?.getString(KEY_CONSUMED_DEEPLINK_URI) + val currentUri = intent?.data?.toString() + if (currentUri == null || currentUri != consumedUri) { + appViewModel.handleDeeplinkIntent(intent) + } installSplashScreen() enableAppEdgeToEdge() @@ -201,6 +210,11 @@ class MainActivity : FragmentActivity() { appViewModel.handleDeeplinkIntent(intent) } + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + intent?.data?.toString()?.let { outState.putString(KEY_CONSUMED_DEEPLINK_URI, it) } + } + override fun onDestroy() { super.onDestroy() if (!settingsViewModel.notificationsGranted.value) { diff --git a/app/src/main/java/to/bitkit/utils/Errors.kt b/app/src/main/java/to/bitkit/utils/Errors.kt index 5dd62ed95..5ec25ced0 100644 --- a/app/src/main/java/to/bitkit/utils/Errors.kt +++ b/app/src/main/java/to/bitkit/utils/Errors.kt @@ -21,6 +21,7 @@ sealed class ServiceError(message: String) : AppError(message) { class CurrencyRateUnavailable : ServiceError("Currency rate unavailable") class BlocktankInfoUnavailable : ServiceError("Blocktank info not available") class GeoBlocked : ServiceError("Geo blocked user") + class GiftClaimPaymentNotReceived : ServiceError("Gift claim payment not received") } class HttpError(message: String, val code: Int = 500, cause: Throwable? = null) : AppError(message, cause) diff --git a/changelog.d/next/929.fixed.md b/changelog.d/next/929.fixed.md new file mode 100644 index 000000000..4b970ab00 --- /dev/null +++ b/changelog.d/next/929.fixed.md @@ -0,0 +1 @@ +Fix gift card flow showing false-positive confetti when the LSP payment fails, and re-opening unexpectedly after an app language change.