Skip to content
Draft
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
7 changes: 7 additions & 0 deletions app/src/main/java/to/bitkit/repositories/PaykitAllowance.kt
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,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 {
Expand Down
29 changes: 27 additions & 2 deletions app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,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<PaykitPaymentRequestId, Int>()
private val _allowances = MutableStateFlow<List<PaykitAllowance>>(emptyList())
private val _localState = MutableStateFlow(PaykitAllowanceLocalState())
Expand Down Expand Up @@ -245,6 +246,29 @@ class PaykitAllowanceRepo @Inject constructor(
paykitSdkService.acceptAllowance(it.counterparty, 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<PaykitAllowanceEntry> = 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<Unit> = respond(entryId) {
paykitSdkService.rejectAllowance(it.counterparty, it.allowanceId)
}
Expand Down Expand Up @@ -292,11 +316,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 }
}
}

Expand Down
14 changes: 14 additions & 0 deletions app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ import to.bitkit.repositories.LightningRepo
import to.bitkit.repositories.LnurlPayInvoiceMismatchError
import to.bitkit.repositories.MethodId
import to.bitkit.repositories.NodeEventUpdate
import to.bitkit.repositories.PaykitAllowanceEntry
import to.bitkit.repositories.PaykitAllowanceError
import to.bitkit.repositories.PaykitAllowanceEvent
import to.bitkit.repositories.PaykitAllowanceRepo
Expand Down Expand Up @@ -888,6 +889,7 @@ class AppViewModel @Inject constructor(
paykitPaymentRequestRepo.refresh().onSuccess {
activityRepo.backfillPaykitContacts()
paykitAllowanceRepo.refresh()
announceAcceptedAllowanceOffers(paykitAllowanceRepo.acceptOffersFromAllowers())
if (paykitAllowanceRepo.processIncomingRequests(paykitPaymentRequestRepo.pendingRequests.value)) {
paykitPaymentRequestRepo.refresh()
}
Expand Down Expand Up @@ -925,6 +927,18 @@ class AppViewModel @Inject constructor(
}
}

private fun announceAcceptedAllowanceOffers(offers: List<PaykitAllowanceEntry>) {
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.
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1095,6 +1095,8 @@
<string name="subscriptions__allowance_monthly_allowance">Monthly allowance</string>
<string name="subscriptions__allowance_monthly_limit">Monthly limit</string>
<string name="subscriptions__allowance_no_contacts">Add a contact first. Allowances cover payment requests from your contacts.</string>
<string name="subscriptions__allowance_offer_accepted_description">{name} set up an allowance for you</string>
<string name="subscriptions__allowance_offer_accepted_title">Allowance Added</string>
<string name="subscriptions__allowance_offer_explanation">{name}\'s wallet will pay your requests within these limits without asking each time. Either of you can end it.</string>
<string name="subscriptions__allowance_offer_headline"><![CDATA[{name} offers\n<accent>an allowance</accent>]]></string>
<string name="subscriptions__allowance_paid_automatically">{amount} paid automatically</string>
Expand Down
104 changes: 102 additions & 2 deletions app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -271,8 +271,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())).thenReturn(proposalRecord)
sut.activate(identity)
Expand All @@ -289,6 +289,100 @@ class PaykitAllowanceRepoTest : BaseUnitTest() {
verify(sdk).processOutboundPrivateMessages(fixtures.counterpartyKey)
}

@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())).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, 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)
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())
}

@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())).thenThrow(RuntimeException("offline"))
sut.activate(identity)

assertTrue(sut.acceptOffersFromAllowers().isEmpty())
assertNull(sut.proposalForPresentation())

whenever(sdk.acceptAllowance(any(), any())).thenReturn(offer)
assertEquals(1, sut.acceptOffersFromAllowers().size)
verify(sdk, times(2)).acceptAllowance(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())
assertEquals(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())
}

@Test
fun `offers are not accepted without an identity`() = test {
records = listOf(receivedProposal())

assertTrue(sut.acceptOffersFromAllowers().isEmpty())

verify(sdk, never()).acceptAllowance(any(), any())
}

@Test
fun `answering fails when nothing in the entry awaits an answer`() = test {
records = listOf(fixtures.record())
Expand Down Expand Up @@ -452,6 +546,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,
Expand Down
33 changes: 32 additions & 1 deletion app/src/test/java/to/bitkit/repositories/PaykitAllowanceTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,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)
Expand Down Expand Up @@ -560,11 +590,12 @@ internal object PaykitAllowanceFixtures {
monthlyAnchor: Instant? = septemberAnchor,
expiresAt: Instant? = null,
lastEventAt: Instant? = null,
isProposedByMe: Boolean = role == PaykitAllowance.Role.ALLOWER,
) = PaykitAllowance(
id = PaykitAllowance.Id(counterparty, allowanceId),
role = role,
lifecycleState = state,
isProposedByMe = role == PaykitAllowance.Role.ALLOWER,
isProposedByMe = isProposedByMe,
perPaymentMaxSats = perPaymentMaxSats,
monthlyLimitSats = monthlyLimitSats,
monthlyAnchor = monthlyAnchor,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,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(), any()) }.thenReturn(Result.success(Unit))
Expand Down
1 change: 1 addition & 0 deletions changelog.d/next/1385.added.md
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 12 additions & 8 deletions journeys/allowances/README.md
Original file line number Diff line number Diff line change
@@ -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.

Expand All @@ -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
Expand All @@ -39,13 +40,16 @@ killed right after handing the payment to the node never paid twice after relaun
`AllowancePerPaymentStop-<index>` and `AllowanceMonthlyStop-<index>`, `AllowanceSummary`,
`AllowanceSave`
- List row: `AllowanceRow-<allowanceId>` 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-<paymentRequestId>`
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 "<payer> 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.
Loading
Loading