macsync/@packages/imessage/Sources/IMessageSync/Reader.swift

484 lines
19 KiB
Swift

import Contacts
import Foundation
import GRDB
import LilithLogging
import MacSyncShared
private let log = AppLogger.logger(for: "IMessage.Reader")
struct iMessageConversation {
let id: String
let displayName: String
let isGroup: Bool
let participantIds: [String]
}
struct ContactInfo {
let identifier: String
let displayName: String
let phoneNumber: String?
let email: String?
let birthday: Date?
}
struct iMessageAttachment {
let filename: String?
let mimeType: String?
let transferName: String?
let size: Int
let data: String?
}
struct iMessage {
let guid: String
let conversationId: String
let senderId: String?
let isFromMe: Bool
let date: Date
let dateDelivered: Date?
let dateRead: Date?
let text: String?
let attributedBody: String?
let associatedMessageType: Int?
let associatedMessageGuid: String?
let isAudioMessage: Bool
let expressiveSendStyleId: String?
let replyToGuid: String?
let threadOriginatorGuid: String?
let groupTitle: String?
let balloonBundleId: String?
let service: String?
let senderIdentifier: String?
let senderDisplayName: String?
let senderPhoneNumber: String?
let senderEmail: String?
let attachments: [iMessageAttachment]
let attachmentsCount: Int
let attachmentsTotalSize: Int
let attachmentsFiletypes: [String]
}
class iMessageReader: MessageReaderProtocol {
static let shared = iMessageReader()
private var dbQueue: DatabaseQueue?
private let chatDbPath: String
private let contactStore = CNContactStore()
var contactCache: [String: ContactInfo] = [:]
private init() {
let homeDir = FileManager.default.homeDirectoryForCurrentUser
chatDbPath = homeDir.appendingPathComponent("Library/Messages/chat.db").path
}
func loadContacts() async -> Bool {
do {
let status = CNContactStore.authorizationStatus(for: .contacts)
if status == .notDetermined {
let granted = try await contactStore.requestAccess(for: .contacts)
if !granted { return false }
} else if status != .authorized {
return false
}
let keysToFetch: [CNKeyDescriptor] = [
CNContactIdentifierKey as CNKeyDescriptor,
CNContactGivenNameKey as CNKeyDescriptor,
CNContactFamilyNameKey as CNKeyDescriptor,
CNContactPhoneNumbersKey as CNKeyDescriptor,
CNContactEmailAddressesKey as CNKeyDescriptor,
CNContactBirthdayKey as CNKeyDescriptor
]
let request = CNContactFetchRequest(keysToFetch: keysToFetch)
try contactStore.enumerateContacts(with: request) { contact, _ in
let fullName = [contact.givenName, contact.familyName]
.filter { !$0.isEmpty }
.joined(separator: " ")
guard !fullName.isEmpty else { return }
let phones = contact.phoneNumbers.map { PhoneUtils.normalize($0.value.stringValue) }
let emails = contact.emailAddresses.map { ($0.value as String).lowercased() }
var birthdayDate: Date?
if let birthday = contact.birthday {
birthdayDate = Calendar.current.date(from: birthday)
}
let completeContact = ContactInfo(
identifier: phones.first ?? emails.first ?? contact.identifier,
displayName: fullName,
phoneNumber: phones.first,
email: emails.first,
birthday: birthdayDate
)
for phone in phones { self.contactCache[phone] = completeContact }
for email in emails { self.contactCache[email] = completeContact }
self.contactCache[contact.identifier] = completeContact
}
log.info("Loaded \(self.contactCache.count) contact entries")
return true
} catch {
log.warning("Failed to load contacts: \(error)")
return false
}
}
func lookupContact(identifier: String) -> ContactInfo? {
if let contact = contactCache[identifier.lowercased()] { return contact }
return contactCache[PhoneUtils.normalize(identifier)]
}
func getAllContacts() -> [ContactInfo] {
var seen = Set<String>()
return contactCache.values.filter { contact in
if seen.contains(contact.identifier) { return false }
seen.insert(contact.identifier)
return true
}
}
func getDatabaseQueue() -> DatabaseQueue? { dbQueue }
func connect() throws {
guard FileManager.default.fileExists(atPath: chatDbPath) else {
throw iMessageError.databaseNotFound
}
var config = Configuration()
config.readonly = true
dbQueue = try DatabaseQueue(path: chatDbPath, configuration: config)
}
func getConversations() throws -> [iMessageConversation] {
guard let db = dbQueue else { throw iMessageError.notConnected }
return try db.read { db in
let sql = """
SELECT
c.guid,
c.display_name,
c.group_id,
GROUP_CONCAT(DISTINCT h.id) as participant_ids,
MAX(m.date) as last_message_date
FROM chat c
LEFT JOIN chat_handle_join chj ON c.ROWID = chj.chat_id
LEFT JOIN handle h ON chj.handle_id = h.ROWID
LEFT JOIN chat_message_join cmj ON c.ROWID = cmj.chat_id
LEFT JOIN message m ON cmj.message_id = m.ROWID
GROUP BY c.ROWID
ORDER BY last_message_date DESC NULLS LAST
"""
return try Row.fetchAll(db, sql: sql).map { row in
let participantStr = row["participant_ids"] as? String ?? ""
let participants = participantStr.split(separator: ",").map(String.init)
var displayName: String = row["display_name"] ?? ""
if displayName.isEmpty && participants.count == 1 {
if let contact = lookupContact(identifier: participants[0]) {
displayName = contact.displayName
}
}
if displayName.isEmpty {
displayName = participants.first ?? "Unknown"
}
return iMessageConversation(
id: row["guid"],
displayName: displayName,
isGroup: row["group_id"] != nil,
participantIds: participants
)
}
}
}
func getMessages(conversationId: String, since: Date? = nil) throws -> [iMessage] {
guard let db = dbQueue else { throw iMessageError.notConnected }
return try db.read { db in
var sql = """
SELECT
m.guid,
m.ROWID as message_rowid,
c.guid as conversation_id,
h.id as sender_id,
m.is_from_me,
m.text,
m.attributedBody,
m.associated_message_type,
m.associated_message_guid,
m.is_audio_message,
m.expressive_send_style_id,
m.reply_to_guid,
m.thread_originator_guid,
m.group_title,
m.balloon_bundle_id,
m.service as message_service,
a.filename as attachment_filename,
a.mime_type as attachment_mime_type,
a.transfer_name as attachment_transfer_name,
a.total_bytes as attachment_size,
CAST(m.date AS REAL) / 1000000000.0 + 978307200 as date_unix,
CAST(m.date_delivered AS REAL) / 1000000000.0 + 978307200 as date_delivered_unix,
CAST(m.date_read AS REAL) / 1000000000.0 + 978307200 as date_read_unix
FROM message m
JOIN chat_message_join cmj ON m.ROWID = cmj.message_id
JOIN chat c ON cmj.chat_id = c.ROWID
LEFT JOIN handle h ON m.handle_id = h.ROWID
LEFT JOIN message_attachment_join maj ON m.ROWID = maj.message_id
LEFT JOIN attachment a ON maj.attachment_id = a.ROWID
WHERE c.guid = ?
"""
var arguments: [DatabaseValueConvertible] = [conversationId]
if let since = since {
let unixTime = Int64((since.timeIntervalSince1970 - 978307200) * 1_000_000_000)
sql += " AND m.date >= ?"
arguments.append(unixTime)
}
sql += " ORDER BY m.date DESC, m.ROWID DESC"
let rows = try Row.fetchAll(db, sql: sql, arguments: StatementArguments(arguments))
var messageMap: [String: (row: Row, attachments: [iMessageAttachment])] = [:]
var messageOrder: [String] = []
for row in rows {
let guid: String = row["guid"]
var attachment: iMessageAttachment?
if let attachmentFilename: String = row["attachment_filename"] {
let attachmentSize: Int = row["attachment_size"] ?? 0
var attachmentData: String?
if attachmentSize > 0 {
attachmentData = self.readAttachmentFile(filename: attachmentFilename)
}
attachment = iMessageAttachment(
filename: attachmentFilename,
mimeType: row["attachment_mime_type"],
transferName: row["attachment_transfer_name"],
size: attachmentSize,
data: attachmentData
)
}
if var existing = messageMap[guid] {
if let attachment = attachment {
existing.attachments.append(attachment)
messageMap[guid] = existing
}
} else {
messageOrder.append(guid)
messageMap[guid] = (row: row, attachments: attachment.map { [$0] } ?? [])
}
}
return messageOrder.compactMap { guid -> iMessage? in
guard let entry = messageMap[guid] else { return nil }
let row = entry.row
let attachments = entry.attachments
let dateUnix: TimeInterval = row["date_unix"]
let deliveredUnix: TimeInterval? = row["date_delivered_unix"]
let readUnix: TimeInterval? = row["date_read_unix"]
let rawText: String? = row["text"]
let rawAttributedBody: Data? = row["attributedBody"]
var attributedBodyBase64: String?
// Prefer the text column; fall back to decoding attributedBody (NSKeyedArchiver) for
// newer messages where Apple stores the string only in the attributed blob.
var resolvedText: String? = rawText.flatMap { $0.isEmpty ? nil : $0 }
if let bodyData = rawAttributedBody {
attributedBodyBase64 = bodyData.base64EncodedString()
if resolvedText == nil || resolvedText!.isEmpty {
if let decoded = try? NSKeyedUnarchiver.unarchivedObject(
ofClass: NSAttributedString.self, from: bodyData
) {
let s = decoded.string.trimmingCharacters(in: .whitespacesAndNewlines)
if !s.isEmpty { resolvedText = s }
}
// Secondary heuristic fallback if NSKeyedUnarchiver fails.
if resolvedText == nil || resolvedText!.isEmpty {
resolvedText = self.extractTextFromAttributedBody(bodyData)
}
}
}
let senderId: String? = row["sender_id"]
var senderDisplayName: String?
var senderPhoneNumber: String?
var senderEmail: String?
if let senderId = senderId {
if let contact = self.lookupContact(identifier: senderId) {
senderDisplayName = contact.displayName
senderPhoneNumber = contact.phoneNumber
senderEmail = contact.email
}
}
let attachmentsCount = attachments.count
let attachmentsTotalSize = attachments.reduce(0) { $0 + $1.size }
let attachmentsFiletypes = Array(Set(attachments.compactMap { $0.mimeType })).sorted()
return iMessage(
guid: row["guid"],
conversationId: row["conversation_id"],
senderId: senderId,
isFromMe: row["is_from_me"] == 1,
date: Date(timeIntervalSince1970: dateUnix),
dateDelivered: deliveredUnix.map { Date(timeIntervalSince1970: $0) },
dateRead: readUnix.map { Date(timeIntervalSince1970: $0) },
text: resolvedText,
attributedBody: attributedBodyBase64,
associatedMessageType: row["associated_message_type"],
associatedMessageGuid: row["associated_message_guid"],
isAudioMessage: row["is_audio_message"] == 1,
expressiveSendStyleId: row["expressive_send_style_id"],
replyToGuid: row["reply_to_guid"],
threadOriginatorGuid: row["thread_originator_guid"],
groupTitle: row["group_title"],
balloonBundleId: row["balloon_bundle_id"],
service: row["message_service"],
senderIdentifier: senderId,
senderDisplayName: senderDisplayName,
senderPhoneNumber: senderPhoneNumber,
senderEmail: senderEmail,
attachments: attachments,
attachmentsCount: attachmentsCount,
attachmentsTotalSize: attachmentsTotalSize,
attachmentsFiletypes: attachmentsFiletypes
)
}
}
}
private func readAttachmentFile(filename: String) -> String? {
let expandedPath: String
if filename.hasPrefix("~/") {
let homeDir = FileManager.default.homeDirectoryForCurrentUser.path
expandedPath = homeDir + filename.dropFirst(1)
} else {
expandedPath = filename
}
guard FileManager.default.fileExists(atPath: expandedPath) else {
log.info("Attachment not found: \(expandedPath)")
return nil
}
do {
let data = try Data(contentsOf: URL(fileURLWithPath: expandedPath))
return data.base64EncodedString()
} catch {
log.warning("Failed to read attachment: \(error)")
return nil
}
}
func extractTextFromAttributedBody(_ data: Data) -> String? {
guard !data.isEmpty else { return nil }
let bytes = [UInt8](data)
let marker: [UInt8] = [78, 83, 83, 116, 114, 105, 110, 103] // "NSString"
if let markerIndex = findSubsequence(in: bytes, subsequence: marker) {
let contentStart = markerIndex + 8 + 5
if contentStart < bytes.count {
let lengthByte = bytes[contentStart]
let textStart: Int
let textLength: Int
if lengthByte == 0x81 {
if contentStart + 3 <= bytes.count {
textLength = Int(bytes[contentStart + 1]) | (Int(bytes[contentStart + 2]) << 8)
textStart = contentStart + 3
} else {
return nil
}
} else {
textLength = Int(lengthByte)
textStart = contentStart + 1
}
if textStart + textLength <= bytes.count && textLength > 0 {
let textData = Data(bytes[textStart..<(textStart + textLength)])
if let text = String(data: textData, encoding: .utf8) {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty { return trimmed }
}
}
}
}
var longestText: String?
var currentRun = Data()
for byte in bytes {
if (byte >= 0x20 && byte <= 0x7E) || byte >= 0x80 || byte == 0x0A || byte == 0x0D {
currentRun.append(byte)
} else {
if currentRun.count > 3 {
if let str = String(data: currentRun, encoding: .utf8),
str.rangeOfCharacter(from: .letters) != nil {
let trimmed = str.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.count > (longestText?.count ?? 0) &&
!trimmed.hasPrefix("NS") &&
!trimmed.hasPrefix("__") &&
!trimmed.contains("attributedString") {
longestText = trimmed
}
}
}
currentRun = Data()
}
}
if currentRun.count > 3 {
if let str = String(data: currentRun, encoding: .utf8),
str.rangeOfCharacter(from: .letters) != nil {
let trimmed = str.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.count > (longestText?.count ?? 0) &&
!trimmed.hasPrefix("NS") &&
!trimmed.hasPrefix("__") &&
!trimmed.contains("attributedString") {
longestText = trimmed
}
}
}
return longestText
}
func findSubsequence(in array: [UInt8], subsequence: [UInt8]) -> Int? {
guard subsequence.count <= array.count else { return nil }
return (0...(array.count - subsequence.count))
.first { Array(array[$0..<($0 + subsequence.count)]) == subsequence }
}
}
enum iMessageError: LocalizedError {
case databaseNotFound
case notConnected
var errorDescription: String? {
switch self {
case .databaseNotFound:
return "iMessage database not found. Grant Full Disk Access in System Preferences."
case .notConnected:
return "Not connected to iMessage database"
}
}
}