From 51a79d0ecdec46056efb6048f360437f40b63c82 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 11:54:40 -0400 Subject: [PATCH 1/7] feat(chat): scaffold against E2EE contract additions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit messaging_service.proto adds ENCRYPTION_NOT_ALLOWED to SendMessageResponse.Result and EditMessageResponse.Result (EncryptedContent sent outside a DM). Add the case to ErrorSendMessage/ErrorEditMessage after the existing cases so raw values still line up with the server, and route it through the existing failure/classification paths. model.proto's Content.type oneof adds an encrypted case; map it through the same unsupported-content path as media/system for now. Actual E2EE (X25519/HKDF/ XChaCha20) is a cross-platform parity hotspot and needs its own decision — this only keeps decoding from crashing/dropping the switch. chat/v1/model.proto's Metadata adds creator (group chats) and use_e2ee (DMs, transitional migration flag). Mirror both onto Conversation; use_e2ee is stored but not acted on yet. --- .../Flip API/Services/ChatMessagingService.swift | 11 ++++++++++- .../Models/Conversation/Conversation.swift | 15 ++++++++++++++- .../Models/Conversation/ConversationMessage.swift | 5 ++++- 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatMessagingService.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatMessagingService.swift index 64dfd97d1..a4769dee6 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatMessagingService.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatMessagingService.swift @@ -224,7 +224,7 @@ final class ChatMessagingService: Sendable { await MainActor.run { completion(.success(MessageMutation(message: message, isConflict: error == .conflict))) } - case .denied, .messageNotFound, .cannotEdit, .unknown, .transportFailure, .cancelled, .rejected: + case .denied, .messageNotFound, .cannotEdit, .encryptionNotAllowed, .unknown, .transportFailure, .cancelled, .rejected: logger.error("Failed to edit message") await MainActor.run { completion(.failure(error)) } } @@ -353,6 +353,8 @@ public enum ErrorGetDelta: Int, Error { public enum ErrorSendMessage: Int, Error { case ok case denied + /// The content is EncryptedContent and the chat is not a DM. + case encryptionNotAllowed case unknown = -1 case transportFailure = -2 case cancelled = -3 @@ -365,6 +367,8 @@ public enum ErrorEditMessage: Int, Error { case messageNotFound case cannotEdit case conflict + /// The content is EncryptedContent and the chat is not a DM. + case encryptionNotAllowed case unknown = -1 case transportFailure = -2 case cancelled = -3 @@ -441,7 +445,10 @@ extension ErrorSendMessage: ServerError, TransportClassifiableError { switch self { case .ok, .transportFailure: .suppressed case .cancelled: .info + // Denied is an expected membership/business outcome; encryptionNotAllowed is a client-side + // contract violation (sending EncryptedContent outside a DM), not a server hiccup. case .denied: .info + case .encryptionNotAllowed: .error case .unknown, .rejected: .error } } @@ -455,6 +462,8 @@ extension ErrorEditMessage: ServerError, TransportClassifiableError { // `conflict` is the concurrency guard doing its job, and the rest are expected // membership/business outcomes — none is a client defect. case .denied, .messageNotFound, .cannotEdit, .conflict: .info + // A client-side contract violation (sending EncryptedContent outside a DM), not a server hiccup. + case .encryptionNotAllowed: .error case .unknown, .rejected: .error } } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/Conversation.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/Conversation.swift index 2b92936f6..169eebd75 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/Conversation.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/Conversation.swift @@ -49,7 +49,16 @@ public struct Conversation: Identifiable, Hashable, Sendable { /// server has reported one. See ``ConversationViewerState``. public var viewerState: ConversationViewerState? - public init(id: ConversationID, members: [ConversationMember], lastMessage: ConversationMessage?, lastActivity: Date, type: ConversationType = .contactDm, isHidden: Bool = false, title: String? = nil, latestEventSequence: UInt64 = 0, picture: ProfilePicture? = nil, rosterSummary: ConversationRosterSummary = ConversationRosterSummary(memberCount: 0, version: 0), rules: ConversationRules? = nil, viewerState: ConversationViewerState? = nil) { + /// The chat's creator. Only ever set for group chats; `nil` for DMs. + public var creator: UserID? + + /// Whether messages in this chat are end-to-end encrypted. DMs only, always `false` for group + /// chats. A transitional migration flag — see `Flipcash_Chat_V1_Metadata.useE2Ee` — that this + /// client does not yet act on: E2EE send/receive is a cross-platform parity hotspot with its + /// own implementation decision still pending. + public var useE2Ee: Bool + + public init(id: ConversationID, members: [ConversationMember], lastMessage: ConversationMessage?, lastActivity: Date, type: ConversationType = .contactDm, isHidden: Bool = false, title: String? = nil, latestEventSequence: UInt64 = 0, picture: ProfilePicture? = nil, rosterSummary: ConversationRosterSummary = ConversationRosterSummary(memberCount: 0, version: 0), rules: ConversationRules? = nil, viewerState: ConversationViewerState? = nil, creator: UserID? = nil, useE2Ee: Bool = false) { self.id = id self.members = members self.lastMessage = lastMessage @@ -62,6 +71,8 @@ public struct Conversation: Identifiable, Hashable, Sendable { self.rosterSummary = rosterSummary self.rules = rules self.viewerState = viewerState + self.creator = creator + self.useE2Ee = useE2Ee } } @@ -145,6 +156,8 @@ extension Conversation { self.rosterSummary = ConversationRosterSummary(proto.rosterSummary) self.rules = proto.hasRules ? ConversationRules(proto.rules) : nil self.viewerState = proto.hasViewerState ? ConversationViewerState(proto.viewerState) : nil + self.creator = proto.hasCreator ? (try? UUID(data: proto.creator.value)) : nil + self.useE2Ee = proto.useE2Ee } /// The member that isn't the signed-in user, used to title the conversation. diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift index acafa174a..4a336fc19 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift @@ -196,7 +196,10 @@ extension ConversationMessage { self.content = .text(textContent.text) self.cashAction = nil repliedTo = replyContent.hasRepliedMessageID ? MessageID(replyContent.repliedMessageID) : nil - case .media, .system, .none: + // EncryptedContent is a cross-platform parity hotspot (X25519/HKDF/XChaCha20); decrypting + // it is not implemented here. It renders the same as any other content this client can't + // represent: the message is dropped rather than shown, same as `.media`/`.system` today. + case .media, .system, .encrypted, .none: return nil } From 36351cdab0445808c4b4fe05e0386a199d068f03 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 11:54:52 -0400 Subject: [PATCH 2/7] fix(push): handle ChatMetadata.message_ref oneof push/v1/model.proto moved ChatMetadata.message into a message_ref oneof alongside a new message_id, so payload.chatMetadata.hasMessage no longer compiles. Switch on messageRef instead; the id-only case (long messages) returns nil here rather than fetching via Messaging.GetMessage. Not a regression in practice: NotificationService's transcript prefetch (cachePreview) already fetches the chat's recent messages over its own connection independently of the embedded payload, so an id-only push still gets a rendered preview, just without the no-network fast path the embedded-message case gives. Left a TODO on chatMessage(_:) naming the proto field and the gap, in case that fast path turns out to matter later. --- .../Push/NotificationPayload.swift | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/FlipcashCore/Sources/FlipcashCore/Push/NotificationPayload.swift b/FlipcashCore/Sources/FlipcashCore/Push/NotificationPayload.swift index dbd5f1784..a016fdc03 100644 --- a/FlipcashCore/Sources/FlipcashCore/Push/NotificationPayload.swift +++ b/FlipcashCore/Sources/FlipcashCore/Push/NotificationPayload.swift @@ -47,18 +47,30 @@ public enum NotificationPayload { } /// The message a CHAT push carries inline, or `nil` when the push isn't a chat message, carries - /// no chat metadata, predates the server embedding the message, or carries content this client - /// can't represent. + /// no chat metadata, predates the server embedding the message, carries content this client + /// can't represent, or only carries the message's id (a long message — see + /// `Flipcash_Push_V1_ChatMetadata.messageRef`). /// /// The embedded message is the only part of a push that needs no network to become store rows. /// It carries the same `eventSequence` the transcript fetch would return for it, so it merges /// with a fetched message rather than competing with one. + /// + /// TODO(push/v1/model.proto ChatMetadata.message_ref): when only `messageID` is present, this + /// returns nil rather than fetching the message via `Messaging.GetMessage`. The notification + /// service extension's transcript prefetch (`NotificationService.cachePreview`) already fetches + /// the chat's recent messages independently of this value, so the id-only case is not silently + /// dropped in practice — it just doesn't get the "needs no network" fast path this doc comment + /// describes. Wire up a `GetMessage` fetch here (or at the call site) if that gap matters. public static func chatMessage(_ userInfo: [AnyHashable: Any]) -> ConversationMessage? { guard let payload = decode(userInfo), payload.category == .chat, payload.hasChatMetadata else { return nil } - guard payload.chatMetadata.hasMessage else { return nil } - return ConversationMessage(payload.chatMetadata.message) + switch payload.chatMetadata.messageRef { + case .message(let message): + return ConversationMessage(message) + case .messageID, nil: + return nil + } } /// Whether the recipient had the chat muted when a CHAT push was sent. The push is still From 950509e02fe3b1c897c5e15e3b7bffbd9d0575ef Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 12:38:33 -0400 Subject: [PATCH 3/7] feat(chat): keep and render encrypted messages as unsupported Content.encrypted decoded to nil, so an encrypted DM message vanished from the transcript instead of being stored, unlike Android's "unsupported message" placeholder. Add ConversationMessage.Content.encrypted(scheme:nonce:ciphertext:), persist all three fields in the SQLite store (kind 3, three new nullable columns, schema version 40), and render it as the existing .deleted tombstone bubble with new copy: "This message isn't supported on this version". Decryption itself (X25519/HKDF/XChaCha20) is out of scope; the content is kept verbatim, not decoded. Wire the new case through every place that switches on Content so nothing crashes or silently drops it: chat-list preview, reply quoting, copy/link detection, and message capabilities (no copy/edit/reply capability, matching a tombstone). .media/.system are still dropped by design -- unchanged. --- .../Controllers/ConversationController.swift | 5 ++++ .../Conversation/ChatItem+Conversation.swift | 19 ++++++++++-- .../Conversation/ConversationScreen.swift | 4 ++- .../Core/Spotlight/ChatSpotlightItem.swift | 2 +- .../Models/Chat/ChatMessage.swift | 6 ++++ .../Models/Chat/ChatPreviewMapping.swift | 8 +++-- .../Conversation/ConversationMessage.swift | 24 ++++++++++++--- .../Conversation/MessageCapability.swift | 5 ++-- .../Database+Conversations.swift | 20 ++++++++++++- .../Sources/FlipcashStore/Database.swift | 2 +- .../Sources/FlipcashStore/Schema.swift | 8 +++++ .../ConversationModelMappingTests.swift | 25 ++++++++++++++++ .../ConversationStoreMutationTests.swift | 1 + .../Database+ConversationsTests.swift | 29 ++++++++++++++++++- 14 files changed, 143 insertions(+), 15 deletions(-) diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index 600638780..124e39e99 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -1193,6 +1193,11 @@ final class ConversationController { case .deleted: return nil + + case .encrypted: + // No plaintext to preview -- the row still surfaces (it's still the newest activity), + // just with a blank subtitle, same as an empty text body above. + return nil } } diff --git a/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift b/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift index d364948bf..7727afc03 100644 --- a/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift +++ b/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift @@ -118,7 +118,7 @@ extension ChatItem { func isEmojiOnlyBody(_ message: ConversationMessage) -> Bool { switch message.content { case .text(let text): EmojiOnlyDetector.isEmojiOnly(text) - case .cash, .deleted: false + case .cash, .deleted, .encrypted: false } } func rendersBare(_ message: ConversationMessage) -> Bool { @@ -131,7 +131,7 @@ extension ChatItem { let layouts = messages.map { message in switch message.content { case .text(let text): Self.rows(for: text, preview: detectedLink(in: text, card: linkCard)) - case .cash, .deleted: [RowLayout(part: nil, text: nil, preview: nil)] + case .cash, .deleted, .encrypted: [RowLayout(part: nil, text: nil, preview: nil)] } } // A card row breaks the bubble run the way bare emoji do, so what faces a neighbour is the @@ -202,6 +202,11 @@ extension ChatItem { ? "You deleted this message" : "This message was deleted" ) + case .encrypted: + // Decryption isn't implemented on this client -- a cross-platform parity hotspot -- + // so an encrypted message renders as the same non-interactive placeholder bubble a + // tombstone does, with copy matching Android's unsupported-content bubble. + content = .deleted(ChatMessage.unsupportedContentCopy) } // The status line rides on the bubble itself (not a separate row, so a send is a clean @@ -334,6 +339,16 @@ extension ChatItem { kind: .unavailable, authorID: original.senderID ) + case .encrypted: + // No plaintext to preview -- same unavailable treatment as a quote whose original the + // local database never saw. + return ChatQuote( + stableID: nil, + authorName: authorName, + snippet: ChatQuote.unavailableSnippet, + kind: .unavailable, + authorID: original.senderID + ) } } diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index 363009aa5..2d045a9f1 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -895,6 +895,8 @@ struct ConversationScreen: View { ) case .deleted: (ChatQuote.deletedSnippet, .unavailable) + case .encrypted: + (ChatQuote.unavailableSnippet, .unavailable) } } @@ -980,7 +982,7 @@ struct ConversationScreen: View { case .cash(let fiat): Analytics.tokenInfoOpened(from: .openedFromChat, mint: fiat.mint) router.push(.currencyInfo(fiat.mint)) - case .text, .deleted: + case .text, .deleted, .encrypted: break } } diff --git a/Flipcash/Core/Spotlight/ChatSpotlightItem.swift b/Flipcash/Core/Spotlight/ChatSpotlightItem.swift index 761c70d6c..79542d376 100644 --- a/Flipcash/Core/Spotlight/ChatSpotlightItem.swift +++ b/Flipcash/Core/Spotlight/ChatSpotlightItem.swift @@ -59,7 +59,7 @@ nonisolated struct ChatSpotlightItem { switch message?.content { case .text(let text): text case .cash(let amount): "Cash · \(amount.nativeAmount.formatted())" - case .deleted, nil: nil + case .deleted, .encrypted, nil: nil } } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMessage.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMessage.swift index a1eb1ea5c..900f4252c 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMessage.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMessage.swift @@ -95,6 +95,12 @@ public struct ChatMessage: Hashable, Sendable, Codable, Identifiable { } } + /// Placeholder copy for content this client has no way to render -- today, an encrypted + /// message it cannot decrypt. Reuses the tombstone's `.deleted` display case (same + /// non-interactive bubble style) with wording that says "unsupported" rather than "deleted", + /// matching Android's copy for the same content. + public static let unsupportedContentCopy = "This message isn't supported on this version" + /// Whether this row draws as the link card on its own, with no bubble behind it. /// /// The card is already a surface with its own rounded shape, so a bubble behind it would draw diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatPreviewMapping.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatPreviewMapping.swift index c7fcfa0d9..f6477b18a 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatPreviewMapping.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatPreviewMapping.swift @@ -77,6 +77,10 @@ extension ChatItem { iconURL: branding?.iconURL, isTip: message.cashAction == .tipped )) + case .encrypted: + // Not filtered above (only tombstones are): an encrypted message stays a real, + // visible row, same as the in-app transcript, just with no plaintext to preview. + content = .deleted(ChatMessage.unsupportedContentCopy) case .deleted: continue // filtered out above; unreachable, kept for switch exhaustiveness } @@ -86,8 +90,8 @@ extension ChatItem { // at most three rows and never groups them. let isEmojiOnly: Bool switch message.content { - case .text(let text): isEmojiOnly = EmojiOnlyDetector.isEmojiOnly(text) - case .cash, .deleted: isEmojiOnly = false + case .text(let text): isEmojiOnly = EmojiOnlyDetector.isEmojiOnly(text) + case .cash, .deleted, .encrypted: isEmojiOnly = false } items.append(.message(ChatMessage( diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift index 4a336fc19..4249ebf36 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift @@ -48,6 +48,12 @@ public struct ConversationMessage: Identifiable, Hashable, Sendable { case text(String) case cash(ExchangedFiat) case deleted(Deletion) + /// End-to-end-encrypted content this client cannot decrypt (decryption isn't implemented + /// yet -- a cross-platform parity hotspot). `scheme` is the wire `EncryptedContent.Scheme` + /// raw value, kept as `Int` so this model doesn't depend on the generated proto enum. + /// Stored verbatim -- nonce and ciphertext are never inspected -- so the message round-trips + /// byte for byte back to the wire on re-send/edit, and renders as an "unsupported" bubble. + case encrypted(scheme: Int, nonce: Data, ciphertext: Data) } public let id: MessageID @@ -196,10 +202,20 @@ extension ConversationMessage { self.content = .text(textContent.text) self.cashAction = nil repliedTo = replyContent.hasRepliedMessageID ? MessageID(replyContent.repliedMessageID) : nil - // EncryptedContent is a cross-platform parity hotspot (X25519/HKDF/XChaCha20); decrypting - // it is not implemented here. It renders the same as any other content this client can't - // represent: the message is dropped rather than shown, same as `.media`/`.system` today. - case .media, .system, .encrypted, .none: + case .encrypted(let encryptedContent): + // EncryptedContent is a cross-platform parity hotspot (X25519/HKDF/XChaCha20); decrypting + // it is not implemented here. Unlike `.media`/`.system`, the message is kept -- stored + // verbatim and rendered as an "unsupported" bubble -- so it doesn't silently vanish from + // the transcript the way Android's client no longer does either. + self.content = .encrypted( + scheme: encryptedContent.scheme.rawValue, + nonce: encryptedContent.nonce, + ciphertext: encryptedContent.ciphertext + ) + self.cashAction = nil + repliedTo = nil + // `.media`/`.system` are dropped by design: the message is not stored and not shown. + case .media, .system, .none: return nil } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageCapability.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageCapability.swift index 81c55ed37..70d4438ea 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageCapability.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageCapability.swift @@ -89,8 +89,9 @@ extension MessageCapability { now: Date ) -> Set { switch message.content { - case .deleted: - // Nothing is left to act on, and a tombstone must not be re-deleted. + case .deleted, .encrypted: + // Nothing is left to act on: a tombstone must not be re-deleted, and this client has no + // plaintext to copy, quote, or edit for an encrypted message it cannot decrypt. return [] case .cash: // Reply is a cash message's only capability: there is no text to copy, the server diff --git a/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift b/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift index a194ab760..3ad85575b 100644 --- a/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift @@ -491,6 +491,9 @@ nonisolated extension Database { var mint: PublicKey? var deletedBy: UUID? var deletedAt: Double? + var encryptedScheme: Int? + var encryptedNonce: Data? + var encryptedCiphertext: Data? switch message.content { case .text(let value): @@ -506,6 +509,11 @@ nonisolated extension Database { kind = 2 deletedBy = deletion.deletedBy deletedAt = deletion.deletedAt.timeIntervalSinceReferenceDate + case .encrypted(let scheme, let nonce, let ciphertext): + kind = 3 + encryptedScheme = scheme + encryptedNonce = nonce + encryptedCiphertext = ciphertext } let cashAction: Int? = switch message.cashAction { @@ -534,7 +542,10 @@ nonisolated extension Database { m.repliedToId <- message.repliedTo?.value, m.lastEditedTs <- message.lastEditedTs?.timeIntervalSinceReferenceDate, m.deletedBy <- deletedBy, - m.deletedAt <- deletedAt + m.deletedAt <- deletedAt, + m.encryptedScheme <- encryptedScheme, + m.encryptedNonce <- encryptedNonce, + m.encryptedCiphertext <- encryptedCiphertext ) ) } @@ -638,6 +649,13 @@ nonisolated extension Database { deletedAt: row[m.deletedAt].map(Date.init(timeIntervalSinceReferenceDate:)) ?? date ) ) + case 3: + guard let scheme = row[m.encryptedScheme], + let nonce = row[m.encryptedNonce], + let ciphertext = row[m.encryptedCiphertext] else { + return nil + } + content = .encrypted(scheme: scheme, nonce: nonce, ciphertext: ciphertext) default: return nil } diff --git a/FlipcashCore/Sources/FlipcashStore/Database.swift b/FlipcashCore/Sources/FlipcashStore/Database.swift index b7e1b9815..4cf650ca8 100644 --- a/FlipcashCore/Sources/FlipcashStore/Database.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database.swift @@ -197,7 +197,7 @@ nonisolated open class Database: @unchecked Sendable { /// the notification service extension needs the same number to decide whether the store on disk /// is one it understands, and an extension cannot read the app's `Info.plist` — separate bundles. /// Both targets link this module, so they cannot disagree. - public static let schemaVersion = 39 + public static let schemaVersion = 40 /// Removes the store and the write-ahead log files beside it. /// diff --git a/FlipcashCore/Sources/FlipcashStore/Schema.swift b/FlipcashCore/Sources/FlipcashStore/Schema.swift index 88eae5045..1ea1e508c 100644 --- a/FlipcashCore/Sources/FlipcashStore/Schema.swift +++ b/FlipcashCore/Sources/FlipcashStore/Schema.swift @@ -349,6 +349,11 @@ nonisolated public struct ConversationMessageTable: Sendable { // because the schema version can only be bumped once per rebuild, and adding it later would // cost users a second full resync. public let repliedToId = Expression ("repliedToId") + // `.encrypted` content, decomposed the way cash amounts are. All three nil for a non-encrypted + // row. `encryptedScheme` is the wire `EncryptedContent.Scheme` raw value. + public let encryptedScheme = Expression ("encryptedScheme") + public let encryptedNonce = Expression ("encryptedNonce") + public let encryptedCiphertext = Expression ("encryptedCiphertext") // When the sender last edited this message; nil if never edited. public let lastEditedTs = Expression ("lastEditedTs") // Tombstone detail. Both nil for a message that has not been deleted. @@ -622,6 +627,9 @@ nonisolated extension Database { t.column(conversationMessageTable.lastEditedTs) t.column(conversationMessageTable.deletedBy) t.column(conversationMessageTable.deletedAt) + t.column(conversationMessageTable.encryptedScheme) + t.column(conversationMessageTable.encryptedNonce) + t.column(conversationMessageTable.encryptedCiphertext) t.primaryKey(conversationMessageTable.conversationId, conversationMessageTable.id) }) } diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift index cf3a290a9..5a192b45f 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift @@ -510,6 +510,31 @@ struct ConversationMessageMetadataTests { #expect(message.lastEditedTs == nil) } + @Test("Encrypted content maps to .encrypted, keeping scheme, nonce, and ciphertext") + func encryptedMessageParses() throws { + let nonce = Data(repeating: 0xCD, count: 24) + let ciphertext = Data([0x0A, 0x0B, 0x0C]) + let proto = Flipcash_Messaging_V1_Message.with { + $0.messageID = .with { $0.value = 14 } + $0.content = [.with { + $0.encrypted = .with { + $0.scheme = .x25519Xchacha20Poly1305 + $0.nonce = nonce + $0.ciphertext = ciphertext + } + }] + } + + let message = try #require(ConversationMessage(proto)) + guard case .encrypted(let scheme, let gotNonce, let gotCiphertext) = message.content else { + Issue.record("Expected encrypted content") + return + } + #expect(scheme == Flipcash_Messaging_V1_EncryptedContent.Scheme.x25519Xchacha20Poly1305.rawValue) + #expect(gotNonce == nonce) + #expect(gotCiphertext == ciphertext) + } + @Test("replacingContent preserves identity and ordering") func replacingContentPreservesIdentity() { let original = ConversationMessage( diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ConversationStoreMutationTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ConversationStoreMutationTests.swift index 484cbac5b..33d7fa008 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/ConversationStoreMutationTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/ConversationStoreMutationTests.swift @@ -31,6 +31,7 @@ struct ConversationStoreMutationTests { case .text(let value): value case .deleted: "" case .cash: "" + case .encrypted: "" } } } diff --git a/FlipcashTests/Database/Database+ConversationsTests.swift b/FlipcashTests/Database/Database+ConversationsTests.swift index 787808327..8776b7d09 100644 --- a/FlipcashTests/Database/Database+ConversationsTests.swift +++ b/FlipcashTests/Database/Database+ConversationsTests.swift @@ -103,7 +103,7 @@ struct DatabaseConversationsTests { switch loadedMessage.content { case .cash(let loadedExchanged): #expect(loadedExchanged.nativeAmount.value == amount) - case .text, .deleted: + case .text, .deleted, .encrypted: Issue.record("Expected cash message content") } } @@ -413,6 +413,33 @@ struct DatabaseConversationsTests { #expect(loaded.last?.isDeleted == true) } + @Test("An encrypted message round-trips scheme, nonce, and ciphertext byte for byte") + func encryptedMessageRoundTrip() throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + let id = ConversationID.test(1) + let message = ConversationMessage( + id: MessageID(value: 1), + senderID: otherID, + content: .encrypted(scheme: 1, nonce: Data(repeating: 0xAB, count: 24), ciphertext: Data([0x01, 0x02, 0x03, 0x04])), + date: Date(timeIntervalSince1970: 10), + unreadSeq: 1, + eventSequence: 3 + ) + + try database.upsertConversationMessages([message], conversationID: id) + + let loaded = try database.getConversationMessages(conversationID: id) + #expect(loaded == [message]) + guard case .encrypted(let scheme, let nonce, let ciphertext) = loaded.first?.content else { + Issue.record("expected encrypted content") + return + } + #expect(scheme == 1) + #expect(nonce == Data(repeating: 0xAB, count: 24)) + #expect(ciphertext == Data([0x01, 0x02, 0x03, 0x04])) + } + @Test("the catch-up cursor round-trips and survives a feed replace") func catchupCursorRoundTrip() throws { let (database, url) = try Database.makeTemp() From dd629850553006f26f04142c24ad3a726cd3b5ba Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 12:39:10 -0400 Subject: [PATCH 4/7] fix(chat): re-encode encrypted content instead of failing Every path that turns a domain ConversationMessage.Content back into a proto Content assumed .text. The only such path is ConversationController.retry(clientMessageID:in:), which pattern- matched `case .text` and silently no-opped for anything else -- including the encrypted case just added, which this client can't regenerate ciphertext for. Give ConversationMessage.Content an asProto() that switches exhaustively: .text encodes as before, .encrypted re-encodes the stored scheme/nonce/ciphertext byte for byte (a round trip of bytes this client never decrypted, not encryption), and .cash/.deleted throw ConversationMessageContentEncodingError.unsupported -- neither is ever resent this way (cash has its own send path, a tombstone is a mutation result). No fatalError, preconditionFailure, or force-unwrap on any branch. retry() now switches on pending.content: .text sends as before; .encrypted/.cash/.deleted call asProto() and log the thrown error rather than resending, since there's no outbox path for them today. Caller trace: retry(clientMessageID:in:) is the only place a stored ConversationMessage.Content is converted back to a proto Content -- send/edit/reply all build a fresh .text Content directly rather than converting an existing domain value. --- .../Controllers/ConversationController.swift | 25 ++++- .../Conversation/ConversationMessage.swift | 31 +++++++ .../ConversationModelMappingTests.swift | 92 +++++++++++++++++++ 3 files changed, 144 insertions(+), 4 deletions(-) diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index 124e39e99..7b7396401 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -1561,10 +1561,27 @@ final class ConversationController { /// so a double-tap (or a tap during a slow in-flight retry) can't fire concurrent sends. func retry(clientMessageID: UUID, in conversationID: ConversationID) async { guard let pending = store.pendingMessage(clientMessageID: clientMessageID, in: conversationID), - pending.status == .failed, - case .text(let text) = pending.content else { return } - store.markPending(clientMessageID: clientMessageID, status: .sending, in: conversationID) - _ = await deliver(clientMessageID: clientMessageID, text: text, repliedTo: pending.repliedTo, to: conversationID) + pending.status == .failed else { return } + // Only `.text` is ever sent by this client today -- `deliver(text:)` is the only send path, + // and `send(_:to:)` only ever creates a `.text` pending row -- so this is unreachable in + // practice. It's a `switch` rather than the narrow `guard case .text` it replaces so a future + // pending shape (e.g. an outbox that can hold `.encrypted`) fails loudly via the log below + // instead of silently never retrying, and so `Content.asProto()`'s own crash-free contract + // (no fatalError/force-unwrap for `.encrypted`/`.cash`/`.deleted`) is exercised here too. + switch pending.content { + case .text(let text): + store.markPending(clientMessageID: clientMessageID, status: .sending, in: conversationID) + _ = await deliver(clientMessageID: clientMessageID, text: text, repliedTo: pending.repliedTo, to: conversationID) + case .encrypted, .cash, .deleted: + if case .failure(let error) = Result(catching: { try pending.content.asProto() }) { + logger.error("Cannot retry a send this client has no path to re-send", metadata: [ + "conversationID": "\(conversationID)", + "error": "\(error)", + ]) + } + // Nothing to resend over the existing text-only send RPC; leave it `.failed` rather than + // looping forever or crashing. + } } private func deliver(clientMessageID: UUID, text: String, repliedTo: MessageID?, to conversationID: ConversationID) async -> Bool { diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift index 4249ebf36..481ea0ec5 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift @@ -231,3 +231,34 @@ extension ConversationMessage { self.redacted = proto.redacted } } + +/// Content this client has no way to turn back into a proto `Content` to send. Thrown rather than +/// asserted: `.cash` and `.deleted` are never round-tripped this way (cash has its own send path; +/// a tombstone is a mutation result, never resent), but a caller that tries should get a normal +/// error, not a crash. +public enum ConversationMessageContentEncodingError: Error, Sendable { + case unsupported(ConversationMessage.Content) +} + +extension ConversationMessage.Content { + /// Encodes this content back to the wire `Content` it would be sent as. For `.encrypted` this + /// is a byte-for-byte round trip of the stored scheme/nonce/ciphertext -- not encryption, since + /// this client never decrypted them in the first place -- so a retried/re-sent encrypted message + /// reaches the server unchanged rather than being dropped or crashing the sender. + public func asProto() throws -> Flipcash_Messaging_V1_Content { + switch self { + case .text(let text): + return .with { $0.type = .text(.with { $0.text = text }) } + case .encrypted(let scheme, let nonce, let ciphertext): + return .with { + $0.type = .encrypted(.with { + $0.scheme = Flipcash_Messaging_V1_EncryptedContent.Scheme(rawValue: scheme) ?? .unknown + $0.nonce = nonce + $0.ciphertext = ciphertext + }) + } + case .cash, .deleted: + throw ConversationMessageContentEncodingError.unsupported(self) + } + } +} diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift index 5a192b45f..3542720fa 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift @@ -150,6 +150,33 @@ struct ConversationModelMappingTests { #expect(Conversation(proto).type == .contactDm) } + @Test("Metadata maps the group's creator and use_e2ee") + func dmMetadataMapsCreatorAndUseE2Ee() { + let creatorUUID = UUID() + let proto = Flipcash_Chat_V1_Metadata.with { + $0.chatID = .with { $0.value = Data(repeating: 0xAB, count: 32) } + $0.type = .group + $0.creator = .with { $0.value = creatorUUID.data } + $0.useE2Ee = true + } + + let conversation = Conversation(proto) + #expect(conversation.creator == creatorUUID) + #expect(conversation.useE2Ee) + } + + @Test("Metadata without a creator or use_e2ee maps to nil/false") + func dmMetadataWithoutCreatorOrUseE2Ee() { + let proto = Flipcash_Chat_V1_Metadata.with { + $0.chatID = .with { $0.value = Data(repeating: 0xAB, count: 32) } + $0.type = .contactDm + } + + let conversation = Conversation(proto) + #expect(conversation.creator == nil) + #expect(conversation.useE2Ee == false) + } + @Test("Metadata maps the group chat type and title") func dmMetadataMapsGroupTypeAndTitle() { let proto = Flipcash_Chat_V1_Metadata.with { @@ -615,3 +642,68 @@ struct ConversationMessageReplyMappingTests { #expect(edited.repliedTo == MessageID(value: 9)) } } + +@Suite("ConversationMessage.Content -> proto encoding") +struct ConversationMessageContentEncodingTests { + + @Test("Text content encodes to a proto text body") + func textEncodes() throws { + let proto = try ConversationMessage.Content.text("hi").asProto() + guard case .text(let body) = proto.type else { + Issue.record("Expected text content") + return + } + #expect(body.text == "hi") + } + + @Test("Encrypted content round-trips scheme, nonce, and ciphertext byte for byte -- not decrypted, just re-encoded") + func encryptedContentRoundTrips() throws { + let nonce = Data(repeating: 0xEF, count: 24) + let ciphertext = Data([0x01, 0x02, 0x03]) + let content = ConversationMessage.Content.encrypted( + scheme: Flipcash_Messaging_V1_EncryptedContent.Scheme.x25519Xchacha20Poly1305.rawValue, + nonce: nonce, + ciphertext: ciphertext + ) + + let proto = try content.asProto() + guard case .encrypted(let encrypted) = proto.type else { + Issue.record("Expected encrypted content") + return + } + #expect(encrypted.scheme == .x25519Xchacha20Poly1305) + #expect(encrypted.nonce == nonce) + #expect(encrypted.ciphertext == ciphertext) + + // And decoding that proto back gives the identical domain value -- a full round trip. + let roundTripped = Flipcash_Messaging_V1_Message.with { + $0.messageID = .with { $0.value = 1 } + $0.content = [proto] + } + #expect(try #require(ConversationMessage(roundTripped)).content == content) + } + + @Test("An unrecognized encrypted scheme raw value encodes as .UNRECOGNIZED rather than crashing") + func unrecognizedSchemeFallsBackToUnknown() throws { + let content = ConversationMessage.Content.encrypted(scheme: 99, nonce: Data(), ciphertext: Data()) + let proto = try content.asProto() + guard case .encrypted(let encrypted) = proto.type else { + Issue.record("Expected encrypted content") + return + } + #expect(encrypted.scheme == .UNRECOGNIZED(99)) + } + + @Test("Cash and deleted content throw rather than crash -- there is no wire shape a client resends for either") + func cashAndDeletedThrow() { + let cash = ConversationMessage.Content.cash(ExchangedFiat( + onChainAmount: TokenAmount(quarks: 1, mint: .usdf), + nativeAmount: FiatAmount(value: 1, currency: .usd), + currencyRate: Rate(fx: 1, currency: .usd) + )) + #expect(throws: ConversationMessageContentEncodingError.self) { try cash.asProto() } + + let deleted = ConversationMessage.Content.deleted(.init(deletedBy: nil, deletedAt: .now)) + #expect(throws: ConversationMessageContentEncodingError.self) { try deleted.asProto() } + } +} From 54dbeb9ff3250d8948549b9a47df7fdf00a60c42 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 12:39:23 -0400 Subject: [PATCH 5/7] fix(chat): persist conversation creator and use_e2ee chat.v1.Metadata's creator (group chats) and use_e2ee were mapped from the proto onto Conversation but never written to or read back from the SQLite store, so a conversation rebuilt from disk on cold start lost both. Add creator (nullable UUID) and useE2Ee (bool, default false) columns to ConversationTable and wire them through writeConversation/ getConversations. No separate schema-version bump: the columns land in version 40, the same rebuild already needed for the encrypted- message columns, since the version can only be bumped once per rebuild and splitting it across two commits would cost users a second resync for no benefit. --- .../Database+Conversations.swift | 6 ++++- .../Sources/FlipcashStore/Schema.swift | 8 ++++++ .../Database+ConversationsTests.swift | 25 +++++++++++++++++++ 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift b/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift index 3ad85575b..e912460eb 100644 --- a/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift @@ -99,7 +99,9 @@ nonisolated extension Database { version: row[c.rosterVersion] ), rules: conversationRules(from: row), - viewerState: conversationViewerState(from: row) + viewerState: conversationViewerState(from: row), + creator: row[c.creator], + useE2Ee: row[c.useE2Ee] ) } } @@ -426,6 +428,8 @@ nonisolated extension Database { c.rosterVersion <- conversation.rosterSummary.version, c.rules <- conversation.rules.flatMap { try? JSONEncoder().encode($0) }, c.viewerState <- conversation.viewerState.flatMap { try? JSONEncoder().encode($0) }, + c.creator <- conversation.creator, + c.useE2Ee <- conversation.useE2Ee, onConflictOf: c.id ) ) diff --git a/FlipcashCore/Sources/FlipcashStore/Schema.swift b/FlipcashCore/Sources/FlipcashStore/Schema.swift index 1ea1e508c..b978e61d2 100644 --- a/FlipcashCore/Sources/FlipcashStore/Schema.swift +++ b/FlipcashCore/Sources/FlipcashStore/Schema.swift @@ -273,6 +273,12 @@ nonisolated public struct ConversationTable: Sendable { // FlipcashAPI, so the proto can't be stored; ConversationRules is Codable // for exactly this. public let rules = Expression ("rules") + // The group's creator, group chats only; nil for a DM or a group whose metadata predates the + // field. Nil-able rather than defaulted, since "no creator" and "not yet known" are both real. + public let creator = Expression ("creator") + // Whether the chat is end-to-end encrypted. Defaults false: every conversation before this + // field existed, and every DM/group the server hasn't opted in, reads as not-E2EE. + public let useE2Ee = Expression ("useE2Ee") // The signed-in viewer's per-chat state (currently mute) as JSON, same // reason as `rules`. The whole struct is stored, version included, so a // cold-start reload can't beat a fresher stream update; the mute is stored @@ -574,6 +580,8 @@ nonisolated extension Database { t.column(conversationTable.rosterVersion, defaultValue: 0) t.column(conversationTable.rules) t.column(conversationTable.viewerState) + t.column(conversationTable.creator) + t.column(conversationTable.useE2Ee, defaultValue: false) }) } diff --git a/FlipcashTests/Database/Database+ConversationsTests.swift b/FlipcashTests/Database/Database+ConversationsTests.swift index 8776b7d09..ce86cf129 100644 --- a/FlipcashTests/Database/Database+ConversationsTests.swift +++ b/FlipcashTests/Database/Database+ConversationsTests.swift @@ -512,6 +512,31 @@ struct DatabaseConversationsTests { #expect(loaded.lastMessage == preview) } + @Test("A group's creator and use_e2ee round-trip; a DM's nil creator round-trips as nil") + func creatorAndUseE2EeRoundTrip() throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + var group = conversation(byte: 1) + group.creator = otherID + group.useE2Ee = true + try database.upsertConversation(group) + + let loadedGroup = try #require(try database.getConversations().first) + #expect(loadedGroup.creator == otherID) + #expect(loadedGroup.useE2Ee == true) + } + + @Test("A conversation with no stored creator/use_e2ee round-trips as nil/false") + func creatorAndUseE2EeDefaultRoundTrip() throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + try database.upsertConversation(conversation(byte: 1)) + + let loaded = try #require(try database.getConversations().first) + #expect(loaded.creator == nil) + #expect(loaded.useE2Ee == false) + } + @Test("Mute state round-trips as its expiry and version, not as a boolean") func viewerStateRoundTrip() throws { let (database, url) = try Database.makeTemp() From 2bdb5280d0b5015f2252f1929bc5d9c7fbb0e7da Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 12:54:14 -0400 Subject: [PATCH 6/7] chore(store): take schema version 41 instead of 40 The open feat/chat-reactions branch also moves schemaVersion from 39 to 40. Identical one-line edits merge without a conflict, so whichever branch landed second would change the store with no new version, and installs already at 40 would keep a store missing its columns. Skipping 40 costs nothing: the launch check only compares the recorded version against this one. --- FlipcashCore/Sources/FlipcashStore/Database.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FlipcashCore/Sources/FlipcashStore/Database.swift b/FlipcashCore/Sources/FlipcashStore/Database.swift index 4cf650ca8..73769d417 100644 --- a/FlipcashCore/Sources/FlipcashStore/Database.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database.swift @@ -197,7 +197,7 @@ nonisolated open class Database: @unchecked Sendable { /// the notification service extension needs the same number to decide whether the store on disk /// is one it understands, and an extension cannot read the app's `Info.plist` — separate bundles. /// Both targets link this module, so they cannot disagree. - public static let schemaVersion = 40 + public static let schemaVersion = 41 /// Removes the store and the write-ahead log files beside it. /// From 75861b847e52bc1509fde77a0c0fa51e71bc3004 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 12:54:14 -0400 Subject: [PATCH 7/7] chore(deps): bump ocp-client-protocol to 0.6.0 and flipcash2-client-protocol to 0.12.0 Neither version is published yet, so this does not resolve until both client packages are released. --- FlipcashAPI/Package.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/FlipcashAPI/Package.swift b/FlipcashAPI/Package.swift index ddc50f5d7..10c2da2ab 100644 --- a/FlipcashAPI/Package.swift +++ b/FlipcashAPI/Package.swift @@ -55,8 +55,8 @@ enum ContractPackage: String, CaseIterable { /// The pinned version consumed when this package isn't building against a local checkout. var version: Version { switch self { - case .ocp: return "0.5.0" - case .flipcash2: return "0.11.0" + case .ocp: return "0.6.0" + case .flipcash2: return "0.12.0" } }