Models: - Convert Conversation and ChatMessage to SwiftData @Model classes with a one-to-many relationship and cascade delete. - Rename ChatMessage.id to clientID and drop Conversation.id in favor of jid (the string id would clash with PersistentIdentifier). - Keep MessageReaction as a Codable value stored in an array attribute. Persistence: - Add ArchiveStore, a per-account ModelContainer/ModelContext that replaces the JSON ChatArchive with load/save/erase. - Add ArchiveMetadataRecord for the durable MAM checkpoints, cursor, locally deleted message IDs and roster contact JIDs. - Add LegacyArchiveImporter to import the old JSON snapshot once and delete the file afterwards, preserving existing history. AppModel: - Replace ChatArchive with ArchiveStore and persist via context.save(); insert new models and delete the replaced object on upsert merges so SwiftData never keeps an orphaned duplicate. Tests: - Replace ChatArchiveTests with ArchiveStoreTests (store round-trip and legacy import) and drop the now-obsolete Codable round-trip assertions.
410 lines
15 KiB
Swift
410 lines
15 KiB
Swift
import Foundation
|
|
|
|
struct PhoneWatchSnapshot: Codable {
|
|
struct Chat: Codable {
|
|
let jid: String
|
|
let name: String
|
|
let unread: Int
|
|
let messages: [Message]
|
|
}
|
|
|
|
struct Message: Codable, Identifiable {
|
|
let id: String
|
|
let body: String
|
|
let timestamp: Date
|
|
let outgoing: Bool
|
|
let encrypted: Bool
|
|
}
|
|
|
|
let chats: [Chat]
|
|
}
|
|
|
|
struct WatchVoiceMessage: Sendable {
|
|
let transferID: String
|
|
let jid: String
|
|
let filename: String
|
|
let duration: TimeInterval?
|
|
let data: Data
|
|
|
|
var stableMessageID: String {
|
|
"watch-\(transferID)"
|
|
}
|
|
}
|
|
|
|
#if os(iOS) && canImport(WatchConnectivity)
|
|
import WatchConnectivity
|
|
|
|
final class PhoneWatchBridge: NSObject, WCSessionDelegate {
|
|
var onReply: ((String, String) -> Void)?
|
|
var onVoiceMessage: ((WatchVoiceMessage) -> Void)? {
|
|
didSet { deliverPendingVoiceMessages() }
|
|
}
|
|
private let session: WCSession?
|
|
private let snapshotQueue = DispatchQueue(
|
|
label: "app.luma.watch-snapshot",
|
|
qos: .utility
|
|
)
|
|
private var latestSnapshot: Data?
|
|
private var pendingVoiceMessages: [WatchVoiceMessage] = []
|
|
private var deliveredVoiceTransferIDs: Set<String> = []
|
|
|
|
override init() {
|
|
session =
|
|
!RuntimeEnvironment.isRunningTests && WCSession.isSupported()
|
|
? .default
|
|
: nil
|
|
super.init()
|
|
pendingVoiceMessages = Self.loadPersistedVoiceMessages()
|
|
session?.delegate = self
|
|
session?.activate()
|
|
}
|
|
|
|
func update(conversations: [Conversation], messages: [ChatMessage]) {
|
|
guard let session else { return }
|
|
// Copy-on-write snapshots are cheap here; the filtering, sorting and
|
|
// JSON encoding are not. Keep that work away from SwiftUI's main run
|
|
// loop, especially immediately after a MAM page is committed.
|
|
snapshotQueue.async { [weak self, weak session] in
|
|
guard let self,
|
|
let session,
|
|
let data = Self.makeSnapshotData(
|
|
conversations: conversations,
|
|
messages: messages
|
|
),
|
|
data != self.latestSnapshot else { return }
|
|
self.latestSnapshot = data
|
|
self.publishLatestSnapshot(using: session)
|
|
}
|
|
}
|
|
|
|
private static func makeSnapshotData(
|
|
conversations: [Conversation],
|
|
messages: [ChatMessage]
|
|
) -> Data? {
|
|
let visibleConversations = Array(conversations.prefix(20))
|
|
let visibleConversationIDs = Set(visibleConversations.map(\.jid))
|
|
var recentMessagesByConversation: [String: [ChatMessage]] = [:]
|
|
|
|
// Keep a bounded, chronologically sorted window. MAM can append an old
|
|
// overlapping stanza after a newer live message, so array order alone
|
|
// cannot identify the latest messages reliably.
|
|
for message in messages where visibleConversationIDs.contains(message.conversationID) {
|
|
var recent = recentMessagesByConversation[message.conversationID] ?? []
|
|
let insertionIndex = Self.insertionIndex(for: message, in: recent)
|
|
recent.insert(message, at: insertionIndex)
|
|
if recent.count > 20 {
|
|
recent.removeFirst(recent.count - 20)
|
|
}
|
|
recentMessagesByConversation[message.conversationID] = recent
|
|
}
|
|
|
|
let chats = visibleConversations.map { conversation in
|
|
PhoneWatchSnapshot.Chat(
|
|
jid: conversation.jid,
|
|
name: conversation.displayName,
|
|
unread: conversation.unreadCount,
|
|
messages: (recentMessagesByConversation[conversation.jid] ?? [])
|
|
.map {
|
|
PhoneWatchSnapshot.Message(
|
|
id: $0.clientID,
|
|
body: $0.previewText,
|
|
timestamp: $0.timestamp,
|
|
outgoing: $0.direction == .outgoing,
|
|
encrypted: $0.security == .omemo
|
|
)
|
|
}
|
|
)
|
|
}
|
|
return try? JSONEncoder.watchEncoder.encode(PhoneWatchSnapshot(chats: chats))
|
|
}
|
|
|
|
private static func insertionIndex(
|
|
for message: ChatMessage,
|
|
in sortedMessages: [ChatMessage]
|
|
) -> Int {
|
|
var lowerBound = 0
|
|
var upperBound = sortedMessages.count
|
|
while lowerBound < upperBound {
|
|
let middle = lowerBound + (upperBound - lowerBound) / 2
|
|
let existing = sortedMessages[middle]
|
|
let existingComesFirst = existing.timestamp < message.timestamp
|
|
|| (existing.timestamp == message.timestamp && existing.clientID < message.clientID)
|
|
if existingComesFirst {
|
|
lowerBound = middle + 1
|
|
} else {
|
|
upperBound = middle
|
|
}
|
|
}
|
|
return lowerBound
|
|
}
|
|
|
|
func session(
|
|
_ session: WCSession,
|
|
activationDidCompleteWith activationState: WCSessionActivationState,
|
|
error: Error?
|
|
) {
|
|
guard activationState == .activated else { return }
|
|
snapshotQueue.async { [weak self, weak session] in
|
|
guard let self, let session else { return }
|
|
self.publishLatestSnapshot(using: session)
|
|
}
|
|
}
|
|
|
|
func sessionDidBecomeInactive(_ session: WCSession) {}
|
|
|
|
func sessionDidDeactivate(_ session: WCSession) {
|
|
guard !RuntimeEnvironment.isRunningTests else { return }
|
|
session.activate()
|
|
}
|
|
|
|
func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
|
|
consume(message)
|
|
}
|
|
|
|
func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any]) {
|
|
consume(userInfo)
|
|
}
|
|
|
|
func session(_ session: WCSession, didReceive file: WCSessionFile) {
|
|
guard let metadata = file.metadata,
|
|
metadata["action"] as? String == "voice",
|
|
let transferID = metadata["transferID"] as? String,
|
|
UUID(uuidString: transferID) != nil,
|
|
let jid = metadata["jid"] as? String,
|
|
!jid.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
|
|
|
|
do {
|
|
// WCSession owns this URL only for the duration of the callback.
|
|
// Copy the bytes before handing the recording to the async upload
|
|
// pipeline so the temporary file cannot disappear underneath it.
|
|
let data = try Data(contentsOf: file.fileURL)
|
|
guard !data.isEmpty else { throw WatchVoiceBridgeError.emptyRecording }
|
|
let duration = (metadata["duration"] as? NSNumber)?.doubleValue
|
|
let filename = (metadata["filename"] as? String).flatMap { $0.isEmpty ? nil : $0 }
|
|
?? file.fileURL.lastPathComponent
|
|
let message = WatchVoiceMessage(
|
|
transferID: transferID,
|
|
jid: jid,
|
|
filename: filename,
|
|
duration: duration,
|
|
data: data
|
|
)
|
|
try Self.persistVoiceMessage(message)
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.deliverVoiceMessage(message)
|
|
}
|
|
} catch {
|
|
reportVoiceResult(
|
|
transferID: transferID,
|
|
success: false,
|
|
error: "iPhone не смог прочитать запись: \(error.localizedDescription)",
|
|
jid: jid
|
|
)
|
|
}
|
|
}
|
|
|
|
func reportVoiceResult(
|
|
transferID: String,
|
|
success: Bool,
|
|
error: String? = nil,
|
|
jid explicitJID: String? = nil
|
|
) {
|
|
guard let session else { return }
|
|
let jid = explicitJID ?? Self.persistedVoiceJID(for: transferID)
|
|
Self.removePersistedVoiceMessage(transferID: transferID)
|
|
deliveredVoiceTransferIDs.remove(transferID)
|
|
var payload: [String: Any] = [
|
|
"action": "voiceResult",
|
|
"transferID": transferID,
|
|
"success": success
|
|
]
|
|
if let jid, !jid.isEmpty {
|
|
payload["jid"] = jid
|
|
}
|
|
if let error, !error.isEmpty {
|
|
payload["error"] = error
|
|
}
|
|
|
|
if session.isReachable {
|
|
session.sendMessage(payload, replyHandler: nil) { _ in
|
|
session.transferUserInfo(payload)
|
|
}
|
|
} else {
|
|
session.transferUserInfo(payload)
|
|
}
|
|
}
|
|
|
|
private func consume(_ payload: [String: Any]) {
|
|
guard payload["action"] as? String == "reply",
|
|
let jid = payload["jid"] as? String,
|
|
let text = payload["text"] as? String,
|
|
!text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
|
|
onReply?(jid, text)
|
|
}
|
|
|
|
private func publishLatestSnapshot(using session: WCSession) {
|
|
guard let latestSnapshot else { return }
|
|
try? session.updateApplicationContext(["snapshot": latestSnapshot])
|
|
}
|
|
|
|
private func deliverVoiceMessage(_ message: WatchVoiceMessage) {
|
|
if let onVoiceMessage {
|
|
guard deliveredVoiceTransferIDs.insert(message.transferID).inserted else { return }
|
|
onVoiceMessage(message)
|
|
} else if !pendingVoiceMessages.contains(where: { $0.transferID == message.transferID }) {
|
|
pendingVoiceMessages.append(message)
|
|
}
|
|
}
|
|
|
|
private func deliverPendingVoiceMessages() {
|
|
guard let onVoiceMessage, !pendingVoiceMessages.isEmpty else { return }
|
|
let pending = pendingVoiceMessages
|
|
pendingVoiceMessages.removeAll()
|
|
for message in pending where deliveredVoiceTransferIDs.insert(message.transferID).inserted {
|
|
onVoiceMessage(message)
|
|
}
|
|
}
|
|
|
|
private static func persistVoiceMessage(_ message: WatchVoiceMessage) throws {
|
|
let directory = try watchVoiceOutboxDirectory()
|
|
let audioFilename = "\(message.transferID).m4a"
|
|
let audioURL = directory.appendingPathComponent(audioFilename)
|
|
let metadataURL = directory.appendingPathComponent("\(message.transferID).json")
|
|
let stored = StoredWatchVoiceMessage(
|
|
transferID: message.transferID,
|
|
jid: message.jid,
|
|
filename: message.filename,
|
|
duration: message.duration,
|
|
audioFilename: audioFilename,
|
|
createdAt: Date()
|
|
)
|
|
|
|
do {
|
|
try message.data.write(to: audioURL, options: [.atomic])
|
|
try JSONEncoder().encode(stored).write(to: metadataURL, options: [.atomic])
|
|
try? FileManager.default.setAttributes(
|
|
[.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication],
|
|
ofItemAtPath: audioURL.path
|
|
)
|
|
try? FileManager.default.setAttributes(
|
|
[.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication],
|
|
ofItemAtPath: metadataURL.path
|
|
)
|
|
} catch {
|
|
try? FileManager.default.removeItem(at: audioURL)
|
|
try? FileManager.default.removeItem(at: metadataURL)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
private static func loadPersistedVoiceMessages() -> [WatchVoiceMessage] {
|
|
guard let directory = try? watchVoiceOutboxDirectory() else { return [] }
|
|
let metadataURLs = (try? FileManager.default.contentsOfDirectory(
|
|
at: directory,
|
|
includingPropertiesForKeys: nil
|
|
)) ?? []
|
|
let expiration = Date().addingTimeInterval(-7 * 24 * 60 * 60)
|
|
|
|
return metadataURLs
|
|
.filter { $0.pathExtension.lowercased() == "json" }
|
|
.compactMap { metadataURL in
|
|
guard let metadataData = try? Data(contentsOf: metadataURL),
|
|
let stored = try? JSONDecoder().decode(
|
|
StoredWatchVoiceMessage.self,
|
|
from: metadataData
|
|
),
|
|
stored.createdAt >= expiration else {
|
|
removePersistedVoiceMessage(
|
|
transferID: metadataURL.deletingPathExtension().lastPathComponent
|
|
)
|
|
return nil
|
|
}
|
|
let audioURL = directory.appendingPathComponent(stored.audioFilename)
|
|
guard let data = try? Data(contentsOf: audioURL), !data.isEmpty else {
|
|
removePersistedVoiceMessage(transferID: stored.transferID)
|
|
return nil
|
|
}
|
|
return WatchVoiceMessage(
|
|
transferID: stored.transferID,
|
|
jid: stored.jid,
|
|
filename: stored.filename,
|
|
duration: stored.duration,
|
|
data: data
|
|
)
|
|
}
|
|
.sorted { $0.transferID < $1.transferID }
|
|
}
|
|
|
|
private static func persistedVoiceJID(for transferID: String) -> String? {
|
|
guard let directory = try? watchVoiceOutboxDirectory(),
|
|
let data = try? Data(contentsOf: directory.appendingPathComponent("\(transferID).json")),
|
|
let stored = try? JSONDecoder().decode(StoredWatchVoiceMessage.self, from: data) else {
|
|
return nil
|
|
}
|
|
return stored.jid
|
|
}
|
|
|
|
private static func removePersistedVoiceMessage(transferID: String) {
|
|
guard UUID(uuidString: transferID) != nil,
|
|
let directory = try? watchVoiceOutboxDirectory() else { return }
|
|
let metadataURL = directory.appendingPathComponent("\(transferID).json")
|
|
if let data = try? Data(contentsOf: metadataURL),
|
|
let stored = try? JSONDecoder().decode(StoredWatchVoiceMessage.self, from: data) {
|
|
try? FileManager.default.removeItem(
|
|
at: directory.appendingPathComponent(stored.audioFilename)
|
|
)
|
|
} else {
|
|
try? FileManager.default.removeItem(
|
|
at: directory.appendingPathComponent("\(transferID).m4a")
|
|
)
|
|
}
|
|
try? FileManager.default.removeItem(at: metadataURL)
|
|
}
|
|
|
|
private static func watchVoiceOutboxDirectory() throws -> URL {
|
|
let base = FileManager.default.urls(
|
|
for: .applicationSupportDirectory,
|
|
in: .userDomainMask
|
|
).first ?? FileManager.default.temporaryDirectory
|
|
let directory = base.appendingPathComponent("Luma/WatchVoiceOutbox", isDirectory: true)
|
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
return directory
|
|
}
|
|
}
|
|
|
|
private struct StoredWatchVoiceMessage: Codable {
|
|
let transferID: String
|
|
let jid: String
|
|
let filename: String
|
|
let duration: TimeInterval?
|
|
let audioFilename: String
|
|
let createdAt: Date
|
|
}
|
|
|
|
#else
|
|
|
|
final class PhoneWatchBridge {
|
|
var onReply: ((String, String) -> Void)?
|
|
var onVoiceMessage: ((WatchVoiceMessage) -> Void)?
|
|
func update(conversations: [Conversation], messages: [ChatMessage]) {}
|
|
func reportVoiceResult(transferID: String, success: Bool, error: String? = nil) {}
|
|
}
|
|
|
|
#endif
|
|
|
|
private extension JSONEncoder {
|
|
static var watchEncoder: JSONEncoder {
|
|
let encoder = JSONEncoder()
|
|
encoder.dateEncodingStrategy = .millisecondsSince1970
|
|
return encoder
|
|
}
|
|
}
|
|
|
|
private enum WatchVoiceBridgeError: LocalizedError {
|
|
case emptyRecording
|
|
|
|
var errorDescription: String? {
|
|
"Файл голосового сообщения пуст."
|
|
}
|
|
}
|