From c2a3e21c84e1febaabeffb612664dacb40906dbd Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Tue, 16 Jun 2026 21:34:49 -0400 Subject: [PATCH] Extract ChatDBReader and MessageBuilder from DatabaseHandler (#67) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ChatDBReader owns the SQLite read layer (SQL → row DTOs, attributedBody decoding); MessageBuilder converts DTOs → Message/Person/Group/Attachment with an injectable contactNameResolver. DatabaseHandler keeps polling/WAL orchestration and inbound deferral policy only. Removes the unused MessageSource pass-through seam (protocol + InMemoryMessageSource + convenience init) now that the real chat.db→Message seam has dedicated adapters. Adds MessageBuilderTests covering DTO→entity conversion in isolation (no SQLite or Contacts dependency). Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 4 +- Jared.xcodeproj/project.pbxproj | 32 +-- Jared/ChatDBReader.swift | 252 ++++++++++++++++++++++++ Jared/DatabaseHandler.swift | 283 ++++----------------------- Jared/InMemoryMessageSource.swift | 15 -- Jared/MessageBuilder.swift | 80 ++++++++ Jared/MessageSource.swift | 11 -- JaredTests/MessageBuilderTests.swift | 161 +++++++++++++++ JaredTests/MessageSourceTests.swift | 46 ----- 9 files changed, 548 insertions(+), 336 deletions(-) create mode 100644 Jared/ChatDBReader.swift delete mode 100644 Jared/InMemoryMessageSource.swift create mode 100644 Jared/MessageBuilder.swift delete mode 100644 Jared/MessageSource.swift create mode 100644 JaredTests/MessageBuilderTests.swift delete mode 100644 JaredTests/MessageSourceTests.swift diff --git a/AGENTS.md b/AGENTS.md index 05bbc9a..8429a55 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,7 +45,9 @@ DatabaseHandler (SQLite polling) | File/Target | Role | |---|---| | `JaredFramework/` | Public API framework. Defines `Message`, `Route`, `RoutingModule`, `MessageSender`, entities, etc. | -| `Jared/DatabaseHandler.swift` | Polls `chat.db`, constructs `Message` objects, feeds them to `Router` | +| `Jared/DatabaseHandler.swift` | Polls `chat.db` (WAL watch + interval), applies inbound deferral policy, feeds `Message`s to `Router` | +| `Jared/ChatDBReader.swift` | SQLite read layer: SQL → row DTOs (`MessageRow`, `GroupParticipantRow`, `AttachmentRow`), incl. `attributedBody` decoding. No `Message`/routing knowledge | +| `Jared/MessageBuilder.swift` | DTO → `Message`/`Person`/`Group`/`Attachment`; injectable `contactNameResolver` (unit-testable without SQLite or Contacts) | | `Jared/Router.swift` | Matches incoming messages against all registered `Route`s | | `Jared/PluginManager.swift` | Manages the list of built-in `RoutingModule`s | | `Jared/CoreModule.swift` | Built-in commands: `/ping`, `/send`, `/schedule`, `/name`, `/whoami`, `/barf`, etc. | diff --git a/Jared.xcodeproj/project.pbxproj b/Jared.xcodeproj/project.pbxproj index c5cc550..3351594 100644 --- a/Jared.xcodeproj/project.pbxproj +++ b/Jared.xcodeproj/project.pbxproj @@ -37,11 +37,6 @@ EC000001000000000000AA44 /* ScheduleModuleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC000001000000000000AA45 /* ScheduleModuleTests.swift */; }; EC000001000000000000AA46 /* PersistentContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = B353946124E64DDE00F9424A /* PersistentContainer.swift */; }; EC000001000000000000AA47 /* CoreModule.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = B3F185F524E51C880051C696 /* CoreModule.xcdatamodeld */; }; - EC000001000000000000AA50 /* MessageSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC000001000000000000AA53 /* MessageSource.swift */; }; - EC000001000000000000AA51 /* MessageSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC000001000000000000AA53 /* MessageSource.swift */; }; - EC000001000000000000AA54 /* InMemoryMessageSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC000001000000000000AA57 /* InMemoryMessageSource.swift */; }; - EC000001000000000000AA55 /* InMemoryMessageSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC000001000000000000AA57 /* InMemoryMessageSource.swift */; }; - EC000001000000000000AA58 /* MessageSourceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC000001000000000000AA59 /* MessageSourceTests.swift */; }; EC000001000000000000AA30 /* ConfigurationApplier.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC000001000000000000AA33 /* ConfigurationApplier.swift */; }; EC000001000000000000AA08 /* ConfigurationWatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC000001000000000000AA10 /* ConfigurationWatcher.swift */; }; EC000001000000000000AA09 /* ConfigurationWatcherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC000001000000000000AA11 /* ConfigurationWatcherTests.swift */; }; @@ -63,6 +58,8 @@ B353946424E7A89A00F9424A /* Message.swift in Sources */ = {isa = PBXBuildFile; fileRef = B353946324E7A89A00F9424A /* Message.swift */; }; B353946824E7C58E00F9424A /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = B3BBA7CD1CB3259A00E1749C /* Localizable.strings */; }; B35CE7152196AD4E002F52A7 /* DatabaseHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = B35CE7142196AD4E002F52A7 /* DatabaseHandler.swift */; }; + EC67DB00000000000000A002 /* ChatDBReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC67DB00000000000000A001 /* ChatDBReader.swift */; }; + EC67MB00000000000000A002 /* MessageBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC67MB00000000000000A001 /* MessageBuilder.swift */; }; B361EEBB1F1DE24C0041113C /* config.json in Resources */ = {isa = PBXBuildFile; fileRef = B361EEBA1F1DE24C0041113C /* config.json */; }; B36563EF1F3D68980064B114 /* TouchBarTextFieldExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B36563EE1F3D68980064B114 /* TouchBarTextFieldExtension.swift */; }; B36710E224DFB5EC00DA1C5D /* JaredMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = B347A4A824DD119A00E93657 /* JaredMock.swift */; }; @@ -97,6 +94,8 @@ B3B844C524D9288000DC162A /* RouterDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3B844C324D9287F00DC162A /* RouterDelegate.swift */; }; B3B844C724D9298000DC162A /* MockRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3B844C624D9298000DC162A /* MockRouter.swift */; }; B3B844C824D92C2200DC162A /* DatabaseHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = B35CE7142196AD4E002F52A7 /* DatabaseHandler.swift */; }; + EC67DB00000000000000A004 /* ChatDBReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC67DB00000000000000A001 /* ChatDBReader.swift */; }; + EC67MB00000000000000A004 /* MessageBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC67MB00000000000000A001 /* MessageBuilder.swift */; }; B3B844C924D92D1500DC162A /* ContactHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3327DC32206607B009DD882 /* ContactHelper.swift */; }; B3B844CB24DA5D1700DC162A /* DiskAccessDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3B844CA24DA5D1700DC162A /* DiskAccessDelegate.swift */; }; B3B844CC24DA671E00DC162A /* DiskAccessDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3B844CA24DA5D1700DC162A /* DiskAccessDelegate.swift */; }; @@ -122,6 +121,7 @@ B3EF1DAC2206E17900953DE7 /* Entities.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3EF1DAB2206E17900953DE7 /* Entities.swift */; }; B3EF1DAE2206E19100953DE7 /* Bodies.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3EF1DAD2206E19100953DE7 /* Bodies.swift */; }; B3EF1DB02206E4EB00953DE7 /* MessageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3EF1DAF2206E4EB00953DE7 /* MessageTests.swift */; }; + EC67MT00000000000000A002 /* MessageBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC67MT00000000000000A001 /* MessageBuilderTests.swift */; }; B3F185F724E51C880051C696 /* CoreModule.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = B3F185F524E51C880051C696 /* CoreModule.xcdatamodeld */; }; B3FB28F224E9BAF7003BA6CB /* PermissionsHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3FB28F124E9BAF7003BA6CB /* PermissionsHelper.swift */; }; B3FB28F424E9BCBD003BA6CB /* JaredConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3FB28F324E9BCBD003BA6CB /* JaredConstants.swift */; }; @@ -207,9 +207,6 @@ EC66DC00000000000000A004 /* WebhookRouteFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebhookRouteFactory.swift; sourceTree = ""; }; EC000001000000000000AA43 /* ScheduleModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScheduleModule.swift; sourceTree = ""; }; EC000001000000000000AA45 /* ScheduleModuleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScheduleModuleTests.swift; sourceTree = ""; }; - EC000001000000000000AA53 /* MessageSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageSource.swift; sourceTree = ""; }; - EC000001000000000000AA57 /* InMemoryMessageSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InMemoryMessageSource.swift; sourceTree = ""; }; - EC000001000000000000AA59 /* MessageSourceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageSourceTests.swift; sourceTree = ""; }; EC000001000000000000AA33 /* ConfigurationApplier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationApplier.swift; sourceTree = ""; }; EC000001000000000000AA10 /* ConfigurationWatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationWatcher.swift; sourceTree = ""; }; EC000001000000000000AA11 /* ConfigurationWatcherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationWatcherTests.swift; sourceTree = ""; }; @@ -227,6 +224,8 @@ B353946124E64DDE00F9424A /* PersistentContainer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PersistentContainer.swift; sourceTree = ""; }; B353946324E7A89A00F9424A /* Message.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Message.swift; sourceTree = ""; }; B35CE7142196AD4E002F52A7 /* DatabaseHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseHandler.swift; sourceTree = ""; }; + EC67DB00000000000000A001 /* ChatDBReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatDBReader.swift; sourceTree = ""; }; + EC67MB00000000000000A001 /* MessageBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageBuilder.swift; sourceTree = ""; }; B361EEBA1F1DE24C0041113C /* config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = config.json; sourceTree = ""; }; B36563EE1F3D68980064B114 /* TouchBarTextFieldExtension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TouchBarTextFieldExtension.swift; sourceTree = ""; }; B369508F24DE8FD00027CB1E /* Configuration.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Configuration.plist; sourceTree = ""; }; @@ -273,6 +272,7 @@ B3EF1DAB2206E17900953DE7 /* Entities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Entities.swift; sourceTree = ""; }; B3EF1DAD2206E19100953DE7 /* Bodies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bodies.swift; sourceTree = ""; }; B3EF1DAF2206E4EB00953DE7 /* MessageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageTests.swift; sourceTree = ""; }; + EC67MT00000000000000A001 /* MessageBuilderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageBuilderTests.swift; sourceTree = ""; }; B3F185F624E51C880051C696 /* CoreModule.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = CoreModule.xcdatamodel; sourceTree = ""; }; B3FB28F124E9BAF7003BA6CB /* PermissionsHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionsHelper.swift; sourceTree = ""; }; B3FB28F324E9BCBD003BA6CB /* JaredConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JaredConstants.swift; sourceTree = ""; }; @@ -382,6 +382,7 @@ EC000001000000000000AA45 /* ScheduleModuleTests.swift */, EC000001000000000000AA24 /* MessageMatcherTests.swift */, B3EF1DAF2206E4EB00953DE7 /* MessageTests.swift */, + EC67MT00000000000000A001 /* MessageBuilderTests.swift */, B3B844B724D7791700DC162A /* ActionTypeTest.swift */, B347A4AE24DD1CD000E93657 /* GlobalTest.swift */, B347A4AC24DD170C00E93657 /* ActionTest.swift */, @@ -390,7 +391,6 @@ B3B844CF24DA6AC800DC162A /* JaredWebServerTest.swift */, TM0000010000000000000005 /* TunnelManagerTests.swift */, B3B844BF24D8F91900DC162A /* DatabaseHandlerTest.swift */, - EC000001000000000000AA59 /* MessageSourceTests.swift */, B319235624DD311B001904BA /* InternalModuleTest.swift */, B3B844C124D8FA7D00DC162A /* DatabaseTestHelper.swift */, B3B844D124DA6B1200DC162A /* Mocks */, @@ -468,14 +468,14 @@ EC66DC00000000000000A001 /* WebhookDeliveryClient.swift */, EC66DC00000000000000A004 /* WebhookRouteFactory.swift */, EC000001000000000000AA43 /* ScheduleModule.swift */, - EC000001000000000000AA53 /* MessageSource.swift */, - EC000001000000000000AA57 /* InMemoryMessageSource.swift */, EC000001000000000000AA33 /* ConfigurationApplier.swift */, EC000001000000000000AA10 /* ConfigurationWatcher.swift */, EC000001000000000000AA23 /* MessageMatcher.swift */, B3C59283224DD17900116ECB /* JaredWebServer.swift */, TM0000010000000000000003 /* TunnelManager.swift */, B35CE7142196AD4E002F52A7 /* DatabaseHandler.swift */, + EC67DB00000000000000A001 /* ChatDBReader.swift */, + EC67MB00000000000000A001 /* MessageBuilder.swift */, B38BB6B024EBB530000700F7 /* Helpers */, B38BB6AE24EBB4CE000700F7 /* Structures */, B3BF17C9244ED26800CC44C5 /* Delegates */, @@ -781,6 +781,8 @@ B36563EF1F3D68980064B114 /* TouchBarTextFieldExtension.swift in Sources */, B3F185F724E51C880051C696 /* CoreModule.xcdatamodeld in Sources */, B35CE7152196AD4E002F52A7 /* DatabaseHandler.swift in Sources */, + EC67DB00000000000000A002 /* ChatDBReader.swift in Sources */, + EC67MB00000000000000A002 /* MessageBuilder.swift in Sources */, B3BF17C6244EC47700CC44C5 /* MessageDelegate.swift in Sources */, B3327DC42206607B009DD882 /* ContactHelper.swift in Sources */, B332787C1CB2CB1300BBDD3A /* ViewController.swift in Sources */, @@ -788,8 +790,6 @@ EC66DC00000000000000A002 /* WebhookDeliveryClient.swift in Sources */, EC66DC00000000000000A005 /* WebhookRouteFactory.swift in Sources */, EC000001000000000000AA40 /* ScheduleModule.swift in Sources */, - EC000001000000000000AA50 /* MessageSource.swift in Sources */, - EC000001000000000000AA54 /* InMemoryMessageSource.swift in Sources */, EC000001000000000000AA30 /* ConfigurationApplier.swift in Sources */, EC000001000000000000AA08 /* ConfigurationWatcher.swift in Sources */, EC000001000000000000AA20 /* MessageMatcher.swift in Sources */, @@ -828,8 +828,6 @@ B319235724DD311B001904BA /* InternalModuleTest.swift in Sources */, B38BB6AF24EBB4D8000700F7 /* Configuration.swift in Sources */, EC000001000000000000AA41 /* ScheduleModule.swift in Sources */, - EC000001000000000000AA51 /* MessageSource.swift in Sources */, - EC000001000000000000AA55 /* InMemoryMessageSource.swift in Sources */, EC000001000000000000AA46 /* PersistentContainer.swift in Sources */, EC000001000000000000AA47 /* CoreModule.xcdatamodeld in Sources */, EC000001000000000000AA32 /* ConfigurationApplier.swift in Sources */, @@ -837,6 +835,8 @@ EC000001000000000000AA22 /* MessageMatcher.swift in Sources */, B3B844C924D92D1500DC162A /* ContactHelper.swift in Sources */, B3B844C824D92C2200DC162A /* DatabaseHandler.swift in Sources */, + EC67DB00000000000000A004 /* ChatDBReader.swift in Sources */, + EC67MB00000000000000A004 /* MessageBuilder.swift in Sources */, B3B844C024D8F91900DC162A /* DatabaseHandlerTest.swift in Sources */, EC000001000000000000AB02 /* RouteProvider.swift in Sources */, B3B7E52524EA00BC00A3FBD1 /* JaredConstants.swift in Sources */, @@ -857,8 +857,8 @@ EC000001000000000000AA09 /* ConfigurationWatcherTests.swift in Sources */, EC000001000000000000AA21 /* MessageMatcherTests.swift in Sources */, EC000001000000000000AA44 /* ScheduleModuleTests.swift in Sources */, - EC000001000000000000AA58 /* MessageSourceTests.swift in Sources */, B3EF1DB02206E4EB00953DE7 /* MessageTests.swift in Sources */, + EC67MT00000000000000A002 /* MessageBuilderTests.swift in Sources */, B32DE51C24D552D300AB3A71 /* MockPluginManager.swift in Sources */, B3B844D024DA6AC800DC162A /* JaredWebServerTest.swift in Sources */, TM0000010000000000000004 /* TunnelManagerTests.swift in Sources */, diff --git a/Jared/ChatDBReader.swift b/Jared/ChatDBReader.swift new file mode 100644 index 0000000..a39c2b9 --- /dev/null +++ b/Jared/ChatDBReader.swift @@ -0,0 +1,252 @@ +// +// ChatDBReader.swift +// Jared +// +// SQLite access layer for the Messages chat.db. Owns the read-only database +// handle and the SQL queries, returning plain row DTOs with no knowledge of +// `Message`, routing, or contact resolution. +// + +import Foundation +import SQLite3 + +// MARK: - Row DTOs + +/// One row from the new-message query. `text` is already resolved from either the +/// `text` column or the Ventura-era `attributedBody` blob. +struct MessageRow { + let senderHandle: String? + let text: String? + let rowID: String? + let roomName: String? + let isFromMe: Bool + let destination: String? + let epochDate: TimeInterval + let hasAttachment: Bool + let sendStyle: String? + let associatedMessageType: Int32 + let associatedMessageGUID: String? + let guid: String? + let destinationCallerID: String? + let messageHandleID: Int32 +} + +/// One participant row for a group chat lookup. +struct GroupParticipantRow { + let handle: String + let groupName: String? + let chatGUID: String? +} + +struct AttachmentRow { + let rowID: String + let fileName: String + let mimeType: String + let transferName: String + let isSticker: Bool +} + +// MARK: - Reader + +class ChatDBReader { + private static let groupQuery = """ + SELECT handle.id, display_name, chat.guid + FROM chat_handle_join INNER JOIN handle ON chat_handle_join.handle_id = handle.ROWID + INNER JOIN chat ON chat_handle_join.chat_id = chat.ROWID + WHERE chat.chat_identifier = ? + """ + private static let attachmentQuery = """ + SELECT ROWID, + filename, + mime_type, + transfer_name, + is_sticker + FROM attachment + INNER JOIN message_attachment_join + ON attachment.ROWID = message_attachment_join.attachment_id + WHERE message_id = ? + """ + private static let newRecordquery = """ + SELECT handle.id, message.text, message.ROWID, message.cache_roomnames, message.is_from_me, message.destination_caller_id, + message.date/1000000000 + strftime("%s", "2001-01-01"), + message.cache_has_attachments, + message.expressive_send_style_id, + message.associated_message_type, + message.associated_message_guid, message.guid, destination_caller_id, + message.handle_id, message.attributedBody + FROM message LEFT JOIN handle + ON message.handle_id = handle.ROWID + WHERE message.ROWID > ? ORDER BY message.ROWID ASC + """ + private static let maxRecordIDQuery = "SELECT MAX(rowID) FROM message" + + private var db: OpaquePointer? + + /// Opens the chat database read-only. Returns nil if the database cannot be + /// opened (e.g. missing Full Disk Access). + init?(databaseLocation: URL) { + if sqlite3_open_v2(databaseLocation.path, &db, SQLITE_OPEN_READONLY, nil) != SQLITE_OK { + let errorMessage = db.flatMap { sqlite3_errmsg($0) }.map { String(cString: $0) } ?? "Unknown SQLite error" + NSLog("Error opening SQLite database at %@: %@", databaseLocation.path, errorMessage) + if let db = db { sqlite3_close(db) } + db = nil + return nil + } + } + + deinit { + if let db = db, sqlite3_close(db) != SQLITE_OK { + print("error closing database") + } + db = nil + } + + // MARK: - Queries + + func currentMaxRecordID() -> String { + var id: String? + var statement: OpaquePointer? + + if sqlite3_prepare_v2(db, ChatDBReader.maxRecordIDQuery, -1, &statement, nil) != SQLITE_OK { + let errmsg = String(cString: sqlite3_errmsg(db)!) + print("error preparing select: \(errmsg)") + } + + while sqlite3_step(statement) == SQLITE_ROW { + guard let idcString = sqlite3_column_text(statement, 0) else { + break + } + id = String(cString: idcString) + } + + if sqlite3_finalize(statement) != SQLITE_OK { + let errmsg = String(cString: sqlite3_errmsg(db)!) + print("error finalizing prepared statement: \(errmsg)") + } + + return id ?? "0" + } + + func fetchNewMessageRows(sinceID: String?) -> [MessageRow] { + var statement: OpaquePointer? + defer { sqlite3_finalize(statement) } + + if sqlite3_prepare_v2(db, ChatDBReader.newRecordquery, -1, &statement, nil) != SQLITE_OK { + let errmsg = String(cString: sqlite3_errmsg(db)!) + print("error preparing select: \(errmsg)") + } + + if sqlite3_bind_text(statement, 1, sinceID ?? "1000000000", -1, SQLITE_TRANSIENT) != SQLITE_OK { + let errmsg = String(cString: sqlite3_errmsg(db)!) + print("failure binding: \(errmsg)") + } + + var rows = [MessageRow]() + while sqlite3_step(statement) == SQLITE_ROW { + let rawText = unwrapStringColumn(for: statement, at: 1) + let text = (rawText?.isEmpty == false ? rawText : nil) ?? extractAttributedBodyText(for: statement, at: 14) + rows.append(MessageRow( + senderHandle: unwrapStringColumn(for: statement, at: 0), + text: text, + rowID: unwrapStringColumn(for: statement, at: 2), + roomName: unwrapStringColumn(for: statement, at: 3), + isFromMe: sqlite3_column_int(statement, 4) == 1, + destination: unwrapStringColumn(for: statement, at: 5), + epochDate: TimeInterval(sqlite3_column_int64(statement, 6)), + hasAttachment: sqlite3_column_int(statement, 7) == 1, + sendStyle: unwrapStringColumn(for: statement, at: 8), + associatedMessageType: sqlite3_column_int(statement, 9), + associatedMessageGUID: unwrapStringColumn(for: statement, at: 10), + guid: unwrapStringColumn(for: statement, at: 11), + destinationCallerID: unwrapStringColumn(for: statement, at: 12), + messageHandleID: sqlite3_column_int(statement, 13) + )) + } + return rows + } + + func fetchGroupParticipantRows(chatID: String) -> [GroupParticipantRow] { + var statement: OpaquePointer? + defer { sqlite3_finalize(statement) } + + if sqlite3_prepare_v2(db, ChatDBReader.groupQuery, -1, &statement, nil) != SQLITE_OK { + let errmsg = String(cString: sqlite3_errmsg(db)!) + print("error preparing select: \(errmsg)") + } + + if sqlite3_bind_text(statement, 1, chatID, -1, SQLITE_TRANSIENT) != SQLITE_OK { + let errmsg = String(cString: sqlite3_errmsg(db)!) + print("failure binding foo: \(errmsg)") + } + + var rows = [GroupParticipantRow]() + while sqlite3_step(statement) == SQLITE_ROW { + guard let idcString = sqlite3_column_text(statement, 0) else { + break + } + rows.append(GroupParticipantRow( + handle: String(cString: idcString), + groupName: unwrapStringColumn(for: statement, at: 1), + chatGUID: unwrapStringColumn(for: statement, at: 2) + )) + } + return rows + } + + func fetchAttachmentRows(messageID: String) -> [AttachmentRow] { + var statement: OpaquePointer? + + if sqlite3_prepare_v2(db, ChatDBReader.attachmentQuery, -1, &statement, nil) != SQLITE_OK { + let errmsg = String(cString: sqlite3_errmsg(db)!) + print("error preparing select: \(errmsg)") + } + + if sqlite3_bind_text(statement, 1, messageID, -1, SQLITE_TRANSIENT) != SQLITE_OK { + let errmsg = String(cString: sqlite3_errmsg(db)!) + print("failure binding: \(errmsg)") + } + + var rows = [AttachmentRow]() + while sqlite3_step(statement) == SQLITE_ROW { + guard let rowID = unwrapStringColumn(for: statement, at: 0) else { continue } + guard let fileName = unwrapStringColumn(for: statement, at: 1) else { continue } + guard let mimeType = unwrapStringColumn(for: statement, at: 2) else { continue } + guard let transferName = unwrapStringColumn(for: statement, at: 3) else { continue } + let isSticker = sqlite3_column_int(statement, 4) == 1 + rows.append(AttachmentRow(rowID: rowID, fileName: fileName, mimeType: mimeType, + transferName: transferName, isSticker: isSticker)) + } + + if sqlite3_finalize(statement) != SQLITE_OK { + let errmsg = String(cString: sqlite3_errmsg(db)!) + print("error finalizing prepared statement: \(errmsg)") + } + + return rows + } + + // MARK: - Column helpers + + private func unwrapStringColumn(for sqlStatement: OpaquePointer?, at column: Int32) -> String? { + if let cString = sqlite3_column_text(sqlStatement, column) { + return String(cString: cString) + } else { + return nil + } + } + + /// Decodes the NSAttributedString blob in `attributedBody` to extract plain text. + /// Apple moved message text from the `text` column to `attributedBody` in macOS Ventura/Sonoma. + private func extractAttributedBodyText(for sqlStatement: OpaquePointer?, at column: Int32) -> String? { + let byteCount = sqlite3_column_bytes(sqlStatement, column) + guard byteCount > 0, let bytes = sqlite3_column_blob(sqlStatement, column) else { + return nil + } + let data = Data(bytes: bytes, count: Int(byteCount)) + let obj: Any? = NSUnarchiver.unarchiveObject(with: data) + ?? (try? NSKeyedUnarchiver.unarchivedObject(ofClass: NSAttributedString.self, from: data)) + guard let attributed = obj as? NSAttributedString else { return nil } + let text = attributed.string + return text.isEmpty ? nil : text + } +} diff --git a/Jared/DatabaseHandler.swift b/Jared/DatabaseHandler.swift index c6f181f..3639071 100644 --- a/Jared/DatabaseHandler.swift +++ b/Jared/DatabaseHandler.swift @@ -13,45 +13,11 @@ import JaredFramework import SQLite3 class DatabaseHandler { - private static let groupQuery = """ - SELECT handle.id, display_name, chat.guid - FROM chat_handle_join INNER JOIN handle ON chat_handle_join.handle_id = handle.ROWID - INNER JOIN chat ON chat_handle_join.chat_id = chat.ROWID - WHERE chat.chat_identifier = ? - """ - private static let attachmentQuery = """ - SELECT ROWID, - filename, - mime_type, - transfer_name, - is_sticker - FROM attachment - INNER JOIN message_attachment_join - ON attachment.ROWID = message_attachment_join.attachment_id - WHERE message_id = ? - """ - private static let newRecordquery = """ - SELECT handle.id, message.text, message.ROWID, message.cache_roomnames, message.is_from_me, message.destination_caller_id, - message.date/1000000000 + strftime("%s", "2001-01-01"), - message.cache_has_attachments, - message.expressive_send_style_id, - message.associated_message_type, - message.associated_message_guid, message.guid, destination_caller_id, - message.handle_id, message.attributedBody - FROM message LEFT JOIN handle - ON message.handle_id = handle.ROWID - WHERE message.ROWID > ? ORDER BY message.ROWID ASC - """ - private static let maxRecordIDQuery = "SELECT MAX(rowID) FROM message" - - var db: OpaquePointer? var querySinceID: String? var refreshSeconds = 5.0 - var statement: OpaquePointer? = nil var router: RouterDelegate? - /// Resolves a handle to a contact's given name. Injectable so tests can avoid - /// the Contacts XPC service, which hangs in the xctest sandbox. - private let contactNameResolver: (String) -> String? + private let reader: ChatDBReader? + private let builder: MessageBuilder private var walSource: DispatchSourceFileSystemObject? private var walContinuation: AsyncStream.Continuation? private var backgroundTask: Task? @@ -63,45 +29,32 @@ class DatabaseHandler { walContinuation?.yield(()) } - /// Convenience init for testing: routes messages from an injected MessageSource without opening SQLite. - init(messageSource: MessageSource, router: RouterDelegate) { - self.router = router - self.contactNameResolver = { ContactHelper.RetreiveContact(handle: $0)?.givenName } - messageSource.onMessage = { [weak self] message in - self?.router?.route(message: message) - } - } - init(router: RouterDelegate, databaseLocation: URL, diskAccessDelegate: DiskAccessDelegate?, enableBackgroundPolling: Bool = true, contactNameResolver: @escaping (String) -> String? = { ContactHelper.RetreiveContact(handle: $0)?.givenName }) { self.router = router - self.contactNameResolver = contactNameResolver - - if sqlite3_open_v2(databaseLocation.path, &db, SQLITE_OPEN_READONLY, nil) != SQLITE_OK { - let errorMessage = db.flatMap { sqlite3_errmsg($0) }.map { String(cString: $0) } ?? "Unknown SQLite error" - NSLog("Error opening SQLite database at %@: %@", databaseLocation.path, errorMessage) + self.builder = MessageBuilder(contactNameResolver: contactNameResolver) + + guard let reader = ChatDBReader(databaseLocation: databaseLocation) else { + self.reader = nil UserDefaults.standard.set(false, forKey: JaredConstants.fullDiskAccess) diskAccessDelegate?.displayAccessError() return } + self.reader = reader UserDefaults.standard.set(true, forKey: JaredConstants.fullDiskAccess) - - querySinceID = getCurrentMaxRecordID() + + querySinceID = reader.currentMaxRecordID() if enableBackgroundPolling { start() startWALWatcher(databaseLocation: databaseLocation) } } - + deinit { backgroundTask?.cancel() walContinuation?.finish() walSource?.cancel() - if let db = db, sqlite3_close(db) != SQLITE_OK { - print("error closing database") - } - db = nil } - + func start() { var cont: AsyncStream.Continuation? let stream = AsyncStream { cont = $0 } @@ -161,216 +114,52 @@ class DatabaseHandler { walSource = source } - private func getCurrentMaxRecordID() -> String { - var id: String? - var statement: OpaquePointer? - - if sqlite3_prepare_v2(db, DatabaseHandler.maxRecordIDQuery, -1, &statement, nil) != SQLITE_OK { - let errmsg = String(cString: sqlite3_errmsg(db)!) - print("error preparing select: \(errmsg)") - } - - while sqlite3_step(statement) == SQLITE_ROW { - guard let idcString = sqlite3_column_text(statement, 0) else { - break - } - - id = String(cString: idcString) - } - - if sqlite3_finalize(statement) != SQLITE_OK { - let errmsg = String(cString: sqlite3_errmsg(db)!) - print("error finalizing prepared statement: \(errmsg)") - } - - return id ?? "0" - } - - private func retrieveGroupInfo(chatID: String?) -> Group? { - guard let chatHandle = chatID else { - return nil - } - - var statement: OpaquePointer? - defer { sqlite3_finalize(statement) } - - if sqlite3_prepare_v2(db, DatabaseHandler.groupQuery, -1, &statement, nil) != SQLITE_OK { - let errmsg = String(cString: sqlite3_errmsg(db)!) - print("error preparing select: \(errmsg)") - } - - if sqlite3_bind_text(statement, 1, chatID, -1, SQLITE_TRANSIENT) != SQLITE_OK { - let errmsg = String(cString: sqlite3_errmsg(db)!) - print("failure binding foo: \(errmsg)") - } - - var People = [Person]() - var groupName: String? - var chatGUID: String? - var foundRow = false - - while sqlite3_step(statement) == SQLITE_ROW { - foundRow = true - guard let idcString = sqlite3_column_text(statement, 0) else { - break - } - groupName = unwrapStringColumn(for: statement, at: 1) - chatGUID = unwrapStringColumn(for: statement, at: 2) - - let handle = String(cString: idcString) - let givenName = contactNameResolver(handle) - - People.append(Person(givenName: givenName, handle: handle, isMe: false)) - } - - guard foundRow else { - return nil - } - - return Group(name: groupName, handle: chatGUID ?? chatHandle, participants: People) - } - - private func unwrapStringColumn(for sqlStatement: OpaquePointer?, at column: Int32) -> String? { - if let cString = sqlite3_column_text(sqlStatement, column) { - return String(cString: cString) - } else { - return nil - } - } - - /// Decodes the NSAttributedString blob in `attributedBody` to extract plain text. - /// Apple moved message text from the `text` column to `attributedBody` in macOS Ventura/Sonoma. - private func extractAttributedBodyText(for sqlStatement: OpaquePointer?, at column: Int32) -> String? { - let byteCount = sqlite3_column_bytes(sqlStatement, column) - guard byteCount > 0, let bytes = sqlite3_column_blob(sqlStatement, column) else { - return nil - } - let data = Data(bytes: bytes, count: Int(byteCount)) - let obj: Any? = NSUnarchiver.unarchiveObject(with: data) - ?? (try? NSKeyedUnarchiver.unarchivedObject(ofClass: NSAttributedString.self, from: data)) - guard let attributed = obj as? NSAttributedString else { return nil } - let text = attributed.string - return text.isEmpty ? nil : text - } - - private func retrieveAttachments(forMessage messageID: String) -> [Attachment] { - var attachmentStatement: OpaquePointer? = nil - - defer { attachmentStatement = nil } - - if sqlite3_prepare_v2(db, DatabaseHandler.attachmentQuery, -1, &attachmentStatement, nil) != SQLITE_OK { - let errmsg = String(cString: sqlite3_errmsg(db)!) - print("error preparing select: \(errmsg)") - } - - if sqlite3_bind_text(attachmentStatement, 1, messageID, -1, SQLITE_TRANSIENT) != SQLITE_OK { - let errmsg = String(cString: sqlite3_errmsg(db)!) - print("failure binding: \(errmsg)") - } - - var attachments = [Attachment]() - - while sqlite3_step(attachmentStatement) == SQLITE_ROW { - guard let rowID = unwrapStringColumn(for: attachmentStatement, at: 0) else { continue } - guard let fileName = unwrapStringColumn(for: attachmentStatement, at: 1) else { continue } - guard let mimeType = unwrapStringColumn(for: attachmentStatement, at: 2) else { continue } - guard let transferName = unwrapStringColumn(for: attachmentStatement, at: 3) else { continue } - let isSticker = sqlite3_column_int(attachmentStatement, 4) == 1 - - attachments.append(Attachment(id: Int(rowID)!, filePath: fileName, mimeType: mimeType, fileName: transferName, isSticker: isSticker)) - } - - if sqlite3_finalize(attachmentStatement) != SQLITE_OK { - let errmsg = String(cString: sqlite3_errmsg(db)!) - print("error finalizing prepared statement: \(errmsg)") - } - - return attachments - } - @discardableResult internal func queryNewRecords() -> Double { let start = Date() - defer { statement = nil } - - if sqlite3_prepare_v2(db, DatabaseHandler.newRecordquery, -1, &statement, nil) != SQLITE_OK { - let errmsg = String(cString: sqlite3_errmsg(db)!) - print("error preparing select: \(errmsg)") - } - - if sqlite3_bind_text(statement, 1, querySinceID ?? "1000000000", -1, SQLITE_TRANSIENT) != SQLITE_OK { - let errmsg = String(cString: sqlite3_errmsg(db)!) - print("failure binding: \(errmsg)") - } - - while sqlite3_step(statement) == SQLITE_ROW { - var senderHandleOptional = unwrapStringColumn(for: statement, at: 0) - let rawText = unwrapStringColumn(for: statement, at: 1) - let textOptional = (rawText?.isEmpty == false ? rawText : nil) ?? extractAttributedBodyText(for: statement, at: 14) - let rowID = unwrapStringColumn(for: statement, at: 2) - let roomName = unwrapStringColumn(for: statement, at: 3) - let isFromMe = sqlite3_column_int(statement, 4) == 1 - let destinationOptional = unwrapStringColumn(for: statement, at: 5) - let epochDate = TimeInterval(sqlite3_column_int64(statement, 6)) - let hasAttachment = sqlite3_column_int(statement, 7) == 1 - let sendStyle = unwrapStringColumn(for: statement, at: 8) - let associatedMessageType = sqlite3_column_int(statement, 9) - let associatedMessageGUID = unwrapStringColumn(for: statement, at: 10) - let guid = unwrapStringColumn(for: statement, at: 11) - let destinationCallerId = unwrapStringColumn(for: statement, at: 12) - let messageHandleId = sqlite3_column_int(statement, 13) + guard let reader = reader else { return 0 } - if !isFromMe, senderHandleOptional == nil { - if messageHandleId > 0 { + for row in reader.fetchNewMessageRows(sinceID: querySinceID) { + var senderHandleOptional = row.senderHandle + + if !row.isFromMe, senderHandleOptional == nil { + if row.messageHandleID > 0 { // handle_id points at a missing row — never substitute destination_caller_id. - querySinceID = rowID + querySinceID = row.rowID continue } // handle_id still 0: Messages may not have linked the sender yet. Defer one poll // so we don't treat a remote message as self-chat (destination_caller_id as sender). - if let id = rowID, !deferredIncomingRowIDs.contains(id) { + if let id = row.rowID, !deferredIncomingRowIDs.contains(id) { deferredIncomingRowIDs.insert(id) break } - senderHandleOptional = destinationCallerId ?? destinationOptional + senderHandleOptional = row.destinationCallerID ?? row.destination } - guard let senderHandle = senderHandleOptional, let text = textOptional, let destination = destinationOptional else { - querySinceID = rowID + guard let senderHandle = senderHandleOptional, let text = row.text, let destination = row.destination else { + querySinceID = row.rowID continue } - - let cleanDestination = destination.hasPrefix("mailto:") ? String(destination.dropFirst(7)) : destination - let cleanSenderHandle = senderHandle.hasPrefix("mailto:") ? String(senderHandle.dropFirst(7)) : senderHandle - let buddyName = contactNameResolver(cleanSenderHandle) - let myName = contactNameResolver(cleanDestination) - let sender: Person - let recipient: RecipientEntity - let group = retrieveGroupInfo(chatID: roomName) - - if (isFromMe) { - sender = Person(givenName: myName, handle: cleanDestination, isMe: true) - recipient = group ?? Person(givenName: buddyName, handle: cleanSenderHandle, isMe: false) - } else { - sender = Person(givenName: buddyName, handle: cleanSenderHandle, isMe: false) - recipient = group ?? Person(givenName: myName, handle: cleanDestination, isMe: true) + + let group = row.roomName.flatMap { roomName in + builder.buildGroup(chatHandle: roomName, + participantRows: reader.fetchGroupParticipantRows(chatID: roomName)) } - - let message = Message(body: TextBody(text), date: Date(timeIntervalSince1970: epochDate), sender: sender, recipient: recipient, guid: guid, attachments: hasAttachment ? retrieveAttachments(forMessage: rowID ?? "") : [], - sendStyle: sendStyle, associatedMessageType: Int(associatedMessageType), associatedMessageGUID: associatedMessageGUID) - + let attachments = row.hasAttachment + ? builder.buildAttachments(from: reader.fetchAttachmentRows(messageID: row.rowID ?? "")) + : [] + + let message = builder.buildMessage(from: row, senderHandle: senderHandle, text: text, + destination: destination, group: group, attachments: attachments) + router?.route(message: message) - querySinceID = rowID - if let rowID { + querySinceID = row.rowID + if let rowID = row.rowID { deferredIncomingRowIDs.remove(rowID) } } - - if sqlite3_finalize(statement) != SQLITE_OK { - let errmsg = String(cString: sqlite3_errmsg(db)!) - print("error finalizing prepared statement: \(errmsg)") - } - + return NSDate().timeIntervalSince(start) } } diff --git a/Jared/InMemoryMessageSource.swift b/Jared/InMemoryMessageSource.swift deleted file mode 100644 index e057d5c..0000000 --- a/Jared/InMemoryMessageSource.swift +++ /dev/null @@ -1,15 +0,0 @@ -// -// InMemoryMessageSource.swift -// Jared -// - -import JaredFramework - -/// Test adapter for MessageSource. Push messages directly for unit tests. -class InMemoryMessageSource: MessageSource { - var onMessage: ((Message) -> Void)? - - func push(_ message: Message) { - onMessage?(message) - } -} diff --git a/Jared/MessageBuilder.swift b/Jared/MessageBuilder.swift new file mode 100644 index 0000000..b7e477e --- /dev/null +++ b/Jared/MessageBuilder.swift @@ -0,0 +1,80 @@ +// +// MessageBuilder.swift +// Jared +// +// Converts raw chat.db row DTOs into `Message` / `Person` / `Group` entities. +// Has no SQLite knowledge — contact-name resolution is injected so the builder +// is unit-testable without the Contacts framework or a live database. +// + +import Foundation +import JaredFramework + +struct MessageBuilder { + /// Resolves a handle to a contact's given name. Injectable so tests can avoid + /// the Contacts XPC service, which hangs in the xctest sandbox. + let contactNameResolver: (String) -> String? + + init(contactNameResolver: @escaping (String) -> String?) { + self.contactNameResolver = contactNameResolver + } + + /// Builds a `Group` from participant rows, or nil when the chat has no rows. + func buildGroup(chatHandle: String, participantRows: [GroupParticipantRow]) -> Group? { + guard !participantRows.isEmpty else { return nil } + + let people = participantRows.map { row in + Person(givenName: contactNameResolver(row.handle), handle: row.handle, isMe: false) + } + let groupName = participantRows.compactMap { $0.groupName }.last + let chatGUID = participantRows.compactMap { $0.chatGUID }.last + + return Group(name: groupName, handle: chatGUID ?? chatHandle, participants: people) + } + + func buildAttachments(from rows: [AttachmentRow]) -> [Attachment] { + rows.compactMap { row in + guard let id = Int(row.rowID) else { return nil } + return Attachment(id: id, filePath: row.fileName, mimeType: row.mimeType, + fileName: row.transferName, isSticker: row.isSticker) + } + } + + /// Builds a `Message` from a row whose sender handle, text, and destination have + /// already been resolved by the caller's inbound policy. + func buildMessage(from row: MessageRow, + senderHandle: String, + text: String, + destination: String, + group: Group?, + attachments: [Attachment]) -> Message { + let cleanDestination = stripMailto(destination) + let cleanSenderHandle = stripMailto(senderHandle) + let buddyName = contactNameResolver(cleanSenderHandle) + let myName = contactNameResolver(cleanDestination) + + let sender: Person + let recipient: RecipientEntity + if row.isFromMe { + sender = Person(givenName: myName, handle: cleanDestination, isMe: true) + recipient = group ?? Person(givenName: buddyName, handle: cleanSenderHandle, isMe: false) + } else { + sender = Person(givenName: buddyName, handle: cleanSenderHandle, isMe: false) + recipient = group ?? Person(givenName: myName, handle: cleanDestination, isMe: true) + } + + return Message(body: TextBody(text), + date: Date(timeIntervalSince1970: row.epochDate), + sender: sender, + recipient: recipient, + guid: row.guid, + attachments: attachments, + sendStyle: row.sendStyle, + associatedMessageType: Int(row.associatedMessageType), + associatedMessageGUID: row.associatedMessageGUID) + } + + private func stripMailto(_ handle: String) -> String { + handle.hasPrefix("mailto:") ? String(handle.dropFirst(7)) : handle + } +} diff --git a/Jared/MessageSource.swift b/Jared/MessageSource.swift deleted file mode 100644 index e8fe449..0000000 --- a/Jared/MessageSource.swift +++ /dev/null @@ -1,11 +0,0 @@ -// -// MessageSource.swift -// Jared -// - -import JaredFramework - -/// A source of incoming messages. Implementors call `onMessage` for each new message. -protocol MessageSource: AnyObject { - var onMessage: ((Message) -> Void)? { get set } -} diff --git a/JaredTests/MessageBuilderTests.swift b/JaredTests/MessageBuilderTests.swift new file mode 100644 index 0000000..f668257 --- /dev/null +++ b/JaredTests/MessageBuilderTests.swift @@ -0,0 +1,161 @@ +// +// MessageBuilderTests.swift +// JaredTests +// +// Unit tests for MessageBuilder in isolation — pure DTO → entity conversion, +// no SQLite or Contacts dependency. +// + +import XCTest +@testable import Jared +import JaredFramework + +class MessageBuilderTests: XCTestCase { + + private func makeRow( + senderHandle: String? = "+15551234567", + text: String? = "hello", + rowID: String? = "42", + roomName: String? = nil, + isFromMe: Bool = false, + destination: String? = "+15559998888", + epochDate: TimeInterval = 1_495_061_841, + hasAttachment: Bool = false, + sendStyle: String? = nil, + associatedMessageType: Int32 = 0, + associatedMessageGUID: String? = nil, + guid: String? = "msg-guid", + destinationCallerID: String? = "+15559998888", + messageHandleID: Int32 = 1 + ) -> MessageRow { + MessageRow(senderHandle: senderHandle, text: text, rowID: rowID, roomName: roomName, + isFromMe: isFromMe, destination: destination, epochDate: epochDate, + hasAttachment: hasAttachment, sendStyle: sendStyle, + associatedMessageType: associatedMessageType, associatedMessageGUID: associatedMessageGUID, + guid: guid, destinationCallerID: destinationCallerID, messageHandleID: messageHandleID) + } + + private func builder(resolver: @escaping (String) -> String? = { _ in nil }) -> MessageBuilder { + MessageBuilder(contactNameResolver: resolver) + } + + // MARK: - buildMessage + + func testIncomingMessageSenderIsRemoteRecipientIsMe() { + let row = makeRow(senderHandle: "+15551234567", isFromMe: false, destination: "+15559998888") + let message = builder().buildMessage(from: row, senderHandle: "+15551234567", + text: "hi", destination: "+15559998888", + group: nil, attachments: []) + + let sender = message.sender as? Person + let recipient = message.recipient as? Person + XCTAssertEqual(sender?.handle, "+15551234567") + XCTAssertEqual(sender?.isMe, false) + XCTAssertEqual(recipient?.handle, "+15559998888") + XCTAssertEqual(recipient?.isMe, true) + } + + func testOutgoingMessageSenderIsMeRecipientIsRemote() { + let row = makeRow(isFromMe: true) + let message = builder().buildMessage(from: row, senderHandle: "+15551234567", + text: "hi", destination: "+15559998888", + group: nil, attachments: []) + + let sender = message.sender as? Person + let recipient = message.recipient as? Person + XCTAssertEqual(sender?.handle, "+15559998888") + XCTAssertEqual(sender?.isMe, true) + XCTAssertEqual(recipient?.handle, "+15551234567") + XCTAssertEqual(recipient?.isMe, false) + } + + func testMailtoPrefixIsStrippedFromHandles() { + let row = makeRow(isFromMe: false) + let message = builder().buildMessage(from: row, senderHandle: "mailto:zeke@example.com", + text: "hi", destination: "mailto:me@example.com", + group: nil, attachments: []) + + let sender = message.sender as? Person + let recipient = message.recipient as? Person + XCTAssertEqual(sender?.handle, "zeke@example.com") + XCTAssertEqual(recipient?.handle, "me@example.com") + } + + func testGroupBecomesRecipientForIncomingMessage() { + let group = Group(name: "Squad", handle: "iMessage;+;chat123", + participants: [Person(givenName: nil, handle: "+15551234567", isMe: false)]) + let row = makeRow(roomName: "chat123", isFromMe: false) + let message = builder().buildMessage(from: row, senderHandle: "+15551234567", + text: "hi", destination: "+15559998888", + group: group, attachments: []) + + XCTAssertEqual((message.recipient as? Group)?.handle, "iMessage;+;chat123") + } + + func testContactNameResolverPopulatesGivenName() { + let resolver: (String) -> String? = { $0 == "+15551234567" ? "Zeke" : nil } + let row = makeRow(isFromMe: false) + let message = builder(resolver: resolver).buildMessage(from: row, senderHandle: "+15551234567", + text: "hi", destination: "+15559998888", + group: nil, attachments: []) + + XCTAssertEqual((message.sender as? Person)?.givenName, "Zeke") + } + + func testMessageCarriesBodyDateAndGuid() { + let row = makeRow(epochDate: 1_495_061_841, guid: "abc-123") + let message = builder().buildMessage(from: row, senderHandle: "+15551234567", + text: "expressive!", destination: "+15559998888", + group: nil, attachments: []) + + XCTAssertEqual((message.body as? TextBody)?.message, "expressive!") + XCTAssertEqual(message.guid, "abc-123") + XCTAssertEqual(message.date, Date(timeIntervalSince1970: 1_495_061_841)) + } + + // MARK: - buildGroup + + func testBuildGroupReturnsNilForEmptyRows() { + XCTAssertNil(builder().buildGroup(chatHandle: "chat123", participantRows: [])) + } + + func testBuildGroupUsesChatGUIDWhenPresent() { + let rows = [ + GroupParticipantRow(handle: "+15551234567", groupName: "Squad", chatGUID: "iMessage;+;guid"), + GroupParticipantRow(handle: "+15559998888", groupName: "Squad", chatGUID: "iMessage;+;guid"), + ] + let group = builder().buildGroup(chatHandle: "fallback", participantRows: rows) + XCTAssertEqual(group?.handle, "iMessage;+;guid") + XCTAssertEqual(group?.name, "Squad") + XCTAssertEqual(group?.participants.count, 2) + } + + func testBuildGroupFallsBackToChatHandleWhenGUIDMissing() { + let rows = [GroupParticipantRow(handle: "+15551234567", groupName: nil, chatGUID: nil)] + let group = builder().buildGroup(chatHandle: "fallback-handle", participantRows: rows) + XCTAssertEqual(group?.handle, "fallback-handle") + XCTAssertNil(group?.name) + } + + // MARK: - buildAttachments + + func testBuildAttachmentsMapsRows() { + let rows = [ + AttachmentRow(rowID: "7", fileName: "~/img.jpg", mimeType: "image/jpeg", + transferName: "img.jpg", isSticker: false), + ] + let attachments = builder().buildAttachments(from: rows) + XCTAssertEqual(attachments.count, 1) + XCTAssertEqual(attachments.first?.id, 7) + XCTAssertEqual(attachments.first?.mimeType, "image/jpeg") + XCTAssertEqual(attachments.first?.isSticker, false) + } + + func testBuildAttachmentsSkipsNonNumericRowID() { + let rows = [ + AttachmentRow(rowID: "not-a-number", fileName: "f", mimeType: "m", + transferName: "t", isSticker: false), + ] + XCTAssertEqual(builder().buildAttachments(from: rows).count, 0) + } +} diff --git a/JaredTests/MessageSourceTests.swift b/JaredTests/MessageSourceTests.swift deleted file mode 100644 index 8a70a11..0000000 --- a/JaredTests/MessageSourceTests.swift +++ /dev/null @@ -1,46 +0,0 @@ -// -// MessageSourceTests.swift -// JaredTests -// - -import XCTest -import JaredFramework -@testable import Jared - -class MessageSourceTests: XCTestCase { - let sender = Person(givenName: "Test", handle: "test-handle", isMe: false) - let me = Person(givenName: "Me", handle: "me-handle", isMe: true) - - func makeMessage(_ text: String) -> Message { - Message(body: TextBody(text), date: Date(), sender: sender, recipient: me) - } - - func testInMemorySourceDeliversMessages() { - let source = InMemoryMessageSource() - var received: [Message] = [] - source.onMessage = { received.append($0) } - - let msg = makeMessage("hello") - source.push(msg) - - XCTAssertEqual(received.count, 1) - XCTAssertEqual((received.first?.body as? TextBody)?.message, "hello") - } - - func testInMemorySourceDeliversMultipleMessages() { - let source = InMemoryMessageSource() - var received: [Message] = [] - source.onMessage = { received.append($0) } - - source.push(makeMessage("one")) - source.push(makeMessage("two")) - source.push(makeMessage("three")) - - XCTAssertEqual(received.count, 3) - } - - func testInMemorySourceWithNoHandlerDoesNotCrash() { - let source = InMemoryMessageSource() - source.push(makeMessage("ignored")) - } -}