Skip to content

Commit 8a2c53d

Browse files
JOHNJOHN
authored andcommitted
feat: add proposal storage with identity-scoped persistence
1 parent b7bda82 commit 8a2c53d

4 files changed

Lines changed: 302 additions & 15 deletions

File tree

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
package to.bitkit.paykit.storage
2+
3+
import kotlinx.serialization.Serializable
4+
import kotlinx.serialization.decodeFromString
5+
import kotlinx.serialization.encodeToString
6+
import kotlinx.serialization.json.Json
7+
import to.bitkit.paykit.workers.DiscoveredSubscriptionProposal
8+
import to.bitkit.utils.Logger
9+
import javax.inject.Inject
10+
import javax.inject.Singleton
11+
12+
/**
13+
* Manages persistent storage of subscription proposals.
14+
*
15+
* Proposals are persisted per-identity to avoid leaking data across identities.
16+
* Seen IDs are tracked to prevent duplicate notifications.
17+
*
18+
* Accept/decline is local-only (no remote delete on provider storage).
19+
*/
20+
@Singleton
21+
class SubscriptionProposalStorage @Inject constructor(
22+
private val keychain: PaykitKeychainStorage,
23+
) {
24+
companion object {
25+
private const val TAG = "SubscriptionProposalStorage"
26+
}
27+
28+
private var proposalsCache: MutableMap<String, List<StoredProposal>>? = null
29+
private var seenIdsCache: MutableMap<String, Set<String>>? = null
30+
private var declinedIdsCache: MutableMap<String, Set<String>>? = null
31+
32+
private fun proposalsKey(identityPubkey: String) = "proposals.$identityPubkey"
33+
private fun seenIdsKey(identityPubkey: String) = "proposals.seen.$identityPubkey"
34+
private fun declinedIdsKey(identityPubkey: String) = "proposals.declined.$identityPubkey"
35+
36+
/**
37+
* List all stored proposals for the given identity.
38+
*/
39+
fun listProposals(identityPubkey: String): List<StoredProposal> {
40+
if (proposalsCache?.containsKey(identityPubkey) == true) {
41+
return proposalsCache!![identityPubkey]!!
42+
}
43+
44+
return try {
45+
val data = keychain.retrieve(proposalsKey(identityPubkey)) ?: return emptyList()
46+
val json = String(data)
47+
val proposals = Json.decodeFromString<List<StoredProposal>>(json)
48+
if (proposalsCache == null) proposalsCache = mutableMapOf()
49+
proposalsCache!![identityPubkey] = proposals
50+
proposals
51+
} catch (e: Exception) {
52+
Logger.error("Failed to load proposals", e, context = TAG)
53+
emptyList()
54+
}
55+
}
56+
57+
/**
58+
* Get pending proposals (not accepted or declined).
59+
*/
60+
fun pendingProposals(identityPubkey: String): List<StoredProposal> {
61+
val declined = getDeclinedIds(identityPubkey)
62+
return listProposals(identityPubkey).filter { it.status == ProposalStatus.PENDING && it.id !in declined }
63+
}
64+
65+
/**
66+
* Save a discovered proposal. Returns true if this is a new proposal.
67+
*/
68+
suspend fun saveProposal(
69+
identityPubkey: String,
70+
proposal: DiscoveredSubscriptionProposal,
71+
): Boolean {
72+
val proposals = listProposals(identityPubkey).toMutableList()
73+
val existing = proposals.indexOfFirst { it.id == proposal.subscriptionId }
74+
75+
if (existing >= 0) {
76+
return false
77+
}
78+
79+
proposals.add(
80+
StoredProposal(
81+
id = proposal.subscriptionId,
82+
providerPubkey = proposal.providerPubkey,
83+
amountSats = proposal.amountSats,
84+
description = proposal.description,
85+
frequency = proposal.frequency,
86+
createdAt = proposal.createdAt,
87+
status = ProposalStatus.PENDING,
88+
),
89+
)
90+
persistProposals(identityPubkey, proposals)
91+
return true
92+
}
93+
94+
/**
95+
* Mark a proposal as accepted.
96+
*/
97+
suspend fun markAccepted(identityPubkey: String, proposalId: String) {
98+
val proposals = listProposals(identityPubkey).toMutableList()
99+
val index = proposals.indexOfFirst { it.id == proposalId }
100+
if (index >= 0) {
101+
proposals[index] = proposals[index].copy(status = ProposalStatus.ACCEPTED)
102+
persistProposals(identityPubkey, proposals)
103+
}
104+
}
105+
106+
/**
107+
* Mark a proposal as declined (local-only, hides from inbox).
108+
*/
109+
suspend fun markDeclined(identityPubkey: String, proposalId: String) {
110+
val declined = getDeclinedIds(identityPubkey).toMutableSet()
111+
declined.add(proposalId)
112+
persistDeclinedIds(identityPubkey, declined)
113+
114+
val proposals = listProposals(identityPubkey).toMutableList()
115+
val index = proposals.indexOfFirst { it.id == proposalId }
116+
if (index >= 0) {
117+
proposals[index] = proposals[index].copy(status = ProposalStatus.DECLINED)
118+
persistProposals(identityPubkey, proposals)
119+
}
120+
}
121+
122+
/**
123+
* Check if a proposal has been seen (notified about).
124+
*/
125+
fun hasSeen(identityPubkey: String, proposalId: String): Boolean {
126+
return getSeenIds(identityPubkey).contains(proposalId)
127+
}
128+
129+
/**
130+
* Mark a proposal as seen (prevents duplicate notifications).
131+
*/
132+
suspend fun markSeen(identityPubkey: String, proposalId: String) {
133+
val seen = getSeenIds(identityPubkey).toMutableSet()
134+
seen.add(proposalId)
135+
persistSeenIds(identityPubkey, seen)
136+
}
137+
138+
/**
139+
* Clear all data for an identity.
140+
*/
141+
suspend fun clearAll(identityPubkey: String) {
142+
keychain.delete(proposalsKey(identityPubkey))
143+
keychain.delete(seenIdsKey(identityPubkey))
144+
keychain.delete(declinedIdsKey(identityPubkey))
145+
proposalsCache?.remove(identityPubkey)
146+
seenIdsCache?.remove(identityPubkey)
147+
declinedIdsCache?.remove(identityPubkey)
148+
}
149+
150+
private fun getSeenIds(identityPubkey: String): Set<String> {
151+
if (seenIdsCache?.containsKey(identityPubkey) == true) {
152+
return seenIdsCache!![identityPubkey]!!
153+
}
154+
155+
return try {
156+
val data = keychain.retrieve(seenIdsKey(identityPubkey)) ?: return emptySet()
157+
val json = String(data)
158+
val ids = Json.decodeFromString<Set<String>>(json)
159+
if (seenIdsCache == null) seenIdsCache = mutableMapOf()
160+
seenIdsCache!![identityPubkey] = ids
161+
ids
162+
} catch (e: Exception) {
163+
Logger.error("Failed to load seen proposal IDs", e, context = TAG)
164+
emptySet()
165+
}
166+
}
167+
168+
private fun getDeclinedIds(identityPubkey: String): Set<String> {
169+
if (declinedIdsCache?.containsKey(identityPubkey) == true) {
170+
return declinedIdsCache!![identityPubkey]!!
171+
}
172+
173+
return try {
174+
val data = keychain.retrieve(declinedIdsKey(identityPubkey)) ?: return emptySet()
175+
val json = String(data)
176+
val ids = Json.decodeFromString<Set<String>>(json)
177+
if (declinedIdsCache == null) declinedIdsCache = mutableMapOf()
178+
declinedIdsCache!![identityPubkey] = ids
179+
ids
180+
} catch (e: Exception) {
181+
Logger.error("Failed to load declined proposal IDs", e, context = TAG)
182+
emptySet()
183+
}
184+
}
185+
186+
private suspend fun persistProposals(identityPubkey: String, proposals: List<StoredProposal>) {
187+
try {
188+
val json = Json.encodeToString(proposals)
189+
keychain.store(proposalsKey(identityPubkey), json.toByteArray())
190+
if (proposalsCache == null) proposalsCache = mutableMapOf()
191+
proposalsCache!![identityPubkey] = proposals
192+
} catch (e: Exception) {
193+
Logger.error("Failed to persist proposals", e, context = TAG)
194+
throw PaykitStorageException.SaveFailed(proposalsKey(identityPubkey))
195+
}
196+
}
197+
198+
private suspend fun persistSeenIds(identityPubkey: String, ids: Set<String>) {
199+
try {
200+
val json = Json.encodeToString(ids)
201+
keychain.store(seenIdsKey(identityPubkey), json.toByteArray())
202+
if (seenIdsCache == null) seenIdsCache = mutableMapOf()
203+
seenIdsCache!![identityPubkey] = ids
204+
} catch (e: Exception) {
205+
Logger.error("Failed to persist seen proposal IDs", e, context = TAG)
206+
}
207+
}
208+
209+
private suspend fun persistDeclinedIds(identityPubkey: String, ids: Set<String>) {
210+
try {
211+
val json = Json.encodeToString(ids)
212+
keychain.store(declinedIdsKey(identityPubkey), json.toByteArray())
213+
if (declinedIdsCache == null) declinedIdsCache = mutableMapOf()
214+
declinedIdsCache!![identityPubkey] = ids
215+
} catch (e: Exception) {
216+
Logger.error("Failed to persist declined proposal IDs", e, context = TAG)
217+
}
218+
}
219+
}
220+
221+
/**
222+
* A stored subscription proposal with local status.
223+
*/
224+
@Serializable
225+
data class StoredProposal(
226+
val id: String,
227+
val providerPubkey: String,
228+
val amountSats: Long,
229+
val description: String?,
230+
val frequency: String,
231+
val createdAt: Long,
232+
val status: ProposalStatus,
233+
)
234+
235+
@Serializable
236+
enum class ProposalStatus {
237+
PENDING,
238+
ACCEPTED,
239+
DECLINED,
240+
}
241+

app/src/main/java/to/bitkit/paykit/viewmodels/SubscriptionsViewModel.kt

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import to.bitkit.paykit.models.Subscription
1414
import to.bitkit.paykit.models.SubscriptionProposal
1515
import to.bitkit.paykit.services.DirectoryService
1616
import to.bitkit.paykit.storage.AutoPayStorage
17+
import to.bitkit.paykit.storage.SubscriptionProposalStorage
1718
import to.bitkit.paykit.storage.SubscriptionStorage
1819
import to.bitkit.paykit.workers.DiscoveredSubscriptionProposal
1920
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -26,6 +27,7 @@ import javax.inject.Inject
2627
@HiltViewModel
2728
class SubscriptionsViewModel @Inject constructor(
2829
private val subscriptionStorage: SubscriptionStorage,
30+
private val proposalStorage: SubscriptionProposalStorage,
2931
private val directoryService: DirectoryService,
3032
private val autoPayStorage: AutoPayStorage,
3133
private val keyManager: KeyManager,
@@ -146,7 +148,17 @@ class SubscriptionsViewModel @Inject constructor(
146148
return@launch
147149
}
148150
runCatching {
149-
directoryService.discoverSubscriptionProposals(ownerPubkey)
151+
// Load from persisted storage (polling worker handles discovery)
152+
proposalStorage.pendingProposals(ownerPubkey).map { stored ->
153+
DiscoveredSubscriptionProposal(
154+
subscriptionId = stored.id,
155+
providerPubkey = stored.providerPubkey,
156+
amountSats = stored.amountSats,
157+
description = stored.description,
158+
frequency = stored.frequency,
159+
createdAt = stored.createdAt,
160+
)
161+
}
150162
}.onSuccess { proposals ->
151163
_uiState.update { it.copy(incomingProposals = proposals, isLoadingProposals = false) }
152164
}.onFailure { e ->
@@ -196,6 +208,12 @@ class SubscriptionsViewModel @Inject constructor(
196208
viewModelScope.launch {
197209
_uiState.update { it.copy(isAccepting = true, error = null) }
198210

211+
val ownerPubkey = keyManager.getCurrentPublicKeyZ32()
212+
if (ownerPubkey == null) {
213+
_uiState.update { it.copy(isAccepting = false, error = "No identity configured") }
214+
return@launch
215+
}
216+
199217
val subscription = Subscription.create(
200218
providerName = proposal.providerPubkey.take(8),
201219
providerPubkey = proposal.providerPubkey,
@@ -207,6 +225,9 @@ class SubscriptionsViewModel @Inject constructor(
207225
runCatching {
208226
subscriptionStorage.saveSubscription(subscription)
209227

228+
// Mark proposal as accepted in proposal storage
229+
proposalStorage.markAccepted(ownerPubkey, proposal.subscriptionId)
230+
210231
if (enableAutopay) {
211232
val rule = AutoPayRule(
212233
id = subscription.id,
@@ -229,10 +250,6 @@ class SubscriptionsViewModel @Inject constructor(
229250
autoPayStorage.savePeerLimit(limit)
230251
}
231252
}
232-
233-
// NOTE: In the v0 provider-storage model, proposals are stored on the provider's
234-
// homeserver. Subscribers cannot delete proposals from provider storage.
235-
// Mark as accepted locally; the proposal remains on provider storage (their cleanup).
236253
}.onSuccess {
237254
loadSubscriptions()
238255
loadIncomingProposals()
@@ -254,13 +271,9 @@ class SubscriptionsViewModel @Inject constructor(
254271
return@launch
255272
}
256273

257-
// NOTE: In the v0 provider-storage model, proposals are stored on the provider's
258-
// homeserver. Subscribers cannot delete proposals from provider storage.
259-
// Mark as declined locally; the proposal remains on provider storage (their cleanup).
260274
runCatching {
261-
// Local-only decline: remove from seen set to avoid re-showing
262-
// (actual remote delete is not possible in provider-storage model)
263-
Logger.debug("Declining proposal ${proposal.subscriptionId} (local-only)", context = TAG)
275+
// Mark as declined in proposal storage (local-only; no remote delete)
276+
proposalStorage.markDeclined(ownerPubkey, proposal.subscriptionId)
264277
}.onSuccess {
265278
loadIncomingProposals()
266279
_uiState.update { it.copy(isDeclining = false) }

app/src/main/java/to/bitkit/paykit/workers/PaykitPollingWorker.kt

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ class PaykitPollingWorker @AssistedInject constructor(
5757
private val paykitManager: PaykitManager,
5858
private val paymentService: PaykitPaymentService,
5959
private val paymentRequestStorage: PaymentRequestStorage,
60+
private val proposalStorage: to.bitkit.paykit.storage.SubscriptionProposalStorage,
61+
private val keyManager: to.bitkit.paykit.KeyManager,
6062
) : CoroutineWorker(appContext, workerParams) {
6163

6264
companion object {
@@ -68,7 +70,7 @@ class PaykitPollingWorker @AssistedInject constructor(
6870
private const val NODE_READY_TIMEOUT_MS = 30_000L
6971
private const val MIN_BACKOFF_MILLIS = 10_000L
7072

71-
// Set of seen request IDs to avoid duplicate notifications
73+
// Set of seen payment request IDs to avoid duplicate notifications (in-memory cache only)
7274
private val seenRequestIds = mutableSetOf<String>()
7375

7476
fun schedule(context: Context) {
@@ -297,8 +299,35 @@ class PaykitPollingWorker @AssistedInject constructor(
297299
}
298300

299301
private suspend fun processSubscriptionProposal(request: DiscoveredRequest) {
300-
// Subscription proposals always need manual approval
301-
sendSubscriptionProposalNotification(request)
302+
val identityPubkey = keyManager.getCurrentPublicKeyZ32()
303+
if (identityPubkey.isNullOrBlank()) {
304+
Logger.warn("No identity pubkey, skipping proposal processing", context = TAG)
305+
return
306+
}
307+
308+
// Check if already seen (prevents duplicate notifications across restarts)
309+
if (proposalStorage.hasSeen(identityPubkey, request.requestId)) {
310+
Logger.debug("Proposal ${request.requestId} already seen, skipping notification", context = TAG)
311+
return
312+
}
313+
314+
// Persist the proposal and mark as seen
315+
val proposal = DiscoveredSubscriptionProposal(
316+
subscriptionId = request.requestId,
317+
providerPubkey = request.fromPubkey,
318+
amountSats = request.amountSats,
319+
description = request.description,
320+
frequency = "monthly", // Default if not specified
321+
createdAt = request.createdAt,
322+
)
323+
324+
val isNew = proposalStorage.saveProposal(identityPubkey, proposal)
325+
proposalStorage.markSeen(identityPubkey, request.requestId)
326+
327+
// Only notify for new proposals
328+
if (isNew) {
329+
sendSubscriptionProposalNotification(request)
330+
}
302331
}
303332

304333
private suspend fun executePayment(request: DiscoveredRequest): kotlin.Result<Unit> = runCatching {

0 commit comments

Comments
 (0)