0.10.6
This commit is contained in:
commit
92660a4ba0
228 files changed
+24496
No files matched your search
@@ -0,0 +1,405 @@
|
||||
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 = 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(\.id))
|
||||
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.id] ?? [])
|
||||
.map {
|
||||
PhoneWatchSnapshot.Message(
|
||||
id: $0.id,
|
||||
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.id < message.id)
|
||||
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) {
|
||||
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? {
|
||||
"Файл голосового сообщения пуст."
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user