Skip to content
Closed
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
1 change: 1 addition & 0 deletions Flipcash/Core/Controllers/ConversationController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1200,6 +1200,7 @@ final class ConversationController {
private func applyGroupFeed(_ groups: [Conversation]) {
let departed = store.setGroupFeed(groups)
reconcileHidden()
paymentRequests?.forget(conversations: departed)
persist(operation: "replace-group-feed") {
try database.replaceGroupFeed(groups.map(withStoreMembers), departed: departed)
}
Expand Down
12 changes: 10 additions & 2 deletions Flipcash/Core/Controllers/EncryptedChatClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,15 @@ final class EncryptedChatClient: Sendable {

private let client: FlipClient
private let keyring: ChatKeyring
/// The chat as the database holds it, for opening messages while the server can't be reached.
private let storedConversation: @Sendable (ConversationID) -> Conversation?

/// Every chat seen on its way through, so a message can be opened without a `GetChat`.
private let conversations = OSAllocatedUnfairLock<[ConversationID: Conversation]>(initialState: [:])

init(client: FlipClient, keyring: ChatKeyring) {
init(client: FlipClient, storedConversation: @escaping @Sendable (ConversationID) -> Conversation? = { _ in nil }, keyring: ChatKeyring) {
self.client = client
self.storedConversation = storedConversation
self.keyring = keyring
}

Expand All @@ -43,8 +46,13 @@ final class EncryptedChatClient: Sendable {
return fetched
}

/// How `conversationID`'s messages open; waits on the key when the chat can't be fetched.
/// How `conversationID`'s messages open; waits on the key when the chat is neither stored nor
/// fetchable. The stored copy is enough to open with and is not remembered, since a send needs the
/// server's current encryption choice.
private func opener(_ conversationID: ConversationID, owner: KeyPair) async -> ChatOpener {
if let known = conversations.withLock({ $0[conversationID] }) ?? storedConversation(conversationID) {
return await keyring.opener(for: known)
}
guard let conversation = try? await conversation(conversationID, owner: owner) else { return .awaitingKey }
return await keyring.opener(for: conversation)
}
Expand Down
64 changes: 61 additions & 3 deletions Flipcash/Core/Screens/PaymentRequest/PaymentRequests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import Foundation
import Observation
import FlipcashCore
import FlipcashStore

nonisolated private let logger = Logger(label: "flipcash.payment-requests")

Expand All @@ -18,6 +19,11 @@ nonisolated private let logger = Logger(label: "flipcash.payment-requests")
/// ``ingest(_:)``, which keeps the greater version and, at an equal version, EXPIRED over OPEN; live
/// updates go through ``apply(_:)``, which follows the same version rule. Updates are best effort, so
/// the polls stay as reconciliation.
///
/// Every copy that lands is also written to the database, which applies the same merge rule, and a
/// chat's or a request's stored copies are read back ahead of the network read so cards and details
/// draw offline and after a restart. Writes and reads are best effort: a failed one is logged and the
/// network path carries on. `unavailable`, the seals and the retry sets are session state and are not stored.
@MainActor
@Observable
final class PaymentRequests {
Expand Down Expand Up @@ -62,6 +68,7 @@ final class PaymentRequests {
@ObservationIgnored private let closeRequest: Close
@ObservationIgnored private let markPaidShare: MarkPaid
@ObservationIgnored private let getPage: GetPage
@ObservationIgnored private let database: Database?
@ObservationIgnored private var inFlight: Set<PaymentRequestID> = []
@ObservationIgnored private var sealReads: Set<ConversationID> = []
/// Requests whose last read failed short of a server answer, retried on the next chat refetch.
Expand All @@ -76,8 +83,10 @@ final class PaymentRequests {
create: @escaping Create = { _, _, _ in throw Unconfigured() },
close: @escaping Close = { _ in throw Unconfigured() },
markPaid: @escaping MarkPaid = { _, _ in throw Unconfigured() },
getPage: @escaping GetPage = { _, _, _, _ in throw Unconfigured() }
getPage: @escaping GetPage = { _, _, _, _ in throw Unconfigured() },
database: Database? = nil
) {
self.database = database
self.getChatPage = getChatPage
self.getRequest = getRequest
self.openingSeal = openingSeal
Expand Down Expand Up @@ -149,16 +158,62 @@ final class PaymentRequests {
GroupPaymentRequest.deckOrder(requests.values.filter { $0.chatID == conversationID }, viewer: viewer, at: now)
}

/// Merges fetched copies into the store.
/// Drops the held requests and chat seals of conversations the user has left, so a card for a
/// departed chat can't redraw. The database rows go with the conversation's own delete.
func forget(conversations departed: Set<ConversationID>) {
guard !departed.isEmpty else { return }
requests = requests.filter { entry in entry.value.chatID.map { !departed.contains($0) } ?? true }
for id in departed { seals[id] = nil }
}

/// Merges fetched copies into the store and writes them through to the database.
func ingest(_ incoming: [GroupPaymentRequest]) {
merge(incoming)
unavailable.subtract(incoming.map(\.id))
persist(incoming)
}

/// Reads `conversationID`'s stored requests into the store, ahead of the network read.
func hydrate(_ conversationID: ConversationID) async {
guard let database else { return }
do {
let stored = try await Task.detached { try database.paymentRequests(in: conversationID) }.value
merge(stored)
} catch {
logger.warning("Failed to read a chat's stored payment requests", metadata: ["error": "\(error)"])
}
}

/// Reads the stored copy of `id` into the store, ahead of the network read.
private func hydrate(_ id: PaymentRequestID) async {
guard let database else { return }
do {
let stored = try await Task.detached { try database.paymentRequest(id: id) }.value
if let stored { merge([stored]) }
} catch {
logger.warning("Failed to read a stored payment request", metadata: ["error": "\(error)"])
}
}

/// Writes `requests` to the database, which keeps the greater version per request. A failure
/// leaves the in-memory copy in place and never fails the fetch it came from.
private func persist(_ requests: [GroupPaymentRequest]) {
guard let database, !requests.isEmpty else { return }
do {
try database.upsertPaymentRequests(requests)
} catch {
logger.warning("Failed to store payment requests", metadata: ["error": "\(error)"])
}
}

private func merge(_ incoming: [GroupPaymentRequest]) {
for request in incoming {
if let existing = requests[request.id] {
let kept = GroupPaymentRequest.merged(existing: existing, incoming: request)
if kept != existing { requests[request.id] = kept }
} else {
requests[request.id] = request
}
unavailable.remove(request.id)
}
}

Expand All @@ -181,6 +236,7 @@ final class PaymentRequests {
break
case .applied(let applied):
requests[update.id] = applied
persist([applied])
case .refetch:
if !refetch.contains(update.id) { refetch.append(update.id) }
}
Expand All @@ -204,6 +260,7 @@ final class PaymentRequests {
/// Reads `conversationID`'s OPEN and RESOLVED first pages, and its seal if not held yet. A failed
/// read keeps what was held.
func refreshChat(_ conversationID: ConversationID) async {
await hydrate(conversationID)
async let sealed: Void = loadSeal(for: conversationID)
do {
async let open = getChatPage(conversationID, .open, Self.openPageSize)
Expand Down Expand Up @@ -257,6 +314,7 @@ final class PaymentRequests {
/// viewer pays under, `nil` when the viewer has no unpaid share. Throws what the read threw.
@discardableResult
func refresh(_ id: PaymentRequestID) async throws -> Data? {
if requests[id] == nil { await hydrate(id) }
do {
let fetched = try await getRequest(id)
ingest([fetched.request])
Expand Down
11 changes: 8 additions & 3 deletions Flipcash/Core/Session/SessionAuthenticator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -684,7 +684,7 @@ final class SessionContainer {
/// NotificationService extension reads it to decide whether a push may interrupt.
let chatArchive: ChatArchiveStore

/// The signed-in user's private-group chat keys, in memory and reopened from the server.
/// The signed-in user's private-group chat keys, in memory and reopened from the stored envelope or the server.
let groupKeys: GroupKeyEnvelopes

/// The private groups the signed-in user has asked to join.
Expand Down Expand Up @@ -800,7 +800,8 @@ final class SessionContainer {
},
resolveKey: { [flipClient, owner = session.ownerKeyPair] userID in
try await flipClient.resolveUserID(userID, owner: owner)
}
},
envelopeStore: database
)
self.groupKeys = groupKeys

Expand All @@ -816,6 +817,9 @@ final class SessionContainer {

let chatClient = EncryptedChatClient(
client: flipClient,
storedConversation: { [database] conversationID in
try? database.getConversations().first { $0.id == conversationID }
},
keyring: ChatKeyring(
owner: session.ownerKeyPair,
selfUserID: session.userID,
Expand Down Expand Up @@ -1032,7 +1036,8 @@ final class SessionContainer {
pageSize: pageSize,
pagingToken: pagingToken
)
}
},
database: database
)
conversationController.paymentRequests = self.paymentRequests
self.rosterSearch = mentionSearch
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
//
// GroupKeyEnvelopeStore.swift
// FlipcashCore
//
// Copyright © 2026 Code Inc. All rights reserved.
//

import Foundation
import FlipcashAPI

/// Where the signed-in user's private-group key envelopes are kept between launches, so an
/// encrypted chat opens offline.
///
/// It holds the envelope as the server returned it, never the unwrapped chat key: the key is opened
/// from the envelope with the owner key each launch, with the same checks as a fetched one.
public protocol GroupKeyEnvelopeStore: Sendable {
/// The envelope stored for `conversationID`, or nil when none is stored or it no longer decodes.
func material(for conversationID: ConversationID) throws -> GroupKeyMaterial?
/// Stores `material` for `conversationID`, replacing any earlier one.
func store(_ material: GroupKeyMaterial, for conversationID: ConversationID) throws
/// Removes whatever is stored for `conversationID`.
func remove(for conversationID: ConversationID) throws
}

extension GroupKeyMaterial {

/// The material as the bytes of the `GetKeyEnvelopeResponse` the server returns it in.
public func serialized() throws -> Data {
var response = Flipcash_Chat_V1_GetKeyEnvelopeResponse()
response.result = .ok
switch self {
case .own(let envelope, let wrappedBy):
response.keyEnvelope = envelope.proto
if let wrappedBy { response.wrappedBy = wrappedBy.proto }
case .invite(let bundle, let wrappedBy, let wrappedByPublicKey):
response.inviteKeyBundle = .with {
$0.inviteID = bundle.inviteID.proto
$0.keyEnvelope = bundle.keyEnvelope.proto
$0.secretEnvelope = bundle.secretEnvelope.proto
}
if let wrappedBy { response.wrappedBy = wrappedBy.proto }
if let wrappedByPublicKey { response.wrappedByPublicKey = wrappedByPublicKey.proto }
}
return try response.serializedData()
}

/// The material `serialized()` wrote, or nil when `data` is not a response carrying an envelope.
public init?(serialized data: Data) {
guard let response = try? Flipcash_Chat_V1_GetKeyEnvelopeResponse(serializedBytes: data) else { return nil }
let wrappedBy = response.hasWrappedBy ? try? UserID(data: response.wrappedBy.value) : nil
let wrappedByPublicKey = response.hasWrappedByPublicKey ? try? PublicKey(response.wrappedByPublicKey.value) : nil
switch response.envelope {
case .keyEnvelope(let envelope):
self = .own(envelope: ConversationKeyEnvelope(envelope), wrappedBy: wrappedBy)
case .inviteKeyBundle(let bundle):
self = .invite(InviteKeyBundle(bundle), wrappedBy: wrappedBy, wrappedByPublicKey: wrappedByPublicKey)
case nil:
return nil
}
}
}
Loading