Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.flipcash.shared.chat

import com.flipcash.app.persistence.sources.PaymentRequestDataSource
import com.flipcash.services.models.chat.IdempotencyKey
import com.flipcash.services.chat.GroupContentCrypto
import com.flipcash.services.controllers.PaymentRequestController
Expand All @@ -17,11 +18,14 @@ import com.flipcash.services.models.paymentrequest.PaymentRequestText
import com.flipcash.services.models.paymentrequest.PaymentRequestUpdate
import com.flipcash.services.models.paymentrequest.newerOf
import com.getcode.opencode.model.core.ID
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import javax.inject.Inject
Expand All @@ -34,12 +38,17 @@ import javax.inject.Singleton
* its text is opened once, then [merge] keeps the newer copy. The chat's event stream also announces
* changes, best effort, which [onUpdates] applies by version (see [applyUpdate]); polling stays as
* reconciliation for whatever it misses.
* Every change to the held map is also written to [store] (see [persist]), which keeps the newer copy
* by the contract's rule, and a chat's stored requests are loaded into the map before its first
* refetch ([hydrate]), so cards draw offline and after a restart. Only requests are stored:
* [missing] and the in-flight fetches are per-process and start empty.
* A request's `payment_intent_id` never enters the store; [fetch] hands it to the caller.
*/
@Singleton
class PaymentRequests @Inject constructor(
private val controller: PaymentRequestController,
private val crypto: GroupContentCrypto,
private val store: PaymentRequestDataSource,
) : PaymentRequestUpdateSink {
private val requests = MutableStateFlow<Map<ID, PaymentRequest>>(emptyMap())

Expand All @@ -52,14 +61,23 @@ class PaymentRequests @Inject constructor(

private val inFlight = MutableStateFlow<Set<ID>>(emptySet())

/** The held requests made in [chatId], in no particular order. */
fun inChat(chatId: ChatId): Flow<List<PaymentRequest>> =
requests.map { all -> all.values.filter { it.chatId == chatId } }.distinctUntilChanged()
private val hydrated = MutableStateFlow<Set<ChatId>>(emptySet())

fun request(id: ID): Flow<PaymentRequest?> = requests.map { it[id] }.distinctUntilChanged()
/** The held requests made in [chatId], in no particular order; its stored ones are loaded first. */
fun inChat(chatId: ChatId): Flow<List<PaymentRequest>> = flow {
hydrate(chatId)
emitAll(requests.map { all -> all.values.filter { it.chatId == chatId } }.distinctUntilChanged())
}

/** The held request, loaded from storage first when the map has none. */
fun request(id: ID): Flow<PaymentRequest?> = flow {
if (id !in requests.value) hydrate(id)
emitAll(requests.map { it[id] }.distinctUntilChanged())
}

/** Refetches [chatId]'s open requests (first 100) and recently resolved ones (first 20). */
suspend fun loadChat(chatId: ChatId): Result<Unit> = runCatching {
hydrate(chatId)
val open = controller.getChatPaymentRequests(chatId, PaymentRequestSection.OPEN, QueryOptions(limit = OPEN_PAGE))
.getOrThrow()
val resolved = controller.getChatPaymentRequests(chatId, PaymentRequestSection.RESOLVED, QueryOptions(limit = RESOLVED_PAGE))
Expand Down Expand Up @@ -116,6 +134,7 @@ class PaymentRequests @Inject constructor(
suspend fun ingest(incoming: List<PaymentRequest>) {
val opened = incoming.map { open(it) }
requests.update { held -> opened.fold(held, ::merge) }
persist(opened)
}

/**
Expand All @@ -131,11 +150,16 @@ class PaymentRequests @Inject constructor(
is PaymentRequestUpdate.Kind.Created -> ingest(listOf(kind.paymentRequest))
is PaymentRequestUpdate.Kind.Updates, PaymentRequestUpdate.Kind.Unknown -> {
var stale = false
var applied: PaymentRequest? = null
requests.update { held ->
val outcome = applyUpdate(held, update)
stale = outcome.refetch
applied = outcome.requests[update.paymentRequestId]
?.takeIf { it !== held[update.paymentRequestId] }
outcome.requests
}
// The whole request at its new version, which the store keeps if it is newer.
applied?.let { persist(listOf(it)) }
if (stale) refetch += update.paymentRequestId
}
}
Expand All @@ -155,9 +179,63 @@ class PaymentRequests @Inject constructor(
}
}

/** Drops what the session holds; the stored requests stay for the account's next session. */
fun clear() {
requests.value = emptyMap()
_missing.value = emptySet()
hydrated.value = emptySet()
}

/**
* Drops [chatId]'s requests from what the session holds, for a chat the viewer has left. The
* stored rows go with the membership change (see `ChatMetadataDataSource.removeFromChat`); this
* is the in-memory half, and un-marks the chat as hydrated so a rejoin loads afresh.
*/
fun forget(chatId: ChatId) {
val gone = requests.value.filterValues { it.chatId == chatId }.keys
requests.update { held -> held - gone }
_missing.update { it - gone }
hydrated.update { it - chatId }
}

/** Erases the stored requests, with the rest of the account's cache. */
suspend fun clearStored() = store.clear()

/**
* Loads [chatId]'s stored requests into the map, once per session. They merge like fetched
* copies, so a newer one already held stays, and they are not written back. A store that fails
* leaves the map as it was.
*/
private suspend fun hydrate(chatId: ChatId) {
var first = false
hydrated.update { held -> first = chatId !in held; held.plusElement(chatId) }
if (!first) return
val stored = storeCatching { store.inChat(chatId) } ?: return
load(stored)
}

private suspend fun hydrate(id: ID) {
val stored = storeCatching { store.find(id) } ?: return
load(listOf(stored))
}

private suspend fun load(stored: List<PaymentRequest>) {
val opened = stored.map { open(it) }
requests.update { held -> opened.fold(held, ::merge) }
}

private suspend fun persist(written: List<PaymentRequest>) {
storeCatching { store.merge(written) }
}

// The store is a cache of what the network says: a failure to read or write it must not fail
// the fetch or the update it rode along with.
private suspend fun <T> storeCatching(block: suspend () -> T): T? = try {
block()
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
null
}

private suspend fun ingestOne(request: PaymentRequest): PaymentRequest {
Expand All @@ -167,11 +245,12 @@ class PaymentRequests @Inject constructor(

private suspend fun open(request: PaymentRequest): PaymentRequest {
val kind = request.kind as? PaymentRequest.Kind.GroupSplit ?: return request
if (kind.split.text !is PaymentRequestText.Encrypted) return request
val sealed = kind.split.text as? PaymentRequestText.Encrypted ?: return request
// A key that can't be fetched yet leaves the text sealed; a later fetch opens it.
val text = crypto.openPaymentRequestText(kind.chatId, request.creator, kind.split.text)
val text = crypto.openPaymentRequestText(kind.chatId, request.creator, sealed)
.getOrElse { return request }
return request.copy(kind = kind.copy(split = kind.split.copy(text = text)))
// The sealed form rides along so storage keeps the text as received.
return request.copy(kind = kind.copy(split = kind.split.copy(text = text, sealedText = sealed)))
}

/** [requests] after an update, and whether the request must be refetched to reconcile it. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ import com.flipcash.shared.chat.internal.RealChatCoordinator
import com.flipcash.shared.chat.internal.RealChatDraftStore
import com.flipcash.shared.chat.internal.delegates.MediaSendDelegate
import com.flipcash.shared.chat.media.ChatMediaSending
import com.flipcash.app.persistence.sources.GroupKeyEnvelopeDataSource
import com.flipcash.services.chat.GroupKeyEnvelopeStore
import com.getcode.opencode.providers.SessionListener
import dagger.Binds
import dagger.Module
Expand Down Expand Up @@ -109,6 +111,11 @@ abstract class ChatModule {
impl: PaymentRequests
): PaymentRequestUpdateSink

@Binds
internal abstract fun bindGroupKeyEnvelopeStore(
impl: GroupKeyEnvelopeDataSource
): GroupKeyEnvelopeStore

@Binds
internal abstract fun bindReactionAlertSink(
impl: ReactionAlertController
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import android.os.Handler
import android.os.Looper
import androidx.lifecycle.ProcessLifecycleOwner
import com.flipcash.libs.coroutines.DispatcherProvider
import com.flipcash.services.chat.GroupKeyEnvelopeStore
import com.flipcash.services.models.chat.ChatId
import com.flipcash.services.user.UserManager
import com.flipcash.shared.chat.ChatArchiveStore
Expand Down Expand Up @@ -109,6 +110,7 @@ class RealChatCoordinator @Inject constructor(
private val rosterUpdates: RosterUpdateSink = RosterUpdateSink.None,
private val paymentRequests: PaymentRequests? = null,
private val hostInvites: HostInvites? = null,
private val groupKeyEnvelopes: GroupKeyEnvelopeStore? = null,
) : ChatCoordinator,
SessionListener,
DefaultLifecycleObserver,
Expand Down Expand Up @@ -345,6 +347,9 @@ class RealChatCoordinator @Inject constructor(
// record is left behind, harmlessly, because the chat is no longer a member chat and
// so is in neither feed.
archiveStore.unarchive(chatId)
// The stored requests went with the membership change; this is the in-memory copy.
// A removal or remote leave has no path here, and its cards stay until the session ends.
paymentRequests?.forget(chatId)
}

/**
Expand Down Expand Up @@ -451,6 +456,9 @@ class RealChatCoordinator @Inject constructor(
// the same file — and account deletion is the one path that is meant to erase them.
draftStore.clearAll()
archiveStore.clearAll()
paymentRequests?.clearStored()
// Wrapped chat keys, stored per account like the rows above.
groupKeyEnvelopes?.clear()
trace(tag = TAG, message = "cache cleared", type = TraceType.Process)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,12 @@ class GroupFeedDelegate @Inject constructor(
override suspend fun leave(chatId: ChatId): Result<Unit> {
metadataDataSource.setMembership(chatId, isMember = false)
return chatController.leaveChat(chatId)
.onSuccess { metadataDataSource.clearViewerState(chatId) }
.onSuccess {
metadataDataSource.clearViewerState(chatId)
// On success only, like the viewer state: a failed leave keeps the user in the
// chat, which still needs its key and requests.
metadataDataSource.removeFromChat(chatId)
}
.onFailure {
trace(tag = TAG, message = "Leave failed for $chatId", type = TraceType.Error)
metadataDataSource.setMembership(chatId, isMember = true)
Expand Down Expand Up @@ -247,7 +252,7 @@ class GroupFeedDelegate @Inject constructor(

is RosterChange.MemberLeft -> {
if (change.userId != selfId) continue
metadataDataSource.setMembership(chatId, isMember = false)
metadataDataSource.removeFromChat(chatId)
// Same reason as [leave]: the mute is gone server-side the moment you are out
// of the chat, whether you left from this device or another one.
metadataDataSource.clearViewerState(chatId)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ class ChatCoordinatorTeardownTest {
private lateinit var archiveStore: ChatArchiveStore
private lateinit var groupFeedDelegate: GroupFeedDelegate
private lateinit var hostInvites: HostInvites
private lateinit var groupKeyEnvelopes: com.flipcash.services.chat.GroupKeyEnvelopeStore
private lateinit var coordinator: RealChatCoordinator

@Before
Expand All @@ -78,6 +79,7 @@ class ChatCoordinatorTeardownTest {
archiveStore = mockk(relaxed = true)
groupFeedDelegate = mockk(relaxed = true)
hostInvites = mockk(relaxed = true)
groupKeyEnvelopes = mockk(relaxed = true)
val messagingController = mockk<ChatMessagingController>(relaxed = true)
val stateHolder = ChatStateHolder()

Expand Down Expand Up @@ -130,6 +132,7 @@ class ChatCoordinatorTeardownTest {
reactionsDelegate = mockk(relaxed = true),
archiveStore = archiveStore,
hostInvites = hostInvites,
groupKeyEnvelopes = groupKeyEnvelopes,
)
}

Expand Down Expand Up @@ -189,6 +192,16 @@ class ChatCoordinatorTeardownTest {
coVerify(exactly = 1) { archiveStore.clearAll() }
}

/** Wrapped chat keys live in the per-account database, so erasure wipes them and teardown keeps them. */
@Test
fun `clearCache drops stored key envelopes, teardown keeps them`() = runTest {
coordinator.teardown()
coVerify(exactly = 0) { groupKeyEnvelopes.clear() }

coordinator.clearCache()
coVerify(exactly = 1) { groupKeyEnvelopes.clear() }
}

@Test
fun `a successful leave unarchives the chat`() = runTest {
val chatId = ChatId("aabbccdd")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,24 @@ class GroupFeedDelegateTest {
coVerify(exactly = 0) { metadataDataSource.setMembership(chatId, isMember = true) }
}

@Test
fun `a successful leave drops the chat's key envelope and payment requests`() = runTest {
coEvery { controller.leaveChat(chatId) } returns Result.success(Unit)

subject.leave(chatId)

coVerify(exactly = 1) { metadataDataSource.removeFromChat(chatId) }
}

@Test
fun `a failed leave keeps the chat's key envelope and payment requests`() = runTest {
coEvery { controller.leaveChat(chatId) } returns Result.failure(Throwable("unreachable"))

subject.leave(chatId)

coVerify(exactly = 0) { metadataDataSource.removeFromChat(any<ChatId>()) }
}

@Test
fun `a failed leave puts the chat back`() = runTest {
coEvery { controller.leaveChat(chatId) } returns Result.failure(Throwable("unreachable"))
Expand Down Expand Up @@ -320,7 +338,7 @@ class GroupFeedDelegateTest {

subject.applyRosterChanges(chatId, listOf(change))

coVerify { metadataDataSource.setMembership(chatId, isMember = false) }
coVerify { metadataDataSource.removeFromChat(chatId) }
}

@Test
Expand All @@ -333,6 +351,7 @@ class GroupFeedDelegateTest {
subject.applyRosterChanges(chatId, listOf(change))

coVerify(exactly = 0) { metadataDataSource.setMembership(any<ChatId>(), any()) }
coVerify(exactly = 0) { metadataDataSource.removeFromChat(any<ChatId>()) }
coVerify { rosterStateHolder.apply(chatId, listOf(change)) }
}
}
Loading
Loading