From a30687583baea60ad44094a9f78637208636ba23 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 30 Sep 2026 03:02:03 +0200 Subject: [PATCH 1/8] feat: accept allowance offers automatically on the allowee's wallet --- .../to/bitkit/repositories/PaykitAllowance.kt | 7 ++ .../repositories/PaykitAllowanceRepo.kt | 29 ++++- .../java/to/bitkit/viewmodels/AppViewModel.kt | 14 +++ app/src/main/res/values/strings.xml | 2 + .../repositories/PaykitAllowanceRepoTest.kt | 104 +++++++++++++++++- .../repositories/PaykitAllowanceTest.kt | 33 +++++- .../viewmodels/AppViewModelSendFlowTest.kt | 1 + changelog.d/next/1353.added.md | 1 + journeys/allowances/README.md | 20 ++-- journeys/allowances/set-and-accept.xml | 14 +-- 10 files changed, 205 insertions(+), 20 deletions(-) create mode 100644 changelog.d/next/1353.added.md diff --git a/app/src/main/java/to/bitkit/repositories/PaykitAllowance.kt b/app/src/main/java/to/bitkit/repositories/PaykitAllowance.kt index 82ddae7538..279615f8d0 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitAllowance.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitAllowance.kt @@ -81,6 +81,13 @@ data class PaykitAllowance( val isAnswerable: Boolean get() = lifecycleState == AllowanceLifecycleState.PROPOSED && !isProposedByMe + /** + * A received proposal whose proposer took the allower role, which leaves this wallet the allowee. The money and the + * decision are the allower's, so Bitkit accepts it without asking. A received proposal that makes this wallet the + * allower (a payee asking for an allowance) is not an offer and waits for the user's decision. + */ + val isOfferFromAllower: Boolean get() = isAnswerable && role == Role.ALLOWEE + fun status(now: Instant): Status = when (lifecycleState) { AllowanceLifecycleState.PROPOSED -> if (isProposedByMe) Status.AWAITING_ANSWER else Status.AWAITING_MY_ANSWER AllowanceLifecycleState.ACCEPTED -> when { diff --git a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt index 34e1a82982..053b55053d 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt @@ -119,6 +119,7 @@ class PaykitAllowanceRepo @Inject constructor( private val refreshMutex = Mutex() private val publishLock = Any() private val isProcessingRequests = AtomicBoolean(false) + private val isAcceptingOffers = AtomicBoolean(false) private val manualRequestSignatures = ConcurrentHashMap() private val _allowances = MutableStateFlow>(emptyList()) private val _localState = MutableStateFlow(PaykitAllowanceLocalState()) @@ -312,6 +313,29 @@ class PaykitAllowanceRepo @Inject constructor( paykitSdkService.acceptAllowance(it.counterparty, it.counterpartyReceiverPath, it.allowanceId) } + /** + * Accepts the offers that make this wallet the allowee and returns the ones now active so the caller can tell the + * user. A failed accept stays unanswered and is tried again on the next refresh. + */ + suspend fun acceptOffersFromAllowers(): List = withContext(ioDispatcher) { + if (activeIdentity == null) return@withContext emptyList() + val offers = _entries.value.filter { entry -> + val answerable = entry.allowances.filter { it.isAnswerable } + answerable.isNotEmpty() && answerable.all { it.isOfferFromAllower } + } + if (offers.isEmpty() || !isAcceptingOffers.compareAndSet(false, true)) return@withContext emptyList() + + try { + offers.filter { offer -> + accept(offer.id).onFailure { + Logger.warn("Failed to accept an allowance offer", it, context = TAG) + }.isSuccess + } + } finally { + isAcceptingOffers.set(false) + } + } + suspend fun reject(entryId: String): Result = respond(entryId) { paykitSdkService.rejectAllowance(it.counterparty, it.counterpartyReceiverPath, it.allowanceId) } @@ -388,11 +412,12 @@ class PaykitAllowanceRepo @Inject constructor( // region Presentation - /** The first received proposal that still needs an answer and was not shown yet. */ + /** The first received proposal that needs the user's answer and was not shown yet; offers are accepted instead. */ fun proposalForPresentation(): PaykitAllowanceEntry? { val presented = _localState.value.presentedProposalIds return _entries.value.firstOrNull { entry -> - entry.isAnswerable && entry.allowances.none { it.allowanceId in presented } + entry.allowances.any { it.isAnswerable && !it.isOfferFromAllower } && + entry.allowances.none { it.allowanceId in presented } } } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 229a40de2a..6aefa758c1 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -161,6 +161,7 @@ import to.bitkit.repositories.PaykitPaymentRequestDiagnostics import to.bitkit.repositories.PaykitPaymentRequestDraft import to.bitkit.repositories.PaykitPaymentRequestError import to.bitkit.repositories.PaykitPaymentRequestId +import to.bitkit.repositories.PaykitAllowanceEntry import to.bitkit.repositories.PaykitAllowanceError import to.bitkit.repositories.PaykitAllowanceEvent import to.bitkit.repositories.PaykitAllowanceRepo @@ -881,6 +882,7 @@ class AppViewModel @Inject constructor( if (refreshMaintenance) paykitPaymentProofRepo.reconcile() paykitPaymentRequestRepo.refresh().onSuccess { paykitAllowanceRepo.refresh() + announceAcceptedAllowanceOffers(paykitAllowanceRepo.acceptOffersFromAllowers()) if (paykitAllowanceRepo.processIncomingRequests(paykitPaymentRequestRepo.pendingRequests.value)) { paykitPaymentRequestRepo.refresh() } @@ -918,6 +920,18 @@ class AppViewModel @Inject constructor( } } + private fun announceAcceptedAllowanceOffers(offers: List) { + offers.forEach { offer -> + notifyAllowanceEvent( + type = Toast.ToastType.SUCCESS, + title = context.getString(R.string.subscriptions__allowance_offer_accepted_title), + description = context.getString(R.string.subscriptions__allowance_offer_accepted_description) + .replace("{name}", allowanceContactName(offer.counterparty)), + testTag = "AllowanceOfferAcceptedToast", + ) + } + } + /** * Allowance events post a system notification when allowed, as on iOS: the request sheet that opens right after a * limit is reached would cover an in-app toast. Without the permission they fall back to a toast. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f92330814d..1fb0cb08a0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1092,6 +1092,8 @@ Monthly allowance Monthly limit Add a contact first. Allowances cover payment requests from your contacts. + {name} set up an allowance for you + Allowance Added {name}\'s wallet will pay your requests within these limits without asking each time. Either of you can end it. an allowance]]> {amount} paid automatically diff --git a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt index 9687c73f5b..c7d03ef03f 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt @@ -354,8 +354,8 @@ class PaykitAllowanceRepoTest : BaseUnitTest() { } @Test - fun `received proposal is presented once and accepting answers it`() = test { - val proposalRecord = receivedProposal() + fun `received ask is presented once and accepting answers it`() = test { + val proposalRecord = receivedAsk() records = listOf(proposalRecord) whenever(sdk.acceptAllowance(any(), any(), any())).thenReturn(proposalRecord) sut.activate(identity) @@ -372,6 +372,100 @@ class PaykitAllowanceRepoTest : BaseUnitTest() { verify(sdk).processOutboundPrivateMessages(fixtures.counterpartyKey, PaykitReceiverPaths.WALLET) } + @Test + fun `offer from the allower is accepted without the review sheet and not announced twice`() = test { + // The proposer took the allower role, so this wallet is the allowee. + val offer = receivedProposal() + records = listOf(offer) + whenever(sdk.acceptAllowance(any(), any(), any())).thenAnswer { + records = listOf(fixtures.record(localRole = AllowanceLocalRole.ALLOWEE, proposedByMe = false)) + records.single() + } + sut.activate(identity) + assertNull(sut.proposalForPresentation(), "An offer from the allower never opens the review sheet") + + val accepted = sut.acceptOffersFromAllowers() + + assertEquals(listOf(fixtures.counterpartyKey), accepted.map { it.counterparty }) + verify(sdk).acceptAllowance(fixtures.counterpartyKey, PaykitReceiverPaths.WALLET, WALLET_ALLOWANCE_ID) + verify(sdk).processOutboundPrivateMessages(fixtures.counterpartyKey, PaykitReceiverPaths.WALLET) + val entry = sut.entries.value.single() + assertEquals(PaykitAllowance.Status.ACTIVE, entry.status(fixtures.now)) + assertEquals(PaykitAllowance.Role.ALLOWEE, entry.role) + assertTrue(entry.canEnd, "The allowee can still end it") + assertNull(sut.proposalForPresentation()) + + assertTrue(sut.acceptOffersFromAllowers().isEmpty(), "An accepted offer is not accepted or announced twice") + verify(sdk, times(1)).acceptAllowance(any(), any(), any()) + } + + @Test + fun `failed offer accept stays unanswered and is retried on the next refresh`() = test { + val offer = receivedProposal() + records = listOf(offer) + whenever(sdk.acceptAllowance(any(), any(), any())).thenThrow(RuntimeException("offline")) + sut.activate(identity) + + assertTrue(sut.acceptOffersFromAllowers().isEmpty()) + assertNull(sut.proposalForPresentation()) + + whenever(sdk.acceptAllowance(any(), any(), any())).thenReturn(offer) + assertEquals(1, sut.acceptOffersFromAllowers().size) + verify(sdk, times(2)).acceptAllowance(any(), any(), any()) + } + + @Test + fun `ask from the allowee is left for the review sheet`() = test { + // The proposer took the allowee role, so this wallet is the allower and pays: the user decides. + records = listOf(receivedAsk()) + sut.activate(identity) + + assertTrue(sut.acceptOffersFromAllowers().isEmpty()) + + verify(sdk, never()).acceptAllowance(any(), any(), any()) + assertEquals(WALLET_ALLOWANCE_ID, sut.proposalForPresentation()?.id) + assertEquals(PaykitAllowance.Status.AWAITING_MY_ANSWER, sut.entries.value.single().status(fixtures.now)) + } + + @Test + fun `proposal sent or already answered is never accepted`() = test { + records = listOf( + fixtures.record(allowanceId = "sent", state = AllowanceLifecycleState.PROPOSED, proposedByMe = true), + fixtures.record( + allowanceId = "declined", + localRole = AllowanceLocalRole.ALLOWEE, + state = AllowanceLifecycleState.REJECTED, + proposedByMe = false, + ), + fixtures.record( + allowanceId = "ended", + localRole = AllowanceLocalRole.ALLOWEE, + state = AllowanceLifecycleState.ENDED, + proposedByMe = false, + ), + fixtures.record( + allowanceId = "active", + localRole = AllowanceLocalRole.ALLOWEE, + state = AllowanceLifecycleState.ACCEPTED, + proposedByMe = false, + ), + ) + sut.activate(identity) + + assertTrue(sut.acceptOffersFromAllowers().isEmpty()) + + verify(sdk, never()).acceptAllowance(any(), any(), any()) + } + + @Test + fun `offers are not accepted without an identity`() = test { + records = listOf(receivedProposal()) + + assertTrue(sut.acceptOffersFromAllowers().isEmpty()) + + verify(sdk, never()).acceptAllowance(any(), any(), any()) + } + @Test fun `answering fails when nothing in the entry awaits an answer`() = test { records = listOf(fixtures.record()) @@ -538,6 +632,12 @@ class PaykitAllowanceRepoTest : BaseUnitTest() { proposedByMe = false, ) + private fun receivedAsk() = fixtures.record( + localRole = AllowanceLocalRole.ALLOWER, + state = AllowanceLifecycleState.PROPOSED, + proposedByMe = false, + ) + @Suppress("LongParameterList") private fun journalEntry( attemptId: String, diff --git a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceTest.kt index faa65324ad..8ef32113ae 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceTest.kt @@ -41,6 +41,36 @@ class PaykitAllowanceTest : BaseUnitTest() { // region Records + @Test + fun `only a received proposal from the allower is an offer`() { + val fromAllower = fixtures.allowance( + role = PaykitAllowance.Role.ALLOWEE, + state = AllowanceLifecycleState.PROPOSED, + isProposedByMe = false, + ) + assertTrue(fromAllower.isOfferFromAllower) + assertTrue(fromAllower.isAnswerable) + + val fromAllowee = fixtures.allowance( + role = PaykitAllowance.Role.ALLOWER, + state = AllowanceLifecycleState.PROPOSED, + isProposedByMe = false, + ) + assertFalse(fromAllowee.isOfferFromAllower) + assertTrue(fromAllowee.isAnswerable) + + for (role in PaykitAllowance.Role.entries) { + val sent = fixtures.allowance(role = role, state = AllowanceLifecycleState.PROPOSED, isProposedByMe = true) + assertFalse(sent.isOfferFromAllower) + } + val answered = fixtures.allowance( + role = PaykitAllowance.Role.ALLOWEE, + state = AllowanceLifecycleState.ACCEPTED, + isProposedByMe = false, + ) + assertFalse(answered.isOfferFromAllower) + } + @Test fun `record reads back sats, anchor, role and allowlist`() { val allowlist = listOf(fixtures.lightningIdentifier, fixtures.onchainIdentifier) @@ -584,11 +614,12 @@ internal object PaykitAllowanceFixtures { monthlyAnchor: Instant? = septemberAnchor, expiresAt: Instant? = null, lastEventAt: Instant? = null, + isProposedByMe: Boolean = role == PaykitAllowance.Role.ALLOWER, ) = PaykitAllowance( id = PaykitAllowance.Id(counterparty, receiverPath, allowanceId), role = role, lifecycleState = state, - isProposedByMe = role == PaykitAllowance.Role.ALLOWER, + isProposedByMe = isProposedByMe, perPaymentMaxSats = perPaymentMaxSats, monthlyLimitSats = monthlyLimitSats, monthlyAnchor = monthlyAnchor, diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index e355374265..d6a84eaf9d 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -394,6 +394,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(paykitAllowanceRepo.events).thenReturn(MutableSharedFlow()) whenever { paykitAllowanceRepo.refresh() }.thenReturn(Result.success(Unit)) whenever { paykitAllowanceRepo.beginManualPayment(any(), any()) }.thenReturn(Result.success(null)) + whenever { paykitAllowanceRepo.acceptOffersFromAllowers() }.thenReturn(emptyList()) whenever { paykitAllowanceRepo.processIncomingRequests(any()) }.thenReturn(false) whenever { paykitAllowanceRepo.isAutomaticallyHandling(any()) }.thenReturn(false) whenever { paykitPaymentProofRepo.prepare(any(), any(), any()) }.thenReturn(Result.success(Unit)) diff --git a/changelog.d/next/1353.added.md b/changelog.d/next/1353.added.md new file mode 100644 index 0000000000..70ea6904fb --- /dev/null +++ b/changelog.d/next/1353.added.md @@ -0,0 +1 @@ +Allowances now activate automatically on the receiving wallet: when a contact sets up an allowance for you, Bitkit accepts it and lets you know. diff --git a/journeys/allowances/README.md b/journeys/allowances/README.md index da9ccc442f..5e8426cb4b 100644 --- a/journeys/allowances/README.md +++ b/journeys/allowances/README.md @@ -1,7 +1,7 @@ # Allowances journeys Cover the Paykit allowance lifecycle between two Bitkit instances: the payer sets an allowance for a -contact, the payee accepts it, requests within the limits are paid without asking, a request above a +contact, the payee's wallet accepts it by itself, requests within the limits are paid without asking, a request above a limit falls back to the normal Payment Request sheet, and either side ends it. The restart journey pins the one rule that must never break: a payment interrupted mid-flight is never paid twice. @@ -14,9 +14,10 @@ that sets the allowance), the other the payee (the one that sends requests). Aut over Lightning only, so the payer needs a spending balance above 50,000 sats with a usable channel, and the payee needs receiving capacity; fund both through the staging LSP. -Grant the payer notification permission first (`adb shell pm grant to.bitkit.dev -android.permission.POST_NOTIFICATIONS`): the "Payment Executed" and "Limit Reached" events post a -system notification when they can, and fall back to a toast that `android layout` cannot see. +Grant both instances notification permission first (`adb shell pm grant to.bitkit.dev +android.permission.POST_NOTIFICATIONS`): the payer's "Payment Executed" and "Limit Reached" events and +the payee's "Allowance Added" event post a system notification when they can, and fall back to a toast +that `android layout` cannot see. The journeys set $5 a payment and $50 a month, the second stop on each slider, and the cap journey sets $5 a payment and $10 a month, the first monthly stop. Requests are one-time Payment Requests @@ -39,13 +40,16 @@ killed right after handing the payment to the node never paid twice after relaun `AllowancePerPaymentStop-` and `AllowanceMonthlyStop-`, `AllowanceSummary`, `AllowanceSave` - List row: `AllowanceRow-` with its status line `AllowanceRowStatus` -- Review sheet (payee): `AllowanceReview`, `AllowanceCounterparty`, `AllowancePerPaymentValue`, - `AllowanceMonthlyValue`, `AllowanceAccept`, `AllowanceDecline` +- Offer notification (payee): the toast `AllowanceOfferAcceptedToast` when notifications are off +- Review sheet: `AllowanceReview`, `AllowanceCounterparty`, `AllowancePerPaymentValue`, + `AllowanceMonthlyValue`, `AllowanceAccept`, `AllowanceDecline`; it opens only on the allower's wallet + for an allowance the payee asked for, never for the payer's own offer - Detail sheet: `AllowanceDetail`, `AllowancePaidSoFar`, `AllowanceDetailStatus` - Payments tab: `Tab-payments`, `PaymentRequestCreate`, `PaymentRequestRow-` whose subtitle ends with "· Auto-paid" for an automatic payment - Incoming request: `PaymentRequestsBell`, `PaymentRequestsSheet` -The review sheet on the payee opens on its own about a second after the proposal arrives; no tap is -needed to reach it. `android layout` can omit test tags applied to plain `Box` and `Column` +The payee's wallet accepts the payer's offer as soon as it arrives, about a second after the +proposal is received, and posts " set up an allowance for you"; no review sheet opens and no +tap is needed. `android layout` can omit test tags applied to plain `Box` and `Column` containers; use the raw UI Automator hierarchy when a documented container tag is missing. diff --git a/journeys/allowances/set-and-accept.xml b/journeys/allowances/set-and-accept.xml index 60c12949e9..96a206b1e4 100644 --- a/journeys/allowances/set-and-accept.xml +++ b/journeys/allowances/set-and-accept.xml @@ -1,9 +1,10 @@ - The payer sets an allowance for a contact from the Allowances tab, and the payee sees the offer - open by itself and accepts it. Until the payee answers, the payer's row reads "Waiting for an - answer"; after it, both sides show the allowance as Active. The other allowance journeys start - from the Active state this one ends in. + The payer sets an allowance for a contact from the Allowances tab, and the payee's wallet accepts + the offer by itself: no review sheet opens and the payee taps nothing. The payee gets a "<payer> + set up an allowance for you" notification, and both sides show the allowance as Active. Until the + payee's wallet has received the offer, the payer's row reads "Waiting for an answer". The other + allowance journeys start from the Active state this one ends in. Launch the E2E Bitkit app with Paykit UI enabled on both instances, each with the other saved as a linked contact, the payer holding a spendable balance above 50,000 sats with a usable Lightning channel @@ -18,9 +19,8 @@ Tap Save Allowance (testTag "AllowanceSave") Verify the sheet dismisses and the list shows one row for the payee (testTag "AllowanceRow-<allowanceId>") whose status line (testTag "AllowanceRowStatus") reads "Waiting for an answer", with "$ 50.00" over "Monthly limit" on the right Switch to the payee instance and bring Bitkit to the foreground - Verify the Allowance review sheet (testTag "AllowanceReview") opens on its own within a few seconds, headed "<payer> offers an allowance", with the payer's contact card (testTag "AllowanceCounterparty"), PER PAYMENT "Up to $5.00" (testTag "AllowancePerPaymentValue") and EACH MONTH "Up to $50.00" (testTag "AllowanceMonthlyValue") - Tap Accept (testTag "AllowanceAccept") - Verify the sheet dismisses; open the drawer menu, tap Subscriptions, tap the Allowances tab and verify the payer's row reads "Active" followed by the per-payment limit + Verify no Allowance review sheet (testTag "AllowanceReview") opens on the payee and that a notification "Allowance Added" reading "<payer> set up an allowance for you" arrives (or, with notifications off, the toast with testTag "AllowanceOfferAcceptedToast") + On the payee, open the drawer menu, tap Subscriptions, tap the Allowances tab and verify the payer's row reads "Active" followed by the per-payment limit, with no Accept or Decline step Switch back to the payer instance and verify its row now reads "Active · $5 a payment" From d28ef24b8eb7b084f5e7a051b6b914544edec3fd Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 30 Sep 2026 03:03:31 +0200 Subject: [PATCH 2/8] chore: rename changelog fragment --- changelog.d/next/{1353.added.md => 1385.added.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{1353.added.md => 1385.added.md} (100%) diff --git a/changelog.d/next/1353.added.md b/changelog.d/next/1385.added.md similarity index 100% rename from changelog.d/next/1353.added.md rename to changelog.d/next/1385.added.md From cf330109dad5c8b7b7cf2672fc3b2febc1d48955 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 1 Oct 2026 22:11:24 +0200 Subject: [PATCH 3/8] test: align offer acceptance tests with identity-bound allowances --- .../repositories/PaykitAllowanceRepoTest.kt | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt index 4394c8eedf..4d194e5b71 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt @@ -294,7 +294,7 @@ class PaykitAllowanceRepoTest : BaseUnitTest() { // The proposer took the allower role, so this wallet is the allowee. val offer = receivedProposal() records = listOf(offer) - whenever(sdk.acceptAllowance(any(), any(), any())).thenAnswer { + whenever(sdk.acceptAllowance(any(), any())).thenAnswer { records = listOf(fixtures.record(localRole = AllowanceLocalRole.ALLOWEE, proposedByMe = false)) records.single() } @@ -304,8 +304,8 @@ class PaykitAllowanceRepoTest : BaseUnitTest() { val accepted = sut.acceptOffersFromAllowers() assertEquals(listOf(fixtures.counterpartyKey), accepted.map { it.counterparty }) - verify(sdk).acceptAllowance(fixtures.counterpartyKey, PaykitReceiverPaths.WALLET, WALLET_ALLOWANCE_ID) - verify(sdk).processOutboundPrivateMessages(fixtures.counterpartyKey, PaykitReceiverPaths.WALLET) + verify(sdk).acceptAllowance(fixtures.counterpartyKey, ALLOWANCE_ID) + verify(sdk).processOutboundPrivateMessages(fixtures.counterpartyKey) val entry = sut.entries.value.single() assertEquals(PaykitAllowance.Status.ACTIVE, entry.status(fixtures.now)) assertEquals(PaykitAllowance.Role.ALLOWEE, entry.role) @@ -313,22 +313,22 @@ class PaykitAllowanceRepoTest : BaseUnitTest() { assertNull(sut.proposalForPresentation()) assertTrue(sut.acceptOffersFromAllowers().isEmpty(), "An accepted offer is not accepted or announced twice") - verify(sdk, times(1)).acceptAllowance(any(), any(), any()) + verify(sdk, times(1)).acceptAllowance(any(), any()) } @Test fun `failed offer accept stays unanswered and is retried on the next refresh`() = test { val offer = receivedProposal() records = listOf(offer) - whenever(sdk.acceptAllowance(any(), any(), any())).thenThrow(RuntimeException("offline")) + whenever(sdk.acceptAllowance(any(), any())).thenThrow(RuntimeException("offline")) sut.activate(identity) assertTrue(sut.acceptOffersFromAllowers().isEmpty()) assertNull(sut.proposalForPresentation()) - whenever(sdk.acceptAllowance(any(), any(), any())).thenReturn(offer) + whenever(sdk.acceptAllowance(any(), any())).thenReturn(offer) assertEquals(1, sut.acceptOffersFromAllowers().size) - verify(sdk, times(2)).acceptAllowance(any(), any(), any()) + verify(sdk, times(2)).acceptAllowance(any(), any()) } @Test @@ -339,8 +339,8 @@ class PaykitAllowanceRepoTest : BaseUnitTest() { assertTrue(sut.acceptOffersFromAllowers().isEmpty()) - verify(sdk, never()).acceptAllowance(any(), any(), any()) - assertEquals(WALLET_ALLOWANCE_ID, sut.proposalForPresentation()?.id) + verify(sdk, never()).acceptAllowance(any(), any()) + assertEquals(ALLOWANCE_ID, sut.proposalForPresentation()?.id) assertEquals(PaykitAllowance.Status.AWAITING_MY_ANSWER, sut.entries.value.single().status(fixtures.now)) } @@ -371,7 +371,7 @@ class PaykitAllowanceRepoTest : BaseUnitTest() { assertTrue(sut.acceptOffersFromAllowers().isEmpty()) - verify(sdk, never()).acceptAllowance(any(), any(), any()) + verify(sdk, never()).acceptAllowance(any(), any()) } @Test @@ -380,7 +380,7 @@ class PaykitAllowanceRepoTest : BaseUnitTest() { assertTrue(sut.acceptOffersFromAllowers().isEmpty()) - verify(sdk, never()).acceptAllowance(any(), any(), any()) + verify(sdk, never()).acceptAllowance(any(), any()) } @Test From 2d8d4e5330f92c5974cf8eb84ef923532d2f5f7c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 19 Oct 2026 23:24:25 +0200 Subject: [PATCH 4/8] fix: accept allowance offers only as checked and while the session lasts --- .../repositories/PaykitAllowanceRepo.kt | 40 ++++--- .../java/to/bitkit/viewmodels/AppViewModel.kt | 6 +- .../repositories/PaykitAllowanceRepoTest.kt | 100 ++++++++++++++++-- .../viewmodels/AppViewModelSendFlowTest.kt | 2 +- 4 files changed, 125 insertions(+), 23 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt index 46efcd4400..baf438e68f 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt @@ -239,17 +239,23 @@ class PaykitAllowanceRepo @Inject constructor( } } - suspend fun accept(entryId: String): Result = respond(entryId) { - val proposalStreamItemId = it.proposalStreamItemId ?: throw PaykitAllowanceError.Unavailable - paykitSdkService.acceptAllowance(it.counterparty, it.allowanceId, proposalStreamItemId) + suspend fun accept(entryId: String): Result = respond(entry(entryId)) { acceptProposal(it) } + + private suspend fun acceptProposal(allowance: PaykitAllowance): AllowanceRecord { + val proposalStreamItemId = allowance.proposalStreamItemId ?: throw PaykitAllowanceError.Unavailable + return paykitSdkService.acceptAllowance(allowance.counterparty, allowance.allowanceId, proposalStreamItemId) } /** * Accepts the offers that make this wallet the allowee and returns the ones now active so the caller can tell the - * user. A failed accept stays unanswered and is tried again on the next refresh. + * user. A failed accept stays unanswered and is tried again on the next refresh. Stops, and returns nothing to + * announce, once the identity it started with is gone or [canContinue] turns false. */ - suspend fun acceptOffersFromAllowers(): List = withContext(ioDispatcher) { - if (activeIdentity == null) return@withContext emptyList() + suspend fun acceptOffersFromAllowers( + canContinue: () -> Boolean = { true }, + ): List = withContext(ioDispatcher) { + val identity = activeIdentity ?: return@withContext emptyList() + val isCurrent = { activeIdentity == identity && canContinue() } val offers = _entries.value.filter { entry -> val answerable = entry.allowances.filter { it.isAnswerable } answerable.isNotEmpty() && answerable.all { it.isOfferFromAllower } @@ -257,17 +263,21 @@ class PaykitAllowanceRepo @Inject constructor( if (offers.isEmpty() || !isAcceptingOffers.compareAndSet(false, true)) return@withContext emptyList() try { - offers.filter { offer -> - accept(offer.id).onFailure { - Logger.warn("Failed to accept an allowance offer", it, context = TAG) - }.isSuccess + val accepted = mutableListOf() + for (offer in offers) { + if (!isCurrent()) return@withContext emptyList() + // Answers the entry that was checked, so an ask that joined it since is never accepted here. + respond(offer) { acceptProposal(it) } + .onSuccess { accepted += offer } + .onFailure { Logger.warn("Failed to accept an allowance offer", it, context = TAG) } } + if (isCurrent()) accepted else emptyList() } finally { isAcceptingOffers.set(false) } } - suspend fun reject(entryId: String): Result = respond(entryId) { + suspend fun reject(entryId: String): Result = respond(entry(entryId)) { paykitSdkService.rejectAllowance(it.counterparty, it.allowanceId) } @@ -286,12 +296,12 @@ class PaykitAllowanceRepo @Inject constructor( } private suspend fun respond( - entryId: String, + entry: PaykitAllowanceEntry?, response: suspend (PaykitAllowance) -> AllowanceRecord, ): Result = withContext(ioDispatcher) { runSuspendCatching { val identity = activeIdentity ?: throw PaykitAllowanceError.Unavailable - val entry = entry(entryId) ?: throw PaykitAllowanceError.Unavailable + if (entry == null) throw PaykitAllowanceError.Unavailable val answerable = entry.allowances.filter { it.isAnswerable } if (answerable.isEmpty()) throw PaykitAllowanceError.Unavailable for (allowance in answerable) { @@ -299,7 +309,9 @@ class PaykitAllowanceRepo @Inject constructor( sendQueuedMessages(allowance.counterparty) } val ids = entry.allowances.map { it.allowanceId } - executor.updateLocalState(identity) { it.copy(presentedProposalIds = it.presentedProposalIds + ids) } + if (activeIdentity == identity) { + executor.updateLocalState(identity) { it.copy(presentedProposalIds = it.presentedProposalIds + ids) } + } refresh() Unit } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 43f3d61edb..fe835f95f4 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -1158,7 +1158,11 @@ class AppViewModel @Inject constructor( clearHandledSubscriptionNotification() if (mode == PaykitPaymentRequestRefreshMode.FULL) reconcilePaykitUsdt() paykitAllowanceRepo.refresh() - announceAcceptedAllowanceOffers(paykitAllowanceRepo.acceptOffersFromAllowers()) + val acceptedOffers = paykitAllowanceRepo.acceptOffersFromAllowers { + !privatePaykitRepo.isProfileDeletionInProgress.value && + !(onlyWhileActive && isPaymentRequestPollingStopped.value) + } + announceAcceptedAllowanceOffers(acceptedOffers) if (processAllowanceRequests()) paykitPaymentRequestRepo.refresh() presentNextIncomingPaykitPaymentRequest() } diff --git a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt index 0db1bf4c8f..864a0c2871 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt @@ -340,20 +340,99 @@ class PaykitAllowanceRepoTest : BaseUnitTest() { } @Test - fun `failed offer accept stays unanswered and is retried on the next refresh`() = test { - val offer = receivedProposal() - records = listOf(offer) + fun `failed offer accept stays unanswered and is retried and announced once on the next refresh`() = test { + records = listOf(receivedProposal()) whenever(sdk.acceptAllowance(any(), any(), any())).thenThrow(RuntimeException("offline")) sut.activate(identity) - assertTrue(sut.acceptOffersFromAllowers().isEmpty()) - assertNull(sut.proposalForPresentation()) + assertTrue(sut.acceptOffersFromAllowers().isEmpty(), "A failed accept is not announced") + assertEquals(PaykitAllowance.Status.AWAITING_MY_ANSWER, sut.entries.value.single().status(fixtures.now)) + assertNull(sut.proposalForPresentation(), "A failed offer still never opens the review sheet") - whenever(sdk.acceptAllowance(any(), any(), any())).thenReturn(offer) - assertEquals(1, sut.acceptOffersFromAllowers().size) + acceptFlipsRecordToActive() + assertEquals(listOf(fixtures.counterpartyKey), sut.acceptOffersFromAllowers().map { it.counterparty }) + assertTrue(sut.acceptOffersFromAllowers().isEmpty()) verify(sdk, times(2)).acceptAllowance(any(), any(), any()) } + @Test + fun `offers are accepted and announced only while the session is current`() = test { + records = listOf(receivedProposal()) + acceptFlipsRecordToActive() + sut.activate(identity) + + assertTrue(sut.acceptOffersFromAllowers { false }.isEmpty()) + verify(sdk, never()).acceptAllowance(any(), any(), any()) + + var checks = 0 + val endedDuringAccept = sut.acceptOffersFromAllowers { ++checks == 1 } + + assertTrue(endedDuringAccept.isEmpty(), "An accept that outlived its session is not announced") + verify(sdk).acceptAllowance(fixtures.counterpartyKey, ALLOWANCE_ID, 1uL) + } + + @Test + fun `offer accept never answers an ask that joined the entry after the check`() = test { + val secondAllower = "pubky" + "a".repeat(52) + val asker = "pubky" + "m".repeat(52) + val offerX = fixtures.record( + allowanceId = "X", + localRole = AllowanceLocalRole.ALLOWEE, + state = AllowanceLifecycleState.PROPOSED, + proposedByMe = false, + ) + records = listOf( + fixtures.record( + allowanceId = "Y", + counterparty = secondAllower, + localRole = AllowanceLocalRole.ALLOWEE, + state = AllowanceLifecycleState.PROPOSED, + proposedByMe = false, + ), + offerX, + ) + whenever(sdk.acceptAllowance(any(), any(), any())).thenAnswer { + if (it.getArgument(1) == "Y") { + records = listOf( + fixtures.record( + allowanceId = "Y", + counterparty = secondAllower, + localRole = AllowanceLocalRole.ALLOWEE, + proposedByMe = false, + ), + offerX, + fixtures.record( + allowanceId = "X", + counterparty = asker, + localRole = AllowanceLocalRole.ALLOWER, + state = AllowanceLifecycleState.PROPOSED, + proposedByMe = false, + ), + ) + } + records.first() + } + sut.activate(identity) + + sut.acceptOffersFromAllowers() + + verify(sdk).acceptAllowance(eq(fixtures.counterpartyKey), eq("X"), any()) + verify(sdk, never()).acceptAllowance(eq(asker), eq("X"), any()) + } + + @Test + fun `offer accepted after the identity changed is not announced`() = test { + records = listOf(receivedProposal()) + whenever(sdk.acceptAllowance(any(), any(), any())).thenAnswer { + sut.deactivate() + records.single() + } + sut.activate(identity) + + assertTrue(sut.acceptOffersFromAllowers().isEmpty()) + verify(sdk).acceptAllowance(fixtures.counterpartyKey, ALLOWANCE_ID, 1uL) + } + @Test fun `ask from the allowee is left for the review sheet`() = test { // The proposer took the allowee role, so this wallet is the allower and pays: the user decides. @@ -595,6 +674,13 @@ class PaykitAllowanceRepoTest : BaseUnitTest() { createdAtMillis = fixtures.now.toEpochMilliseconds(), ) + private fun acceptFlipsRecordToActive() { + whenever { sdk.acceptAllowance(any(), any(), any()) }.thenAnswer { + records = listOf(fixtures.record(localRole = AllowanceLocalRole.ALLOWEE, proposedByMe = false)) + records.single() + } + } + private fun receivedProposal() = fixtures.record( localRole = AllowanceLocalRole.ALLOWEE, state = AllowanceLifecycleState.PROPOSED, diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index c7a1532c23..c3a71c0827 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -698,7 +698,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(paykitAllowanceRepo.events).thenReturn(MutableSharedFlow()) whenever { paykitAllowanceRepo.refresh() }.thenReturn(Result.success(Unit)) whenever { paykitAllowanceRepo.beginManualPayment(any(), any()) }.thenReturn(Result.success(null)) - whenever { paykitAllowanceRepo.acceptOffersFromAllowers() }.thenReturn(emptyList()) + whenever { paykitAllowanceRepo.acceptOffersFromAllowers(any()) }.thenReturn(emptyList()) whenever { paykitAllowanceRepo.processIncomingRequests(any()) }.thenReturn(false) whenever { paykitAllowanceRepo.isAutomaticallyHandling(any()) }.thenReturn(false) whenever { paykitPaymentProofRepo.captureOriginalOnchainPayer(any()) } From 88ac11650834128c74a6b36c5357b1e1f235df87 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 20 Oct 2026 16:10:03 +0200 Subject: [PATCH 5/8] test: present an allowee's ask by its contact-scoped entry id --- .../test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt index 40d74228da..5f6c270da7 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt @@ -462,7 +462,7 @@ class PaykitAllowanceRepoTest : BaseUnitTest() { assertTrue(sut.acceptOffersFromAllowers().isEmpty()) verify(sdk, never()).acceptAllowance(any(), any(), any()) - assertEquals(ALLOWANCE_ID, sut.proposalForPresentation()?.id) + assertEquals(entryKey, sut.proposalForPresentation()?.id) assertEquals(PaykitAllowance.Status.AWAITING_MY_ANSWER, sut.entries.value.single().status(fixtures.now)) } From 9b64319de8895797b09126d9ace006dc6c124e3b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 20 Oct 2026 18:16:51 +0200 Subject: [PATCH 6/8] test: drop a duplicated verify left by the base merge --- .../test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt index 5f6c270da7..c5e79af4cb 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt @@ -503,7 +503,6 @@ class PaykitAllowanceRepoTest : BaseUnitTest() { assertTrue(sut.acceptOffersFromAllowers().isEmpty()) verify(sdk, never()).acceptAllowance(any(), any(), any()) - verify(sdk, never()).acceptAllowance(any(), any(), any()) } @Test From 60d82cc3a72dc3afd0d0a14c220102f54c6a1503 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 21 Oct 2026 20:44:54 +0200 Subject: [PATCH 7/8] feat: translate the allowance added notification --- app/src/main/res/values-ar/strings.xml | 2 ++ app/src/main/res/values-b+es+419/strings.xml | 2 ++ app/src/main/res/values-ca/strings.xml | 2 ++ app/src/main/res/values-cs/strings.xml | 2 ++ app/src/main/res/values-de/strings.xml | 2 ++ app/src/main/res/values-el/strings.xml | 2 ++ app/src/main/res/values-es-rES/strings.xml | 2 ++ app/src/main/res/values-es/strings.xml | 2 ++ app/src/main/res/values-fr/strings.xml | 2 ++ app/src/main/res/values-it/strings.xml | 2 ++ app/src/main/res/values-nl/strings.xml | 2 ++ app/src/main/res/values-pl/strings.xml | 2 ++ app/src/main/res/values-pt-rBR/strings.xml | 2 ++ app/src/main/res/values-pt/strings.xml | 2 ++ app/src/main/res/values-ru/strings.xml | 2 ++ 15 files changed, 30 insertions(+) diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 597ab37d93..7f50972a6f 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -1475,6 +1475,8 @@ الميزانية الشهرية الحد الشهري أضف جهة اتصال أولًا. تغطي الميزانيات طلبات الدفع من جهات اتصالك. + {name} أعدّ لك ميزانية + تمت إضافة الميزانية ستدفع محفظة {name} طلباتك ضمن هذه الحدود دون السؤال في كل مرة. يمكن لأي منكما إنهاؤها. {name} يعرض عليك\n<accent>ميزانية</accent> تم دفع {amount} تلقائيًا diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml index 0d7c6b6457..ac40936a2d 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -1490,6 +1490,8 @@ Asignación mensual Límite mensual Primero añada un contacto. Las asignaciones cubren las solicitudes de pago de sus contactos. + {name} configuró una asignación para usted + Asignación agregada La billetera de {name} pagará sus solicitudes dentro de estos límites sin preguntar cada vez. Cualquiera de los dos puede finalizarla. {name} le ofrece\n<accent>una asignación</accent> {amount} pagado automáticamente diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 77d5335ca7..bfccfd69cb 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -1487,6 +1487,8 @@ Assignació mensual Límit mensual Primer afegeix un contacte. Les assignacions cobreixen les sol·licituds de pagament dels teus contactes. + {name} t\'ha configurat una assignació + Assignació afegida La cartera de {name} pagarà les teves sol·licituds dins d\'aquests límits sense preguntar cada vegada. Qualsevol de vosaltres hi pot posar fi. {name} t\'ofereix\n<accent>una assignació</accent> {amount} pagat automàticament diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 7729d92c97..0c24b9caf1 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -1489,6 +1489,8 @@ Měsíční rozpočet Měsíční limit Nejprve přidejte kontakt. Rozpočty se vztahují na žádosti o platbu od vašich kontaktů. + {name} vám nastavil rozpočet + Rozpočet přidán Peněženka uživatele {name} bude platit vaše žádosti v rámci těchto limitů, aniž by se pokaždé ptala. Kterákoli strana jej může ukončit. {name} nabízí\n<accent>rozpočet</accent> {amount} zaplaceno automaticky diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 1ce7c4c9b2..154a444aa5 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1488,6 +1488,8 @@ Monatliches Budget Monatslimit Füge zuerst einen Kontakt hinzu. Budgets gelten für Zahlungsanforderungen deiner Kontakte. + {name} hat dir ein Budget eingerichtet + Budget hinzugefügt Die Wallet von {name} bezahlt deine Anforderungen innerhalb dieser Limits, ohne jedes Mal nachzufragen. Ihr könnt es beide beenden. {name} bietet dir\n<accent>ein Budget</accent> {amount} automatisch bezahlt diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index d2f008dc41..b600260071 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -1481,6 +1481,8 @@ Μηνιαίος προϋπολογισμός Μηνιαίο όριο Πρόσθεσε πρώτα μια επαφή. Οι προϋπολογισμοί καλύπτουν αιτήματα πληρωμής από τις επαφές σου. + {name} σού όρισε έναν προϋπολογισμό + Ο προϋπολογισμός προστέθηκε Το πορτοφόλι της επαφής {name} θα πληρώνει τα αιτήματά σου εντός αυτών των ορίων χωρίς να ρωτά κάθε φορά. Οποιοσδήποτε από τους δύο μπορεί να τον τερματίσει. {name} σού προσφέρει\n<accent>έναν προϋπολογισμό</accent> {amount} πληρώθηκαν αυτόματα diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index ce4b894458..b9fca3b8b6 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -1485,6 +1485,8 @@ Asignación mensual Límite mensual Primero añade un contacto. Las asignaciones cubren las solicitudes de pago de tus contactos. + {name} te ha configurado una asignación + Asignación añadida El monedero de {name} pagará tus solicitudes dentro de estos límites sin preguntar cada vez. Cualquiera de los dos puede finalizarla. {name} te ofrece\n<accent>una asignación</accent> {amount} pagado automáticamente diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 4a12f3a47b..1a6adb3fca 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1485,6 +1485,8 @@ Asignación mensual Límite mensual Primero añada un contacto. Las asignaciones cubren las solicitudes de pago de sus contactos. + {name} configuró una asignación para usted + Asignación añadida El monedero de {name} pagará sus solicitudes dentro de estos límites sin preguntar cada vez. Cualquiera de los dos puede finalizarla. {name} le ofrece\n<accent>una asignación</accent> {amount} pagado automáticamente diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index ea86fd2499..80f8deb062 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -1486,6 +1486,8 @@ Allocation mensuelle Limite mensuelle Ajoutez d\'abord un contact. Les allocations couvrent les demandes de paiement de vos contacts. + {name} vous a configuré une allocation + Allocation ajoutée Le portefeuille de {name} paiera vos demandes dans ces limites sans demander à chaque fois. Chacun de vous peut y mettre fin. {name} vous offre\n<accent>une allocation</accent> {amount} payé automatiquement diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 1baa5020fe..fcf93ea711 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -1489,6 +1489,8 @@ Budget mensile Limite mensile Aggiungi prima un contatto. I budget coprono le richieste di pagamento dei tuoi contatti. + {name} ha impostato un budget per te + Budget Aggiunto Il wallet di {name} pagherà le tue richieste entro questi limiti senza chiedere ogni volta. Ognuno di voi può terminarlo. {name} ti offre\n<accent>un budget</accent> {amount} pagati automaticamente diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index e5ba0e3cd1..d89057a73a 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -1487,6 +1487,8 @@ Maandelijkse toelage Maandlimiet Voeg eerst een contact toe. Toelagen gelden voor betaalverzoeken van uw contacten. + {name} heeft een toelage voor u ingesteld + Toelage toegevoegd De wallet van {name} betaalt uw verzoeken binnen deze limieten zonder elke keer te vragen. Beide partijen kunnen de toelage beëindigen. {name} biedt\n<accent>een toelage</accent> aan {amount} automatisch betaald diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index a0bd13203d..cf8c71e969 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -1488,6 +1488,8 @@ Budżet miesięczny Limit miesięczny Najpierw dodaj kontakt. Budżety obejmują żądania płatności od Twoich kontaktów. + {name} ustawił(a) dla Ciebie budżet + Budżet dodany Portfel {name} będzie opłacać Twoje żądania w ramach tych limitów bez pytania za każdym razem. Każda ze stron może go zakończyć. {name} oferuje\n<accent>budżet</accent> {amount} opłacono automatycznie diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 40d092d2e2..ab90784d51 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -1489,6 +1489,8 @@ Mesada mensal Limite mensal Adicione um contato primeiro. As mesadas cobrem solicitações de pagamento dos seus contatos. + {name} configurou uma mesada para você + Mesada Adicionada A carteira de {name} pagará suas solicitações dentro destes limites sem perguntar a cada vez. Qualquer um de vocês pode encerrá-la. {name} oferece\n<accent>uma mesada</accent> {amount} pago automaticamente diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 2a9cf41e87..dd785f7be5 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -1475,6 +1475,8 @@ Mesada mensal Limite mensal Adicione primeiro um contacto. As mesadas cobrem pedidos de pagamento dos seus contactos. + {name} configurou uma mesada para si + Mesada adicionada A carteira de {name} pagará os seus pedidos dentro destes limites sem perguntar de cada vez. Qualquer uma das partes pode terminá-la. {name} oferece\n<accent>uma mesada</accent> {amount} pago automaticamente diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index bb02ac51e7..11da04328f 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1517,6 +1517,8 @@ Bitkit должен увеличить приёмную ёмкость ваше Ежемесячный бюджет Месячный лимит Сначала добавьте контакт. Бюджеты распространяются на запросы на оплату от ваших контактов. + {name} настроил(а) для вас бюджет + Бюджет Добавлен Кошелёк {name} будет оплачивать ваши запросы в пределах этих лимитов, не спрашивая каждый раз. Любая из сторон может его завершить. {name} предлагает\n<accent>бюджет</accent> {amount} оплачено автоматически From e45f4ecb822193bc7837d934ef24c722c760cc9d Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 22 Oct 2026 13:04:04 +0200 Subject: [PATCH 8/8] fix: announce an offer accepted as the app goes to the background --- .../repositories/PaykitAllowanceRepo.kt | 10 +++-- .../java/to/bitkit/viewmodels/AppViewModel.kt | 8 ++-- .../repositories/PaykitAllowanceRepoTest.kt | 37 +++++++++++++++++-- .../viewmodels/AppViewModelSendFlowTest.kt | 2 +- 4 files changed, 44 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt index 82eb6c8665..bf7a8329df 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt @@ -282,14 +282,16 @@ class PaykitAllowanceRepo @Inject constructor( /** * Accepts the offers that make this wallet the allowee and returns the ones now active so the caller can tell the - * user. A failed accept stays unanswered and is tried again on the next refresh. Stops, and returns nothing to - * announce, once the identity it started with is gone or [canContinue] turns false. + * user. A failed accept stays unanswered and is tried again on the next refresh. Stops accepting once + * [canContinue] turns false and still returns what it accepted, since an active allowance is never announced + * later; returns nothing to announce once the identity it started with is gone or [canAnnounce] turns false. */ suspend fun acceptOffersFromAllowers( + canAnnounce: () -> Boolean = { true }, canContinue: () -> Boolean = { true }, ): List = withContext(ioDispatcher) { val identity = activeIdentity ?: return@withContext emptyList() - val isCurrent = { activeIdentity == identity && canContinue() } + val isCurrent = { activeIdentity == identity && canAnnounce() } val offers = _entries.value.filter { entry -> val answerable = entry.allowances.filter { it.isAnswerable } answerable.isNotEmpty() && answerable.all { it.isOfferFromAllower } @@ -299,7 +301,7 @@ class PaykitAllowanceRepo @Inject constructor( try { val accepted = mutableListOf() for (offer in offers) { - if (!isCurrent()) return@withContext emptyList() + if (!isCurrent() || !canContinue()) break // Answers the entry that was checked, so an ask that joined it since is never accepted here. respond(offer) { acceptProposal(it) } .onSuccess { accepted += offer } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 1eb2fdeba5..47c441e63d 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -1189,10 +1189,10 @@ class AppViewModel @Inject constructor( releaseCoverage() } return result.onSuccess { - val acceptedOffers = paykitAllowanceRepo.acceptOffersFromAllowers { - !privatePaykitRepo.isProfileDeletionInProgress.value && - !(onlyWhileActive && isPaymentRequestPollingStopped.value) - } + val acceptedOffers = paykitAllowanceRepo.acceptOffersFromAllowers( + canAnnounce = { !privatePaykitRepo.isProfileDeletionInProgress.value }, + canContinue = { !(onlyWhileActive && isPaymentRequestPollingStopped.value) }, + ) announceAcceptedAllowanceOffers(acceptedOffers) if (processAllowanceRequests()) paykitPaymentRequestRepo.refresh() presentNextIncomingPaykitPaymentRequest() diff --git a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt index ded89e690a..b101c6ff1e 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt @@ -377,18 +377,47 @@ class PaykitAllowanceRepoTest : BaseUnitTest() { } @Test - fun `offers are accepted and announced only while the session is current`() = test { + fun `offers are neither accepted nor announced while the session cannot announce or continue`() = test { records = listOf(receivedProposal()) acceptFlipsRecordToActive() sut.activate(identity) - assertTrue(sut.acceptOffersFromAllowers { false }.isEmpty()) + assertTrue(sut.acceptOffersFromAllowers(canAnnounce = { false }).isEmpty()) + assertTrue(sut.acceptOffersFromAllowers(canContinue = { false }).isEmpty()) + verify(sdk, never()).acceptAllowance(any(), any(), any()) + } + + @Test + fun `offer accepted as the app backgrounds is still announced and no further offer is accepted`() = test { + val secondOffer = fixtures.record( + allowanceId = "Y", + counterparty = fixtures.otherCounterpartyKey, + localRole = AllowanceLocalRole.ALLOWEE, + state = AllowanceLifecycleState.PROPOSED, + proposedByMe = false, + ) + records = listOf(receivedProposal(), secondOffer) + whenever(sdk.acceptAllowance(any(), any(), any())).thenAnswer { records.first() } + sut.activate(identity) + + var checks = 0 + val announced = sut.acceptOffersFromAllowers(canContinue = { ++checks == 1 }) + + assertEquals(1, announced.size, "An offer already accepted is announced, since it is never announced later") + verify(sdk, times(1)).acceptAllowance(any(), any(), any()) + } + + @Test + fun `offer accepted while the profile is being deleted is not announced`() = test { + records = listOf(receivedProposal()) + acceptFlipsRecordToActive() + sut.activate(identity) var checks = 0 - val endedDuringAccept = sut.acceptOffersFromAllowers { ++checks == 1 } + val announced = sut.acceptOffersFromAllowers(canAnnounce = { ++checks == 1 }) - assertTrue(endedDuringAccept.isEmpty(), "An accept that outlived its session is not announced") + assertTrue(announced.isEmpty(), "An accept that outlived its session is not announced") verify(sdk).acceptAllowance(fixtures.counterpartyKey, ALLOWANCE_ID, 1uL) } diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 1be48ab769..3efc838b5c 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -710,7 +710,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(paykitAllowanceRepo.events).thenReturn(MutableSharedFlow()) whenever { paykitAllowanceRepo.refresh() }.thenReturn(Result.success(Unit)) whenever { paykitAllowanceRepo.beginManualPayment(any(), any()) }.thenReturn(Result.success(null)) - whenever { paykitAllowanceRepo.acceptOffersFromAllowers(any()) }.thenReturn(emptyList()) + whenever { paykitAllowanceRepo.acceptOffersFromAllowers(any(), any()) }.thenReturn(emptyList()) whenever { paykitAllowanceRepo.processIncomingRequests(any()) }.thenReturn(false) whenever { paykitAllowanceRepo.isAutomaticallyHandling(any()) }.thenReturn(false) whenever(paykitAllowanceRepo.isUpdating).thenReturn(false)