diff --git a/App/Controllers/ServerController.swift b/App/Controllers/ServerController.swift index e2eeef00..eaecad48 100644 --- a/App/Controllers/ServerController.swift +++ b/App/Controllers/ServerController.swift @@ -61,6 +61,9 @@ enum ServiceRegistry { ShortcutsService.shared, UtilitiesService.shared, ] + if VoicemailService.isSupported { + services.append(VoicemailService.shared) + } #if WEATHERKIT_AVAILABLE services.append(WeatherService.shared) #endif @@ -78,6 +81,7 @@ enum ServiceRegistry { remindersEnabled: Binding, shortcutsEnabled: Binding, utilitiesEnabled: Binding, + voicemailEnabled: Binding, weatherEnabled: Binding ) -> [ServiceConfig] { var configs: [ServiceConfig] = [ @@ -145,6 +149,17 @@ enum ServiceRegistry { binding: shortcutsEnabled ), ] + if VoicemailService.isSupported { + configs.append( + ServiceConfig( + name: "Voicemail", + iconName: "recordingtape", + color: .teal, + service: VoicemailService.shared, + binding: voicemailEnabled + ) + ) + } #if WEATHERKIT_AVAILABLE configs.append( ServiceConfig( @@ -184,6 +199,7 @@ final class ServerController: ObservableObject { @AppStorage("remindersEnabled") private var remindersEnabled = false @AppStorage("shortcutsEnabled") private var shortcutsEnabled = false @AppStorage("utilitiesEnabled") private var utilitiesEnabled = true // Default enabled + @AppStorage("voicemailEnabled") private var voicemailEnabled = false @AppStorage("weatherEnabled") private var weatherEnabled = false // MARK: - AppStorage for Trusted Clients @@ -206,6 +222,7 @@ final class ServerController: ObservableObject { remindersEnabled: $remindersEnabled, shortcutsEnabled: $shortcutsEnabled, utilitiesEnabled: $utilitiesEnabled, + voicemailEnabled: $voicemailEnabled, weatherEnabled: $weatherEnabled ) } diff --git a/App/Services/Voicemail.swift b/App/Services/Voicemail.swift new file mode 100644 index 00000000..51911dec --- /dev/null +++ b/App/Services/Voicemail.swift @@ -0,0 +1,517 @@ +import AppKit +import OSLog + +private let log = Logger.service("voicemail") +private let dataStoreDirectoryPath = + "/Users/\(NSUserName())/Library/Group Containers/group.com.apple.FaceTime/com.apple.facetimemessagestored/Data Store" +private let dataStoreBookmarkKey: String = "me.mattt.iMCP.voicemailDataStoreBookmark" +private let defaultLimit = 30 + +/// Reads voicemails synced to the Mac by the Phone app on macOS 26 and later, +/// including FaceTime audio and video messages. +final class VoicemailService: NSObject, Service, NSOpenSavePanelDelegate { + static let shared = VoicemailService() + + /// Whether this Mac can run the Phone app that creates the voicemail store + /// (macOS 26 and later). The app sandbox hides the store until access is granted, + /// so registration cannot rely on the store being present at the default path; + /// activation requests the grant instead. + static var isSupported: Bool { + if #available(macOS 26.0, *) { return true } + return false + } + + private static var defaultDatabasePath: String { + dataStoreDirectoryPath + "/" + VoicemailStore.databaseFileName + } + + func activate() async throws { + log.debug("Starting voicemail service activation") + try await requestDataStoreAccess() + log.debug("Successfully activated voicemail service") + } + + var isActivated: Bool { + get async { + var isActivated = canAccessStoreAtDefaultPath + // Only probe the bookmark when one exists, so polling a Mac that has + // not granted access yet does not log an error every time. + if UserDefaults.standard.data(forKey: dataStoreBookmarkKey) != nil { + isActivated = isActivated || canAccessStoreUsingBookmark + } + log.debug("Voicemail service activation status: \(isActivated)") + return isActivated + } + } + + var tools: [Tool] { + Tool( + name: "voicemails_fetch", + description: + "Fetch voicemails from the Mac (synced from iPhone, including FaceTime audio messages)", + inputSchema: .object( + properties: [ + "sender": .string( + description: + "Phone number or address to filter by (partial match supported)" + ), + "start": .string( + description: + "Start of the date range (inclusive). ISO 8601 format. If timezone is omitted, local time is assumed. Date-only uses local midnight.", + format: .dateTime + ), + "end": .string( + description: + "End of the date range (exclusive). ISO 8601 format. If timezone is omitted, local time is assumed. A date-only value includes that whole day.", + format: .dateTime + ), + "type": .string( + description: "Filter by message type, or omit for all", + enum: ["carrierVoicemail", "faceTimeAudio", "faceTimeVideo"] + ), + "limit": .integer( + description: "Maximum voicemails to return", + default: .int(defaultLimit), + minimum: 1 + ), + ], + additionalProperties: false + ), + annotations: .init( + title: "Fetch Voicemails", + readOnlyHint: true, + openWorldHint: false + ) + ) { arguments in + let limit = try self.argument("limit", in: arguments, as: \.intValue) ?? defaultLimit + guard limit >= 1 else { + throw ArgumentError.invalid("limit must be a positive integer") + } + var request = VoicemailMessage.FetchRequest(limit: limit) + request.sender = try self.argument("sender", in: arguments, as: \.stringValue) + if let type = try self.argument("type", in: arguments, as: \.stringValue) { + guard let kind = VoicemailMessage.Kind(rawValue: type) else { + throw ArgumentError.invalid( + "type must be one of: carrierVoicemail, faceTimeAudio, faceTimeVideo" + ) + } + request.kind = kind + } + if let start = try self.argument("start", in: arguments, as: \.stringValue) { + guard + let parsed = ISO8601DateFormatter.parsedLenientISO8601Date( + fromISO8601String: start + ) + else { + throw ArgumentError.invalid("start must be an ISO 8601 date") + } + request.startDate = Calendar.current.normalizedStartDate( + from: parsed.date, + isDateOnly: parsed.isDateOnly + ) + } + if let end = try self.argument("end", in: arguments, as: \.stringValue) { + guard + let parsed = ISO8601DateFormatter.parsedLenientISO8601Date( + fromISO8601String: end + ) + else { + throw ArgumentError.invalid("end must be an ISO 8601 date") + } + request.endDate = Calendar.current.normalizedEndDate( + from: parsed.date, + isDateOnly: parsed.isDateOnly + ) + } + + try await self.requestDataStoreAccess() + let voicemails = try self.withStore { try $0.fetch(request) } + + return [ + "@context": "https://schema.org", + "@type": "ItemList", + "name": "Voicemails", + "numberOfItems": .int(voicemails.count), + "itemListElement": Value.array(voicemails.map(\.value)), + ] + } + + Tool( + name: "voicemail_transcript_fetch", + description: "Fetch the transcript of a voicemail", + inputSchema: .object( + properties: [ + "id": .string( + description: "Identifier of the voicemail, as returned by voicemails_fetch" + ) + ], + required: ["id"], + additionalProperties: false + ), + annotations: .init( + title: "Fetch Voicemail Transcript", + readOnlyHint: true, + openWorldHint: false + ) + ) { arguments in + let id = try self.messageIdentifier(from: arguments) + try await self.requestDataStoreAccess() + + let transcript = try self.withStore { store -> VoicemailTranscript? in + guard try store.message(id: id) != nil else { + throw TranscriptError.notFound(id) + } + return try store.transcript(id: id) + } + guard let transcript else { + throw TranscriptError.notTranscribed(id) + } + + var object: [String: Value] = [ + "@id": .string(String(id)), + "text": .string(transcript.text), + ] + if let confidence = transcript.confidence { + object["confidence"] = .double(confidence) + } + if !transcript.segments.isEmpty { + object["segment"] = Value.array(transcript.segments.map(\.value)) + } + return Value.object(object) + } + + Tool( + name: "voicemail_audio_fetch", + description: + "Fetch the audio of a voicemail (carrier voicemails and FaceTime audio messages)", + inputSchema: .object( + properties: [ + "id": .string( + description: "Identifier of the voicemail, as returned by voicemails_fetch" + ) + ], + required: ["id"], + additionalProperties: false + ), + annotations: .init( + title: "Fetch Voicemail Audio", + readOnlyHint: true, + openWorldHint: false + ) + ) { arguments in + let id = try self.messageIdentifier(from: arguments) + try await self.requestDataStoreAccess() + + let audio = try self.withStore { store -> (mimeType: String, data: Data)? in + guard let message = try store.message(id: id) else { + return nil + } + guard let mimeType = store.audioMimeType(for: message) else { + throw AudioError.unsupportedFileType(id) + } + guard let data = try store.audioData(for: message) else { + throw AudioError.audioMissing(id) + } + return (mimeType, data) + } + guard let audio else { + throw AudioError.notFound(id) + } + return Value.data(mimeType: audio.mimeType, audio.data) + } + } + + // MARK: - Data Store Access + + /// Ensures the voicemail store is readable, asking the user to grant access if needed. + /// + /// The grant has to cover the `Data Store` folder, not just the database file. + /// The store is a WAL-mode SQLite database, so SQLite also opens the `-wal` and + /// `-shm` files next to it, and the message audio lives in the `Assets` tree + /// beside the database. A bookmark on the file alone would leave both unreadable. + private func requestDataStoreAccess() async throws { + if canAccessStoreAtDefaultPath { + log.debug("Using voicemail store at default path") + return + } + + if canAccessStoreUsingBookmark { + log.debug("Using voicemail store from stored bookmark") + return + } + + log.debug("Opening folder picker for manual voicemail store selection") + guard try await showDataStoreAccessAlert() else { + throw DataStoreAccessError.userDeclinedAccess + } + + guard let selectedURL = try await showFolderPicker() else { + // Dismissing the picker is the same answer as Cancel on the alert. + throw DataStoreAccessError.userDeclinedAccess + } + + guard FileManager.default.isReadableFile(atPath: databaseURL(in: selectedURL).path) else { + throw DataStoreAccessError.fileNotReadable + } + + try storeBookmark(for: selectedURL) + log.debug("Granted access to voicemail store") + } + + /// Returns an optional argument, or throws if it is present with the wrong type. + private func argument( + _ name: String, + in arguments: [String: Value], + as transform: (Value) -> T? + ) throws -> T? { + guard let value = arguments[name], !value.isNull else { return nil } + guard let result = transform(value) else { + throw ArgumentError.invalid("\(name) has the wrong type") + } + return result + } + + /// The numeric record identifier passed as the `id` argument. + private func messageIdentifier(from arguments: [String: Value]) throws -> Int { + guard let identifier = arguments["id"]?.stringValue, let id = Int(identifier) else { + throw ArgumentError.invalid("id must be the identifier of a voicemail") + } + return id + } + + private var canAccessStoreAtDefaultPath: Bool { + return FileManager.default.isReadableFile(atPath: VoicemailService.defaultDatabasePath) + } + + private var canAccessStoreUsingBookmark: Bool { + do { + let url = try resolveBookmarkedStoreDirectory() + return try withSecurityScopedAccess(url) { _ in + FileManager.default.isReadableFile(atPath: databaseURL(in: url).path) + } + } catch { + log.error( + "Error accessing voicemail store with bookmark: \(error.localizedDescription)" + ) + return false + } + } + + private func databaseURL(in directory: URL) -> URL { + return directory.appendingPathComponent(VoicemailStore.databaseFileName) + } + + private func resolveBookmarkedStoreDirectory() throws -> URL { + guard + let bookmarkData = UserDefaults.standard.data(forKey: dataStoreBookmarkKey) + else { + throw DataStoreAccessError.noBookmarkFound + } + + var isStale = false + return try URL( + resolvingBookmarkData: bookmarkData, + options: .withSecurityScope, + relativeTo: nil, + bookmarkDataIsStale: &isStale + ) + } + + private func withSecurityScopedAccess(_ url: URL, _ operation: (URL) throws -> T) throws -> T { + guard url.startAccessingSecurityScopedResource() else { + log.error("Failed to start accessing security-scoped resource") + throw DataStoreAccessError.securityScopeAccessFailed + } + defer { url.stopAccessingSecurityScopedResource() } + return try operation(url) + } + + private func withStore(_ operation: (VoicemailStore) throws -> T) throws -> T { + if canAccessStoreAtDefaultPath { + return try operation(VoicemailStore(directoryPath: dataStoreDirectoryPath)) + } + + let directory = try resolveBookmarkedStoreDirectory() + + // The grant must stay open until the last read: + // SQLite opens the write-ahead log lazily on the first statement. + return try withSecurityScopedAccess(directory) { url in + try operation(VoicemailStore(directoryPath: url.path)) + } + } + + // MARK: - Errors + + private enum DataStoreAccessError: LocalizedError { + case noBookmarkFound + case securityScopeAccessFailed + case userDeclinedAccess + case invalidFolderSelected + case fileNotReadable + + var errorDescription: String? { + switch self { + case .noBookmarkFound: + return "No stored bookmark found for voicemail store access" + case .securityScopeAccessFailed: + return "Failed to access security-scoped resource" + case .userDeclinedAccess: + return "User declined to grant access to the voicemail store" + case .invalidFolderSelected: + return + "Voicemail store access denied or the selection is not the Data Store folder" + case .fileNotReadable: + return "The selected folder has no readable \(VoicemailStore.databaseFileName)" + } + } + } + + private enum ArgumentError: LocalizedError { + case invalid(String) + + var errorDescription: String? { + switch self { + case .invalid(let message): + return "Invalid argument: \(message)" + } + } + } + + private enum TranscriptError: LocalizedError { + case notFound(Int) + case notTranscribed(Int) + + var errorDescription: String? { + switch self { + case .notFound(let id): + return "No voicemail found with identifier \(id)" + case .notTranscribed(let id): + return + "The voicemail with identifier \(id) has no transcript; its audio is available from voicemail_audio_fetch" + } + } + } + + private enum AudioError: LocalizedError { + case notFound(Int) + case unsupportedFileType(Int) + case audioMissing(Int) + + var errorDescription: String? { + switch self { + case .notFound(let id): + return "No voicemail found with identifier \(id)" + case .unsupportedFileType(let id): + return + "The message with identifier \(id) is a video message, which this tool does not return; only carrier voicemails and FaceTime audio messages do" + case .audioMissing(let id): + return + "The audio file of the voicemail with identifier \(id) is missing from the store" + } + } + } + + // MARK: - UI + + @MainActor + private func showDataStoreAccessAlert() async throws -> Bool { + let alert = NSAlert() + alert.messageText = "Voicemail Store Access Required" + alert.informativeText = """ + To read your voicemails, we need access to their Data Store folder: \ + the database, its write-ahead log, and the audio files next to it. + + In the next screen, please select the `Data Store` folder and click "Grant Access". + """ + alert.alertStyle = .informational + alert.addButton(withTitle: "Continue") + alert.addButton(withTitle: "Cancel") + + return alert.runModal() == .alertFirstButtonReturn + } + + /// Returns the selected folder, or nil when the user dismisses the panel. + @MainActor + private func showFolderPicker() async throws -> URL? { + let openPanel = NSOpenPanel() + openPanel.delegate = self + openPanel.message = + "Please select your voicemail folder (~/Library/Group Containers/group.com.apple.FaceTime/com.apple.facetimemessagestored/Data Store)" + openPanel.prompt = "Grant Access" + openPanel.directoryURL = URL(fileURLWithPath: dataStoreDirectoryPath) + .deletingLastPathComponent() + openPanel.allowsMultipleSelection = false + openPanel.canChooseDirectories = true + openPanel.canChooseFiles = false + openPanel.showsHiddenFiles = true + + guard openPanel.runModal() == .OK, let url = openPanel.url else { + return nil + } + guard isDataStoreDirectory(url) else { + throw DataStoreAccessError.invalidFolderSelected + } + + return url + } + + private func storeBookmark(for url: URL) throws { + let bookmarkData = try url.bookmarkData( + options: [.withSecurityScope, .securityScopeAllowOnlyReadAccess], + includingResourceValuesForKeys: nil, + relativeTo: nil + ) + UserDefaults.standard.set(bookmarkData, forKey: dataStoreBookmarkKey) + log.debug("Successfully created and stored bookmark") + } + + private func isDataStoreDirectory(_ url: URL) -> Bool { + return url.lastPathComponent == "Data Store" + } + + // NSOpenSavePanelDelegate method to constrain the selection to the Data Store folder + func panel(_ sender: Any, shouldEnable url: URL) -> Bool { + let shouldEnable = isDataStoreDirectory(url) + log.debug( + "File selection panel: \(shouldEnable ? "enabling" : "disabling") URL: \(url.path)" + ) + return shouldEnable + } +} + +// MARK: - + +extension VoicemailMessage { + /// The record as a JSON object for tool output. + var value: Value { + let minutes = Int(duration) / 60 + let seconds = Int(duration) % 60 + var object: [String: Value] = [ + "@id": .string(String(id)), + "sender": .string(sender ?? "Unknown"), + "date": .string(date.formatted(.iso8601)), + "duration": .string(minutes > 0 ? "\(minutes)m \(seconds)s" : "\(seconds)s"), + "durationSeconds": .double(duration), + "isRead": .bool(isRead), + "transcribed": .bool(hasTranscript), + "hasAudio": .bool(hasAudio), + ] + if let kind { + object["type"] = .string(kind.rawValue) + } + return .object(object) + } +} + +extension VoicemailTranscript.Segment { + /// The segment as a JSON object for tool output. + var value: Value { + var object: [String: Value] = [ + "text": .string(text) + ] + if let confidence { + object["confidence"] = .double(confidence) + } + return .object(object) + } +} diff --git a/App/Services/VoicemailStore.swift b/App/Services/VoicemailStore.swift new file mode 100644 index 00000000..b19b7fca --- /dev/null +++ b/App/Services/VoicemailStore.swift @@ -0,0 +1,623 @@ +import Foundation +import SQLite3 + +/// A read-only view of the voicemail store kept by the Phone app on macOS 26 and later. +/// +/// Carrier and FaceTime voicemails sync to the Mac through the `facetimemessagestored` +/// daemon into a Core Data SQLite store inside the FaceTime group container. Message +/// audio lives in an `Assets` tree next to the database, addressed by each record's +/// UUID. The schema is private to the daemon; this store reads only the columns it +/// needs and never writes. +struct VoicemailStore { + static let databaseFileName = "FaceTimeMessageStore-local.sqlitedb" + + /// Path of the `Data Store` folder holding the database and the `Assets` tree. + let directoryPath: String + + var databasePath: String { + directoryPath + "/" + VoicemailStore.databaseFileName + } + + func fetch(_ request: VoicemailMessage.FetchRequest) throws -> [VoicemailMessage] { + var messages = try read { try $0.fetch(request) } + for index in messages.indices { + resolveClassification(of: &messages[index]) + } + if let kind = request.kind, kind.requiresAssetInspection { + // These kinds could not take a SQL LIMIT: it would cut rows before + // their assets reveal which `.MOV` kind they are. + messages.removeAll { $0.kind != kind } + messages = Array(messages.prefix(request.limit)) + } + return messages + } + + func message(id: Int) throws -> VoicemailMessage? { + guard var message = try read({ try $0.message(id: id) }) else { return nil } + resolveClassification(of: &message) + return message + } + + /// The transcript of the message, or nil when the store has none for it. + func transcript(id: Int) throws -> VoicemailTranscript? { + guard let data = try read({ try $0.transcriptData(id: id) }) else { return nil } + return try VoicemailTranscriptDecoder.decode(data) + } + + /// Location of the message's audio file in the `Assets` tree, when it exists on disk. + func audioURL(for message: VoicemailMessage) -> URL? { + guard let audioFileName = message.audioFileName else { return nil } + let url = URL(fileURLWithPath: directoryPath).appendingPathComponent(audioFileName) + return FileManager.default.fileExists(atPath: url.path) ? url : nil + } + + /// The bytes of the message's audio, when it exists in a playable format. + func audioData(for message: VoicemailMessage) throws -> Data? { + guard audioMimeType(for: message) != nil, let url = audioURL(for: message) else { + return nil + } + return try Data(contentsOf: url) + } + + /// The MIME type of the message's audio, when it is a format clients can play. + /// Audio-only `.MOV` carrier voicemails carry AAC audio in a QuickTime + /// container, which `audio/mp4` players handle. + func audioMimeType(for message: VoicemailMessage) -> String? { + switch message.fileType { + case "amr": return "audio/amr" + case "m4a": return "audio/mp4" + case "MOV": return message.kind == .carrierVoicemail ? "audio/mp4" : nil + default: return nil + } + } + + /// Whether the message's audio is available: an existing file in a playable format. + private func hasPlayableAudio(_ message: VoicemailMessage) -> Bool { + audioMimeType(for: message) != nil && audioURL(for: message) != nil + } + + /// Sets the message's kind from its asset, then whether its audio is playable. + /// + /// Since the iCloud voicemail era the daemon stores carrier voicemails as + /// audio-only `.MOV` files, the same `ZFILETYPE` FaceTime video messages use; + /// a video track is the only thing that tells them apart. Rows whose asset + /// is missing or unreadable keep the conservative `faceTimeVideo` default. + private func resolveClassification(of message: inout VoicemailMessage) { + if message.fileType == "MOV", + let url = audioURL(for: message), + let data = try? Data(contentsOf: url, options: .mappedIfSafe), + QuickTimeMovie.isAudioOnlyMovie(data) + { + message.kind = .carrierVoicemail + } + message.hasAudio = hasPlayableAudio(message) + } + + /// Reads the store live, honoring its write-ahead log. + /// If SQLite cannot open the log's companion files, for example because they are + /// absent and the folder is read-only, it reads the main file alone instead. + private func read(_ operation: (VoicemailStoreDatabase) throws -> T) throws -> T { + do { + return try operation(VoicemailStoreDatabase(path: databasePath, immutable: false)) + } catch is SQLiteError { + return try operation(VoicemailStoreDatabase(path: databasePath, immutable: true)) + } + } +} + +// MARK: - + +/// A row of the `ZSTOREDMESSAGE` table: one voicemail or FaceTime audio message. +struct VoicemailMessage { + enum Kind: String { + case carrierVoicemail + case faceTimeAudio + case faceTimeVideo + + /// The SQL predicate that selects candidate rows of this kind. Both + /// `.MOV` kinds match the same `ZFILETYPE`; the store inspects their + /// assets to separate audio-only carrier voicemails from video messages. + fileprivate var predicate: String { + switch self { + case .carrierVoicemail: return "ZFILETYPE IN ('amr', 'MOV')" + case .faceTimeAudio: return "ZFILETYPE = 'm4a'" + case .faceTimeVideo: return "ZFILETYPE = 'MOV'" + } + } + + /// Whether selecting rows of this kind requires inspecting their assets. + fileprivate var requiresAssetInspection: Bool { + self == .carrierVoicemail || self == .faceTimeVideo + } + + /// The kind matching a `ZFILETYPE` value, for the kinds the store defines. + fileprivate init?(fileType: String?) { + switch fileType { + case "amr": self = .carrierVoicemail + case "m4a": self = .faceTimeAudio + case "MOV": self = .faceTimeVideo + default: return nil + } + } + } + + let id: Int + let sender: String? + /// When the message was left, as seconds since the Core Data reference date (2001-01-01). + let date: Date + let duration: TimeInterval + let isRead: Bool + let fileType: String? + let recordUUID: Data + let hasTranscript: Bool + /// Whether the message's audio exists in the `Assets` tree in a format clients can play. + var hasAudio = false + + /// The message's kind, refined by the store for `.MOV` rows; see + /// `resolveClassification(of:)`. + var kind: Kind? + + /// Path of the audio file in the `Assets` tree, relative to the store's directory. + var audioFileName: String? { + guard let fileType, !fileType.isEmpty, let uuid = recordUUID.uuidString else { return nil } + return "Assets/" + uuid.prefix(2) + "/" + uuid + "." + fileType + } + + /// Reads the current row of a statement prepared with the store's column list. + fileprivate init(_ statement: OpaquePointer?) { + func text(_ column: Int32) -> String? { + guard let cString = sqlite3_column_text(statement, column) else { return nil } + let string = String(cString: cString) + return string.isEmpty ? nil : string + } + + func blob(_ column: Int32) -> Data { + guard let bytes = sqlite3_column_blob(statement, column) else { return Data() } + let byteCount = Int(sqlite3_column_bytes(statement, column)) + guard byteCount > 0 else { return Data() } + return Data(bytes: bytes, count: byteCount) + } + + id = Int(sqlite3_column_int64(statement, 0)) + sender = text(1) + date = Date(timeIntervalSinceReferenceDate: sqlite3_column_double(statement, 2)) + duration = sqlite3_column_double(statement, 3) + isRead = sqlite3_column_int(statement, 4) == 1 + fileType = text(5) + recordUUID = blob(6) + hasTranscript = sqlite3_column_int(statement, 7) == 1 + kind = Kind(fileType: fileType) + } +} + +extension VoicemailMessage { + struct FetchRequest { + /// Sender (phone number or address) to match (partial, case-insensitive). + var sender: String? + /// Start of the date range (inclusive). + var startDate: Date? + /// End of the date range (exclusive). + var endDate: Date? + var kind: Kind? + var limit: Int + + /// The SQL and its bound values, in placeholder order. + fileprivate var statement: (sql: String, bindings: [SQLiteValue]) { + var conditions = ["ZDATEDELETED IS NULL"] + var bindings: [SQLiteValue] = [] + + if let sender { + conditions.append("ZFROM LIKE ? ESCAPE '\\'") + bindings.append(.text("%" + escapingLikeWildcards(sender) + "%")) + } + if let startDate { + conditions.append("ZDATECREATED >= ?") + bindings.append(.double(startDate.timeIntervalSinceReferenceDate)) + } + if let endDate { + conditions.append("ZDATECREATED < ?") + bindings.append(.double(endDate.timeIntervalSinceReferenceDate)) + } + if let kind { + conditions.append(kind.predicate) + } + + var sql = """ + SELECT \(voicemailColumns) + FROM ZSTOREDMESSAGE + WHERE \(conditions.joined(separator: " AND ")) + ORDER BY ZDATECREATED DESC + """ + // A SQL LIMIT would cut rows before their assets can reveal which + // `.MOV` kind they are, so kinds needing asset inspection fetch all + // candidates and `fetch` applies the limit after classifying. + if kind?.requiresAssetInspection != true { + bindings.append(.int(limit)) + sql += "\nLIMIT ?" + } + return (sql, bindings) + } + + /// Escapes SQL LIKE wildcards, for use with the ESCAPE clause, so the + /// sender filter matches literal text: a_b@example.com must not match + /// axb@example.com. + private func escapingLikeWildcards(_ text: String) -> String { + var escaped = "" + for character in text { + switch character { + case "\\", "%", "_": + escaped.append("\\") + escaped.append(character) + default: + escaped.append(character) + } + } + return escaped + } + } +} + +// MARK: - + +/// A voicemail transcript, as stored by the voicemail daemon. +struct VoicemailTranscript { + struct Segment { + let text: String + let confidence: Double? + } + + /// The flattened transcript text. + let text: String + /// Per-utterance segments, when the store provides them. + let segments: [Segment] + /// Overall confidence, when the store provides it. + let confidence: Double? +} + +/// Decodes the `NSKeyedArchiver` archives the voicemail daemon writes into `ZTRANSCRIPT`. +enum VoicemailTranscriptDecoder { + static func decode(_ data: Data) throws -> VoicemailTranscript { + let plist = try PropertyListSerialization.propertyList(from: data, options: [], format: nil) + guard let archive = plist as? [String: Any], + let objects = archive["$objects"] as? [Any], + let top = archive["$top"] as? [String: Any], + let rootReference = top["root"] + else { + throw DecodingError.malformedArchive + } + + let root = resolve(rootReference, in: objects) + + // Carrier voicemails archive a transcript object with the flattened text + // alongside per-segment detail. + if let dictionary = root as? [String: Any], + dictionary["transcriptionString"] != nil || dictionary["segments"] != nil + { + let segments = (dictionary["segments"] as? [Any] ?? []).compactMap { + $0 as? [String: Any] + }.map(Self.segment) + let text = + (dictionary["transcriptionString"] as? String) + ?? segments.map(\.text).joined(separator: " ") + return VoicemailTranscript( + text: text, + segments: segments, + confidence: dictionary["confidence"] as? Double + ) + } + + // FaceTime messages archive a bare array of segments. + if let elements = root as? [Any] { + let segments = elements.compactMap { $0 as? [String: Any] }.map(Self.segment) + return VoicemailTranscript( + text: segments.map(\.text).joined(separator: " "), + segments: segments, + confidence: nil + ) + } + + throw DecodingError.malformedArchive + } + + private static func segment(_ dictionary: [String: Any]) -> VoicemailTranscript.Segment { + return VoicemailTranscript.Segment( + text: dictionary["text"] as? String ?? "", + confidence: dictionary["confidence"] as? Double + ) + } + + /// Materializes a keyed-archive object graph, following `CFKeyedArchiverUID` + /// references into the archive's `$objects` array. + /// + /// `PropertyListSerialization` hands those references back as opaque Core Foundation + /// types. Their only public surface is the debug description, + /// `{value = N}`, so the index is read from it. + private static func resolve(_ object: Any, in objects: [Any]) -> Any { + if let index = keyedArchiverUIDValue(of: object) { + guard objects.indices.contains(index) else { return NSNull() } + return resolve(objects[index], in: objects) + } + + switch object { + case let dictionary as [String: Any]: + // A mutable-dictionary proxy in the archive: pair its keys with its values. + if let keys = dictionary["NS.keys"] as? [Any], + let values = dictionary["NS.objects"] as? [Any] + { + var pairs: [String: Any] = [:] + for (key, value) in zip(keys, values) { + guard let key = resolve(key, in: objects) as? String else { continue } + pairs[key] = resolve(value, in: objects) + } + return pairs + } + // A mutable-array proxy in the archive: materialize its elements. + if let elements = dictionary["NS.objects"] as? [Any] { + return elements.map { resolve($0, in: objects) } + } + if dictionary["$classname"] != nil { + return NSNull() // A class description, not data. + } + var values: [String: Any] = [:] + for (key, value) in dictionary where key != "$class" { + values[key] = resolve(value, in: objects) + } + return values + case let array as [Any]: + return array.map { resolve($0, in: objects) } + default: + return object + } + } + + /// The `$objects` index a `CFKeyedArchiverUID` refers to, read from its description. + private static func keyedArchiverUIDValue(of object: Any) -> Int? { + let description = String(describing: object) + guard description.hasPrefix(" Bool { + var hasSoundTrack = false + for movie in childPayloads(ofType: "moov", in: data, within: 0 ..< data.count) { + for track in childPayloads(ofType: "trak", in: data, within: movie) { + for media in childPayloads(ofType: "mdia", in: data, within: track) { + for handler in childPayloads(ofType: "hdlr", in: data, within: media) { + switch handlerSubtype(of: handler, in: data) { + case "vide": return false + case "soun": hasSoundTrack = true + default: break + } + } + } + } + } + return hasSoundTrack + } + + /// The handler subtype of an `hdlr` payload — `vide`, `soun`, and so on. + private static func handlerSubtype(of handler: Range, in data: Data) -> String? { + // The subtype sits 8 bytes into the `hdlr` payload, after the + // version/flags and the component type. + let subtype = handler.lowerBound + 8 + guard subtype + 4 <= handler.upperBound else { return nil } + return String(data: data.subdata(in: subtype ..< (subtype + 4)), encoding: .ascii) + } + + /// The payload ranges of the immediate child atoms of the given type, + /// within a container atom's payload — or the whole file, at its root. + private static func childPayloads( + ofType type: String, + in data: Data, + within bounds: Range + ) -> [Range] { + var payloads: [Range] = [] + var offset = bounds.lowerBound + while offset + 8 <= bounds.upperBound { + let header = data.subdata(in: offset ..< (offset + 8)) + var size = header.prefix(4).reduce(0) { $0 << 8 | Int($1) } + var headerSize = 8 + if size == 1 { + // A size of 1 marks an extended header whose 64-bit size + // follows the atom type. + guard offset + 16 <= bounds.upperBound else { break } + let extended = data.subdata(in: (offset + 8) ..< (offset + 16)) + .reduce(UInt64(0)) { $0 << 8 | UInt64($1) } + guard let exact = Int(exactly: extended) else { break } + size = exact == 0 ? bounds.upperBound - offset : exact // To end of container. + headerSize = 16 + } else if size == 0 { + size = bounds.upperBound - offset // To end of container. + } + // Compare against the remaining bytes — a malformed size up to + // `Int.max` would overflow an `offset + size` addition and trap. + guard size >= headerSize, size <= bounds.upperBound - offset else { break } + if header.suffix(4) == Data(type.utf8) { + payloads.append((offset + headerSize) ..< (offset + size)) + } + offset += size + } + return payloads + } +} + +/// The columns of `ZSTOREDMESSAGE` the store reads, in selection order. +private let voicemailColumns = + "Z_PK, ZFROM, ZDATECREATED, ZDURATION, ZISREAD, ZFILETYPE, ZRECORDUUID, ZTRANSCRIPT IS NOT NULL" + +/// A read-only connection to the voicemail store. +private final class VoicemailStoreDatabase { + private let connection: OpaquePointer + + /// Opens the store read-only. + /// With `immutable`, SQLite ignores the write-ahead log and never touches the `-wal` + /// and `-shm` files, so records since the last checkpoint are not visible. + init(path: String, immutable: Bool) throws { + var filename = path + var flags = SQLITE_OPEN_READONLY + if immutable { + var components = URLComponents() + components.scheme = "file" + components.path = path + components.queryItems = [URLQueryItem(name: "immutable", value: "1")] + guard let uri = components.string else { + throw SQLiteError(message: "cannot build a URI for \(path)") + } + filename = uri + flags |= SQLITE_OPEN_URI + } + + var connection: OpaquePointer? + guard sqlite3_open_v2(filename, &connection, flags, nil) == SQLITE_OK else { + defer { sqlite3_close(connection) } + throw SQLiteError(message: String(cString: sqlite3_errmsg(connection))) + } + self.connection = connection! + } + + deinit { + sqlite3_close(connection) + } + + func fetch(_ request: VoicemailMessage.FetchRequest) throws -> [VoicemailMessage] { + let (sql, bindings) = request.statement + + var statement: OpaquePointer? + guard sqlite3_prepare_v2(connection, sql, -1, &statement, nil) == SQLITE_OK else { + throw SQLiteError(message: String(cString: sqlite3_errmsg(connection))) + } + defer { sqlite3_finalize(statement) } + + for (offset, binding) in bindings.enumerated() { + binding.bind(to: statement, at: Int32(offset + 1)) + } + + var messages: [VoicemailMessage] = [] + var result = sqlite3_step(statement) + while result == SQLITE_ROW { + messages.append(VoicemailMessage(statement)) + result = sqlite3_step(statement) + } + guard result == SQLITE_DONE else { + throw SQLiteError(message: String(cString: sqlite3_errmsg(connection))) + } + return messages + } + + func message(id: Int) throws -> VoicemailMessage? { + var statement: OpaquePointer? + let sql = "SELECT \(voicemailColumns) FROM ZSTOREDMESSAGE WHERE Z_PK = ? AND ZDATEDELETED IS NULL" + guard sqlite3_prepare_v2(connection, sql, -1, &statement, nil) == SQLITE_OK else { + throw SQLiteError(message: String(cString: sqlite3_errmsg(connection))) + } + defer { sqlite3_finalize(statement) } + + SQLiteValue.int(id).bind(to: statement, at: 1) + + switch sqlite3_step(statement) { + case SQLITE_ROW: + return VoicemailMessage(statement) + case SQLITE_DONE: + return nil + case let result: + throw SQLiteError(message: "stepping the statement failed with code \(result)") + } + } + + func transcriptData(id: Int) throws -> Data? { + var statement: OpaquePointer? + let sql = "SELECT ZTRANSCRIPT FROM ZSTOREDMESSAGE WHERE Z_PK = ? AND ZDATEDELETED IS NULL" + guard sqlite3_prepare_v2(connection, sql, -1, &statement, nil) == SQLITE_OK else { + throw SQLiteError(message: String(cString: sqlite3_errmsg(connection))) + } + defer { sqlite3_finalize(statement) } + + SQLiteValue.int(id).bind(to: statement, at: 1) + + switch sqlite3_step(statement) { + case SQLITE_ROW: + guard let bytes = sqlite3_column_blob(statement, 0) else { return nil } + let byteCount = Int(sqlite3_column_bytes(statement, 0)) + guard byteCount > 0 else { return nil } + return Data(bytes: bytes, count: byteCount) + case SQLITE_DONE: + return nil + case let result: + throw SQLiteError(message: "stepping the statement failed with code \(result)") + } + } +} + +private struct SQLiteError: LocalizedError { + let message: String + + var errorDescription: String? { + return "SQLite error: \(message)" + } +} + +/// A value bound to a `?` placeholder in a prepared statement. +private enum SQLiteValue { + case text(String) + case double(Double) + case int(Int) + + func bind(to statement: OpaquePointer?, at index: Int32) { + switch self { + case .text(let string): + sqlite3_bind_text(statement, index, string, -1, SQLITE_TRANSIENT) + case .double(let double): + sqlite3_bind_double(statement, index, double) + case .int(let int): + sqlite3_bind_int64(statement, index, Int64(int)) + } + } +} + +private let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self) diff --git a/Assets/voicemail.svg b/Assets/voicemail.svg new file mode 100644 index 00000000..5d06e9f3 --- /dev/null +++ b/Assets/voicemail.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/CLITests/VoicemailStoreTests.swift b/CLITests/VoicemailStoreTests.swift new file mode 100644 index 00000000..ea239f61 --- /dev/null +++ b/CLITests/VoicemailStoreTests.swift @@ -0,0 +1,518 @@ +import Foundation +import SQLite3 +import XCTest + +/// Tests for `VoicemailStore` against a synthesized store: +/// a fresh SQLite database and `Assets` tree in a temporary folder. +/// No row is ever copied from a real voicemail store. +final class VoicemailStoreTests: XCTestCase { + private var folder: URL! + private var store: VoicemailStore! + + override func setUpWithError() throws { + folder = FileManager.default.temporaryDirectory + .appendingPathComponent("voicemail-store-tests-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + store = VoicemailStore(directoryPath: folder.path) + } + + override func tearDownWithError() throws { + try FileManager.default.removeItem(at: folder) + } + + // MARK: - Fixtures + + /// One row of the `ZSTOREDMESSAGE` table, with synthesized values. + private struct Row { + var id: Int + var sender: String? = nil + var created: Double = 0 + var duration: Double = 30 + var isRead: Bool = false + var fileType: String? = nil + var recordUUID: UUID? = nil + var transcript: Data? = nil + var deleted: Double? = nil + } + + /// Fixed dates far from any real message, as seconds since the reference date. + private let firstDate: Double = 700_000_000 + private let secondDate: Double = 750_000_000 + private let thirdDate: Double = 800_000_000 + private let fourthDate: Double = 850_000_000 + + private func makeStore(_ rows: [Row]) throws { + var connection: OpaquePointer? + XCTAssertEqual(sqlite3_open(store.databasePath, &connection), SQLITE_OK) + defer { sqlite3_close(connection) } + + try execute( + """ + CREATE TABLE ZSTOREDMESSAGE ( + Z_PK INTEGER PRIMARY KEY, + ZFROM TEXT, + ZDATECREATED REAL, + ZDATEDELETED REAL, + ZDURATION REAL, + ZISREAD INTEGER, + ZFILETYPE TEXT, + ZRECORDUUID BLOB, + ZTRANSCRIPT BLOB + ) + """, + on: connection + ) + + for row in rows { + try insert(row, into: connection) + } + } + + private func execute(_ sql: String, on connection: OpaquePointer?) throws { + var statement: OpaquePointer? + XCTAssertEqual(sqlite3_prepare_v2(connection, sql, -1, &statement, nil), SQLITE_OK) + defer { sqlite3_finalize(statement) } + XCTAssertEqual(sqlite3_step(statement), SQLITE_DONE) + } + + private func insert(_ row: Row, into connection: OpaquePointer?) throws { + let sql = """ + INSERT INTO ZSTOREDMESSAGE + (Z_PK, ZFROM, ZDATECREATED, ZDATEDELETED, ZDURATION, ZISREAD, ZFILETYPE, ZRECORDUUID, ZTRANSCRIPT) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """ + var statement: OpaquePointer? + XCTAssertEqual(sqlite3_prepare_v2(connection, sql, -1, &statement, nil), SQLITE_OK) + defer { sqlite3_finalize(statement) } + + sqlite3_bind_int64(statement, 1, Int64(row.id)) + bindText(row.sender, to: statement, at: 2) + sqlite3_bind_double(statement, 3, row.created) + bindDouble(row.deleted, to: statement, at: 4) + sqlite3_bind_double(statement, 5, row.duration) + sqlite3_bind_int(statement, 6, row.isRead ? 1 : 0) + bindText(row.fileType, to: statement, at: 7) + bindUUID(row.recordUUID, to: statement, at: 8) + bindData(row.transcript, to: statement, at: 9) + + XCTAssertEqual(sqlite3_step(statement), SQLITE_DONE) + } + + private func bindText(_ text: String?, to statement: OpaquePointer?, at index: Int32) { + guard let text else { + sqlite3_bind_null(statement, index) + return + } + sqlite3_bind_text(statement, index, text, -1, transientDestructor) + } + + private func bindDouble(_ value: Double?, to statement: OpaquePointer?, at index: Int32) { + guard let value else { + sqlite3_bind_null(statement, index) + return + } + sqlite3_bind_double(statement, index, value) + } + + private func bindUUID(_ uuid: UUID?, to statement: OpaquePointer?, at index: Int32) { + guard let uuid else { + sqlite3_bind_null(statement, index) + return + } + withUnsafeBytes(of: uuid.uuid) { bytes in + _ = sqlite3_bind_blob( + statement, + index, + bytes.baseAddress, + Int32(bytes.count), + transientDestructor + ) + } + } + + private func bindData(_ data: Data?, to statement: OpaquePointer?, at index: Int32) { + guard let data else { + sqlite3_bind_null(statement, index) + return + } + data.withUnsafeBytes { bytes in + _ = sqlite3_bind_blob( + statement, + index, + bytes.baseAddress, + Int32(bytes.count), + transientDestructor + ) + } + } + + /// Writes a fake audio file for the row into the store's `Assets` tree. + private func writeAudio(for row: Row, contents: Data = Data("synthesized audio".utf8)) throws { + let uuid = try XCTUnwrap(row.recordUUID) + let fileType = try XCTUnwrap(row.fileType) + let url = + folder + .appendingPathComponent("Assets/\(uuid.uuidString.prefix(2))/\(uuid.uuidString).\(fileType)") + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try contents.write(to: url) + } + + /// A transcript archive in the shape carrier voicemails use: + /// a dictionary with the flattened text alongside per-segment detail. + private func carrierTranscript() throws -> Data { + let segments: [[String: Any]] = [ + ["text": "Hello", "confidence": 0.9], + ["text": "leave a message", "confidence": 0.8], + ] + let root: [String: Any] = [ + "transcriptionString": "Hello, leave a message", + "confidence": 0.85, + "confidenceRating": 2, + "segments": segments, + ] + return try NSKeyedArchiver.archivedData(withRootObject: root, requiringSecureCoding: false) + } + + /// A transcript archive in the shape FaceTime messages use: + /// a bare array of segments. + private func faceTimeTranscript() throws -> Data { + let segments: [[String: Any]] = [ + ["text": "First sentence", "confidence": 0.7], + ["text": "Second sentence", "confidence": 0.6], + ] + return try NSKeyedArchiver.archivedData(withRootObject: segments, requiringSecureCoding: false) + } + + /// An atom with the standard 32-bit size header. + private func atom(_ type: String, payload: Data) -> Data { + var data = withUnsafeBytes(of: UInt32(payload.count + 8).bigEndian) { Data($0) } + data.append(Data(type.utf8)) + return data + payload + } + + /// An atom with the extended-size header QuickTime uses for 64-bit sizes, + /// as large `mdat` atoms carry. + private func extendedSizeAtom(_ type: String, payload: Data) -> Data { + var data = withUnsafeBytes(of: UInt32(1).bigEndian) { Data($0) } + data.append(Data(type.utf8)) + data.append(withUnsafeBytes(of: UInt64(payload.count + 16).bigEndian) { Data($0) }) + return data + payload + } + + /// A synthetic QuickTime movie whose single track declares an audio or a + /// video handler — the structure `QuickTimeMovie` reads. + private func movieFile(hasVideoTrack: Bool) -> Data { + var handler = Data(repeating: 0, count: 8) + handler.append(Data((hasVideoTrack ? "vide" : "soun").utf8)) + handler.append(Data(repeating: 0, count: 12)) + + let media = atom("mdia", payload: atom("hdlr", payload: handler)) + let track = atom("trak", payload: media) + var file = atom("ftyp", payload: Data("qt ".utf8)) + file.append(atom("moov", payload: track)) + return file + } + + // MARK: - Fetching + + func testFetchMapsRowsNewestFirst() throws { + try makeStore([ + Row(id: 1, sender: "+15550101", created: firstDate, fileType: "amr"), + Row( + id: 2, + sender: "facetime@example.com", + created: thirdDate, + duration: 12.5, + isRead: true, + fileType: "m4a" + ), + Row(id: 3, sender: "+15550102", created: secondDate, fileType: "MOV"), + ]) + + let voicemails = try store.fetch(VoicemailMessage.FetchRequest(limit: 30)) + + XCTAssertEqual(voicemails.map(\.id), [2, 3, 1]) + XCTAssertEqual(voicemails[0].sender, "facetime@example.com") + XCTAssertEqual(voicemails[0].date, Date(timeIntervalSinceReferenceDate: thirdDate)) + XCTAssertEqual(voicemails[0].duration, 12.5) + XCTAssertEqual(voicemails[0].isRead, true) + XCTAssertEqual(voicemails[0].kind, .faceTimeAudio) + XCTAssertEqual(voicemails[0].hasTranscript, false) + XCTAssertEqual(voicemails[1].kind, .faceTimeVideo) + XCTAssertEqual(voicemails[2].kind, .carrierVoicemail) + } + + func testFetchFiltersBySenderTypeAndDate() throws { + try makeStore([ + Row(id: 1, sender: "+15550101", created: firstDate, fileType: "amr"), + Row(id: 2, sender: "+15550101", created: secondDate, fileType: "amr"), + Row(id: 3, sender: "facetime@example.com", created: thirdDate, fileType: "m4a"), + ]) + + var request = VoicemailMessage.FetchRequest(limit: 30) + request.sender = "5550101" + XCTAssertEqual(try store.fetch(request).map(\.id), [2, 1]) + + request = VoicemailMessage.FetchRequest(limit: 30) + request.kind = .faceTimeAudio + XCTAssertEqual(try store.fetch(request).map(\.id), [3]) + + request = VoicemailMessage.FetchRequest(limit: 30) + request.startDate = Date(timeIntervalSinceReferenceDate: firstDate) + request.endDate = Date(timeIntervalSinceReferenceDate: thirdDate) + XCTAssertEqual(try store.fetch(request).map(\.id), [2, 1]) + + request = VoicemailMessage.FetchRequest(limit: 2) + XCTAssertEqual(try store.fetch(request).map(\.id), [3, 2]) + } + + func testFetchExcludesDeletedMessages() throws { + try makeStore([ + Row(id: 1, sender: "+15550101", created: firstDate, fileType: "amr"), + Row(id: 2, sender: "+15550102", created: secondDate, fileType: "amr", deleted: secondDate), + ]) + + let voicemails = try store.fetch(VoicemailMessage.FetchRequest(limit: 30)) + + XCTAssertEqual(voicemails.map(\.id), [1]) + } + + func testFetchSenderFilterMatchesLiteralWildcards() throws { + try makeStore([ + Row(id: 1, sender: "a_b@example.com", created: firstDate, fileType: "amr"), + Row(id: 2, sender: "axb@example.com", created: secondDate, fileType: "amr"), + Row(id: 3, sender: "100%.example.com", created: thirdDate, fileType: "amr"), + ]) + + var request = VoicemailMessage.FetchRequest(limit: 30) + request.sender = "_" + XCTAssertEqual(try store.fetch(request).map(\.id), [1]) + + request.sender = "%" + XCTAssertEqual(try store.fetch(request).map(\.id), [3]) + } + + // MARK: - Messages and Transcripts + + func testMessageReturnsRowByID() throws { + try makeStore([ + Row(id: 1, sender: "+15550101", created: firstDate, fileType: "amr", recordUUID: testUUID(1)) + ]) + + let message = try XCTUnwrap(store.message(id: 1)) + XCTAssertEqual(message.sender, "+15550101") + XCTAssertEqual(message.kind, .carrierVoicemail) + XCTAssertNil(try store.message(id: 2)) + } + + func testMessageRoundTrips64BitIdentifiers() throws { + try makeStore([ + Row(id: 5_000_000_000, sender: "+15550101", created: firstDate, fileType: "amr") + ]) + + XCTAssertEqual( + try store.fetch(VoicemailMessage.FetchRequest(limit: 30)).map(\.id), + [5_000_000_000] + ) + + let message = try XCTUnwrap(store.message(id: 5_000_000_000)) + XCTAssertEqual(message.id, 5_000_000_000) + } + + func testTranscriptDecodesCarrierShape() throws { + try makeStore([ + Row(id: 1, sender: "+15550101", created: firstDate, fileType: "amr", transcript: carrierTranscript()) + ]) + + let transcript = try XCTUnwrap(store.transcript(id: 1)) + XCTAssertEqual(transcript.text, "Hello, leave a message") + XCTAssertEqual(transcript.confidence, 0.85) + XCTAssertEqual(transcript.segments.count, 2) + XCTAssertEqual(transcript.segments[0].text, "Hello") + XCTAssertEqual(transcript.segments[0].confidence, 0.9) + XCTAssertEqual(transcript.segments[1].text, "leave a message") + } + + func testTranscriptDecodesFaceTimeShape() throws { + try makeStore([ + Row( + id: 1, + sender: "facetime@example.com", + created: firstDate, + fileType: "m4a", + transcript: faceTimeTranscript() + ) + ]) + + let transcript = try XCTUnwrap(store.transcript(id: 1)) + XCTAssertEqual(transcript.text, "First sentence Second sentence") + XCTAssertNil(transcript.confidence) + XCTAssertEqual(transcript.segments.count, 2) + XCTAssertEqual(transcript.segments[0].text, "First sentence") + XCTAssertEqual(transcript.segments[1].confidence, 0.6) + } + + func testTranscriptIsNilWithoutBlob() throws { + try makeStore([ + Row(id: 1, sender: "+15550101", created: firstDate, fileType: "amr") + ]) + + XCTAssertNil(try store.transcript(id: 1)) + } + + // MARK: - Audio + + func testAudioResolvesByRecordUUID() throws { + let row = Row(id: 1, sender: "+15550101", created: firstDate, fileType: "amr", recordUUID: testUUID(1)) + try makeStore([row]) + try writeAudio(for: row, contents: Data("carrier audio".utf8)) + + let message = try XCTUnwrap(store.message(id: 1)) + XCTAssertEqual(message.hasAudio, true) + XCTAssertEqual(store.audioMimeType(for: message), "audio/amr") + let data = try XCTUnwrap(try store.audioData(for: message)) + XCTAssertEqual(String(data: data, encoding: .utf8), "carrier audio") + } + + func testAudioHandlesOtherTypesAndMissingFiles() throws { + let m4aRow = Row( + id: 1, + sender: "facetime@example.com", + created: firstDate, + fileType: "m4a", + recordUUID: testUUID(1) + ) + let movRow = Row(id: 2, sender: "+15550102", created: secondDate, fileType: "MOV", recordUUID: testUUID(2)) + let missingRow = Row(id: 3, sender: "+15550103", created: thirdDate, fileType: "amr", recordUUID: testUUID(3)) + try makeStore([m4aRow, movRow, missingRow]) + try writeAudio(for: m4aRow) + try writeAudio(for: movRow) + + let m4aMessage = try XCTUnwrap(store.message(id: 1)) + XCTAssertEqual(store.audioMimeType(for: m4aMessage), "audio/mp4") + XCTAssertEqual(m4aMessage.hasAudio, true) + + let movMessage = try XCTUnwrap(store.message(id: 2)) + XCTAssertEqual(movMessage.hasAudio, false) + XCTAssertNil(store.audioMimeType(for: movMessage)) + XCTAssertNil(try store.audioData(for: movMessage)) + + let missingMessage = try XCTUnwrap(store.message(id: 3)) + XCTAssertEqual(missingMessage.hasAudio, false) + XCTAssertNil(try store.audioData(for: missingMessage)) + } + + func testFetchClassifiesMoviesByAsset() throws { + let carrier = Row(id: 1, sender: "+15550101", created: firstDate, fileType: "amr", recordUUID: testUUID(1)) + let audioMovie = Row(id: 2, sender: "+15550102", created: secondDate, fileType: "MOV", recordUUID: testUUID(2)) + let videoMovie = Row(id: 3, sender: "+15550103", created: thirdDate, fileType: "MOV", recordUUID: testUUID(3)) + let missingMovie = Row( + id: 4, + sender: "+15550104", + created: fourthDate, + fileType: "MOV", + recordUUID: testUUID(4) + ) + try makeStore([carrier, audioMovie, videoMovie, missingMovie]) + try writeAudio(for: carrier, contents: Data("carrier audio".utf8)) + try writeAudio(for: audioMovie, contents: movieFile(hasVideoTrack: false)) + try writeAudio(for: videoMovie, contents: movieFile(hasVideoTrack: true)) + + let voicemails = try store.fetch(VoicemailMessage.FetchRequest(limit: 30)) + + XCTAssertEqual( + voicemails.map(\.kind), + [.faceTimeVideo, .faceTimeVideo, .carrierVoicemail, .carrierVoicemail] + ) + XCTAssertEqual(voicemails.map(\.hasAudio), [false, false, true, true]) + } + + func testKindFilterResolvesMoviesByAssetAndAppliesLimitAfter() throws { + let carrier = Row(id: 1, sender: "+15550101", created: firstDate, fileType: "amr", recordUUID: testUUID(1)) + let audioMovie = Row(id: 2, sender: "+15550102", created: secondDate, fileType: "MOV", recordUUID: testUUID(2)) + let videoMovie = Row(id: 3, sender: "+15550103", created: thirdDate, fileType: "MOV", recordUUID: testUUID(3)) + try makeStore([carrier, audioMovie, videoMovie]) + try writeAudio(for: audioMovie, contents: movieFile(hasVideoTrack: false)) + try writeAudio(for: videoMovie, contents: movieFile(hasVideoTrack: true)) + + var request = VoicemailMessage.FetchRequest(limit: 30) + request.kind = .carrierVoicemail + XCTAssertEqual(try store.fetch(request).map(\.id), [2, 1]) + + request.kind = .faceTimeVideo + XCTAssertEqual(try store.fetch(request).map(\.id), [3]) + + request.kind = .carrierVoicemail + request.limit = 1 + XCTAssertEqual(try store.fetch(request).map(\.id), [2]) + } + + func testAudioOnlyMovieIsPlayableCarrierAudio() throws { + let row = Row(id: 1, sender: "+15550101", created: firstDate, fileType: "MOV", recordUUID: testUUID(1)) + try makeStore([row]) + try writeAudio(for: row, contents: movieFile(hasVideoTrack: false)) + + let message = try XCTUnwrap(store.message(id: 1)) + XCTAssertEqual(message.kind, .carrierVoicemail) + XCTAssertEqual(message.hasAudio, true) + XCTAssertEqual(store.audioMimeType(for: message), "audio/mp4") + XCTAssertEqual(try XCTUnwrap(try store.audioData(for: message)), movieFile(hasVideoTrack: false)) + } + + func testQuickTimeMovieProbeDistinguishesAudioOnlyMovies() { + XCTAssertTrue(QuickTimeMovie.isAudioOnlyMovie(movieFile(hasVideoTrack: false))) + XCTAssertFalse(QuickTimeMovie.isAudioOnlyMovie(movieFile(hasVideoTrack: true))) + XCTAssertFalse(QuickTimeMovie.isAudioOnlyMovie(Data("not a movie".utf8))) + XCTAssertFalse(QuickTimeMovie.isAudioOnlyMovie(Data())) + } + + func testQuickTimeMovieSkipsExtendedSizeAtoms() { + var audioFile = atom("ftyp", payload: Data("qt ".utf8)) + audioFile.append(extendedSizeAtom("mdat", payload: Data(repeating: 0, count: 65_536))) + audioFile.append(movieFile(hasVideoTrack: false)) + XCTAssertTrue(QuickTimeMovie.isAudioOnlyMovie(audioFile)) + + var videoFile = atom("ftyp", payload: Data("qt ".utf8)) + videoFile.append(extendedSizeAtom("mdat", payload: Data(repeating: 0, count: 65_536))) + videoFile.append(movieFile(hasVideoTrack: true)) + XCTAssertFalse(QuickTimeMovie.isAudioOnlyMovie(videoFile)) + } + + func testQuickTimeMovieEntersExtendedSizeContainers() { + var handler = Data(repeating: 0, count: 8) + handler.append(Data("soun".utf8)) + handler.append(Data(repeating: 0, count: 12)) + let track = atom("trak", payload: atom("mdia", payload: atom("hdlr", payload: handler))) + + var file = atom("ftyp", payload: Data("qt ".utf8)) + file.append(extendedSizeAtom("moov", payload: track)) + + XCTAssertTrue(QuickTimeMovie.isAudioOnlyMovie(file)) + } + + func testQuickTimeMovieRejectsOverflowingExtendedSizes() { + var malformed = withUnsafeBytes(of: UInt32(1).bigEndian) { Data($0) } + malformed.append(Data("mdat".utf8)) + malformed.append(withUnsafeBytes(of: UInt64(Int.max).bigEndian) { Data($0) }) + + var file = atom("ftyp", payload: Data("qt ".utf8)) + file.append(malformed) + + XCTAssertFalse(QuickTimeMovie.isAudioOnlyMovie(file)) + } + + // MARK: - Helpers + + /// A fixed UUID synthesized for tests, distinct per index. + private func testUUID(_ index: Int) -> UUID { + let pattern = "00000000-0000-0000-0000-00000000000X" + let string = pattern.replacingOccurrences(of: "X", with: String(index)) + return UUID(uuidString: string)! + } +} + +/// The copy-on-bind destructor for `sqlite3_bind_` calls. +private let transientDestructor = unsafeBitCast(-1, to: sqlite3_destructor_type.self) diff --git a/README.md b/README.md index 8bca8320..c808a541 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,13 @@ and a [growing list of clients][mcp-clients] that support the Phone View call history synced from your iPhone, filter calls by participant, date, or call type, and start calls with system confirmation. + + + + + Voicemail + Fetch voicemails synced from your iPhone, with transcripts and audio, filtering by sender, date, or message type. + @@ -322,6 +329,15 @@ Recent calls sit in its write-ahead log next to the store file, so the Phone service asks you to open the `CallHistoryDB` folder rather than the file alone. +### Visual Voicemail Database Access + +Voicemails synced from your iPhone by the Phone app (on macOS 26 and later) +live in a SQLite database at +`~/Library/Group Containers/group.com.apple.FaceTime/com.apple.facetimemessagestored/Data Store/FaceTimeMessageStore-local.sqlitedb`. +The store is a write-ahead log database, and the message audio sits in an `Assets` +tree beside it, so the Voicemail service asks you to open the `Data Store` folder +rather than the file alone. + ### JSON-LD for Tool Results The tools provided by iMCP return results as diff --git a/iMCP.xcodeproj/project.pbxproj b/iMCP.xcodeproj/project.pbxproj index 8bd663bd..e52554dd 100644 --- a/iMCP.xcodeproj/project.pbxproj +++ b/iMCP.xcodeproj/project.pbxproj @@ -15,6 +15,8 @@ B0D1DEADBEEF000000000004 /* BonjourDiscoveryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0D1DEADBEEF000000000003 /* BonjourDiscoveryTests.swift */; }; B0D1DEADBEEF000000000006 /* MCPServiceLoop.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0D1DEADBEEF000000000005 /* MCPServiceLoop.swift */; }; B0D1DEADBEEF000000000008 /* MCPServiceLoopTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0D1DEADBEEF000000000007 /* MCPServiceLoopTests.swift */; }; + B0D1DEADBEEF00000000000E /* VoicemailStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0D1DEADBEEF00000000000D /* VoicemailStore.swift */; }; + B0D1DEADBEEF000000000010 /* VoicemailStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0D1DEADBEEF00000000000F /* VoicemailStoreTests.swift */; }; F809556E2D7888920055B911 /* TypedStream in Frameworks */ = {isa = PBXBuildFile; productRef = F809556D2D7888920055B911 /* TypedStream */; }; F80955702D7888920055B911 /* iMessage in Frameworks */ = {isa = PBXBuildFile; productRef = F809556F2D7888920055B911 /* iMessage */; }; F825BDBF2D9AD00E0063ADD7 /* ServiceLifecycle in Frameworks */ = {isa = PBXBuildFile; productRef = F825BDBE2D9AD00E0063ADD7 /* ServiceLifecycle */; }; @@ -64,6 +66,8 @@ B0D1DEADBEEF000000000003 /* BonjourDiscoveryTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BonjourDiscoveryTests.swift; sourceTree = ""; }; B0D1DEADBEEF000000000005 /* MCPServiceLoop.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MCPServiceLoop.swift; path = ../CLI/MCPServiceLoop.swift; sourceTree = ""; }; B0D1DEADBEEF000000000007 /* MCPServiceLoopTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MCPServiceLoopTests.swift; sourceTree = ""; }; + B0D1DEADBEEF00000000000D /* VoicemailStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = VoicemailStore.swift; path = ../App/Services/VoicemailStore.swift; sourceTree = ""; }; + B0D1DEADBEEF00000000000F /* VoicemailStoreTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VoicemailStoreTests.swift; sourceTree = ""; }; E0E66768348853754D32208C /* Cocoa.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Cocoa.framework; path = Platforms/MacOSX.platform/Developer/SDKs/MacOSX15.0.sdk/System/Library/Frameworks/Cocoa.framework; sourceTree = DEVELOPER_DIR; }; F8F44E6D2D59038D0075D79C /* iMCP.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = iMCP.app; sourceTree = BUILT_PRODUCTS_DIR; }; F8F44EB62D5908D00075D79C /* imcp-server */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = "imcp-server"; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -157,6 +161,8 @@ B0D1DEADBEEF000000000009 /* MCPInitializeCompatibility.swift */, B0D1DEADBEEF00000000000B /* MCPInitializeCompatibilityTests.swift */, B0D1DEADBEEF000000000005 /* MCPServiceLoop.swift */, + B0D1DEADBEEF00000000000D /* VoicemailStore.swift */, + B0D1DEADBEEF00000000000F /* VoicemailStoreTests.swift */, ); path = CLITests; sourceTree = ""; @@ -348,6 +354,8 @@ B0D1DEADBEEF000000000002 /* BonjourDiscovery.swift in Sources */, B0D1DEADBEEF000000000008 /* MCPServiceLoopTests.swift in Sources */, B0D1DEADBEEF000000000006 /* MCPServiceLoop.swift in Sources */, + B0D1DEADBEEF00000000000E /* VoicemailStore.swift in Sources */, + B0D1DEADBEEF000000000010 /* VoicemailStoreTests.swift in Sources */, B0D1DEADBEEF00000000000A /* MCPInitializeCompatibility.swift in Sources */, B0D1DEADBEEF00000000000C /* MCPInitializeCompatibilityTests.swift in Sources */, );