Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@ import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toPixelMap
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.captureToImage
import androidx.compose.ui.test.getUnclippedBoundsInRoot
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
Expand All @@ -39,6 +41,7 @@ import to.bitkit.models.PaykitAmount
import to.bitkit.models.PaykitAsset
import to.bitkit.models.PaykitRequestPricing
import to.bitkit.models.PubkyProfile
import to.bitkit.repositories.MethodId
import to.bitkit.repositories.PaykitPaymentRequest
import to.bitkit.repositories.PaykitRecurrenceUnit
import to.bitkit.repositories.PaykitSubscription
Expand All @@ -51,6 +54,7 @@ import to.bitkit.ui.screens.subscriptions.SubscriptionFirstPaymentProgress
import to.bitkit.ui.shared.modifiers.sheetHeight
import to.bitkit.ui.sheets.SendRoute
import to.bitkit.ui.theme.AppThemeSurface
import to.bitkit.ui.theme.Colors
import to.bitkit.viewmodels.OnchainFeeUi
import to.bitkit.viewmodels.SendMethod
import to.bitkit.viewmodels.SendUiState
Expand Down Expand Up @@ -164,6 +168,38 @@ class SendConfirmScreenTest {
}
}

@Test
fun preparingUsdtRequestUsesUsdtAccentAndCannotPay() {
val request = PaykitPaymentRequest(
paymentRequestId = "preparing-usdt",
counterparty = "requester",
amount = PaykitAmount(PaykitAsset.USDT, 5_000_000u),
paymentReference = "test-reference",
expiresAt = null,
acceptedPaymentEndpointIdentifiers = listOf(MethodId.UsdtArbitrum.rawValue),
)
composeTestRule.setContent {
AppThemeSurface {
CompositionLocalProvider(LocalInspectionMode provides true) {
SendConfirmContent(
uiState = SendUiState(payMethod = SendMethod.LIGHTNING),
isNodeRunning = false,
isLoading = false,
showBiometrics = false,
preparingRequest = request,
modifier = Modifier.sheetHeight()
)
}
}
}
val preparing = composeTestRule.onNodeWithTag("PaymentRequestPreparing")
preparing.assertIsDisplayed().assertIsNotEnabled()
val pixels = preparing.captureToImage().toPixelMap()
assertTrue((0 until pixels.width).any { x ->
(0 until pixels.height).any { y -> pixels[x, y] == Colors.Usdt }
})
}

@Test
fun initialOnchainSubscriptionKeepsReviewLayoutWhilePaying() {
val startsAt = Clock.System.now()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ class UsdtBridgeDetailsTest {
private val sourceTx = "0x" + "ab".repeat(32)
private val deposit = UsdtDeposit(
"xit_4cfc646b-3657-4144-a644-b7880f184686", "bsc", "USDT", 50_000_001u,
sourceTx, "held", null, null,
sourceTx, "held", "standing_route_unavailable", null,
)
private val transfer = UsdtTransfer(
id = "transfer", sender = address, txHash = sourceTx, userOperationHash = null,
Expand Down Expand Up @@ -154,6 +154,8 @@ class UsdtBridgeDetailsTest {
composeTestRule.onNodeWithTag("UsdtRefundAddress").assertDoesNotExist()
composeTestRule.onNodeWithTag("UsdtDepositDetails").performScrollTo().performClick()
composeTestRule.waitForIdle()
composeTestRule.onNodeWithText("standing_route_unavailable").assertDoesNotExist()
composeTestRule.onNodeWithText("This deposit needs provider assistance.", substring = true).assertExists()
composeTestRule.onNodeWithText("USDT address on BSC").performScrollTo().assertIsDisplayed()
composeTestRule.onNodeWithTag("UsdtDepositRefund").assertIsNotEnabled()
assertHeight("UsdtRefundAddress", 56f)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1796,7 +1796,6 @@ class PaykitPaymentRequestRepo @Inject constructor(
}

private fun clearStateLocked() {
setPaymentSubmissionActive(false)
completedRefreshGeneration = -1L
receivedContacts = null
expirationJob?.cancel()
Expand Down
21 changes: 14 additions & 7 deletions app/src/main/java/to/bitkit/repositories/UsdtRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,9 @@ import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.getAndUpdate
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
Expand Down Expand Up @@ -58,8 +57,9 @@ class UsdtRepo @Inject constructor(
private var rateLimitedUntil = 0L
private val _state = MutableStateFlow(UsdtWalletState())
val state = _state.asStateFlow()
private val _receivedTxs = MutableSharedFlow<UsdtTransfer>(extraBufferCapacity = 8)
val receivedTxs = _receivedTxs.asSharedFlow()
fun consumeReceipt(id: String): Boolean = _state.getAndUpdate {
it.copy(pendingReceipts = it.pendingReceipts.filterNot { receipt -> receipt.id == id }.toImmutableList())
}.pendingReceipts.any { it.id == id }

suspend fun refresh(history: Boolean, force: Boolean = false) = withContext(ioDispatcher) {
mutex.withLock {
Expand Down Expand Up @@ -97,13 +97,18 @@ class UsdtRepo @Inject constructor(
}
}

private suspend fun announceNewReceipts(previous: List<UsdtTransfer>?, current: List<UsdtTransfer>) {
private fun announceNewReceipts(previous: List<UsdtTransfer>?, current: List<UsdtTransfer>) {
if (previous == null) return
val knownIds = previous.filter { it.isIncoming && it.status == UsdtTransferStatus.CONFIRMED }
.map { it.id }.toSet()
current.filter {
val received = current.filter {
it.isIncoming && it.status == UsdtTransferStatus.CONFIRMED && it.amount > 0u && it.id !in knownIds
}.forEach { _receivedTxs.emit(it) }
}
_state.update {
it.copy(
pendingReceipts = (it.pendingReceipts + received).distinctBy { receipt -> receipt.id }.toImmutableList()
)
}
}

suspend fun waitForTransfer(id: String): Result<Unit> = withContext(ioDispatcher) {
Expand Down Expand Up @@ -154,6 +159,7 @@ class UsdtRepo @Inject constructor(
address = wallet.receiveAddress(),
transfers = wallet.history().toImmutableList(),
historyComplete = false,
pendingReceipts = persistentListOf(),
pendingDepositIds = persistentSetOf(),
)
}
Expand Down Expand Up @@ -260,6 +266,7 @@ data class UsdtWalletState(
val balance: ULong? = null,
val transfers: ImmutableList<UsdtTransfer> = persistentListOf(),
val historyComplete: Boolean = false,
val pendingReceipts: ImmutableList<UsdtTransfer> = persistentListOf(),
val pendingDepositIds: ImmutableSet<String> = persistentSetOf(),
)

Expand Down
18 changes: 10 additions & 8 deletions app/src/main/java/to/bitkit/ui/ContentView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -569,14 +569,16 @@ fun ContentView(
when (val sheet = currentSheet) {
null -> Unit
is Sheet.Send -> {
SendSheet(
appViewModel = appViewModel,
walletViewModel = walletViewModel,
startDestination = sheet.route,
hardwareWalletId = sheet.hardwareWalletId,
preparingRequest = sheet.preparingRequest,
hwSendViewModel = hwSendViewModel,
)
key(sheet.route) {
Comment thread
ovitrif marked this conversation as resolved.
SendSheet(
appViewModel = appViewModel,
walletViewModel = walletViewModel,
startDestination = sheet.route,
hardwareWalletId = sheet.hardwareWalletId,
preparingRequest = sheet.preparingRequest,
hwSendViewModel = hwSendViewModel,
)
}
}

is Sheet.Receive -> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ fun HwSendSignScreen(
prepareContactPayment: suspend (HwFundingSignedTx) -> Boolean,
authorizeContactPayment: suspend (hasAttemptedBroadcast: Boolean) -> Boolean,
onPaymentDeadlineExpired: suspend (hasAttemptedBroadcast: Boolean) -> Unit,
onPaymentSubmissionChange: (Boolean) -> Unit,
beginPaymentSubmission: () -> (() -> Unit),
paymentDeadlineAt: Instant?,
onBack: () -> Unit,
) {
Expand All @@ -78,45 +78,42 @@ fun HwSendSignScreen(
LaunchedEffect(walletId) {
viewModel.warmUp(walletId)
}
LaunchedEffect(uiState.isSigning) {
onPaymentSubmissionChange(uiState.isSigning)
}
DisposableEffect(viewModel) {
onDispose {
viewModel.cancel()
onPaymentSubmissionChange(false)
}
}
// Without this the sheet's NavHost pops the route itself, ignoring the guard and skipping onBack
BackHandler(onBack = onBackRequest)

val signAndBroadcast = {
viewModel.signAndBroadcast(
request,
prepareContactPayment,
authorizeContactPayment,
onPaymentDeadlineExpired,
beginPaymentSubmission,
)
}

HwSendSignContent(
amountSats = sendUiState.amount,
address = sendUiState.address,
isSigning = uiState.isSigning,
hasPendingBroadcast = uiState.hasPendingBroadcast,
vendor = vendor,
onBack = onBackRequest,
onOpenConnect = {
viewModel.signAndBroadcast(
request,
prepareContactPayment,
authorizeContactPayment,
onPaymentDeadlineExpired
)
},
onOpenConnect = signAndBroadcast,
)

if (uiState.isPassphraseRequired) {
HwPassphrasePromptSheet(
isVerifying = uiState.isVerifyingPassphrase,
onSubmit = { passphrase ->
viewModel.submitPassphrase(
request,
walletId,
passphrase,
prepareContactPayment,
authorizeContactPayment,
onPaymentDeadlineExpired,
onReady = signAndBroadcast,
)
},
onDismiss = viewModel::dismissPassphrase,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,12 +91,14 @@ class HwSendViewModel @Inject constructor(
prepareContactPayment: suspend (HwFundingSignedTx) -> Boolean = { true },
authorizeContactPayment: suspend (hasAttemptedBroadcast: Boolean) -> Boolean = { true },
onPaymentDeadlineExpired: suspend (hasAttemptedBroadcast: Boolean) -> Unit = {},
beginPaymentSubmission: () -> (() -> Unit) = { {} },
) {
if (pendingResult.value != null || _uiState.value.isSigning || signingJob?.isActive == true) return
signingWalletId = request.walletId
_uiState.update { it.copy(isSigning = true) }
val attempt = ++signingAttempt
signingJob = viewModelScope.launch {
val finishSubmission = beginPaymentSubmission()
try {
runCatching {
if (pendingBroadcast?.matches(request) == false) {
Expand Down Expand Up @@ -179,6 +181,7 @@ class HwSendViewModel @Inject constructor(
handleFailure(it, request.walletId)
}
} finally {
finishSubmission()
// A cancelled job can outlive cancel() while a device call returns, and must not
// reset the state of a signing attempt started after it.
if (signingAttempt == attempt) {
Expand Down Expand Up @@ -277,11 +280,9 @@ class HwSendViewModel @Inject constructor(
}

fun submitPassphrase(
request: HwSendRequest,
walletId: String,
passphrase: String,
prepareContactPayment: suspend (HwFundingSignedTx) -> Boolean = { true },
authorizeContactPayment: suspend (hasAttemptedBroadcast: Boolean) -> Boolean = { true },
onPaymentDeadlineExpired: suspend (hasAttemptedBroadcast: Boolean) -> Unit = {},
onReady: () -> Unit,
) {
if (passphrase.isEmpty()) return
val state = _uiState.value
Expand All @@ -292,16 +293,11 @@ class HwSendViewModel @Inject constructor(
_uiState.update { it.copy(isVerifyingPassphrase = true) }
passphraseJob = viewModelScope.launch {
try {
hwWalletRepo.reconnectWithPassphrase(request.walletId, passphrase)
hwWalletRepo.reconnectWithPassphrase(walletId, passphrase)
.onSuccess {
if (!_uiState.value.isPassphraseRequired) return@onSuccess
_uiState.update { it.copy(isPassphraseRequired = false) }
signAndBroadcast(
request,
prepareContactPayment,
authorizeContactPayment,
onPaymentDeadlineExpired,
)
onReady()
}
.onFailure { error ->
if (error is HwPassphraseMismatchError) {
Expand All @@ -311,7 +307,7 @@ class HwSendViewModel @Inject constructor(
description = context.getString(R.string.hardware__passphrase_mismatch),
)
} else {
handleFailure(error, request.walletId)
handleFailure(error, walletId)
}
}
} finally {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import to.bitkit.models.FeeRate
import to.bitkit.models.PaykitAsset
import to.bitkit.models.PubkyProfile
import to.bitkit.models.TransactionSpeed
import to.bitkit.repositories.MethodId
import to.bitkit.repositories.PaykitPaymentRequest
import to.bitkit.ui.components.AddTagButton
import to.bitkit.ui.components.BalanceHeaderView
Expand Down Expand Up @@ -358,7 +359,13 @@ private fun ContentRunning(
val isHardwareFeeLoading = uiState.hardwareWalletId != null && uiState.onchainFeeUi.isLoading

val accentColor = when {
isPreparing -> Colors.Brand
preparingRequest != null -> if (preparingRequest.acceptedPaymentEndpointIdentifiers.toSet() ==
Comment thread
ovitrif marked this conversation as resolved.
setOf(MethodId.UsdtArbitrum.rawValue)
) {
Colors.Usdt
} else {
Colors.White64
}
uiState.payMethod == SendMethod.LIGHTNING -> Colors.Purple
else -> Colors.Brand
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ internal fun UsdtActivityDetail(
)
)
) {
UsdtSupportActions(transfer.supportDetails)
UsdtSupportActions(transfer.supportDetails(stringResource(transfer.status.labelResource)))
}
}
if (showDetails && (transfer.txHash != null || canOpenDeposits)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,10 @@ internal fun UsdtDepositsContent(
BodyS(stringResource(R.string.usdt__deposit_tron_refund_note), color = Colors.White64)
}
DepositSettlement(detail, hideBalance)
UsdtSupportActions(detail.supportDetails, enabled = !state.busy)
UsdtSupportActions(
detail.supportDetails(stringResource(detail.statusResource())),
enabled = !state.busy,
)
}
}
state.error?.let { BodyS(stringResource(it), color = Colors.Brand) }
Expand Down Expand Up @@ -477,7 +480,9 @@ private fun UsdtDepositTechnicalDetails(
} else {
UsdtDepositSummary(detail)
}
detail.statusCode?.let { BodyS(it, color = Colors.White64) }
if (detail.statusResource() == R.string.usdt__deposit_needs_attention) {
BodyS(stringResource(R.string.usdt__deposit_attention), color = Colors.White64)
}
}

@Composable
Expand Down
Loading
Loading