diff --git a/app/src/main/java/to/bitkit/repositories/PaykitAllowance.kt b/app/src/main/java/to/bitkit/repositories/PaykitAllowance.kt index 0a5e2a0dcb..3e502fce26 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitAllowance.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitAllowance.kt @@ -84,6 +84,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 802b0ebe0f..bf7a8329df 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt @@ -105,6 +105,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 updates = AtomicInteger(0) /** True while allowances are refreshing or requests are being admitted, when coverage may still change. */ @@ -272,12 +273,47 @@ 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) } - suspend fun reject(entryId: String): Result = respond(entryId) { + /** + * 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 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 && canAnnounce() } + 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 { + val accepted = mutableListOf() + for (offer in offers) { + 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 } + .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(entry(entryId)) { paykitSdkService.rejectAllowance(it.counterparty, it.allowanceId) } @@ -296,12 +332,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) { @@ -309,7 +345,9 @@ class PaykitAllowanceRepo @Inject constructor( sendQueuedMessages(allowance.counterparty) } val keys = entry.allowances.map { it.key } - executor.updateLocalState(identity) { it.copy(presentedProposalIds = it.presentedProposalIds + keys) } + if (activeIdentity == identity) { + executor.updateLocalState(identity) { it.copy(presentedProposalIds = it.presentedProposalIds + keys) } + } refresh() Unit } @@ -324,11 +362,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.key in presented } + entry.allowances.any { it.isAnswerable && !it.isOfferFromAllower } && + entry.allowances.none { it.key 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 7681b34a00..3f8023b93a 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -179,6 +179,7 @@ import to.bitkit.repositories.OnchainSendBlockedError import to.bitkit.repositories.OnchainSendNotDispatchedError import to.bitkit.repositories.OnchainSendOutcome import to.bitkit.repositories.OnchainSendPendingError +import to.bitkit.repositories.PaykitAllowanceEntry import to.bitkit.repositories.PaykitAllowanceError import to.bitkit.repositories.PaykitAllowanceEvent import to.bitkit.repositories.PaykitAllowanceRepo @@ -1189,6 +1190,11 @@ class AppViewModel @Inject constructor( releaseCoverage() } return result.onSuccess { + val acceptedOffers = paykitAllowanceRepo.acceptOffersFromAllowers( + canAnnounce = { !privatePaykitRepo.isProfileDeletionInProgress.value }, + canContinue = { !(onlyWhileActive && isPaymentRequestPollingStopped.value) }, + ) + announceAcceptedAllowanceOffers(acceptedOffers) if (processAllowanceRequests()) paykitPaymentRequestRepo.refresh() presentNextIncomingPaykitPaymentRequest() } @@ -1241,6 +1247,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. When notifications are off they fall back to a toast. 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} оплачено автоматически diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 96ff21f7d3..f13733355e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1103,6 +1103,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 f227257140..b101c6ff1e 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt @@ -315,8 +315,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) @@ -333,6 +333,208 @@ 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(), 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, 1uL) + 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(), any()) + } + + @Test + 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(), "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") + + 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 neither accepted nor announced while the session cannot announce or continue`() = test { + records = listOf(receivedProposal()) + acceptFlipsRecordToActive() + sut.activate(identity) + + 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 announced = sut.acceptOffersFromAllowers(canAnnounce = { ++checks == 1 }) + + assertTrue(announced.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. + records = listOf(receivedAsk()) + sut.activate(identity) + + assertTrue(sut.acceptOffersFromAllowers().isEmpty()) + + verify(sdk, never()).acceptAllowance(any(), any(), any()) + assertEquals(entryKey, 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 `accepting a proposal never answers another contact's proposal with the same id`() = test { val offer = receivedProposal() @@ -344,16 +546,17 @@ class PaykitAllowanceRepoTest : BaseUnitTest() { lastEventAt = "2026-09-01T10:00:00Z", ) records = listOf(offer, ask) - whenever(sdk.acceptAllowance(any(), any(), any())).thenReturn(offer) + whenever(sdk.acceptAllowance(any(), any(), any())).thenReturn(ask) sut.activate(identity) + // The offer is accepted automatically, so the sheet presents the other contact's ask. val presented = checkNotNull(sut.proposalForPresentation()) - assertEquals(PaykitAllowance.Role.ALLOWEE, presented.role) - assertEquals(fixtures.counterpartyKey, presented.counterparty) + assertEquals(PaykitAllowance.Role.ALLOWER, presented.role) + assertEquals(fixtures.otherCounterpartyKey, presented.counterparty) assertTrue(sut.accept(presented.id).isSuccess) - verify(sdk).acceptAllowance(fixtures.counterpartyKey, ALLOWANCE_ID, 1uL) - verify(sdk, never()).acceptAllowance(eq(fixtures.otherCounterpartyKey), any(), any()) + verify(sdk).acceptAllowance(fixtures.otherCounterpartyKey, ALLOWANCE_ID, 1uL) + verify(sdk, never()).acceptAllowance(eq(fixtures.counterpartyKey), any(), any()) } @Test @@ -585,12 +788,25 @@ 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, 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 fcc64ec732..b8b2101055 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceTest.kt @@ -46,6 +46,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) @@ -603,11 +633,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, diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 6e1288040e..f8ebd1a614 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -712,6 +712,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(), any()) }.thenReturn(emptyList()) whenever { paykitAllowanceRepo.processIncomingRequests(any()) }.thenReturn(false) whenever { paykitAllowanceRepo.isAutomaticallyHandling(any()) }.thenReturn(false) whenever(paykitAllowanceRepo.isUpdating).thenReturn(false) diff --git a/changelog.d/next/1385.added.md b/changelog.d/next/1385.added.md new file mode 100644 index 0000000000..70ea6904fb --- /dev/null +++ b/changelog.d/next/1385.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 e362b0e19e..e438e131ed 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,8 +40,10 @@ 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`; the entry id is the payer's grant id, or the contact key and allowance id on the payee -- 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 @@ -50,6 +53,7 @@ A payer who requires a PIN for payments sees the PIN pad (or biometrics) over th an allowance, and when accepting an allowance a payee asked for; the payee's answers and automatic payments never ask. `pin-gates-allowance-changes.xml` covers it. -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/pin-gates-allowance-changes.xml b/journeys/allowances/pin-gates-allowance-changes.xml index b565565328..c4fd12ff67 100644 --- a/journeys/allowances/pin-gates-allowance-changes.xml +++ b/journeys/allowances/pin-gates-allowance-changes.xml @@ -2,8 +2,8 @@ When the payer requires a PIN for payments, setting an allowance and ending it ask for the same PIN (or biometrics) as Send before anything changes, and a cancelled check changes nothing. The - payee's answers never ask. Automatic payments under an accepted allowance never ask: the checked - grant is their authorisation. + payee's wallet accepts the offer by itself and never asks. Automatic payments under an accepted + allowance never ask: the checked grant is their authorisation. Precondition: the two-instance setup in README.md, with no allowance between them yet. On the payer, enable a PIN you choose under Settings ▸ Security ▸ PIN Code and turn on "Also require for @@ -14,10 +14,10 @@ On the payer instance, open Subscriptions, tap the Allowances tab, tap Add (testTag "AllowanceAdd"), pick the payee and keep $5 a payment and $50 a month Tap Save Allowance (testTag "AllowanceSave") Verify the PIN pad shows over the sheet, then press back - Verify the Set sheet (testTag "SetAllowance") is still showing with the same payee and limits, and the payee instance gets no review sheet + Verify the Set sheet (testTag "SetAllowance") is still showing with the same payee and limits, and the payee instance gets no "Allowance Added" notification Tap Save Allowance again and enter the PIN Verify the sheet dismisses and the payer's row reads "Waiting for an answer" - On the payee instance, verify the review sheet (testTag "AllowanceReview") opens on its own, tap Accept (testTag "AllowanceAccept") and verify no PIN is asked + On the payee instance, verify "Allowance Added" arrives with no review sheet (testTag "AllowanceReview"), no tap and no PIN, and the payer's row turns Active On the payee instance, send the payer a one-time Payment Request for $2 and verify the payer pays it automatically with no PIN pad, per auto-pay-under-limit.xml On the payer instance, tap the payee's row and swipe "Swipe To End Allowance" to the end Verify the PIN pad shows, press back, and verify the detail sheet (testTag "AllowanceDetail") is back with the swipe control at its start and the allowance still Active diff --git a/journeys/allowances/set-and-accept.xml b/journeys/allowances/set-and-accept.xml index b8cf7daa6b..426b8d28db 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-<entryId>") 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"