diff --git a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt index 43f123ab4..9b8dc83c5 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 @@ -46,6 +53,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 @@ -458,26 +466,61 @@ 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 = 3600u, ).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, + ) + + if (Env.isDebug && GIFT_QA_PRE_RECEIVE_DELAY > Duration.ZERO) { + Logger.debug( + "QA window open: sleeping '$GIFT_QA_PRE_RECEIVE_DELAY' before awaiting LDK PaymentReceived " + + "(disable wifi now to simulate routing failure)", + context = TAG, + ) + delay(GIFT_QA_PRE_RECEIVE_DELAY) + } - return GiftClaimResult.SuccessWithLiquidity( - paymentHashOrTxId = giftResponse.bolt11PaymentId ?: giftResponse.id, - sats = giftResponse.bolt11Payment?.paidSat?.toLong() - ?: giftResponse.appliedGiftCode?.giftSat?.toLong() - ?: amount.toLong(), + 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() + + GiftClaimResult.SuccessWithLiquidity( + paymentHashOrTxId = paymentReceived.paymentHash, + sats = receivedSats.takeIf { it > 0 } ?: amount.toLong(), invoice = invoice, code = code, ) @@ -517,6 +560,12 @@ 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 + + // QA aid: in debug builds only, pause after `giftPay` returns and before awaiting + // the LDK PaymentReceived event, so a tester can disable wifi/peer to simulate a + // routing failure. Set to Duration.ZERO to disable. + private val GIFT_QA_PRE_RECEIVE_DELAY: Duration = 15.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..a2b9dfede 100644 --- a/app/src/main/java/to/bitkit/ui/MainActivity.kt +++ b/app/src/main/java/to/bitkit/ui/MainActivity.kt @@ -83,7 +83,11 @@ class MainActivity : FragmentActivity() { desc = getString(R.string.notification__channel_node__body), importance = NotificationManager.IMPORTANCE_LOW ) - appViewModel.handleDeeplinkIntent(intent) + // Skip on Activity recreation (e.g. locale change) — Android re-delivers the + // launching intent and would otherwise re-trigger deeplink flows like the gift sheet. + if (savedInstanceState == null) { + appViewModel.handleDeeplinkIntent(intent) + } installSplashScreen() enableAppEdgeToEdge() 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.