Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
0c8f19a
feat: accept allowance offers automatically on the allowee's wallet
ovitrif Sep 30, 2026
6d92ed7
chore: rename changelog fragment
ovitrif Sep 30, 2026
1d91bd6
Merge remote-tracking branch 'origin/feat/paykit-allowances' into fea…
ovitrif Oct 1, 2026
c24fa2c
Merge remote-tracking branch 'origin/feat/paykit-allowances' into fea…
ovitrif Oct 1, 2026
a3861ce
test: align offer acceptance tests with identity-bound allowances
ovitrif Oct 1, 2026
528fb7b
Merge remote-tracking branch 'origin/feat/paykit-allowances' into fea…
ovitrif Oct 1, 2026
e7a8bd3
Merge remote-tracking branch 'origin/feat/paykit-allowances' into fea…
ovitrif Oct 2, 2026
df5105a
chore: sync allowance auto-accept with feat/paykit-allowances
ovitrif Oct 19, 2026
bdfa7b0
Merge remote-tracking branch 'origin/feat/paykit-allowances' into fea…
ovitrif Oct 19, 2026
19268b6
Merge remote-tracking branch 'origin/feat/paykit-allowances' into fea…
ovitrif Oct 19, 2026
211d4c2
Merge remote-tracking branch 'origin/feat/paykit-allowances' into fea…
ovitrif Oct 19, 2026
fd76015
Merge remote-tracking branch 'origin/feat/paykit-allowances' into fea…
ovitrif Oct 19, 2026
1b61baa
Merge remote-tracking branch 'origin/feat/paykit-allowances' into fea…
ovitrif Oct 19, 2026
ba1c9bc
Merge remote-tracking branch 'origin/feat/paykit-allowances' into fea…
ovitrif Oct 19, 2026
6d5010f
fix: accept allowance offers only as checked and while the session lasts
ovitrif Oct 19, 2026
98de834
Merge remote-tracking branch 'origin/feat/paykit-allowances' into fea…
ovitrif Oct 20, 2026
53540d2
test: present an allowee's ask by its contact-scoped entry id
ovitrif Oct 20, 2026
235c636
test: drop a duplicated verify left by the base merge
ovitrif Oct 20, 2026
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 @@ -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 {
Expand Down
55 changes: 46 additions & 9 deletions app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,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 @@ -238,12 +239,45 @@ class PaykitAllowanceRepo @Inject constructor(
}
}

suspend fun accept(entryId: String): Result<Unit> = respond(entryId) {
val proposalStreamItemId = it.proposalStreamItemId ?: throw PaykitAllowanceError.Unavailable
paykitSdkService.acceptAllowance(it.counterparty, it.allowanceId, proposalStreamItemId)
suspend fun accept(entryId: String): Result<Unit> = 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. Stops, and returns nothing to
* announce, once the identity it started with is gone or [canContinue] turns false.
*/
suspend fun acceptOffersFromAllowers(
canContinue: () -> Boolean = { true },
): List<PaykitAllowanceEntry> = 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 }
}
if (offers.isEmpty() || !isAcceptingOffers.compareAndSet(false, true)) return@withContext emptyList()

try {
val accepted = mutableListOf<PaykitAllowanceEntry>()
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<Unit> = respond(entryId) {
suspend fun reject(entryId: String): Result<Unit> = respond(entry(entryId)) {
paykitSdkService.rejectAllowance(it.counterparty, it.allowanceId)
}

Expand All @@ -262,20 +296,22 @@ class PaykitAllowanceRepo @Inject constructor(
}

private suspend fun respond(
entryId: String,
entry: PaykitAllowanceEntry?,
response: suspend (PaykitAllowance) -> AllowanceRecord,
): Result<Unit> = 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) {
response(allowance)
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
}
Expand All @@ -290,11 +326,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 } &&
Comment thread
ovitrif marked this conversation as resolved.
entry.allowances.none { it.key in presented }
}
}

Expand Down
18 changes: 18 additions & 0 deletions app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1179,6 +1180,11 @@ class AppViewModel @Inject constructor(
clearHandledSubscriptionNotification()
if (mode == PaykitPaymentRequestRefreshMode.FULL) reconcilePaykitUsdt()
paykitAllowanceRepo.refresh()
val acceptedOffers = paykitAllowanceRepo.acceptOffersFromAllowers {
!privatePaykitRepo.isProfileDeletionInProgress.value &&
!(onlyWhileActive && isPaymentRequestPollingStopped.value)
}
announceAcceptedAllowanceOffers(acceptedOffers)
if (processAllowanceRequests()) paykitPaymentRequestRepo.refresh()
presentNextIncomingPaykitPaymentRequest()
}
Expand Down Expand Up @@ -1224,6 +1230,18 @@ class AppViewModel @Inject constructor(
}
}

private fun announceAcceptedAllowanceOffers(offers: List<PaykitAllowanceEntry>) {
offers.forEach { offer ->
notifyAllowanceEvent(
Comment thread
ovitrif marked this conversation as resolved.
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.
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 @@ -1103,6 +1103,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
Loading
Loading