Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,26 @@ for message in try db.fetch(request) {
print("From: \(message.sender)")
print("Content: \(message.text)")
print("Sent at: \(message.date)")
print("Chat: \(message.chatID)")
}
```

> [!NOTE]
> `participantHandles` matches messages sent by those handles
> _and_ messages you sent in chats they take part in.
> Messages you send from another device (iPhone, iPad)
> are synced without a sender handle,
> so they can only be tied to a conversation through their chat —
> which `Message.chatID` exposes.

### Fetching Chats

```swift
// Look a chat up by identifier, e.g. from a message's `chatID`
let request = FetchRequest<Chat>(predicate: .id("iMessage;-;+18002752273"))
if let chat = try db.fetch(request).first {
print("Name: \(chat.displayName ?? "-")")
print("Participants: \(chat.participants)")
}
```

Expand Down
60 changes: 52 additions & 8 deletions Sources/iMessage/Database.swift
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,23 @@ public final class Database {
parameters.append(try bindableInt32(request.limit, name: "limit"))
parameters.append(try bindableInt32(request.offset, name: "offset"))

// When the predicate already joins the chat tables,
// report the chat that satisfied it;
// otherwise look the chat up per message.
let chatGuidColumn =
compiledPredicate.requiresChatJoin
? "c.guid"
: """
(
SELECT c2.guid
FROM chat_message_join cmj2
JOIN chat c2 ON cmj2.chat_id = c2.ROWID
WHERE cmj2.message_id = m.ROWID
ORDER BY cmj2.chat_id
LIMIT 1
)
"""

let query = """
SELECT
m.guid,
Expand All @@ -256,7 +273,8 @@ public final class Database {
m.is_from_me,
h.id,
m.service,
m.date_read
m.date_read,
\(chatGuidColumn) AS chat_guid
FROM message m
\(compiledPredicate.requiresChatJoin ? "JOIN chat_message_join cmj ON m.ROWID = cmj.message_id" : "")
\(compiledPredicate.requiresChatJoin ? "JOIN chat c ON cmj.chat_id = c.ROWID" : "")
Expand Down Expand Up @@ -304,13 +322,17 @@ public final class Database {
let senderText = sqlite3_column_text(statement, 5)
let sender = senderText.map { Account.Handle(rawValue: String(cString: $0)) }

let chatGuidText = sqlite3_column_text(statement, 8)
let chatID = chatGuidText.map { Chat.ID(rawValue: String(cString: $0)) }

return Message(
id: messageID,
text: text,
date: date,
isFromMe: isFromMe,
readAt: readAt,
sender: sender
sender: sender,
chatID: chatID
)
}
}
Expand Down Expand Up @@ -390,17 +412,33 @@ public final class Database {
}
let handleValues = orderedHandleValues(handles)
let placeholders = placeholders(handles.count)
// Messages from the current user only carry a handle
// when they were sent from this Mac;
// those sent from another device (and synced over iCloud) have no handle
// and can only be matched through the chat they belong to.
let condition = """
m.ROWID IN (
SELECT m2.ROWID
FROM message m2
JOIN handle h ON m2.handle_id = h.ROWID
WHERE h.id IN (\(placeholders))
(
m.ROWID IN (
SELECT m2.ROWID
FROM message m2
JOIN handle h ON m2.handle_id = h.ROWID
WHERE h.id IN (\(placeholders))
)
OR (
m.is_from_me = 1
AND m.ROWID IN (
SELECT cmj.message_id
FROM chat_message_join cmj
JOIN chat_handle_join chj ON chj.chat_id = cmj.chat_id
JOIN handle h ON chj.handle_id = h.ROWID
WHERE h.id IN (\(placeholders))
)
)
)
"""
return CompiledPredicate(
whereClause: condition,
parameters: toBindableStrings(handleValues),
parameters: toBindableStrings(handleValues) + toBindableStrings(handleValues),
requiresChatJoin: false
)
case .dateRange(let dateRange):
Expand Down Expand Up @@ -469,6 +507,12 @@ public final class Database {
return CompiledPredicate(whereClause: nil, parameters: [], requiresChatJoin: false)
case .none:
return CompiledPredicate(whereClause: "1 = 0", parameters: [], requiresChatJoin: false)
case .id(let chatID):
return CompiledPredicate(
whereClause: "c.guid = ?",
parameters: [chatID.rawValue],
requiresChatJoin: false
)
case .participantHandles(let handles, let match):
if handles.isEmpty {
let whereClause = match == .all ? nil : "1 = 0"
Expand Down
5 changes: 4 additions & 1 deletion Sources/iMessage/FetchRequest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ public indirect enum MessagePredicate: Sendable, Hashable {
case none
/// Match messages that belong to the specified chat.
case chatID(Chat.ID)
/// Match messages sent by any of the provided handles.
/// Match messages sent by any of the provided handles,
/// as well as messages the current user sent in chats that include any of the provided handles.
case participantHandles(Set<Account.Handle>)
/// Match messages in the half-open date range.
case dateRange(Range<Date>)
Expand All @@ -50,6 +51,8 @@ public indirect enum ChatPredicate: Sendable, Hashable {
case all
/// Match no chats.
case none
/// Match the chat with the specified identifier.
case id(Chat.ID)
/// Match chats by participant handles using the selected mode.
case participantHandles(Set<Account.Handle>, match: ParticipantMatch)
/// Match chats that contain message activity in the half-open date range.
Expand Down
6 changes: 6 additions & 0 deletions Sources/iMessage/Message.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ public struct Message: Identifiable, Hashable, Codable, Sendable {
/// The sender handle for inbound messages.
public let sender: Account.Handle?

/// The identifier of the chat this message belongs to, if any.
///
/// Messages sent by the current user from another device carry no sender handle,
/// so this is the only way to tell which conversation they are part of.
public let chatID: Chat.ID?

/// A Boolean value that indicates whether this message has been read.
public var isRead: Bool {
readAt != nil
Expand Down
138 changes: 128 additions & 10 deletions Tests/iMessageTests/DatabaseTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -91,9 +91,11 @@ struct DatabaseTests {
"person@example.com",
]
let participantMessages = try db.fetchMessages(with: participants)
#expect(participantMessages.count == 3)
#expect(participantMessages.count == 4)
#expect(participantMessages.contains { $0.sender?.rawValue == "+1234567890" })
#expect(participantMessages.contains { $0.sender?.rawValue == "person@example.com" })
// The current user's reply in the chat with these participants is included too.
#expect(participantMessages.contains { $0.id.rawValue == "msg-guid-2" && $0.isFromMe })

// Test with date range
let yesterday = Date().addingTimeInterval(-86400)
Expand All @@ -109,15 +111,24 @@ struct DatabaseTests {
func testFetchMessagesByParticipant() async throws {
let handle: Account.Handle = "+1234567890"

// Test basic fetch
// Test basic fetch: the handle's own messages,
// plus the current user's message in the chat with that handle
let messages = try db.fetchMessages(with: [handle], limit: 10)
#expect(messages.count == 2)
#expect(messages[0].id.rawValue == "msg-guid-1")
#expect(messages[0].text == "Hello!")
#expect(messages[0].isFromMe == false)
#expect(messages[0].isRead == false)
#expect(messages[0].readAt == nil)
#expect(messages[0].sender?.rawValue == "+1234567890")
#expect(messages.count == 3)
#expect(messages.map(\.id.rawValue) == ["msg-guid-2", "msg-guid-1", "msg-guid-4"])

#expect(messages[0].isFromMe == true)
#expect(messages[0].sender == nil)
#expect(messages[0].chatID?.rawValue == "chat-guid-1")

#expect(messages[1].text == "Hello!")
#expect(messages[1].isFromMe == false)
#expect(messages[1].isRead == false)
#expect(messages[1].readAt == nil)
#expect(messages[1].sender?.rawValue == "+1234567890")
#expect(messages[1].chatID?.rawValue == "chat-guid-1")

#expect(messages[2].chatID?.rawValue == "chat-guid-2")

// Test with date range
let yesterday = Date().addingTimeInterval(-86400)
Expand Down Expand Up @@ -168,7 +179,114 @@ struct DatabaseTests {

let messages = try db.fetch(request)
let messageIDs = Set(messages.map(\.id.rawValue))
#expect(messageIDs == ["msg-guid-3", "msg-guid-4", "msg-guid-5"])
// msg-guid-2 is the current user's message in the chat with person@example.com.
#expect(messageIDs == ["msg-guid-2", "msg-guid-3", "msg-guid-4", "msg-guid-5"])
}

@Test
func testMessagesReportTheirChat() async throws {
// Plain fetch: the chat is looked up per message.
let allMessages = try db.fetch(
Database.MessageFetchRequest(sortDescriptors: [.id(.ascending)], limit: 10)
)
#expect(
allMessages.map(\.chatID?.rawValue) == [
"chat-guid-1", "chat-guid-1", "chat-guid-1", "chat-guid-2", "chat-guid-2",
]
)

// Chat-scoped fetch: the joined chat is reported.
let chatMessages = try db.fetch(
Database.MessageFetchRequest(predicate: .chatID("chat-guid-2"), limit: 10)
)
#expect(chatMessages.count == 2)
#expect(chatMessages.allSatisfy { $0.chatID?.rawValue == "chat-guid-2" })

// A message that is not linked to any chat has no chat identifier.
try db.execute(
"""
INSERT INTO message (ROWID, guid, text, attributedBody, handle_id, date, is_from_me, date_read, service)
VALUES (6, 'msg-guid-6', 'Orphan', NULL, 1, \(Date().nanosecondsSinceReferenceDate ?? 0), 0, 0, 'iMessage');
"""
)
let orphan = try db.fetch(
Database.MessageFetchRequest(predicate: .participantHandles(["+1234567890"]), limit: 10)
).first { $0.id.rawValue == "msg-guid-6" }
#expect(orphan != nil)
#expect(orphan?.chatID == nil)
}

@Test
func testParticipantPredicateIncludesOwnMessagesInChat() async throws {
// A message sent by the current user from another device:
// no handle at all (chat.db stores 0), only a chat membership.
try db.execute(
"""
INSERT INTO message (ROWID, guid, text, attributedBody, handle_id, date, is_from_me, date_read, service)
VALUES (6, 'msg-guid-6', 'Sent from my phone', NULL, 0, \(Date().nanosecondsSinceReferenceDate ?? 0), 1, 0, 'iMessage');
INSERT INTO chat_message_join (chat_id, message_id) VALUES (2, 6);
"""
)

func ids(with handles: Set<Account.Handle>) throws -> Set<String> {
let request = Database.MessageFetchRequest(
predicate: .participantHandles(handles),
limit: 10
)
return Set(try db.fetch(request).map(\.id.rawValue))
}

// third@example.com only takes part in chat 2:
// their own message, and mine in that chat — not my message in chat 1.
#expect(try ids(with: ["third@example.com"]) == ["msg-guid-5", "msg-guid-6"])

// +1234567890 takes part in both chats: their messages and mine in either chat,
// but not what person@example.com wrote in chat 1.
#expect(
try ids(with: ["+1234567890"]) == ["msg-guid-1", "msg-guid-2", "msg-guid-4", "msg-guid-6"]
)

// person@example.com only takes part in chat 1.
#expect(try ids(with: ["person@example.com"]) == ["msg-guid-2", "msg-guid-3"])

// Combined with a chat predicate,
// the join path reports the right chat.
let inChat2 = try db.fetch(
Database.MessageFetchRequest(
predicate: .and([
.chatID("chat-guid-2"),
.participantHandles(["+1234567890"]),
]),
limit: 10
)
)
#expect(Set(inChat2.map(\.id.rawValue)) == ["msg-guid-4", "msg-guid-6"])
#expect(inChat2.allSatisfy { $0.chatID?.rawValue == "chat-guid-2" })
}

@Test
func testFetchChatByID() async throws {
let chats = try db.fetch(
Database.ChatFetchRequest(predicate: .id("chat-guid-2"), limit: 10)
)
#expect(chats.count == 1)
#expect(chats[0].id.rawValue == "chat-guid-2")
#expect(chats[0].displayName == "Another Group")
#expect(Set(chats[0].participants.map(\.rawValue)) == ["+1234567890", "third@example.com"])

let missing = try db.fetch(
Database.ChatFetchRequest(predicate: .id("no-such-chat"), limit: 10)
)
#expect(missing.isEmpty)

let several = try db.fetch(
Database.ChatFetchRequest(
predicate: .or([.id("chat-guid-1"), .id("chat-guid-2")]),
sortDescriptors: [.id(.ascending)],
limit: 10
)
)
#expect(several.map(\.id.rawValue) == ["chat-guid-1", "chat-guid-2"])
}

@Test
Expand Down