From 0e4e3e35ddb682dcf090734a4648e6a3c3f6b6ae Mon Sep 17 00:00:00 2001 From: torlando-tech <239676438+torlando-tech@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:34:12 +0000 Subject: [PATCH 1/2] feat(store): lift GRDB message store out of LXMFSwift into app-owned MessageStore/ Phase 0-1 of the Android-parity store lift: re-architect iOS messaging persistence so the GRDB store is app-owned (Sources/ColumbaApp/Services/ MessageStore/) with zero dependency on the LXMFSwift wire library, matching Android's :data module pattern. New app-owned store (GRDB + Foundation only, no LXMFSwift / RNSAPI / ReticulumSwift): - MessageRow.swift: GRDB row for messages table (24 columns, byte-identical to LXMFSwift MessageRecord minus the LXMessage-coupled init/toLXMessage) - ConversationRecordStore.swift: GRDB row for conversations table (struct named ConversationStoreRow to avoid collision with SwiftUI ConversationRow) - GRDBVocabulary.swift: frozen GRDBState (0x00..0xFF) and GRDBDeliveryMethod (0x01/02/03/05) enums, byte-identical to LXMFSwift and NE Python - MessageStore.swift: GRDB actor with migrations v1-v6 byte-identical to LXMFSwift LXMFDatabase; 5 LXMessage-returning methods re-pointed to row-based surface MessageRepository re-scoped to the collector (Android's MessageCollector role): property LXMFSwift.LXMFDatabase -> MessageStore; 5 LXMessage-welded methods rewritten to row-based; only remaining LXMFSwift refs are the wire codec (LXMessage.unpackFromBytes) in recoverFields/normalizedFieldMap. Tests: 14 new MessageStoreTests (schema freeze vs NE writer, state/method vocabulary round-trips, frozen byte values, unknown-method sentinel, save/ read round-trip, inbound-unread increment, cross-process app-reading-NE-row, makeRow field-map + replyTo + sentinel, outbound-id loading). Updated MicronParserTests adapter tests to app-owned row types. Updated 2 static contract tests (qr_contact_send_path, reported_runtime_bugs) to assert the unknown-method invariant at its new location. Verified: 547/547 ColumbaAppTests green; static Python suite 0 new failures vs pristine main; ModelB device build SUCCEEDED (arm64 + NE + codesign); all 4 store .o files compiled into the ModelB target; installed in-place on iPhone (container B43B0B8A), running, zero GRDB/migration errors in diag.log. Note: LXMFSwift is retained as a transitive SPM dependency for the wire codec. Phase 2 (full LXMFSwift + ReticulumSwift removal) is deferred - it requires a native wire codec + Telemetry repoint + reconciler/contract-test teardown and is a distinct larger lift. --- Columba.xcodeproj/project.pbxproj | 28 + .../Services/MessageRepository.swift | 316 +++++------ .../ConversationRecordStore.swift | 120 ++++ .../MessageStore/GRDBVocabulary.swift | 64 +++ .../Services/MessageStore/MessageRow.swift | 202 +++++++ .../Services/MessageStore/MessageStore.swift | 532 ++++++++++++++++++ Tests/ColumbaAppTests/MessageStoreTests.swift | 413 ++++++++++++++ Tests/ColumbaAppTests/MicronParserTests.swift | 137 +++-- .../test_qr_contact_send_path_contract.py | 2 +- Tests/static/test_reported_runtime_bugs.py | 16 +- 10 files changed, 1572 insertions(+), 258 deletions(-) create mode 100644 Sources/ColumbaApp/Services/MessageStore/ConversationRecordStore.swift create mode 100644 Sources/ColumbaApp/Services/MessageStore/GRDBVocabulary.swift create mode 100644 Sources/ColumbaApp/Services/MessageStore/MessageRow.swift create mode 100644 Sources/ColumbaApp/Services/MessageStore/MessageStore.swift create mode 100644 Tests/ColumbaAppTests/MessageStoreTests.swift diff --git a/Columba.xcodeproj/project.pbxproj b/Columba.xcodeproj/project.pbxproj index 52414760..8fa1b6e4 100644 --- a/Columba.xcodeproj/project.pbxproj +++ b/Columba.xcodeproj/project.pbxproj @@ -47,6 +47,10 @@ 024 /* AppServices.swift in Sources */ = {isa = PBXBuildFile; fileRef = F024 /* AppServices.swift */; }; LNP003 /* LocalNetworkProbe.swift in Sources */ = {isa = PBXBuildFile; fileRef = LNP002 /* LocalNetworkProbe.swift */; }; 025 /* MessageRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = F025 /* MessageRepository.swift */; }; + MSGSTOREBF /* MessageStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = MSGSTOREREF /* MessageStore.swift */; }; + MSGDBBF /* GRDBVocabulary.swift in Sources */ = {isa = PBXBuildFile; fileRef = MSGDBREF /* GRDBVocabulary.swift */; }; + MSCVRBF /* ConversationRecordStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = MSCVRREF /* ConversationRecordStore.swift */; }; + MSGROWBF /* MessageRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = MSGROWREF /* MessageRow.swift */; }; CHF004 /* CallHistoryFormatting.swift in Sources */ = {isa = PBXBuildFile; fileRef = CHF003 /* CallHistoryFormatting.swift */; }; CHM002 /* CallHistoryModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = CHM001 /* CallHistoryModels.swift */; }; CHR002 /* CallHistoryRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = CHR001 /* CallHistoryRepository.swift */; }; @@ -291,6 +295,7 @@ ACT02 /* AnnounceClassificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACT01 /* AnnounceClassificationTests.swift */; }; RQP02 /* ReplyQuoteParityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = RQP01 /* ReplyQuoteParityTests.swift */; }; DRAFT002 /* DraftMessageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DRAFT001 /* DraftMessageTests.swift */; }; + MSTST002 /* MessageStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = MSTST001 /* MessageStoreTests.swift */; }; DIAGROT002 /* DiagLogRotationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DIAGROT001 /* DiagLogRotationTests.swift */; }; DA143A000000000000000002 /* DraftAutosaveController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA143A000000000000000001 /* DraftAutosaveController.swift */; }; DA143A000000000000000003 /* DraftAutosaveController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA143A000000000000000001 /* DraftAutosaveController.swift */; }; @@ -328,6 +333,10 @@ C2487934101CA21C68F1F458 /* PythonRuntime.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0C41F9E953D0445F3C34AE7 /* PythonRuntime.swift */; }; C4F369EC98C2B903701CE74A /* SyncStatusBottomSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67B5525A88EBCEAF05E9DE3F /* SyncStatusBottomSheet.swift */; }; C60C9313A6AFB95FCFB8969A /* MessageRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = F025 /* MessageRepository.swift */; }; + MSGSTOREBFMB /* MessageStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = MSGSTOREREF /* MessageStore.swift */; }; + MSGDBBFMB /* GRDBVocabulary.swift in Sources */ = {isa = PBXBuildFile; fileRef = MSGDBREF /* GRDBVocabulary.swift */; }; + MSCVRBFMB /* ConversationRecordStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = MSCVRREF /* ConversationRecordStore.swift */; }; + MSGROWBFMB /* MessageRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = MSGROWREF /* MessageRow.swift */; }; CHF005 /* CallHistoryFormatting.swift in Sources */ = {isa = PBXBuildFile; fileRef = CHF003 /* CallHistoryFormatting.swift */; }; CHM003 /* CallHistoryModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = CHM001 /* CallHistoryModels.swift */; }; CHR003 /* CallHistoryRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = CHR001 /* CallHistoryRepository.swift */; }; @@ -533,6 +542,7 @@ ACT01 /* AnnounceClassificationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnounceClassificationTests.swift; sourceTree = ""; }; RQP01 /* ReplyQuoteParityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplyQuoteParityTests.swift; sourceTree = ""; }; DRAFT001 /* DraftMessageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraftMessageTests.swift; sourceTree = ""; }; + MSTST001 /* MessageStoreTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MessageStoreTests.swift; path = MessageStoreTests.swift; sourceTree = ""; }; DIAGROT001 /* DiagLogRotationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiagLogRotationTests.swift; sourceTree = ""; }; DA143A000000000000000001 /* DraftAutosaveController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraftAutosaveController.swift; sourceTree = ""; }; AD87870C760723D7E77C87E9 /* TCPClientWizardViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TCPClientWizardViewModel.swift; sourceTree = ""; }; @@ -608,6 +618,10 @@ F024 /* AppServices.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppServices.swift; sourceTree = ""; }; LNP002 /* LocalNetworkProbe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalNetworkProbe.swift; sourceTree = ""; }; F025 /* MessageRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRepository.swift; sourceTree = ""; }; + MSGSTOREREF /* MessageStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MessageStore.swift; path = MessageStore/MessageStore.swift; sourceTree = ""; }; + MSGDBREF /* GRDBVocabulary.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = GRDBVocabulary.swift; path = MessageStore/GRDBVocabulary.swift; sourceTree = ""; }; + MSCVRREF /* ConversationRecordStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ConversationRecordStore.swift; path = MessageStore/ConversationRecordStore.swift; sourceTree = ""; }; + MSGROWREF /* MessageRow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MessageRow.swift; path = MessageStore/MessageRow.swift; sourceTree = ""; }; CHF003 /* CallHistoryFormatting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallHistoryFormatting.swift; sourceTree = ""; }; CHM001 /* CallHistoryModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallHistoryModels.swift; sourceTree = ""; }; CHR001 /* CallHistoryRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallHistoryRepository.swift; sourceTree = ""; }; @@ -1142,6 +1156,10 @@ E284643CB4F95B1B3DBA965B /* OneShotLocationProvider.swift */, BKF002 /* BackendFactory.swift */, F025 /* MessageRepository.swift */, + MSGSTOREREF /* MessageStore.swift */, + MSGDBREF /* GRDBVocabulary.swift */, + MSCVRREF /* ConversationRecordStore.swift */, + MSGROWREF /* MessageRow.swift */, CHF003 /* CallHistoryFormatting.swift */, CHM001 /* CallHistoryModels.swift */, CHR001 /* CallHistoryRepository.swift */, @@ -1201,6 +1219,7 @@ ACT01 /* AnnounceClassificationTests.swift */, RQP01 /* ReplyQuoteParityTests.swift */, DRAFT001 /* DraftMessageTests.swift */, + MSTST001 /* MessageStoreTests.swift */, DIAGROT001 /* DiagLogRotationTests.swift */, EA0AA6C472B1D4EFA2896086 /* RNodeSeamTests.swift */, RDRF01 /* ProxyDrainRaceTests.swift */, @@ -1672,6 +1691,10 @@ 369F5A4E725880AF2972AFD2 /* AppServices.swift in Sources */, D591F21C232FBE2AF23D50F5 /* BackendFactory.swift in Sources */, C60C9313A6AFB95FCFB8969A /* MessageRepository.swift in Sources */, + MSGSTOREBF /* MessageStore.swift in Sources */, + MSGDBBF /* GRDBVocabulary.swift in Sources */, + MSCVRBF /* ConversationRecordStore.swift in Sources */, + MSGROWBF /* MessageRow.swift in Sources */, CHF005 /* CallHistoryFormatting.swift in Sources */, CHM003 /* CallHistoryModels.swift in Sources */, CHR003 /* CallHistoryRepository.swift in Sources */, @@ -1894,6 +1917,10 @@ 099A0C66CE2C1444E52912D0 /* DiscoveredInterfacesViewModel.swift in Sources */, BKF001 /* BackendFactory.swift in Sources */, 025 /* MessageRepository.swift in Sources */, + MSGSTOREBFMB /* MessageStore.swift in Sources */, + MSGDBBFMB /* GRDBVocabulary.swift in Sources */, + MSCVRBFMB /* ConversationRecordStore.swift in Sources */, + MSGROWBFMB /* MessageRow.swift in Sources */, CHF004 /* CallHistoryFormatting.swift in Sources */, CHM002 /* CallHistoryModels.swift in Sources */, CHR002 /* CallHistoryRepository.swift in Sources */, @@ -2021,6 +2048,7 @@ ACT02 /* AnnounceClassificationTests.swift in Sources */, RQP02 /* ReplyQuoteParityTests.swift in Sources */, DRAFT002 /* DraftMessageTests.swift in Sources */, + MSTST002 /* MessageStoreTests.swift in Sources */, DIAGROT002 /* DiagLogRotationTests.swift in Sources */, 0FF6A5C5596A39C092AADA29 /* MigrationRoundTripTests.swift in Sources */, A1B2C3D4E5F60718293A4B5C /* IncomingMessageSizeLimitTests.swift in Sources */, diff --git a/Sources/ColumbaApp/Services/MessageRepository.swift b/Sources/ColumbaApp/Services/MessageRepository.swift index 82cdc570..8f251558 100644 --- a/Sources/ColumbaApp/Services/MessageRepository.swift +++ b/Sources/ColumbaApp/Services/MessageRepository.swift @@ -5,16 +5,17 @@ // Async wrapper for the LXMF message store, providing thread-safe message // and conversation access for SwiftUI ViewModels. // -// ── ARCHITECTURE (Track A0 — unify the canonical message store) ────────────── -// This is the SOLE file in ColumbaApp that imports `LXMFSwift`. It opens the -// GRDB-backed `LXMFSwift.LXMFDatabase` that the Swift / Network-Extension -// backend writes (at `/lxmf-swift.db`), so Swift/NE-delivered -// messages show up in the existing SwiftUI layer WITHOUT changing the UI or -// ViewModels — every public method below still takes/returns the RNSAPI -// *Compat* types (RNSAPI.ConversationRecord / RNSAPI.MessageRecord / -// RNSAPI.LXMessage / RNSAPI.IconAppearance / RNSAPI.LXMessageState / -// RNSAPI.LXDeliveryMethod). The GRDB store's LXMFSwift types are adapted to -// the RNSAPI types via the pure `static` mapping funcs at the bottom. +// ── ARCHITECTURE (Android-parity store — app-owned GRDB store) ────────────── +// This is the collector/boundary (Android's MessageCollector). It owns the +// app-side `MessageStore` (Sources/.../MessageStore/) — the GRDB store lifted +// out of LXMFSwift so the store no longer depends on the wire library. It opens +// the same shared `lxmf-swift.db` the Network-Extension's embedded Python node +// writes, so NE-delivered messages show up in the SwiftUI layer WITHOUT +// changing the UI or ViewModels — every public method still takes/returns the +// RNSAPI *Compat* types. `import LXMFSwift` is retained ONLY for the wire codec +// (`LXMessage.unpackFromBytes`) used to recover field maps from wire rows; +// the store itself (MessageStore / MessageRow / ConversationStoreRow / GRDBState / +// GRDBDeliveryMethod) is app-owned and has no LXMFSwift dependency. // // `import LXMFSwift` MUST NOT be added to AppServices.swift / ColumbaApp.swift // / MapView.swift or any other RNSAPI-dense file: LXMFSwift transitively @@ -23,9 +24,9 @@ // use, producing an un-fixable ambiguity cascade. Keep the LXMFSwift import // walled off here. // -// A5 NOTE: this opens the GRDB store read-WRITE for now (the app's Python -// inbound-persist path still writes through here). Once the NE owns all writes -// (Track A5), switch to `LXMFSwift.LXMFDatabase(path:, readonly: true)`. +// NOTE: this opens the store read-WRITE because the app's own raw SQL writes +// (drafts, retry staging, delivery-proof rekeying via `replacementPool`) need +// write access. The NE's inbound-persist path writes the SAME file directly. // import Foundation @@ -52,7 +53,7 @@ public struct DraftRecord: Equatable, Sendable { /// Actor for thread-safe message database operations. /// -/// Wraps the GRDB-backed `LXMFSwift.LXMFDatabase` and exposes RNSAPI Compat +/// Wraps the app-owned `MessageStore` and exposes RNSAPI Compat /// types so the existing ViewModels compile unchanged. All operations are /// serialized through the underlying GRDB actor. public actor MessageRepository { @@ -109,8 +110,8 @@ public actor MessageRepository { // MARK: - Properties - /// The GRDB-backed canonical store written by the Swift / NE backend. - private let database: LXMFSwift.LXMFDatabase + /// The app-owned canonical GRDB store (shared with the NE writer). + private let store: MessageStore /// Suspension-aware pool used only for atomic app-owned retry replacement. private let replacementPool: DatabasePool @@ -125,9 +126,9 @@ public actor MessageRepository { /// owns writes). /// /// - Parameter grdbPath: Filesystem path to `lxmf-swift.db`. - /// - Throws: rethrows `LXMFSwift.LXMFDatabase` initialization errors. + /// - Throws: rethrows `MessageStore` initialization errors. public init(grdbPath: String) throws { - self.database = try LXMFSwift.LXMFDatabase(path: grdbPath, readonly: false) + self.store = try MessageStore(path: grdbPath, readonly: false) var config = Configuration() config.defaultTransactionKind = .immediate config.foreignKeysEnabled = true @@ -174,7 +175,7 @@ public actor MessageRepository { /// Fetch all conversations, sorted by most recent message. public func fetchConversations(limit: Int = 100, offset: Int = 0) async throws -> [RNSAPI.ConversationRecord] { - try await database.getConversations(limit: limit, offset: offset).map(Self.mapConversation) + try await store.getConversations(limit: limit, offset: offset).map(Self.mapConversation) } /// Fetch conversations by their exact destination hashes without applying @@ -184,12 +185,12 @@ public actor MessageRepository { guard !hashes.isEmpty else { return [] } return try await replacementPool.read { db in - var records: [LXMFSwift.ConversationRecord] = [] + var records: [ConversationStoreRow] = [] for start in stride(from: 0, to: hashes.count, by: Self.conversationQueryChunkSize) { let end = min(start + Self.conversationQueryChunkSize, hashes.count) let chunk = Array(hashes[start.. RNSAPI.ConversationRecord? { - try await database.getConversation(hash: conversationHash).map(Self.mapConversation) + try await store.getConversation(hash: conversationHash).map(Self.mapConversation) } /// Return the durable unread-message total used for the application icon @@ -259,7 +260,7 @@ public actor MessageRepository { /// Mark conversation as read (reset unread count). public func markConversationRead(_ conversationHash: Data) async throws { - try await database.markConversationRead(hash: conversationHash) + try await store.markConversationRead(hash: conversationHash) NotificationCenter.default.post( name: Self.conversationReadNotification, object: nil, @@ -269,7 +270,7 @@ public actor MessageRepository { /// Set the unread count for a conversation (e.g. mark as unread with count=1). public func setUnreadCount(_ conversationHash: Data, count: Int) async throws { - try await database.setUnreadCount(hash: conversationHash, count: count) + try await store.setUnreadCount(hash: conversationHash, count: count) } /// Delete a conversation, its messages, and its app-owned draft. @@ -283,7 +284,7 @@ public actor MessageRepository { _ conversationHash: Data, afterCanonicalDelete: (@Sendable () async -> Void)? ) async throws { - try await database.deleteConversation(hash: conversationHash) + try await store.deleteConversation(hash: conversationHash) if let afterCanonicalDelete { await afterCanonicalDelete() } @@ -292,12 +293,12 @@ public actor MessageRepository { /// Delete a single message by its ID hash. public func deleteMessage(_ messageId: Data) async throws { - try await database.deleteMessage(id: messageId) + try await store.deleteMessage(id: messageId) } /// Ensure a conversation exists for a destination. public func ensureConversation(_ conversationHash: Data, displayName: String?) async throws { - try await database.ensureConversation(hash: conversationHash, displayName: displayName) + try await store.ensureConversation(hash: conversationHash, displayName: displayName) } // MARK: - Draft Operations @@ -434,7 +435,7 @@ public actor MessageRepository { /// Set favorite status for a conversation. public func setFavorite(_ conversationHash: Data, isFavorite: Bool) async throws { - try await database.setFavorite(hash: conversationHash, isFavorite: isFavorite) + try await store.setFavorite(hash: conversationHash, isFavorite: isFavorite) NotificationCenter.default.post( name: Self.favoriteStatusChangedNotification, object: nil, @@ -444,12 +445,12 @@ public actor MessageRepository { /// Set pinned status for a conversation. public func setPinned(_ conversationHash: Data, isPinned: Bool) async throws { - try await database.setPinned(hash: conversationHash, isPinned: isPinned) + try await store.setPinned(hash: conversationHash, isPinned: isPinned) } /// Update display name for a conversation. public func updateDisplayName(_ conversationHash: Data, displayName: String?) async throws { - try await database.updateDisplayName(hash: conversationHash, displayName: displayName) + try await store.updateDisplayName(hash: conversationHash, displayName: displayName) } /// Apply an announced peer name only while the durable row still contains @@ -500,34 +501,35 @@ public actor MessageRepository { /// Update peer icon appearance for a conversation. public func updatePeerIcon(_ hash: Data, icon: RNSAPI.IconAppearance) async throws { - try await database.updatePeerIcon(hash, iconName: icon.iconName, fgColor: icon.foregroundColor, bgColor: icon.backgroundColor) + try await store.updatePeerIcon(hash, iconName: icon.iconName, fgColor: icon.foregroundColor, bgColor: icon.backgroundColor) } /// Get peer icon appearance for a conversation. public func getPeerIcon(_ hash: Data) async throws -> RNSAPI.IconAppearance? { - try await database.getPeerIcon(hash).map(Self.mapIcon) + guard let f = try await store.getPeerIconFields(hash) else { return nil } + return RNSAPI.IconAppearance(iconName: f.name, foregroundColor: f.fg, backgroundColor: f.bg) } // MARK: - Reply & Reaction Operations /// Update the reply_to_id for a message. public func updateReplyToId(_ messageId: Data, replyToId: String) async throws { - try await database.updateReplyToId(messageId: messageId, replyToId: replyToId) + try await store.updateReplyToId(messageId: messageId, replyToId: replyToId) } /// Update the reactions_json for a message. public func updateReactions(_ messageId: Data, reactionsJson: String) async throws { - try await database.updateReactions(messageId: messageId, reactionsJson: reactionsJson) + try await store.updateReactions(messageId: messageId, reactionsJson: reactionsJson) } /// Get the reactions_json for a message. public func getReactionsJson(_ messageId: Data) async throws -> String? { - try await database.getReactionsJson(messageId: messageId) + try await store.getReactionsJson(messageId: messageId) } /// Get a single message record by ID (lightweight). public func getMessageRecord(id: Data) async throws -> RNSAPI.MessageRecord? { - try await database.getMessageRecord(id: id).map(Self.mapRecord) + try await store.getMessageRecord(id: id).map(Self.mapRecord) } // MARK: - Message Operations @@ -539,7 +541,7 @@ public actor MessageRepository { /// (app / Python path) or the signed LXMF wire (Swift / NE path). Ordered /// newest-first to match `getMessages`. public func fetchMessages(for conversationHash: Data, limit: Int = 50, offset: Int = 0) async throws -> [RNSAPI.LXMessage] { - try await database.getMessageRecords(forConversation: conversationHash, limit: limit, offset: offset) + try await store.getMessageRecords(forConversation: conversationHash, limit: limit, offset: offset) .map(Self.mapToLXMessage) } @@ -548,7 +550,7 @@ public actor MessageRepository { /// This is the hot read path the chat UI uses. Returns RNSAPI /// `MessageRecord`s mapped directly from the GRDB rows. public func fetchMessageRecords(for conversationHash: Data, limit: Int = 50, offset: Int = 0) async throws -> [RNSAPI.MessageRecord] { - try await database.getMessageRecords(forConversation: conversationHash, limit: limit, offset: offset) + try await store.getMessageRecords(forConversation: conversationHash, limit: limit, offset: offset) .map(Self.mapRecord) } @@ -569,7 +571,7 @@ public actor MessageRepository { /// sequence and are excluded, so notifications describe new durable insertions. public func fetchIncomingMessagesInserted(after cursor: Int64) async throws -> [RNSAPI.LXMessage] { try await replacementPool.read { db in - try LXMFSwift.MessageRecord.fetchAll( + try MessageRow.fetchAll( db, sql: """ SELECT messages.* @@ -593,23 +595,10 @@ public actor MessageRepository { /// which `mapRecord` passes back through as the RNSAPI `packedLxmf` field /// map so the chat UI's `LxmfFieldCodec.unpack` can recover attachments. public func saveMessage(_ message: RNSAPI.LXMessage) async throws { - let mapped = Self.mapToGRDBMessage(message) - if message.method == .unknown { - // LXMFSwift's enum cannot represent unknown, but its persisted record - // stores a raw byte. Build the record from the mapped message, replace - // only that byte with an out-of-domain sentinel, and save the - // conversation + message in one immediate transaction. This prevents - // readers or competing writers from observing a temporary - // Opportunistic value. - var record = try LXMFSwift.MessageRecord(from: mapped) - record.method = 0 - try await replacementPool.write { db in - try Self.updateConversation(for: mapped, in: db) - try record.save(db) - } - } else { - try await database.saveMessage(mapped) - } + // Build the app-owned row directly from the RNSAPI domain type (no wire + // type in the store). `.unknown` method persists as raw sentinel 0. + let row = Self.makeRow(from: message) + try await store.saveMessage(row) if !message.incoming { NotificationCenter.default.post( name: Self.conversationActivityNotification, @@ -619,45 +608,6 @@ public actor MessageRepository { } } - /// Mirror LXMFSwift's conversation update inside the app-owned transaction - /// used for raw method values that its enum cannot represent. - private static func updateConversation( - for message: LXMFSwift.LXMessage, - in db: Database - ) throws { - let conversationHash = message.incoming ? message.sourceHash : message.destinationHash - if var conversation = try LXMFSwift.ConversationRecord - .filter(Column("destination_hash") == conversationHash) - .fetchOne(db) { - if message.timestamp >= conversation.lastMessageTimestamp { - conversation.lastMessageTimestamp = message.timestamp - conversation.updatedAt = Date().timeIntervalSince1970 - if !message.content.isEmpty, - let content = String(data: message.content, encoding: .utf8), - !content.isEmpty { - conversation.lastMessagePreview = String(content.prefix(100)) - } - } - if message.incoming { - conversation.unreadCount += 1 - conversation.isUnread = 1 - } - try conversation.update(db) - } else { - let preview = String(data: message.content, encoding: .utf8).map { - String($0.prefix(100)) - } - let conversation = LXMFSwift.ConversationRecord( - destinationHash: conversationHash, - displayName: nil, - lastMessageTimestamp: message.timestamp, - lastMessagePreview: preview, - unreadCount: message.incoming ? 1 : 0 - ) - try conversation.insert(db) - } - } - /// Atomically rekey an app-owned optimistic or failed row to the canonical /// hash accepted by the network backend. Payload columns stay on the same /// row, so a crash cannot expose both a stale retry and a sent duplicate. @@ -683,13 +633,13 @@ public actor MessageRepository { """, arguments: [ message.hash, - Self.mapStateToGRDB(message.state).rawValue, - Self.mapMethodToGRDB(message.method).rawValue, + Self.mapStateToGRDB(message.state), + Self.mapMethodToGRDB(message.method), message.timestamp, Self.stagedRetryMarker, Date().timeIntervalSince1970, oldId, - LXMFSwift.LXMessageState.failed.rawValue, + GRDBState.failed.rawValue, Self.uncertainRetryMarker + "%", Self.optimisticOutboundMarker, ] @@ -730,7 +680,7 @@ public actor MessageRepository { AND receiving_interface = ? """, arguments: [ - LXMFSwift.LXMessageState.failed.rawValue, + GRDBState.failed.rawValue, Self.uncertainRetryMarker(canonicalHash: canonicalHash), Date().timeIntervalSince1970, messageId, @@ -758,8 +708,8 @@ public actor MessageRepository { """, arguments: StatementArguments([ message.hash, - Self.mapStateToGRDB(message.state).rawValue, - Self.mapMethodToGRDB(message.method).rawValue, + Self.mapStateToGRDB(message.state), + Self.mapMethodToGRDB(message.method), message.timestamp, retryMarker, Date().timeIntervalSince1970, @@ -803,10 +753,10 @@ public actor MessageRepository { WHERE incoming = 0 AND state = ? AND receiving_interface = ? """, arguments: [ - LXMFSwift.LXMessageState.failed.rawValue, + GRDBState.failed.rawValue, Self.uncertainRetryMarker, Date().timeIntervalSince1970, - LXMFSwift.LXMessageState.sending.rawValue, + GRDBState.sending.rawValue, Self.stagedRetryMarker, ] ) @@ -827,7 +777,7 @@ public actor MessageRepository { """, arguments: [ destinationHash, - LXMFSwift.LXMessageState.failed.rawValue, + GRDBState.failed.rawValue, Self.uncertainRetryMarker + "%", ] ) ?? 0 @@ -837,12 +787,12 @@ public actor MessageRepository { /// Get message by ID (LXMessage form), rebuilt from the GRDB record. public func getMessage(id: Data) async throws -> RNSAPI.LXMessage? { - try await database.getMessageRecord(id: id).map(Self.mapToLXMessage) + try await store.getMessageRecord(id: id).map(Self.mapToLXMessage) } /// Check if message exists. public func hasMessage(id: Data) async throws -> Bool { - try await database.hasMessage(id: id) + try await store.hasMessage(id: id) } /// Update message delivery state and optional effective transport without @@ -854,7 +804,7 @@ public actor MessageRepository { method: RNSAPI.LXDeliveryMethod? = nil ) async throws -> Bool { try await replacementPool.write { db in - let incomingState = Int(Self.mapStateToGRDB(state).rawValue) + let incomingState = Int(Self.mapStateToGRDB(state)) let existingState = try Int.fetchOne( db, sql: "SELECT state FROM messages WHERE message_id = ?", @@ -865,7 +815,7 @@ public actor MessageRepository { incoming: incomingState ) let persistedMethod = persistedState == incomingState - ? method.map { Self.mapMethodToGRDB($0).rawValue } + ? method.map { Self.mapMethodToGRDB($0) } : nil try db.execute( sql: """ @@ -890,10 +840,10 @@ public actor MessageRepository { /// rekey it to the wire hash. This path works without an open chat view. static func monotonicDeliveryState(existing: Int?, incoming: Int) -> Int { guard let existing else { return incoming } - let sending = Int(LXMFSwift.LXMessageState.sending.rawValue) - let sent = Int(LXMFSwift.LXMessageState.sent.rawValue) - let delivered = Int(LXMFSwift.LXMessageState.delivered.rawValue) - let failed = Int(LXMFSwift.LXMessageState.failed.rawValue) + let sending = Int(GRDBState.sending.rawValue) + let sent = Int(GRDBState.sent.rawValue) + let delivered = Int(GRDBState.delivered.rawValue) + let failed = Int(GRDBState.failed.rawValue) if existing == delivered || incoming == delivered { return delivered } @@ -916,8 +866,8 @@ public actor MessageRepository { method: RNSAPI.LXDeliveryMethod? = nil ) async throws -> Bool { try await replacementPool.write { db in - let incomingState = Int(Self.mapStateToGRDB(state).rawValue) - let mappedMethod = method.map { Self.mapMethodToGRDB($0).rawValue } + let incomingState = Int(Self.mapStateToGRDB(state)) + let mappedMethod = method.map { Self.mapMethodToGRDB($0) } let now = Date().timeIntervalSince1970 let existingCanonicalState = try Int.fetchOne( db, @@ -1011,22 +961,20 @@ public actor MessageRepository { /// the GRDB `loadPendingOutbound` / `loadFailedOutbound` record IDs and /// re-fetching their raw records, so the result maps through the field-map /// bridge rather than the wire-unpack path. - private func loadOutbound(matching state: LXMFSwift.LXMessageState) async throws -> [RNSAPI.LXMessage] { - // The GRDB store only exposes outbound/failed via LXMessage-returning - // methods (which wire-unpack). Those would throw on app-written rows - // whose `packed_lxmf` is a field map, not real wire — so instead fetch - // the matching records by ID through `getMessageRecord` and bridge. - let messages: [LXMFSwift.LXMessage] + private func loadOutbound(matching state: GRDBState) async throws -> [RNSAPI.LXMessage] { + // The store exposes outbound/failed as raw message IDs (no wire unpack). We + // re-fetch each lightweight row and bridge through the field-map path so the + // mapping is uniform with the read paths above (wire rows would throw if we + // wire-unpacked them here, since app-written rows store a field map). + let ids: [Data] switch state { - case .outbound: messages = (try? await database.loadPendingOutbound()) ?? [] - case .failed: messages = (try? await database.loadFailedOutbound()) ?? [] - default: messages = [] + case .outbound: ids = (try? await store.loadPendingOutboundIds()) ?? [] + case .failed: ids = (try? await store.loadFailedOutboundIds()) ?? [] + default: ids = [] } - // For each (successfully-unpacked) message, re-fetch its lightweight - // record so the RNSAPI mapping is uniform with the read paths above. var out: [RNSAPI.LXMessage] = [] - for m in messages { - if let rec = try await database.getMessageRecord(id: m.hash) { + for id in ids { + if let rec = try await store.getMessageRecord(id: id) { out.append(Self.mapToLXMessage(rec)) } } @@ -1048,8 +996,8 @@ extension MessageRepository { // MARK: Conversation - /// GRDB `ConversationRecord` → RNSAPI `ConversationRecord`. - static func mapConversation(_ c: LXMFSwift.ConversationRecord) -> RNSAPI.ConversationRecord { + /// GRDB `ConversationStoreRow` → RNSAPI `ConversationStoreRow`. + static func mapConversation(_ c: ConversationStoreRow) -> RNSAPI.ConversationRecord { RNSAPI.ConversationRecord( hash: c.destinationHash, displayName: c.displayName ?? "", @@ -1066,15 +1014,6 @@ extension MessageRepository { // MARK: Icon - /// GRDB `IconAppearance` → RNSAPI `IconAppearance`. - static func mapIcon(_ i: LXMFSwift.IconAppearance) -> RNSAPI.IconAppearance { - RNSAPI.IconAppearance( - iconName: i.iconName, - foregroundColor: i.foregroundColor, - backgroundColor: i.backgroundColor - ) - } - // MARK: Message record /// Recover an LXMF field map from a GRDB row's `packed_lxmf`, regardless of @@ -1149,7 +1088,7 @@ extension MessageRepository { /// store the signed LXMF wire — those are unpacked and re-packed as a field /// map so the chat UI's `LxmfFieldCodec.unpack(record.packedLxmf)` recovers /// their attachments/icons too. See `recoverFields` / `normalizedFieldMap`. - static func mapRecord(_ r: LXMFSwift.MessageRecord) -> RNSAPI.MessageRecord { + static func mapRecord(_ r: MessageRow) -> RNSAPI.MessageRecord { RNSAPI.MessageRecord( id: r.messageId, conversationHash: r.conversationHash, @@ -1170,7 +1109,7 @@ extension MessageRepository { } /// GRDB `MessageRecord` → RNSAPI `LXMessage` (via the field-map bridge). - static func mapToLXMessage(_ r: LXMFSwift.MessageRecord) -> RNSAPI.LXMessage { + static func mapToLXMessage(_ r: MessageRow) -> RNSAPI.LXMessage { // Recover fields whether `packed_lxmf` is a field map or the LXMF wire, // so attachment/icon fields survive for Swift/NE-delivered rows too. let fields = recoverFields(from: r.packedLxmf) @@ -1198,35 +1137,48 @@ extension MessageRepository { return msg } - /// RNSAPI `LXMessage` → GRDB `LXMessage` (for the save path). + /// RNSAPI `LXMessage` → app-owned `MessageRow` (for the save path). /// - /// Uses the GRDB no-identity outbound init, then carries the field map in - /// `packed` so `MessageRecord(from:)` persists it into `packed_lxmf`. The - /// conversation key (incoming → sourceHash, outbound → destinationHash) is - /// preserved by setting `incoming` to match. - static func mapToGRDBMessage(_ m: RNSAPI.LXMessage) -> LXMFSwift.LXMessage { - var out = LXMFSwift.LXMessage( + /// Builds the row directly from the RNSAPI domain fields (no wire type in the + /// store). `packed_lxmf` carries the MessagePack field map (empty when there + /// are no fields), mirroring what the lifted `MessageRecord(from:)` produced; + /// the `fields` column stays nil (the app reads it back via `packed_lxmf`). + static func makeRow(from m: RNSAPI.LXMessage) -> MessageRow { + let now = Date().timeIntervalSince1970 + let conversationHash = m.incoming ? m.sourceHash : m.destinationHash + var replyToId: String? = nil + if let appData = m.fields?[RNSAPI.LXMessage.FIELD_APP_DATA] as? [String: Any], + let replyTo = appData["reply_to"] as? String { + replyToId = replyTo + } + let packed = (m.fields?.isEmpty == false) ? LxmfFieldCodec.pack(m.fields!) : Data() + return MessageRow( + messageId: m.hash, + conversationHash: conversationHash, destinationHash: m.destinationHash, sourceHash: m.sourceHash, - content: m.content, - title: m.title, + signature: m.signature, timestamp: m.timestamp, - state: mapStateToGRDB(m.state), - incoming: m.incoming + title: m.title, + content: m.content, + fields: nil, + stamp: m.stamp, + state: Self.mapStateToGRDB(m.state), + method: Self.mapMethodToGRDB(m.method), + deliveryAttempts: m.deliveryAttempts, + progress: 0.0, + incoming: m.incoming, + rssi: m.rssi, + snr: m.snr, + q: m.q, + ratchetId: nil, + receivingInterface: m.receivingInterface, + replyToId: replyToId, + reactionsJson: nil, + packedLxmf: packed, + createdAt: now, + updatedAt: now ) - out.hash = m.hash - out.method = mapMethodToGRDB(m.method) - out.rssi = m.rssi - out.snr = m.snr - out.q = m.q - out.receivingInterface = m.receivingInterface - out.fields = m.fields - // Carry the MessagePack field map as `packed` so `MessageRecord(from:)` - // (which requires non-nil `packed` and copies it to `packed_lxmf`) - // succeeds without the signed LXMF wire. Empty Data when no fields, - // matching `LxmfFieldCodec.pack`'s empty-map convention. - out.packed = (m.fields?.isEmpty == false) ? LxmfFieldCodec.pack(m.fields!) : Data() - return out } // MARK: State @@ -1235,7 +1187,7 @@ extension MessageRepository { /// /// GRDB has `generating`/`rejected`/`cancelled` with no RNSAPI peer: /// `generating` → `.draft`; `rejected`/`cancelled` → `.failed`. - static func mapState(_ s: LXMFSwift.LXMessageState) -> RNSAPI.LXMessageState { + static func mapState(_ s: GRDBState) -> RNSAPI.LXMessageState { switch s { case .generating: return .draft case .outbound: return .outbound @@ -1252,7 +1204,7 @@ extension MessageRepository { /// back to `.sent` (matches the chat UI's `default` arm in /// `Message(from:)`). static func mapState(_ raw: UInt8) -> RNSAPI.LXMessageState { - guard let s = LXMFSwift.LXMessageState(rawValue: raw) else { return .sent } + guard let s = GRDBState(rawValue: raw) else { return .sent } return mapState(s) } @@ -1260,22 +1212,22 @@ extension MessageRepository { /// /// RNSAPI `.received` (inbound) maps to GRDB `.delivered` (the GRDB store /// has no inbound-specific state; `incoming` carries that distinction). - static func mapStateToGRDB(_ s: RNSAPI.LXMessageState) -> LXMFSwift.LXMessageState { + static func mapStateToGRDB(_ s: RNSAPI.LXMessageState) -> UInt8 { switch s { - case .draft: return .generating - case .outbound: return .outbound - case .sending: return .sending - case .sent: return .sent - case .delivered: return .delivered - case .failed: return .failed - case .received: return .delivered + case .draft: return GRDBState.generating.rawValue + case .outbound: return GRDBState.outbound.rawValue + case .sending: return GRDBState.sending.rawValue + case .sent: return GRDBState.sent.rawValue + case .delivered: return GRDBState.delivered.rawValue + case .failed: return GRDBState.failed.rawValue + case .received: return GRDBState.delivered.rawValue } } // MARK: Method /// GRDB `LXDeliveryMethod` (UInt8) → RNSAPI `LXDeliveryMethod`. - static func mapMethod(_ m: LXMFSwift.LXDeliveryMethod) -> RNSAPI.LXDeliveryMethod { + static func mapMethod(_ m: GRDBDeliveryMethod) -> RNSAPI.LXDeliveryMethod { switch m { case .opportunistic: return .opportunistic case .direct: return .direct @@ -1287,7 +1239,7 @@ extension MessageRepository { /// Map a raw GRDB method byte → RNSAPI `LXDeliveryMethod`. Unknown bytes /// fall back to `.unknown`. static func mapMethod(_ raw: UInt8) -> RNSAPI.LXDeliveryMethod { - guard let m = LXMFSwift.LXDeliveryMethod(rawValue: raw) else { return .unknown } + guard let m = GRDBDeliveryMethod(rawValue: raw) else { return .unknown } return mapMethod(m) } @@ -1296,13 +1248,13 @@ extension MessageRepository { /// RNSAPI `.unknown` has no GRDB enum peer. `saveMessage` replaces the /// mapped record's temporary value with raw sentinel zero before inserting /// it in the same transaction, so reads map it back to `.unknown`. - static func mapMethodToGRDB(_ m: RNSAPI.LXDeliveryMethod) -> LXMFSwift.LXDeliveryMethod { + static func mapMethodToGRDB(_ m: RNSAPI.LXDeliveryMethod) -> UInt8 { switch m { - case .opportunistic: return .opportunistic - case .direct: return .direct - case .propagated: return .propagated - case .paper: return .paper - case .unknown: return .opportunistic + case .opportunistic: return GRDBDeliveryMethod.opportunistic.rawValue + case .direct: return GRDBDeliveryMethod.direct.rawValue + case .propagated: return GRDBDeliveryMethod.propagated.rawValue + case .paper: return GRDBDeliveryMethod.paper.rawValue + case .unknown: return 0 } } } diff --git a/Sources/ColumbaApp/Services/MessageStore/ConversationRecordStore.swift b/Sources/ColumbaApp/Services/MessageStore/ConversationRecordStore.swift new file mode 100644 index 00000000..5f2a54a1 --- /dev/null +++ b/Sources/ColumbaApp/Services/MessageStore/ConversationRecordStore.swift @@ -0,0 +1,120 @@ +// +// ConversationRow.swift +// ColumbaApp +// +// App-owned GRDB record for the shared `conversations` table. +// +// ── ANDROID-PARITY STORE (Track: app-owned store) ───────────────────────── +// Lifted verbatim from the LXMFSwift package's `Storage/ConversationStoreRow.swift` +// and re-scoped to the app target so the store is app-owned and does NOT +// depend on the wire library (LXMFSwift / ReticulumSwift) - the same shape as +// Android, whose `:data` Room store has zero dependency on the RNS library. +// The columns are FROZEN: the Network Extension's embedded Python node writes +// this exact table directly (rns_bridge.py), so they must stay byte-identical +// to the schema the Python writer targets. +// + +import Foundation +import GRDB + +/// Database record for LXMF conversations. +/// +/// Aggregates messages by destination hash and tracks metadata +/// for efficient conversation list UI rendering. +/// +/// Named `ConversationStoreRow` to avoid colliding with both the SwiftUI +/// `ConversationRow` view and the RNSAPI domain `ConversationRecord` type, +/// which are in the same scope wherever the store and RNSAPI are both visible. +public struct ConversationStoreRow: Codable, FetchableRecord, PersistableRecord { + public static let databaseTableName = "conversations" + + // MARK: - Database Columns + + /// Destination hash (16 bytes) - primary key + public var destinationHash: Data + + /// Display name (optional, user-provided) + public var displayName: String? + + /// Timestamp of most recent message + public var lastMessageTimestamp: Double + + /// Preview text of most recent message + public var lastMessagePreview: String? + + /// Count of unread messages + public var unreadCount: Int + + /// Legacy flag (1 if unread, 0 if read) - for compatibility + public var isUnread: Int + + /// Record creation timestamp + public var createdAt: Double + + /// Record update timestamp + public var updatedAt: Double + + /// Whether this conversation is marked as favorite/saved contact + public var isFavorite: Int + + /// MDI icon name for peer's profile icon (from LXMF Field 4) + public var iconName: String? + + /// Foreground color hex for peer's icon (6 chars, e.g., "FFFFFF") + public var iconFgColor: String? + + /// Background color hex for peer's icon (6 chars, e.g., "1E88E5") + public var iconBgColor: String? + + /// Whether this conversation is pinned to the top + public var isPinned: Int = 0 + + // MARK: - Column Mapping (snake_case to camelCase) + + enum CodingKeys: String, CodingKey { + case destinationHash = "destination_hash" + case displayName = "display_name" + case lastMessageTimestamp = "last_message_timestamp" + case lastMessagePreview = "last_message_preview" + case unreadCount = "unread_count" + case isUnread = "is_unread" + case createdAt = "created_at" + case updatedAt = "updated_at" + case isFavorite = "is_favorite" + case iconName = "icon_name" + case iconFgColor = "icon_fg_color" + case iconBgColor = "icon_bg_color" + case isPinned = "is_pinned" + } + + // MARK: - Computed Properties + + /// True if conversation has unread messages + public var hasUnreadMessages: Bool { + return unreadCount > 0 + } + + // MARK: - Initialization + + /// Create new conversation record. + public init( + destinationHash: Data, + displayName: String? = nil, + lastMessageTimestamp: Double, + lastMessagePreview: String? = nil, + unreadCount: Int = 0, + isFavorite: Bool = false + ) { + self.destinationHash = destinationHash + self.displayName = displayName + self.lastMessageTimestamp = lastMessageTimestamp + self.lastMessagePreview = lastMessagePreview + self.unreadCount = unreadCount + self.isUnread = unreadCount > 0 ? 1 : 0 + self.isFavorite = isFavorite ? 1 : 0 + + let now = Date().timeIntervalSince1970 + self.createdAt = now + self.updatedAt = now + } +} diff --git a/Sources/ColumbaApp/Services/MessageStore/GRDBVocabulary.swift b/Sources/ColumbaApp/Services/MessageStore/GRDBVocabulary.swift new file mode 100644 index 00000000..f03df129 --- /dev/null +++ b/Sources/ColumbaApp/Services/MessageStore/GRDBVocabulary.swift @@ -0,0 +1,64 @@ +// +// GRDBVocabulary.swift +// ColumbaApp +// +// The shared persistence vocabulary for the `messages.state` and +// `messages.method` columns. +// +// ── ANDROID-PARITY STORE (frozen DB bytes) ──────────────────────────────── +// These byte values are FROZEN: they are the canonical serialization of the +// message state and delivery method in the shared GRDB store, and they are +// shared across processes. The app writes them; the Network Extension's +// embedded Python node writes the same bytes directly into the same table +// (rns_bridge.py: `_GRDB_STATE_DELIVERED = 0x08`, and +// `_inbound_delivery_method_name` maps to the same opportunistic/direct/ +// propagated/paper vocabulary). The values are lifted verbatim from the +// LXMFSwift package's `LXMessageState` / `LXDeliveryMethod` (which documents +// them against the Python LXMF implementation) and re-scoped to the app so +// the store does not depend on the wire library. +// +// The app-facing RNSAPI enums (`RNSAPI.LXMessageState`, `RNSAPI.LXDeliveryMethod`) +// are the *semantic* model the UI speaks; `MessageRepository.mapState*` / +// `mapMethod*` bridge between them and these raw bytes. +// + +import Foundation + +/// Message state byte values persisted in `messages.state`. +/// +/// Reference: LXMF `LXMessage.py` lines 14-22 (mirrored by LXMFSwift). +public enum GRDBState: UInt8 { + /// Message is being constructed (not yet ready to send). Python: GENERATING + case generating = 0x00 + /// Message is ready to send (queued for outbound). Python: OUTBOUND + case outbound = 0x01 + /// Message is actively being transmitted. Python: SENDING + case sending = 0x02 + /// Message successfully sent to transport. Python: SENT + case sent = 0x04 + /// Message delivered to destination (delivery receipt received). Python: DELIVERED + case delivered = 0x08 + /// Message rejected by recipient (unused in client mode). Python: REJECTED + case rejected = 0xFD + /// Message cancelled by sender before delivery. Python: CANCELLED + case cancelled = 0xFE + /// Message delivery failed after all retry attempts. Python: FAILED + case failed = 0xFF +} + +/// Delivery method byte values persisted in `messages.method`. +/// +/// Reference: LXMF `LXMessage.py` lines 29-33 (mirrored by LXMFSwift). The +/// app also persists a sentinel `0` for the RNSAPI `.unknown` method, which has +/// no value in this vocabulary (see `MessageRepository.mapMethodToGRDB`); raw +/// reads map `0` back to `.unknown`. +public enum GRDBDeliveryMethod: UInt8 { + /// Opportunistic delivery (single packet, no link required). Python: OPPORTUNISTIC + case opportunistic = 0x01 + /// Direct delivery over established link. Python: DIRECT + case direct = 0x02 + /// Propagated delivery via propagation nodes. Python: PROPAGATED + case propagated = 0x03 + /// Paper message (QR code or physical medium). Python: PAPER + case paper = 0x05 +} diff --git a/Sources/ColumbaApp/Services/MessageStore/MessageRow.swift b/Sources/ColumbaApp/Services/MessageStore/MessageRow.swift new file mode 100644 index 00000000..fdb5913b --- /dev/null +++ b/Sources/ColumbaApp/Services/MessageStore/MessageRow.swift @@ -0,0 +1,202 @@ +// +// MessageRow.swift +// ColumbaApp +// +// App-owned GRDB record for the shared `messages` table. +// +// ── ANDROID-PARITY STORE (Track: app-owned store) ───────────────────────── +// This is Columba's OWN copy of the GRDB message row. It is lifted verbatim +// from the LXMFSwift package's `Storage/MessageRecord.swift` and re-scoped to +// the app target so the store is app-owned and does NOT depend on the wire +// library (LXMFSwift / ReticulumSwift) - the same shape as Android, whose +// `:data` Room store has zero dependency on the RNS library. +// +// The ONLY things removed from the lifted file are the two methods that +// existed solely to serve the wire type and welded the store to LXMFSwift: +// `init(from: LXMessage)` and `toLXMessage()`. The app maps to/from the RNSAPI +// domain types itself (see MessageRepository), and the Network Extension's +// embedded Python node writes this exact table directly (rns_bridge.py), so +// the columns below are FROZEN and must stay byte-identical to the schema the +// Python inbound writer targets. +// + +import Foundation +import GRDB + +/// Database record for LXMF messages. +/// +/// Persists all fields of the message including the packed bytes +/// (`packed_lxmf`), which carries either the signed LXMF wire (rows the +/// Network-Extension backend persists) or the MessagePack field map (rows the +/// app / Python path persists). See `MessageRepository.mapRecord` / +/// `normalizedFieldMap` for the dual-meaning discriminator. +public struct MessageRow: Codable, FetchableRecord, PersistableRecord { + public static let databaseTableName = "messages" + + // MARK: - Database Columns + + /// Message ID (hash, 32 bytes) + public var messageId: Data + + /// Conversation destination hash (16 bytes) + public var conversationHash: Data + + /// Message destination hash (16 bytes) + public var destinationHash: Data + + /// Message source hash (16 bytes) + public var sourceHash: Data + + /// Ed25519 signature (64 bytes) + public var signature: Data + + /// Message timestamp (Unix time) + public var timestamp: Double + + /// Message title (can be empty) + public var title: Data + + /// Message content + public var content: Data + + /// Fields dictionary (MessagePack encoded, optional) + public var fields: Data? + + /// Proof-of-work stamp (optional) + public var stamp: Data? + + /// Message state (raw byte; see `GRDBState`) + public var state: UInt8 + + /// Delivery method (raw byte; see `GRDBDeliveryMethod`) + public var method: UInt8 + + /// Number of delivery attempts + public var deliveryAttempts: Int + + /// Send/receive progress (0.0 to 1.0) + public var progress: Double + + /// True if incoming message, false if outbound + public var incoming: Bool + + /// RSSI (signal strength, optional) + public var rssi: Double? + + /// SNR (signal-to-noise ratio, optional) + public var snr: Double? + + /// Link quality (optional) + public var q: Double? + + /// Ratchet ID (optional) + public var ratchetId: Data? + + /// Human-readable name of the interface that received this message + public var receivingInterface: String? + + /// ID of the message this is a reply to (optional) + public var replyToId: String? + + /// JSON-encoded reactions for this message (optional) + public var reactionsJson: String? + + /// Packed LXMF wire format (or MessagePack field map) for retransmission + public var packedLxmf: Data + + /// Record creation timestamp + public var createdAt: Double + + /// Record update timestamp + public var updatedAt: Double + + // MARK: - Column Mapping (snake_case to camelCase) + + enum CodingKeys: String, CodingKey { + case messageId = "message_id" + case conversationHash = "conversation_hash" + case destinationHash = "destination_hash" + case sourceHash = "source_hash" + case signature + case timestamp + case title + case content + case fields + case stamp + case state + case method + case deliveryAttempts = "delivery_attempts" + case progress + case incoming + case rssi + case snr + case q + case ratchetId = "ratchet_id" + case receivingInterface = "receiving_interface" + case replyToId = "reply_to_id" + case reactionsJson = "reactions_json" + case packedLxmf = "packed_lxmf" + case createdAt = "created_at" + case updatedAt = "updated_at" + } + + // MARK: - Initialization + + /// Memberwise initializer. Rows are built by the caller (MessageRepository) + /// from RNSAPI domain fields; there is intentionally NO wire-type (LXMessage) + /// constructor here - that is exactly what welded the lifted record to the + /// LXMFSwift library. + public init( + messageId: Data, + conversationHash: Data, + destinationHash: Data, + sourceHash: Data, + signature: Data, + timestamp: Double, + title: Data, + content: Data, + fields: Data?, + stamp: Data?, + state: UInt8, + method: UInt8, + deliveryAttempts: Int, + progress: Double, + incoming: Bool, + rssi: Double?, + snr: Double?, + q: Double?, + ratchetId: Data?, + receivingInterface: String?, + replyToId: String?, + reactionsJson: String?, + packedLxmf: Data, + createdAt: Double, + updatedAt: Double + ) { + self.messageId = messageId + self.conversationHash = conversationHash + self.destinationHash = destinationHash + self.sourceHash = sourceHash + self.signature = signature + self.timestamp = timestamp + self.title = title + self.content = content + self.fields = fields + self.stamp = stamp + self.state = state + self.method = method + self.deliveryAttempts = deliveryAttempts + self.progress = progress + self.incoming = incoming + self.rssi = rssi + self.snr = snr + self.q = q + self.ratchetId = ratchetId + self.receivingInterface = receivingInterface + self.replyToId = replyToId + self.reactionsJson = reactionsJson + self.packedLxmf = packedLxmf + self.createdAt = createdAt + self.updatedAt = updatedAt + } +} diff --git a/Sources/ColumbaApp/Services/MessageStore/MessageStore.swift b/Sources/ColumbaApp/Services/MessageStore/MessageStore.swift new file mode 100644 index 00000000..e2973a57 --- /dev/null +++ b/Sources/ColumbaApp/Services/MessageStore/MessageStore.swift @@ -0,0 +1,532 @@ +// +// MessageStore.swift +// ColumbaApp +// +// App-owned GRDB store for the shared `messages` + `conversations` tables. +// +// ── ANDROID-PARITY STORE (Track: app-owned store) ───────────────────────── +// Lifted from the LXMFSwift package's `Storage/LXMFDatabase.swift` and re-scoped +// to the app target so the store is app-owned and does NOT depend on the wire +// library (LXMFSwift / ReticulumSwift) - the same shape as Android, whose +// `:data` Room store has zero dependency on the RNS library. +// +// The SCHEMA IS FROZEN. The migration bodies below are byte-for-byte identical +// to the LXMFSwift `LXMFDatabase` migrations (v1..v6): the Network Extension's +// embedded Python node opens this same file as the writer and INSERTs inbound +// rows into these exact tables (rns_bridge.py `_write_inbound_to_grdb`), so any +// change here would break the cross-process store. Do not alter columns, types, +// defaults, PKs, FKs, or index names without a coordinated change to the Python +// writer and a migration. +// +// The ONLY structural change from the lifted file: the LXMessage-returning +// methods (`saveMessage(LXMessage)`, `getMessage(id:)`, `getMessages(...)`, +// `loadPendingOutbound()`, `loadFailedOutbound()`, `updateConversation(for:)`) +// are replaced by row-based equivalents operating on `MessageRow` / +// `ConversationStoreRow`. The app (MessageRepository) builds rows from RNSAPI domain +// types; the wire codec stays in the collector, not the store. +// + +import Foundation +import GRDB + +/// Actor for thread-safe message store operations. +/// +/// Manages SQLite database with WAL mode for concurrent access. Stores messages +/// with full packed bytes for retransmission. +public actor MessageStore { + // MARK: - Properties + + private let dbPool: DatabasePool + + // MARK: - Initialization + + /// Create or open the message store. + /// + /// - Parameter path: Database file path + /// - Throws: DatabaseError if initialization fails + public init(path: String, readonly: Bool = false) throws { + // App <-> Network-Extension share this database across processes (Model B). + // The writer (the NE) must survive iOS's 0xDEAD10CC "file busy while suspended" + // kill; the app opens read-only with `readonly: true`. (GRDB DatabaseSharing.) + var config = Configuration() + config.readonly = readonly + // Suspend cleanly when iOS backgrounds the process holding a lock, instead of + // being 0xDEAD10CC-killed. + config.observesSuspensionNotifications = true + // Take write locks up front to avoid cross-process SQLITE_BUSY upgrade + // deadlocks. Writer-only: a read-only pool issues deferred read transactions + // regardless, so this would be dead (and misleading) config on the reader. + if !readonly { + config.defaultTransactionKind = .immediate + } + config.prepareDatabase { db in + if !readonly { + // Enable WAL mode for concurrent reads during writes + try db.execute(sql: "PRAGMA journal_mode=WAL") + // Set synchronous mode to NORMAL for better performance + try db.execute(sql: "PRAGMA synchronous=NORMAL") + } + // Retry for up to 5 seconds if the database is locked + try db.execute(sql: "PRAGMA busy_timeout=5000") + } + + // Create database pool (allows concurrent reads during writes in WAL mode) + dbPool = try DatabasePool(path: path, configuration: config) + + #if os(iOS) + // Deliver-while-locked: the NE must read/write after first unlock even when the + // device is subsequently locked. Pin the data-protection class on the DB and its + // -wal/-shm sidecar files to CompleteUntilFirstUserAuthentication. + if !readonly { + let fm = FileManager.default + for suffix in ["", "-wal", "-shm"] where fm.fileExists(atPath: path + suffix) { + try? fm.setAttributes( + [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], + ofItemAtPath: path + suffix + ) + } + } + #endif + + // Run migrations. SCHEMA FROZEN - see file header. + var migrator = DatabaseMigrator() + + // v1: Initial schema + migrator.registerMigration("v1_initial") { db in + // Create conversations table + try db.create(table: "conversations") { t in + t.column("destination_hash", .blob).primaryKey().notNull() + t.column("display_name", .text) + t.column("last_message_timestamp", .double) + t.column("last_message_preview", .text) + t.column("unread_count", .integer).defaults(to: 0) + t.column("is_unread", .integer).defaults(to: 0) + t.column("created_at", .double).notNull() + t.column("updated_at", .double).notNull() + } + + // Create messages table + try db.create(table: "messages") { t in + t.column("message_id", .blob).primaryKey().notNull() + t.column("conversation_hash", .blob).notNull() + .references("conversations", column: "destination_hash", onDelete: .cascade) + t.column("destination_hash", .blob).notNull() + t.column("source_hash", .blob).notNull() + t.column("signature", .blob).notNull() + t.column("timestamp", .double).notNull() + t.column("title", .blob) + t.column("content", .blob).notNull() + t.column("fields", .blob) + t.column("stamp", .blob) + t.column("state", .integer).notNull() + t.column("method", .integer).notNull() + t.column("delivery_attempts", .integer).defaults(to: 0) + t.column("progress", .double).defaults(to: 0.0) + t.column("incoming", .integer).notNull() + t.column("rssi", .double) + t.column("snr", .double) + t.column("q", .double) + t.column("ratchet_id", .blob) + t.column("packed_lxmf", .blob).notNull() + t.column("created_at", .double).notNull() + t.column("updated_at", .double).notNull() + } + + // Create indexes for fast queries + try db.create(index: "idx_messages_conversation_timestamp", + on: "messages", + columns: ["conversation_hash", "timestamp"]) + try db.create(index: "idx_messages_state", + on: "messages", + columns: ["state"]) + try db.create(index: "idx_messages_timestamp", + on: "messages", + columns: ["timestamp"]) + try db.create(index: "idx_conversations_last_timestamp", + on: "conversations", + columns: ["last_message_timestamp"]) + } + + // v2: Add is_favorite column to conversations + migrator.registerMigration("v2_add_favorite") { db in + try db.alter(table: "conversations") { t in + t.add(column: "is_favorite", .integer).defaults(to: 0) + } + } + + // v3: Add icon appearance columns to conversations + migrator.registerMigration("v3_add_icon_appearance") { db in + try db.alter(table: "conversations") { t in + t.add(column: "icon_name", .text) + t.add(column: "icon_fg_color", .text) + t.add(column: "icon_bg_color", .text) + } + } + + // v4: Add receiving_interface column to messages + migrator.registerMigration("v4_add_receiving_interface") { db in + try db.alter(table: "messages") { t in + t.add(column: "receiving_interface", .text) + } + } + + // v5: Add is_pinned column to conversations + migrator.registerMigration("v5_add_pinned") { db in + try db.alter(table: "conversations") { t in + t.add(column: "is_pinned", .integer).defaults(to: 0) + } + } + + // v6: Add reply and reaction columns to messages + migrator.registerMigration("v6_add_replies_reactions") { db in + try db.alter(table: "messages") { t in + t.add(column: "reply_to_id", .text) + t.add(column: "reactions_json", .text) + } + try db.create(index: "idx_messages_reply_to", on: "messages", columns: ["reply_to_id"]) + } + + // Only the writer runs migrations: GRDB's migrator issues an internal + // `write {}` to atomically check/apply schema versions, which returns + // SQLITE_READONLY (throws) on a read-only pool. Under Model B the writer (the + // NE) owns the schema; the read-only app reader just opens the already-migrated + // store, so migrating from it would throw at init before any read could happen. + if !readonly { + try migrator.migrate(dbPool) + } + } + + // MARK: - Message Operations + + /// Save a message row to the database, creating/updating its conversation. + /// + /// - Parameter row: MessageRow to save (built by MessageRepository from RNSAPI) + /// - Throws: DatabaseError if save fails + public func saveMessage(_ row: MessageRow) throws { + try dbPool.write { db in + // Update/create conversation FIRST (foreign key requires it) + try self.updateConversationFor(row: row, in: db) + // Save message (insert or replace) + try row.save(db) + } + } + + /// Check if message exists. + public func hasMessage(id: Data) throws -> Bool { + try dbPool.read { db in + try MessageRow + .filter(Column("message_id") == id) + .fetchCount(db) > 0 + } + } + + /// Get all conversations ordered by last message timestamp descending. + public func getConversations(limit: Int = 100, offset: Int = 0) throws -> [ConversationStoreRow] { + try dbPool.read { db in + try ConversationStoreRow + .order(Column("last_message_timestamp").desc) + .limit(limit, offset: offset) + .fetchAll(db) + } + } + + /// Get single conversation by destination hash. + public func getConversation(hash: Data) throws -> ConversationStoreRow? { + try dbPool.read { db in + try ConversationStoreRow + .filter(Column("destination_hash") == hash) + .fetchOne(db) + } + } + + /// Mark conversation as read. + public func markConversationRead(hash: Data) throws { + try dbPool.write { db in + try db.execute( + sql: """ + UPDATE conversations + SET unread_count = 0, is_unread = 0, updated_at = ? + WHERE destination_hash = ? + """, + arguments: [Date().timeIntervalSince1970, hash] + ) + } + } + + /// Set the unread count for a conversation (e.g. mark as unread with count=1). + public func setUnreadCount(hash: Data, count: Int) throws { + try dbPool.write { db in + try db.execute( + sql: """ + UPDATE conversations + SET unread_count = ?, is_unread = ?, updated_at = ? + WHERE destination_hash = ? + """, + arguments: [count, count > 0 ? 1 : 0, Date().timeIntervalSince1970, hash] + ) + } + } + + /// Delete conversation and all its messages (CASCADE foreign key). + public func deleteConversation(hash: Data) throws { + try dbPool.write { db in + try db.execute( + sql: "DELETE FROM conversations WHERE destination_hash = ?", + arguments: [hash] + ) + } + } + + /// Delete a single message by its ID hash. + public func deleteMessage(id messageId: Data) throws { + try dbPool.write { db in + try db.execute( + sql: "DELETE FROM messages WHERE message_id = ?", + arguments: [messageId] + ) + } + } + + /// Ensure a conversation exists for a destination. + public func ensureConversation(hash: Data, displayName: String?) throws { + try dbPool.write { db in + if var conversation = try ConversationStoreRow + .filter(Column("destination_hash") == hash) + .fetchOne(db) { + if conversation.displayName == nil, let displayName = displayName { + conversation.displayName = displayName + conversation.updatedAt = Date().timeIntervalSince1970 + try conversation.update(db) + } + } else { + let conversation = ConversationStoreRow( + destinationHash: hash, + displayName: displayName, + lastMessageTimestamp: Date().timeIntervalSince1970, + lastMessagePreview: nil, + unreadCount: 0 + ) + try conversation.insert(db) + } + } + } + + /// Set pinned status for a conversation. + public func setPinned(hash: Data, isPinned: Bool) throws { + try dbPool.write { db in + try db.execute( + sql: """ + UPDATE conversations + SET is_pinned = ?, updated_at = ? + WHERE destination_hash = ? + """, + arguments: [isPinned ? 1 : 0, Date().timeIntervalSince1970, hash] + ) + } + } + + /// Update display name for a conversation. + public func updateDisplayName(hash: Data, displayName: String?) throws { + try dbPool.write { db in + try db.execute( + sql: """ + UPDATE conversations + SET display_name = ?, updated_at = ? + WHERE destination_hash = ? + """, + arguments: [displayName, Date().timeIntervalSince1970, hash] + ) + } + } + + /// Set favorite status for a conversation. + public func setFavorite(hash: Data, isFavorite: Bool) throws { + try dbPool.write { db in + try db.execute( + sql: """ + UPDATE conversations + SET is_favorite = ?, updated_at = ? + WHERE destination_hash = ? + """, + arguments: [isFavorite ? 1 : 0, Date().timeIntervalSince1970, hash] + ) + } + } + + /// Get raw message records for conversation (no wire unpacking). + /// + /// Returns lightweight `MessageRow` structs directly from database, avoiding + /// the wire decode. Use this for UI display paths where only metadata is needed. + public func getMessageRecords(forConversation hash: Data, limit: Int = 200, offset: Int = 0) throws -> [MessageRow] { + try dbPool.read { db in + try MessageRow + .filter(Column("conversation_hash") == hash) + .order(Column("timestamp").desc) + .limit(limit, offset: offset) + .fetchAll(db) + } + } + + /// IDs of messages in the `.outbound` state or `.sent` small-packet messages + /// awaiting a delivery proof. See the original `loadPendingOutbound` comment in + /// LXMFSwift for why `.sent` is scoped to opportunistic/direct here. + public func loadPendingOutboundIds() throws -> [Data] { + try dbPool.read { db in + try Row.fetchAll( + db, + sql: """ + SELECT message_id FROM messages + WHERE ( + state = ? + OR ( + state = ? + AND (method = ? OR method = ?) + ) + ) + ORDER BY timestamp ASC + """, + arguments: [ + GRDBState.outbound.rawValue, + GRDBState.sent.rawValue, + GRDBDeliveryMethod.opportunistic.rawValue, + GRDBDeliveryMethod.direct.rawValue, + ] + ).map { $0["message_id"] as Data } + } + } + + /// IDs of messages in the `.failed` state. + public func loadFailedOutboundIds() throws -> [Data] { + try dbPool.read { db in + try Row.fetchAll( + db, + sql: """ + SELECT message_id FROM messages + WHERE state = ? + ORDER BY timestamp DESC + """, + arguments: [GRDBState.failed.rawValue] + ).map { $0["message_id"] as Data } + } + } + + // MARK: - Reply & Reaction Operations + + /// Update reply-to ID for a message. + public func updateReplyToId(messageId: Data, replyToId: String) throws { + try dbPool.write { db in + try db.execute( + sql: "UPDATE messages SET reply_to_id = ?, updated_at = ? WHERE message_id = ?", + arguments: [replyToId, Date().timeIntervalSince1970, messageId] + ) + } + } + + /// Update reactions JSON for a message. + public func updateReactions(messageId: Data, reactionsJson: String) throws { + try dbPool.write { db in + try db.execute( + sql: "UPDATE messages SET reactions_json = ?, updated_at = ? WHERE message_id = ?", + arguments: [reactionsJson, Date().timeIntervalSince1970, messageId] + ) + } + } + + /// Get reactions JSON for a message. + public func getReactionsJson(messageId: Data) throws -> String? { + try dbPool.read { db in + try String.fetchOne(db, + sql: "SELECT reactions_json FROM messages WHERE message_id = ?", + arguments: [messageId] + ) + } + } + + /// Get a single message record by ID (no wire unpacking). + public func getMessageRecord(id: Data) throws -> MessageRow? { + try dbPool.read { db in + try MessageRow + .filter(Column("message_id") == id) + .fetchOne(db) + } + } + + // MARK: - Icon Appearance + + /// Update peer icon appearance for a conversation. + public func updatePeerIcon(_ hash: Data, iconName: String, fgColor: String, bgColor: String) throws { + try dbPool.write { db in + try db.execute( + sql: """ + UPDATE conversations + SET icon_name = ?, icon_fg_color = ?, icon_bg_color = ?, updated_at = ? + WHERE destination_hash = ? + """, + arguments: [iconName, fgColor, bgColor, Date().timeIntervalSince1970, hash] + ) + } + } + + /// Get peer icon appearance for a conversation as the raw fields. + /// + /// Returns nil when no icon is set. The app maps the three fields to its own + /// `RNSAPI.IconAppearance` (see MessageRepository.mapIcon) so the store does + /// not depend on RNSAPI. + public func getPeerIconFields(_ hash: Data) throws -> (name: String, fg: String, bg: String)? { + try dbPool.read { db in + guard let record = try ConversationStoreRow + .filter(Column("destination_hash") == hash) + .fetchOne(db) else { return nil } + guard let name = record.iconName, + let fg = record.iconFgColor, + let bg = record.iconBgColor else { return nil } + return (name, fg, bg) + } + } + + // MARK: - Private Helpers + + /// Update conversation record for a message row (internal helper). + private func updateConversationFor(row: MessageRow, in db: Database) throws { + let conversationHash = row.incoming ? row.sourceHash : row.destinationHash + + if var conversation = try ConversationStoreRow + .filter(Column("destination_hash") == conversationHash) + .fetchOne(db) { + + // Only update preview/timestamp if this message is newer (or equal). + // This prevents an older outbound save from overwriting a newer + // incoming message's preview when saves race. + if row.timestamp >= conversation.lastMessageTimestamp { + conversation.lastMessageTimestamp = row.timestamp + conversation.updatedAt = Date().timeIntervalSince1970 + + if !row.content.isEmpty, + let contentStr = String(data: row.content, encoding: .utf8), + !contentStr.isEmpty { + conversation.lastMessagePreview = String(contentStr.prefix(100)) + } + } + + // Increment unread count if incoming (regardless of timestamp) + if row.incoming { + conversation.unreadCount += 1 + conversation.isUnread = 1 + } + + try conversation.update(db) + } else { + let preview = String(data: row.content, encoding: .utf8).map { String($0.prefix(100)) } + + let conversation = ConversationStoreRow( + destinationHash: conversationHash, + displayName: nil, + lastMessageTimestamp: row.timestamp, + lastMessagePreview: preview, + unreadCount: row.incoming ? 1 : 0 + ) + + try conversation.insert(db) + } + } +} diff --git a/Tests/ColumbaAppTests/MessageStoreTests.swift b/Tests/ColumbaAppTests/MessageStoreTests.swift new file mode 100644 index 00000000..c695af78 --- /dev/null +++ b/Tests/ColumbaAppTests/MessageStoreTests.swift @@ -0,0 +1,413 @@ +// +// MessageStoreTests.swift +// ColumbaAppTests +// +// Unit + integration coverage for the app-owned `MessageStore` (the GRDB store +// lifted out of LXMFSwift so the store no longer depends on the wire library). +// +// Scope: +// - Schema freeze: the store's `messages` / `conversations` columns are +// byte-for-byte the contract the Network-Extension's embedded Python node +// writes into (rns_bridge.py `_write_inbound_to_grdb`). Any drift here breaks +// cross-process delivery, so we assert the exact column set. +// - State/method vocabulary: the persisted raw bytes round-trip through the +// mapper exactly as the frozen `GRDBState` / `GRDBDeliveryMethod` define. +// - Store round-trip: save a row, read it back, the conversation is upserted +// with the correct preview/unread accounting. +// - Cross-process: a raw inbound row inserted the same way the NE Python does +// (state=DELIVERED, incoming=1) is readable and mappable by the app. +// + +import XCTest +import GRDB +import RNSAPI +@testable import ColumbaApp + +final class MessageStoreTests: XCTestCase { + private func temporaryDatabaseURL() -> URL { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("columba-msgstore-\(UUID().uuidString)") + let url = dir.appendingPathComponent("lxmf-swift.db") + // SQLite (GRDB) does not create parent directories; make the dir first. + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + try? FileManager.default.removeItem(at: url) + return url + } + + // MARK: - Schema freeze (the cross-process contract) + + func testMessageSchemaMatchesNEWriterContract() throws { + let path = temporaryDatabaseURL().path + _ = try MessageStore(path: path) + let pool = try storePool(path: path) + let columns = try schemaColumns(in: pool, "messages") + // Exact column set the NE Python `INSERT OR REPLACE INTO messages (...)` + // writes (rns_bridge.py). Order matters for the frozen contract; assert + // the full set and the critical PK/FK/NOT-NULL-bearing columns. + let expected: Set = [ + "message_id", "conversation_hash", "destination_hash", "source_hash", + "signature", "timestamp", "title", "content", "fields", "stamp", + "state", "method", "delivery_attempts", "progress", "incoming", + "rssi", "snr", "q", "ratchet_id", "packed_lxmf", + "receiving_interface", "reply_to_id", "reactions_json", + "created_at", "updated_at", + ] + XCTAssertEqual(Set(columns), expected, + "messages schema drifted from the NE writer contract") + // message_id is the PK; conversation_hash is an FK to conversations. + let pk = try primaryKeyColumns(in: pool, "messages") + XCTAssertEqual(pk, ["message_id"], "messages PK changed") + let fk = try foreignKeyTables(in: pool, "messages") + XCTAssertEqual(fk, ["conversations"], "messages FK target changed") + } + + func testConversationSchemaMatchesNEWriterContract() throws { + let path = temporaryDatabaseURL().path + _ = try MessageStore(path: path) + let pool = try storePool(path: path) + let columns = try schemaColumns(in: pool, "conversations") + let expected: Set = [ + "destination_hash", "display_name", "last_message_timestamp", + "last_message_preview", "unread_count", "is_unread", + "created_at", "updated_at", "is_favorite", + "icon_name", "icon_fg_color", "icon_bg_color", "is_pinned", + ] + XCTAssertEqual(Set(columns), expected, + "conversations schema drifted from the NE writer contract") + XCTAssertEqual(try primaryKeyColumns(in: pool, "conversations"), ["destination_hash"]) + } + + func testIndexesAreFrozen() throws { + let path = temporaryDatabaseURL().path + _ = try MessageStore(path: path) + let pool = try storePool(path: path) + let indexes = try indexNames(in: pool) + let expected: Set = [ + "idx_messages_conversation_timestamp", "idx_messages_state", + "idx_messages_timestamp", "idx_conversations_last_timestamp", + "idx_messages_reply_to", + ] + for name in expected { + XCTAssertTrue(indexes.contains(name), "missing frozen index \(name)") + } + } + + // MARK: - State / method vocabulary round-trip + + func testStateVocabularyRoundTripsThroughMapper() { + // Every semantic RNSAPI state maps to a byte and back without loss. + for rns in [RNSAPI.LXMessageState.draft, .outbound, .sending, .sent, .delivered, .failed, .received] { + let byte = MessageRepository.mapStateToGRDB(rns) + let back = MessageRepository.mapState(byte) + XCTAssertEqual(back, rns == .received ? .delivered : rns, + "state \(rns) round-trip: byte=\(byte) -> \(back)") + } + } + + func testMethodVocabularyRoundTripsThroughMapper() { + for rns in [RNSAPI.LXDeliveryMethod.opportunistic, .direct, .propagated, .paper, .unknown] { + let byte = MessageRepository.mapMethodToGRDB(rns) + let back = MessageRepository.mapMethod(byte) + XCTAssertEqual(back, rns, "method \(rns) round-trip: byte=\(byte) -> \(back)") + } + } + + func testStateBytesMatchFrozenLXMFSwiftValues() { + // These are the bytes the NE Python writes; they must stay stable. + XCTAssertEqual(GRDBState.generating.rawValue, 0x00) + XCTAssertEqual(GRDBState.outbound.rawValue, 0x01) + XCTAssertEqual(GRDBState.sending.rawValue, 0x02) + XCTAssertEqual(GRDBState.sent.rawValue, 0x04) + XCTAssertEqual(GRDBState.delivered.rawValue, 0x08) + XCTAssertEqual(GRDBState.rejected.rawValue, 0xFD) + XCTAssertEqual(GRDBState.cancelled.rawValue, 0xFE) + XCTAssertEqual(GRDBState.failed.rawValue, 0xFF) + XCTAssertEqual(GRDBDeliveryMethod.opportunistic.rawValue, 0x01) + XCTAssertEqual(GRDBDeliveryMethod.direct.rawValue, 0x02) + XCTAssertEqual(GRDBDeliveryMethod.propagated.rawValue, 0x03) + XCTAssertEqual(GRDBDeliveryMethod.paper.rawValue, 0x05) + } + + func testUnknownMethodPersistsAsSentinelZeroAndMapsBack() { + XCTAssertEqual(MessageRepository.mapMethodToGRDB(.unknown), 0) + XCTAssertEqual(MessageRepository.mapMethod(0), .unknown) + } + + // MARK: - Store round-trip + + func testSaveAndReadMessageRowRoundTrips() async throws { + let store = try MessageStore(path: temporaryDatabaseURL().path) + let now = Date().timeIntervalSince1970 + let dest = Data(repeating: 0x0A, count: 16) + let src = Data(repeating: 0x0B, count: 16) + let msgId = Data(repeating: 0x0C, count: 32) + let content = Data("hello from the app".utf8) + + let row = MessageRow( + messageId: msgId, + conversationHash: dest, + destinationHash: dest, + sourceHash: src, + signature: Data(repeating: 0x11, count: 64), + timestamp: now, + title: Data(), + content: content, + fields: nil, + stamp: nil, + state: GRDBState.outbound.rawValue, + method: GRDBDeliveryMethod.opportunistic.rawValue, + deliveryAttempts: 0, + progress: 0.0, + incoming: false, + rssi: nil, snr: nil, q: nil, ratchetId: nil, + receivingInterface: "loopback", + replyToId: nil, reactionsJson: nil, + packedLxmf: Data(), + createdAt: now, updatedAt: now + ) + try await store.saveMessage(row) + + let readBack = try await store.getMessageRecord(id: msgId) + XCTAssertNotNil(readBack) + XCTAssertEqual(readBack?.messageId, msgId) + XCTAssertEqual(readBack?.content, content) + XCTAssertEqual(readBack?.incoming, false) + XCTAssertEqual(readBack?.state, GRDBState.outbound.rawValue) + XCTAssertEqual(readBack?.method, GRDBDeliveryMethod.opportunistic.rawValue) + + // Conversation upserted: outbound does NOT increment unread. + let convo = try await store.getConversation(hash: dest) + XCTAssertEqual(convo?.lastMessagePreview, "hello from the app") + XCTAssertEqual(convo?.unreadCount, 0) + XCTAssertEqual(convo?.isUnread, 0) + } + + func testInboundRowIncrementsUnreadAndUsesSourceHashAsConversationKey() async throws { + let store = try MessageStore(path: temporaryDatabaseURL().path) + let now = Date().timeIntervalSince1970 + let dest = Data(repeating: 0x0A, count: 16) + let src = Data(repeating: 0x0B, count: 16) + let msgId = Data(repeating: 0x0D, count: 32) + let row = MessageRow( + messageId: msgId, conversationHash: src, destinationHash: dest, sourceHash: src, + signature: Data(repeating: 0x22, count: 64), timestamp: now, title: Data(), + content: Data("inbound msg".utf8), fields: nil, stamp: nil, + state: GRDBState.delivered.rawValue, method: GRDBDeliveryMethod.opportunistic.rawValue, + deliveryAttempts: 0, progress: 0.0, incoming: true, + rssi: nil, snr: nil, q: nil, ratchetId: nil, + receivingInterface: "auto", replyToId: nil, reactionsJson: nil, + packedLxmf: Data(), createdAt: now, updatedAt: now + ) + try await store.saveMessage(row) + // Inbound conversation key is the SOURCE hash. + let convo = try await store.getConversation(hash: src) + XCTAssertEqual(convo?.unreadCount, 1) + XCTAssertEqual(convo?.isUnread, 1) + XCTAssertEqual(convo?.lastMessagePreview, "inbound msg") + } + + // MARK: - Cross-process: app reads a row the NE Python would write + + func testAppReadsRawNEStyleInboundRow() async throws { + // Simulate the NE Python's raw `INSERT OR REPLACE INTO messages` (state + // = DELIVERED 0x08, incoming = 1, fields = NULL, packed_lxmf = the wire + // bytes) by writing it through a plain GRDB pool, then read it back + // through the app store and the field-map bridge. This proves the app can + // consume NE-produced rows with no wire-library dependency in the store. + let path = temporaryDatabaseURL().path + let store = try MessageStore(path: path) + // Let the store create the schema, then use a raw pool for the NE-style insert. + let now = Date().timeIntervalSince1970 + let dest = Data(repeating: 0x0A, count: 16) + let src = Data(repeating: 0x0B, count: 16) + let msgId = Data(repeating: 0x0E, count: 32) + let wire = Data((0..<200).map { UInt8($0) }) // stand-in for the signed wire bytes + + let pool = try DatabasePool(path: path) + try await pool.write { db in + // NE-style conversation upsert (raw SQL, mirroring rns_bridge.py): + // omit is_pinned so the schema DEFAULT 0 applies; is_favorite is a + // literal 0. The NE does both the conversation upsert and the message + // insert in one connection, so the FK is satisfied by the time the + // message row commits. + let exists: Int? = try Int.fetchOne( + db, + sql: "SELECT 1 FROM conversations WHERE destination_hash = ?", + arguments: [src] + ) + if exists == nil { + try db.execute( + sql: """ + INSERT INTO conversations + (destination_hash, display_name, last_message_timestamp, + last_message_preview, unread_count, is_unread, is_favorite, + icon_name, icon_fg_color, icon_bg_color, + created_at, updated_at) + VALUES (?,?,?,?,?,?,0,NULL,NULL,NULL,?,?) + """, + arguments: [src, nil, now, "ne inbound", 1, 1, now, now] + ) + } + try db.execute( + sql: """ + INSERT OR REPLACE INTO messages + (message_id, conversation_hash, destination_hash, source_hash, + signature, timestamp, title, content, fields, stamp, state, + method, delivery_attempts, progress, incoming, rssi, snr, q, + ratchet_id, packed_lxmf, receiving_interface, reply_to_id, + reactions_json, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + """, + arguments: [ + msgId, src, dest, src, + Data(repeating: 0x33, count: 64), now, Data(), Data("ne inbound".utf8), + nil, nil, GRDBState.delivered.rawValue, GRDBDeliveryMethod.opportunistic.rawValue, + 0, 0.0, 1, nil, nil, nil, nil, wire, "auto", nil, nil, now, now, + ] + ) + } + // App reads it back. + let row = try await store.getMessageRecord(id: msgId) + XCTAssertNotNil(row, "app could not read the NE-style inbound row") + XCTAssertEqual(row?.incoming, true) + XCTAssertEqual(row?.state, GRDBState.delivered.rawValue) + XCTAssertEqual(row?.packedLxmf, wire) + // The field-map bridge: a non-field-map byte blob (stand-in wire) maps + // through mapRecord without throwing (wire-with-fields would be unpacked + // by the codec; a random blob falls through to the field-map passthrough). + let record = MessageRepository.mapRecord(row!) + XCTAssertEqual(record.direction, .inbound) + XCTAssertEqual(record.state, RNSAPI.LXMessageState.delivered.rawValue) + XCTAssertEqual(record.packedLxmf, wire) + } + + // MARK: - makeRow preserves the RNSAPI field map in packed_lxmf + + func testMakeRowCarriesFieldMapInPackedLxmf() throws { + var msg = RNSAPI.LXMessage( + destinationHash: Data(repeating: 0x0A, count: 16), + sourceIdentity: nil, + content: Data("body".utf8), + fields: [LxmfFields.FIELD_ICON_APPEARANCE: ["icon": "robot"]] + ) + msg.sourceHash = Data(repeating: 0x0B, count: 16) + msg.hash = Data(repeating: 0x0C, count: 32) + msg.incoming = false + let row = MessageRepository.makeRow(from: msg) + // packed_lxmf is the MessagePack field map; the separate `fields` column is nil. + XCTAssertFalse(row.packedLxmf.isEmpty) + XCTAssertNil(row.fields) + let recovered = LxmfFieldCodec.unpack(row.packedLxmf) + XCTAssertNotNil(recovered, "field map must survive makeRow -> pack -> unpack") + XCTAssertTrue( + recovered?.keys.contains(LxmfFields.FIELD_ICON_APPEARANCE) == true, + "the icon field key must be preserved in packed_lxmf" + ) + } + + func testMakeRowExtractsReplyToFromAppData() throws { + var msg = RNSAPI.LXMessage( + destinationHash: Data(repeating: 0x0A, count: 16), + sourceIdentity: nil, + content: Data("reply".utf8), + fields: [RNSAPI.LXMessage.FIELD_APP_DATA: ["reply_to": "abc123"]] + ) + msg.sourceHash = Data(repeating: 0x0B, count: 16) + msg.hash = Data(repeating: 0x0C, count: 32) + msg.incoming = false + let row = MessageRepository.makeRow(from: msg) + XCTAssertEqual(row.replyToId, "abc123") + } + + func testMakeRowUnknownMethodPersistsSentinelZero() throws { + var msg = RNSAPI.LXMessage( + destinationHash: Data(repeating: 0x0A, count: 16), + sourceIdentity: nil, + content: Data("x".utf8) + ) + msg.sourceHash = Data(repeating: 0x0B, count: 16) + msg.hash = Data(repeating: 0x0C, count: 32) + msg.method = .unknown + let row = MessageRepository.makeRow(from: msg) + XCTAssertEqual(row.method, 0) + } + + // MARK: - Load outbound IDs + + func testLoadPendingOutboundIdsReturnsOnlyMatchingStates() async throws { + let store = try MessageStore(path: temporaryDatabaseURL().path) + let now = Date().timeIntervalSince1970 + func make(id: UInt8, state: GRDBState, method: GRDBDeliveryMethod) -> MessageRow { + MessageRow( + messageId: Data(repeating: id, count: 32), + conversationHash: Data(repeating: 0x0A, count: 16), + destinationHash: Data(repeating: 0x0A, count: 16), + sourceHash: Data(repeating: 0x0B, count: 16), + signature: Data(repeating: 0x44, count: 64), timestamp: now, title: Data(), + content: Data("c\(id)".utf8), fields: nil, stamp: nil, + state: state.rawValue, method: method.rawValue, + deliveryAttempts: 0, progress: 0.0, incoming: false, + rssi: nil, snr: nil, q: nil, ratchetId: nil, + receivingInterface: nil, replyToId: nil, reactionsJson: nil, + packedLxmf: Data(), createdAt: now, updatedAt: now + ) + } + try await store.saveMessage(make(id: 0x01, state: .outbound, method: .opportunistic)) + try await store.saveMessage(make(id: 0x02, state: .sent, method: .opportunistic)) + try await store.saveMessage(make(id: 0x03, state: .sent, method: .propagated)) + try await store.saveMessage(make(id: 0x04, state: .failed, method: .opportunistic)) + + let pending = try await store.loadPendingOutboundIds() + // outbound (.outbound) + sent-small-packet (.sent & opportunistic/direct); + // NOT .sent+propagated (terminal) and NOT .failed. + let pendingSet = Set(pending) + XCTAssertEqual(pendingSet, + [Data(repeating: 0x01, count: 32), + Data(repeating: 0x02, count: 32)]) + let failed = try await store.loadFailedOutboundIds() + XCTAssertEqual(failed, [Data(repeating: 0x04, count: 32)]) + } +} + +// MARK: - Schema introspection helpers (test-only) +// +// The store's `dbPool` is private and not reachable from a test extension, so +// these open their own pool on the same file the store already migrated. That is +// also closer to how the NE Python opens the file - an independent connection. + +private func storePool(path: String) throws -> DatabasePool { + var config = Configuration() + config.readonly = true + return try DatabasePool(path: path, configuration: config) +} + +private func schemaColumns(in pool: DatabasePool, _ table: String) throws -> [String] { + try pool.read { db in + let rows = try Row.fetchAll(db, sql: "PRAGMA table_info(\(table))") + return rows.map { $0["name"] as String } + } +} +private func primaryKeyColumns(in pool: DatabasePool, _ table: String) throws -> [String] { + // `table` is one of this file's own literal schema names (never user input), + // so interpolation is safe. Filtering/ordering happens in SQLite, which + // avoids GRDB's `Row` pragma-value bridging (where `row["pk"] as? Int` does + // not decode reliably). + try pool.read { db in + try String.fetchAll( + db, + sql: "SELECT name FROM pragma_table_info('\(table)') WHERE pk > 0 ORDER BY pk" + ) + } +} +private func foreignKeyTables(in pool: DatabasePool, _ table: String) throws -> [String] { + try pool.read { db in + let rows = try Row.fetchAll(db, sql: "PRAGMA foreign_key_list(\(table))") + return rows.compactMap { $0["table"] as? String } + } +} +private func indexNames(in pool: DatabasePool) throws -> [String] { + try pool.read { db in + let rows = try Row.fetchAll(db, sql: "SELECT name FROM sqlite_master WHERE type='index' AND name NOT LIKE 'sqlite_%'") + return rows.map { $0["name"] as String } + } +} diff --git a/Tests/ColumbaAppTests/MicronParserTests.swift b/Tests/ColumbaAppTests/MicronParserTests.swift index 535a4a69..51e4debe 100644 --- a/Tests/ColumbaAppTests/MicronParserTests.swift +++ b/Tests/ColumbaAppTests/MicronParserTests.swift @@ -1950,7 +1950,7 @@ final class MessageRepositoryAdapterTests: XCTestCase { // MARK: Conversation mapping (Date<-Double, String<-String?) func testMapConversationFullFields() { - var c = LXMFSwift.ConversationRecord( + var c = ConversationStoreRow( destinationHash: Data([0x01, 0x02, 0x03]), displayName: "Alice", lastMessageTimestamp: 1_700_000_000.5, @@ -1980,7 +1980,7 @@ final class MessageRepositoryAdapterTests: XCTestCase { func testMapConversationNilDisplayNameBecomesEmptyString() { // displayName is String? on the GRDB side, non-optional String on RNSAPI. - let c = LXMFSwift.ConversationRecord( + let c = ConversationStoreRow( destinationHash: Data([0xAB]), displayName: nil, lastMessageTimestamp: 0, @@ -1999,14 +1999,14 @@ final class MessageRepositoryAdapterTests: XCTestCase { // MARK: State enum <- UInt8 func testMapStateSemantic() { - XCTAssertEqual(MessageRepository.mapState(LXMFSwift.LXMessageState.generating), .draft) - XCTAssertEqual(MessageRepository.mapState(LXMFSwift.LXMessageState.outbound), .outbound) - XCTAssertEqual(MessageRepository.mapState(LXMFSwift.LXMessageState.sending), .sending) - XCTAssertEqual(MessageRepository.mapState(LXMFSwift.LXMessageState.sent), .sent) - XCTAssertEqual(MessageRepository.mapState(LXMFSwift.LXMessageState.delivered), .delivered) - XCTAssertEqual(MessageRepository.mapState(LXMFSwift.LXMessageState.rejected), .failed) - XCTAssertEqual(MessageRepository.mapState(LXMFSwift.LXMessageState.cancelled), .failed) - XCTAssertEqual(MessageRepository.mapState(LXMFSwift.LXMessageState.failed), .failed) + XCTAssertEqual(MessageRepository.mapState(GRDBState.generating), .draft) + XCTAssertEqual(MessageRepository.mapState(GRDBState.outbound), .outbound) + XCTAssertEqual(MessageRepository.mapState(GRDBState.sending), .sending) + XCTAssertEqual(MessageRepository.mapState(GRDBState.sent), .sent) + XCTAssertEqual(MessageRepository.mapState(GRDBState.delivered), .delivered) + XCTAssertEqual(MessageRepository.mapState(GRDBState.rejected), .failed) + XCTAssertEqual(MessageRepository.mapState(GRDBState.cancelled), .failed) + XCTAssertEqual(MessageRepository.mapState(GRDBState.failed), .failed) } func testMapStateFromRawByte() { @@ -2020,8 +2020,8 @@ final class MessageRepositoryAdapterTests: XCTestCase { func testMapStateToGRDBRoundTrip() { // received is inbound-only on RNSAPI; GRDB has no peer → delivered. - XCTAssertEqual(MessageRepository.mapStateToGRDB(.received), .delivered) - XCTAssertEqual(MessageRepository.mapStateToGRDB(.draft), .generating) + XCTAssertEqual(MessageRepository.mapStateToGRDB(.received), GRDBState.delivered.rawValue) + XCTAssertEqual(MessageRepository.mapStateToGRDB(.draft), GRDBState.generating.rawValue) for s: RNSAPI.LXMessageState in [.outbound, .sending, .sent, .delivered, .failed] { XCTAssertEqual(MessageRepository.mapState(MessageRepository.mapStateToGRDB(s)), s, "round-trip should be stable for \(s)") @@ -2031,10 +2031,10 @@ final class MessageRepositoryAdapterTests: XCTestCase { // MARK: Method enum <- UInt8 func testMapMethodSemantic() { - XCTAssertEqual(MessageRepository.mapMethod(LXMFSwift.LXDeliveryMethod.opportunistic), .opportunistic) - XCTAssertEqual(MessageRepository.mapMethod(LXMFSwift.LXDeliveryMethod.direct), .direct) - XCTAssertEqual(MessageRepository.mapMethod(LXMFSwift.LXDeliveryMethod.propagated), .propagated) - XCTAssertEqual(MessageRepository.mapMethod(LXMFSwift.LXDeliveryMethod.paper), .paper) + XCTAssertEqual(MessageRepository.mapMethod(GRDBDeliveryMethod.opportunistic), .opportunistic) + XCTAssertEqual(MessageRepository.mapMethod(GRDBDeliveryMethod.direct), .direct) + XCTAssertEqual(MessageRepository.mapMethod(GRDBDeliveryMethod.propagated), .propagated) + XCTAssertEqual(MessageRepository.mapMethod(GRDBDeliveryMethod.paper), .paper) } func testMapMethodFromRawByte() { @@ -2047,7 +2047,7 @@ final class MessageRepositoryAdapterTests: XCTestCase { } func testMapMethodToGRDBUnknownDefaultsOpportunistic() { - XCTAssertEqual(MessageRepository.mapMethodToGRDB(.unknown), .opportunistic) + XCTAssertEqual(MessageRepository.mapMethodToGRDB(.unknown), 0) for m: RNSAPI.LXDeliveryMethod in [.opportunistic, .direct, .propagated, .paper] { XCTAssertEqual(MessageRepository.mapMethod(MessageRepository.mapMethodToGRDB(m)), m, "round-trip should be stable for \(m)") @@ -2061,31 +2061,25 @@ final class MessageRepositoryAdapterTests: XCTestCase { /// so seed it from a no-identity LXMFSwift.LXMessage (with `packed` set /// manually so the init's `guard packed != nil` passes), then set the /// columns that `init(from:)` doesn't take from the message. - private func makeGRDBRecord() throws -> LXMFSwift.MessageRecord { - var msg = LXMFSwift.LXMessage( - destinationHash: Data([0xDE, 0xAD, 0x01]), // arbitrary - sourceHash: Data([0x50, 0x52, 0x43]), - content: Data("body".utf8), - title: Data("subj".utf8), - timestamp: 1_650_000_000.25, - state: .delivered, - incoming: true + private func makeGRDBRecord() -> MessageRow { + // Seed an app-owned row directly (no wire-type constructor). incoming so + // conversationHash == sourceHash; packedLxmf carries the field map. + let dest = Data([0xDE, 0xAD, 0x01]) + let src = Data([0x50, 0x52, 0x43]) + let id = Data([0xAA, 0xBB, 0xCC]) + let now = 1_650_000_000.25 + let fields = [LXMFSwift.LXMessage.FIELD_IMAGE: ["png", Data([0x89, 0x50])] as [Any]] + let packed = LxmfFieldCodec.pack(fields) + return MessageRow( + messageId: id, conversationHash: src, destinationHash: dest, sourceHash: src, + signature: Data(), timestamp: now, title: Data("subj".utf8), content: Data("body".utf8), + fields: nil, stamp: nil, state: GRDBState.delivered.rawValue, + method: GRDBDeliveryMethod.direct.rawValue, + deliveryAttempts: 0, progress: 0.0, incoming: true, + rssi: -42.0, snr: 7.5, q: 0.9, ratchetId: nil, receivingInterface: "TCPClient", + replyToId: "deadbeef", reactionsJson: "{\"👍\":[\"abc\"]}", + packedLxmf: packed, createdAt: now, updatedAt: now ) - msg.hash = Data([0xAA, 0xBB, 0xCC]) - msg.method = .direct - msg.rssi = -42.0 - msg.snr = 7.5 - msg.q = 0.9 - msg.receivingInterface = "TCPClient" - // packed carries the MessagePack field map (A0 bridge convention). - msg.fields = [LXMFSwift.LXMessage.FIELD_IMAGE: ["png", Data([0x89, 0x50])] as [Any]] - msg.packed = LxmfFieldCodec.pack(msg.fields!) - - var rec = try LXMFSwift.MessageRecord(from: msg) - // Columns init(from:) doesn't carry from the message: - rec.replyToId = "deadbeef" - rec.reactionsJson = "{\"👍\":[\"abc\"]}" - return rec } func testMapRecordAllFields() throws { @@ -2129,16 +2123,6 @@ final class MessageRepositoryAdapterTests: XCTestCase { XCTAssertNotNil(m.fields?[LXMFSwift.LXMessage.FIELD_IMAGE]) } - // MARK: Icon mapping - - func testMapIcon() { - let i = LXMFSwift.IconAppearance(iconName: "star", foregroundColor: "abcdef", backgroundColor: "012345") - let r = MessageRepository.mapIcon(i) - XCTAssertEqual(r.iconName, "star") - XCTAssertEqual(r.foregroundColor, "abcdef") - XCTAssertEqual(r.backgroundColor, "012345") - } - // MARK: packed_lxmf = field map vs LXMF wire (A0 follow-up #2) // // The chat UI recovers attachments/icons by running @@ -2192,28 +2176,29 @@ final class MessageRepositoryAdapterTests: XCTestCase { /// (a) A realistic FIELD-MAP row (app / Python path): `packed_lxmf` = /// `LxmfFieldCodec.pack(fields)`. Seeded onto a no-identity GRDB LXMessage /// exactly as `MessageRepository.mapToGRDBMessage` does. - private func makeFieldMapRecord() throws -> LXMFSwift.MessageRecord { - var msg = LXMFSwift.LXMessage( - destinationHash: Data([0xDE, 0xAD, 0x10]), - sourceHash: Data([0x50, 0x52, 0x43]), - content: Data("body".utf8), - title: Data("subj".utf8), - timestamp: 1_650_000_000.25, - state: .delivered, - incoming: true + private func makeFieldMapRecord() -> MessageRow { + let dest = Data([0xDE, 0xAD, 0x10]) + let src = Data([0x50, 0x52, 0x43]) + let id = Data([0xAA, 0xBB, 0xCC]) + let now = 1_650_000_000.25 + let fields = attachmentFields() + let packed = LxmfFieldCodec.pack(fields) // FIELD MAP, not wire + return MessageRow( + messageId: id, conversationHash: src, destinationHash: dest, sourceHash: src, + signature: Data(), timestamp: now, title: Data("subj".utf8), content: Data("body".utf8), + fields: nil, stamp: nil, state: GRDBState.delivered.rawValue, + method: GRDBDeliveryMethod.direct.rawValue, + deliveryAttempts: 0, progress: 0.0, incoming: true, + rssi: nil, snr: nil, q: nil, ratchetId: nil, receivingInterface: nil, + replyToId: nil, reactionsJson: nil, packedLxmf: packed, createdAt: now, updatedAt: now ) - msg.hash = Data([0xAA, 0xBB, 0xCC]) - msg.method = .direct - msg.fields = attachmentFields() - msg.packed = LxmfFieldCodec.pack(msg.fields!) // FIELD MAP, not wire - return try LXMFSwift.MessageRecord(from: msg) } /// (b) A realistic WIRE row (Swift / NE path): a genuine LXMessage signed + /// packed to the on-wire format via the real pack path, then persisted — /// `MessageRecord.init(from:)` copies `LXMessage.packed` (the wire) into /// `packed_lxmf`, exactly like `LXMRouter` does on inbound delivery. - private func makeWireRecord() throws -> (rec: LXMFSwift.MessageRecord, wire: Data) { + private func makeWireRecord() throws -> (rec: MessageRow, wire: Data) { // Real ReticulumSwift identity (re-exported via LXMFSwift) so `pack()` // can sign. Qualified to avoid the RNSAPI.Identity Compat-stub collision. // The destination hash value is irrelevant to field recovery — any @@ -2233,7 +2218,17 @@ final class MessageRepositoryAdapterTests: XCTestCase { XCTAssertNil(LxmfFieldCodec.unpack(wire), "wire bytes must NOT decode as a field map (else no bug)") XCTAssertGreaterThan(wire.count, 96, "wire carries dest+src+sig header") - let rec = try LXMFSwift.MessageRecord(from: msg) + let now = msg.timestamp + let rec = MessageRow( + messageId: msg.hash, conversationHash: msg.sourceHash, + destinationHash: msg.destinationHash, sourceHash: msg.sourceHash, + signature: msg.signature, timestamp: now, title: msg.title, content: msg.content, + fields: nil, stamp: msg.stamp, state: GRDBState.delivered.rawValue, + method: GRDBDeliveryMethod.direct.rawValue, + deliveryAttempts: 0, progress: 0.0, incoming: true, + rssi: nil, snr: nil, q: nil, ratchetId: nil, receivingInterface: nil, + replyToId: nil, reactionsJson: nil, packedLxmf: wire, createdAt: now, updatedAt: now + ) XCTAssertEqual(rec.packedLxmf, wire, "record must store the wire verbatim") return (rec, wire) } @@ -2360,10 +2355,10 @@ final class MessageRepositoryAdapterTests: XCTestCase { final class DeliveryProofMonotonicityTests: XCTestCase { func testReducerRejectsStaleRetryButAdmitsTerminalRelayFailure() { - let sending = Int(LXMFSwift.LXMessageState.sending.rawValue) - let sent = Int(LXMFSwift.LXMessageState.sent.rawValue) - let delivered = Int(LXMFSwift.LXMessageState.delivered.rawValue) - let failed = Int(LXMFSwift.LXMessageState.failed.rawValue) + let sending = Int(GRDBState.sending.rawValue) + let sent = Int(GRDBState.sent.rawValue) + let delivered = Int(GRDBState.delivered.rawValue) + let failed = Int(GRDBState.failed.rawValue) XCTAssertEqual(MessageRepository.monotonicDeliveryState(existing: sent, incoming: sending), sent) XCTAssertEqual(MessageRepository.monotonicDeliveryState(existing: sent, incoming: failed), failed) diff --git a/Tests/static/test_qr_contact_send_path_contract.py b/Tests/static/test_qr_contact_send_path_contract.py index 9d8df365..ebb53339 100644 --- a/Tests/static/test_qr_contact_send_path_contract.py +++ b/Tests/static/test_qr_contact_send_path_contract.py @@ -198,7 +198,7 @@ def test_interrupted_retry_is_recovered_once_as_failed(self) -> None: self.assertIn("Verify whether it arrived before retrying", view_model) self.assertIn("repository.hasUncertainRetry(for: conversationHash)", view_model) self.assertIn("public func recoverInterruptedRetries", repository) - self.assertIn("LXMFSwift.LXMessageState.sending.rawValue", repository) + self.assertIn("GRDBState.sending.rawValue", repository) self.assertIn("receiving_interface = ?", repository) self.assertIn("Self.stagedRetryMarker", repository) self.assertIn("Self.uncertainRetryMarker", repository) diff --git a/Tests/static/test_reported_runtime_bugs.py b/Tests/static/test_reported_runtime_bugs.py index 6215f70f..8c949cae 100644 --- a/Tests/static/test_reported_runtime_bugs.py +++ b/Tests/static/test_reported_runtime_bugs.py @@ -161,11 +161,19 @@ def test_inbound_event_preserves_canonical_lxmf_hash_end_to_end(self) -> None: repository = ( ROOT / "Sources/ColumbaApp/Services/MessageRepository.swift" ).read_text() - self.assertIn("if message.method == .unknown", repository) - self.assertIn("record.method = 0", repository) + # The Android-parity store re-scope moved the unknown-method sentinel and + # the atomic save from `saveMessage` into the mappers + `store.saveMessage`. + # The regression this guard targets is unchanged: an inbound message whose + # effective transport is unknown must persist as raw sentinel byte 0 (the + # GRDB enum cannot represent unknown) and map back to `.unknown` on read, + # and the save must be atomic (conversation + message in one write). + self.assertIn("case .unknown: return 0", repository) + self.assertIn( + "guard let m = GRDBDeliveryMethod(rawValue: raw) else { return .unknown }", + repository, + ) + self.assertIn("try await store.saveMessage(row)", repository) self.assertIn("try await replacementPool.write", repository) - self.assertIn("try Self.updateConversation(for: mapped, in: db)", repository) - self.assertIn("try record.save(db)", repository) bubble = MESSAGE_BUBBLE.read_text() self.assertIn("case LXDeliveryMethod.paper.rawValue:", bubble) self.assertIn('self.deliveryMethod = "paper"', bubble) From ff47cbef4896f3a5856e4f5397e62e19af9a26ee Mon Sep 17 00:00:00 2001 From: torlando-tech <239676438+torlando-tech@users.noreply.github.com> Date: Sat, 10 Oct 2026 22:02:16 +0000 Subject: [PATCH 2/2] test(store): strengthen schema-freeze test with column types, NOT NULL, FK on_delete Greptile P2 (PR #219): the schema-freeze test only asserted column names, PK, and FK table name - not column types, NOT NULL, or FK on_delete behavior. A dropped .cascade would still pass while breaking conversation deletion. Extended both tests to assert the full frozen contract: - columnTypes: all 25 messages columns + all 13 conversations columns - notNullColumns: the exact NOT NULL set for both tables - foreignKeyDetails: (table, from, to, on_delete) tuple per FK Three new introspection helpers (columnTypes, notNullColumns, foreignKeyDetails) added alongside the existing schemaColumns/PK/FK/index helpers. All 547 tests green. --- Tests/ColumbaAppTests/MessageStoreTests.swift | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/Tests/ColumbaAppTests/MessageStoreTests.swift b/Tests/ColumbaAppTests/MessageStoreTests.swift index c695af78..5b5f6188 100644 --- a/Tests/ColumbaAppTests/MessageStoreTests.swift +++ b/Tests/ColumbaAppTests/MessageStoreTests.swift @@ -59,6 +59,43 @@ final class MessageStoreTests: XCTestCase { XCTAssertEqual(pk, ["message_id"], "messages PK changed") let fk = try foreignKeyTables(in: pool, "messages") XCTAssertEqual(fk, ["conversations"], "messages FK target changed") + + // Column types are part of the frozen contract too: a .blob <-> .text + // drift would silently corrupt cross-process data the NE writes. + let types = try columnTypes(in: pool, "messages") + let expectedTypes: [String: String] = [ + "message_id": "BLOB", "conversation_hash": "BLOB", "destination_hash": "BLOB", + "source_hash": "BLOB", "signature": "BLOB", "timestamp": "DOUBLE", "title": "BLOB", + "content": "BLOB", "fields": "BLOB", "stamp": "BLOB", "state": "INTEGER", + "method": "INTEGER", "delivery_attempts": "INTEGER", "progress": "DOUBLE", + "incoming": "INTEGER", "rssi": "DOUBLE", "snr": "DOUBLE", "q": "DOUBLE", + "ratchet_id": "BLOB", "packed_lxmf": "BLOB", "created_at": "DOUBLE", + "updated_at": "DOUBLE", "receiving_interface": "TEXT", "reply_to_id": "TEXT", + "reactions_json": "TEXT", + ] + XCTAssertEqual(types, expectedTypes, "messages column types drifted") + + // Required (NOT NULL) columns: the NE writer must satisfy these on every + // INSERT, so a drift here breaks inbound delivery. + let notNull = try notNullColumns(in: pool, "messages") + let expectedNotNull: Set = [ + "message_id", "conversation_hash", "destination_hash", "source_hash", + "signature", "timestamp", "content", "state", "method", "incoming", + "packed_lxmf", "created_at", "updated_at", + ] + XCTAssertEqual(Set(notNull), expectedNotNull, "messages NOT NULL set drifted") + + // FK columns + delete behavior: deleting a conversation must cascade to + // its messages. A name-only check (above) would still pass if .cascade + // were dropped, while breaking conversation deletion. + let fkDetails = try foreignKeyDetails(in: pool, "messages") + XCTAssertEqual(fkDetails.count, 1, "messages FK count changed") + if let fk = fkDetails.first { + XCTAssertEqual(fk.0, "conversations", "messages FK target table drifted") + XCTAssertEqual(fk.1, "conversation_hash", "messages FK from-column drifted") + XCTAssertEqual(fk.2, "destination_hash", "messages FK to-column drifted") + XCTAssertEqual(fk.3, "CASCADE", "messages FK on_delete drifted (cascade lost)") + } } func testConversationSchemaMatchesNEWriterContract() throws { @@ -75,6 +112,20 @@ final class MessageStoreTests: XCTestCase { XCTAssertEqual(Set(columns), expected, "conversations schema drifted from the NE writer contract") XCTAssertEqual(try primaryKeyColumns(in: pool, "conversations"), ["destination_hash"]) + + // Column types + NOT NULL: same cross-process contract as messages. + let types = try columnTypes(in: pool, "conversations") + let expectedTypes: [String: String] = [ + "destination_hash": "BLOB", "display_name": "TEXT", "last_message_timestamp": "DOUBLE", + "last_message_preview": "TEXT", "unread_count": "INTEGER", "is_unread": "INTEGER", + "created_at": "DOUBLE", "updated_at": "DOUBLE", "is_favorite": "INTEGER", + "icon_name": "TEXT", "icon_fg_color": "TEXT", "icon_bg_color": "TEXT", + "is_pinned": "INTEGER", + ] + XCTAssertEqual(types, expectedTypes, "conversations column types drifted") + let notNull = try notNullColumns(in: pool, "conversations") + XCTAssertEqual(Set(notNull), ["destination_hash", "created_at", "updated_at"], + "conversations NOT NULL set drifted") } func testIndexesAreFrozen() throws { @@ -405,6 +456,45 @@ private func foreignKeyTables(in pool: DatabasePool, _ table: String) throws -> return rows.compactMap { $0["table"] as? String } } } +private func columnTypes(in pool: DatabasePool, _ table: String) throws -> [String: String] { + // `PRAGMA table_info` `type` is the declared column type (BLOB/TEXT/INTEGER/ + // REAL, uppercase). The frozen contract pins it per-column. + try pool.read { db in + let rows = try Row.fetchAll(db, sql: "SELECT name, type FROM pragma_table_info('\(table)')") + var result: [String: String] = [:] + for row in rows { result[row["name"] as String] = row["type"] as? String } + return result + } +} +private func notNullColumns(in pool: DatabasePool, _ table: String) throws -> [String] { + // Filtering happens in SQLite (`WHERE notnull = 1`), avoiding GRDB's + // `Row` pragma-value bridging for the 0/1 `notnull` flag. + try pool.read { db in + try String.fetchAll( + db, + sql: "SELECT name FROM pragma_table_info('\(table)') WHERE \"notnull\" = 1" + ) + } +} +private func foreignKeyDetails( + in pool: DatabasePool, _ table: String +) throws -> [(String, String, String, String)] { + // Full FK contract: referenced table, from-column, to-column, and on_delete + // behavior (CASCADE/NO ACTION/... uppercase). A name-only check would miss a + // dropped `.cascade` that breaks conversation deletion. + try pool.read { db in + let rows = try Row.fetchAll( + db, sql: "SELECT \"table\", \"from\", \"to\", \"on_delete\" FROM pragma_foreign_key_list('\(table)')" + ) + return rows.compactMap { row in + guard let t = row["table"] as? String, + let from = row["from"] as? String, + let to = row["to"] as? String, + let onDelete = row["on_delete"] as? String else { return nil } + return (t, from, to, onDelete) + } + } +} private func indexNames(in pool: DatabasePool) throws -> [String] { try pool.read { db in let rows = try Row.fetchAll(db, sql: "SELECT name FROM sqlite_master WHERE type='index' AND name NOT LIKE 'sqlite_%'")