484 lines
19 KiB
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"
|
|
}
|
|
}
|
|
}
|