256 lines
8.9 KiB
Swift
256 lines
8.9 KiB
Swift
import Foundation
|
|
import WatchConnectivity
|
|
|
|
struct WatchSnapshot: Codable {
|
|
struct Chat: Codable, Identifiable, Hashable {
|
|
var id: String { jid }
|
|
let jid: String
|
|
let name: String
|
|
let unread: Int
|
|
let messages: [Message]
|
|
}
|
|
|
|
struct Message: Codable, Identifiable, Hashable {
|
|
let id: String
|
|
let body: String
|
|
let timestamp: Date
|
|
let outgoing: Bool
|
|
let encrypted: Bool
|
|
}
|
|
|
|
let chats: [Chat]
|
|
}
|
|
|
|
@MainActor
|
|
final class WatchSessionModel: NSObject, ObservableObject {
|
|
@Published private(set) var chats: [WatchSnapshot.Chat] = []
|
|
@Published private(set) var phoneReachable = false
|
|
@Published private(set) var voiceStatusByJID: [String: String] = [:]
|
|
@Published var errorMessage: String?
|
|
|
|
private let session: WCSession?
|
|
private let cacheKey = "luma.watch.snapshot.v1"
|
|
private let voiceTransferCacheKey = "luma.watch.voice-transfers.v1"
|
|
private var outgoingVoiceTransfers: [String: OutgoingVoiceTransfer] = [:]
|
|
|
|
override init() {
|
|
session = WCSession.isSupported() ? .default : nil
|
|
super.init()
|
|
loadCachedSnapshot()
|
|
loadOutgoingVoiceTransfers()
|
|
session?.delegate = self
|
|
session?.activate()
|
|
}
|
|
|
|
func send(text rawText: String, to jid: String) {
|
|
let text = rawText.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !text.isEmpty, let session else { return }
|
|
let payload: [String: Any] = [
|
|
"action": "reply",
|
|
"jid": jid,
|
|
"text": text,
|
|
]
|
|
|
|
if session.isReachable {
|
|
session.sendMessage(payload, replyHandler: nil) { [weak self] error in
|
|
Task { @MainActor in self?.errorMessage = error.localizedDescription }
|
|
}
|
|
} else {
|
|
session.transferUserInfo(payload)
|
|
}
|
|
}
|
|
|
|
@discardableResult
|
|
func sendVoice(fileURL: URL, duration: TimeInterval, to jid: String) -> Bool {
|
|
guard let session else {
|
|
errorMessage = "WatchConnectivity недоступен."
|
|
return false
|
|
}
|
|
guard session.activationState == .activated else {
|
|
errorMessage = "Соединение с iPhone ещё не готово. Повторите через несколько секунд."
|
|
return false
|
|
}
|
|
#if os(watchOS)
|
|
guard session.isCompanionAppInstalled else {
|
|
errorMessage = "Установите Luma на связанный iPhone."
|
|
return false
|
|
}
|
|
#endif
|
|
guard duration.isFinite, duration >= 0.4,
|
|
FileManager.default.fileExists(atPath: fileURL.path)
|
|
else {
|
|
errorMessage = "Запись голосового сообщения недоступна."
|
|
return false
|
|
}
|
|
|
|
let transferID = UUID().uuidString
|
|
let normalizedJID = jid.lowercased()
|
|
let metadata: [String: Any] = [
|
|
"action": "voice",
|
|
"transferID": transferID,
|
|
"jid": normalizedJID,
|
|
"filename": fileURL.lastPathComponent,
|
|
"duration": duration,
|
|
]
|
|
outgoingVoiceTransfers[transferID] = OutgoingVoiceTransfer(
|
|
jid: normalizedJID,
|
|
fileURL: fileURL,
|
|
createdAt: Date()
|
|
)
|
|
persistOutgoingVoiceTransfers()
|
|
voiceStatusByJID[normalizedJID] = "Голосовое в очереди на iPhone…"
|
|
session.transferFile(fileURL, metadata: metadata)
|
|
return true
|
|
}
|
|
|
|
func voiceStatus(for jid: String) -> String? {
|
|
voiceStatusByJID[jid.lowercased()]
|
|
}
|
|
|
|
private func apply(data: Data) {
|
|
let decoder = JSONDecoder()
|
|
decoder.dateDecodingStrategy = .millisecondsSince1970
|
|
guard let snapshot = try? decoder.decode(WatchSnapshot.self, from: data) else { return }
|
|
chats = snapshot.chats
|
|
UserDefaults.standard.set(data, forKey: cacheKey)
|
|
}
|
|
|
|
private func loadCachedSnapshot() {
|
|
guard let data = UserDefaults.standard.data(forKey: cacheKey) else { return }
|
|
apply(data: data)
|
|
}
|
|
|
|
private func consume(_ payload: [String: Any]) {
|
|
guard payload["action"] as? String == "voiceResult",
|
|
let transferID = payload["transferID"] as? String,
|
|
let success = payload["success"] as? Bool
|
|
else { return }
|
|
|
|
let transfer = outgoingVoiceTransfers.removeValue(forKey: transferID)
|
|
let jid = transfer?.jid ?? (payload["jid"] as? String)?.lowercased()
|
|
guard let jid else { return }
|
|
|
|
if let transfer {
|
|
try? FileManager.default.removeItem(at: transfer.fileURL)
|
|
}
|
|
persistOutgoingVoiceTransfers()
|
|
if success {
|
|
voiceStatusByJID[jid] = "Голосовое отправлено"
|
|
} else {
|
|
voiceStatusByJID[jid] = "Голосовое не отправлено"
|
|
errorMessage =
|
|
(payload["error"] as? String) ?? "iPhone не смог отправить голосовое сообщение."
|
|
}
|
|
}
|
|
|
|
private func finishVoiceTransfer(
|
|
transferID: String?,
|
|
fallbackURL: URL,
|
|
errorDescription: String?
|
|
) {
|
|
guard let transferID,
|
|
let transfer = outgoingVoiceTransfers[transferID]
|
|
else {
|
|
try? FileManager.default.removeItem(at: fallbackURL)
|
|
return
|
|
}
|
|
|
|
// Once WatchConnectivity has copied the file to the iPhone the source
|
|
// recording is no longer needed. Keep only the transfer metadata while
|
|
// waiting for the actual XMPP upload result from the companion app.
|
|
try? FileManager.default.removeItem(at: transfer.fileURL)
|
|
if let errorDescription {
|
|
outgoingVoiceTransfers.removeValue(forKey: transferID)
|
|
persistOutgoingVoiceTransfers()
|
|
voiceStatusByJID[transfer.jid] = "Не удалось передать запись"
|
|
errorMessage = errorDescription
|
|
} else {
|
|
voiceStatusByJID[transfer.jid] = "iPhone получил запись. Отправляем…"
|
|
}
|
|
}
|
|
|
|
private func loadOutgoingVoiceTransfers() {
|
|
guard let data = UserDefaults.standard.data(forKey: voiceTransferCacheKey),
|
|
let stored = try? JSONDecoder().decode(
|
|
[String: OutgoingVoiceTransfer].self,
|
|
from: data
|
|
)
|
|
else { return }
|
|
let expiration = Date().addingTimeInterval(-7 * 24 * 60 * 60)
|
|
outgoingVoiceTransfers = stored.filter { $0.value.createdAt >= expiration }
|
|
for transfer in outgoingVoiceTransfers.values {
|
|
voiceStatusByJID[transfer.jid] = "Ожидает подтверждения iPhone…"
|
|
}
|
|
persistOutgoingVoiceTransfers()
|
|
}
|
|
|
|
private func persistOutgoingVoiceTransfers() {
|
|
guard let data = try? JSONEncoder().encode(outgoingVoiceTransfers) else { return }
|
|
UserDefaults.standard.set(data, forKey: voiceTransferCacheKey)
|
|
}
|
|
}
|
|
|
|
private struct OutgoingVoiceTransfer: Codable {
|
|
let jid: String
|
|
let fileURL: URL
|
|
let createdAt: Date
|
|
}
|
|
|
|
extension WatchSessionModel: WCSessionDelegate {
|
|
nonisolated func session(
|
|
_ session: WCSession,
|
|
activationDidCompleteWith activationState: WCSessionActivationState,
|
|
error: Error?
|
|
) {
|
|
Task { @MainActor in
|
|
phoneReachable = session.isReachable
|
|
if let error { errorMessage = error.localizedDescription }
|
|
if let data = session.receivedApplicationContext["snapshot"] as? Data {
|
|
apply(data: data)
|
|
}
|
|
}
|
|
}
|
|
|
|
// nonisolated func sessionDidDeactivate(_ session: WCSession) {
|
|
// Task { @MainActor in
|
|
// phoneReachable = false
|
|
// }
|
|
// }
|
|
|
|
nonisolated func sessionReachabilityDidChange(_ session: WCSession) {
|
|
Task { @MainActor in phoneReachable = session.isReachable }
|
|
}
|
|
|
|
nonisolated func session(
|
|
_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]
|
|
) {
|
|
guard let data = applicationContext["snapshot"] as? Data else { return }
|
|
Task { @MainActor in apply(data: data) }
|
|
}
|
|
|
|
nonisolated func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
|
|
Task { @MainActor in consume(message) }
|
|
}
|
|
|
|
nonisolated func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any]) {
|
|
Task { @MainActor in consume(userInfo) }
|
|
}
|
|
|
|
nonisolated func session(
|
|
_ session: WCSession,
|
|
didFinish fileTransfer: WCSessionFileTransfer,
|
|
error: Error?
|
|
) {
|
|
let transferID = fileTransfer.file.metadata?["transferID"] as? String
|
|
let fileURL = fileTransfer.file.fileURL
|
|
let errorDescription = error?.localizedDescription
|
|
Task { @MainActor in
|
|
finishVoiceTransfer(
|
|
transferID: transferID,
|
|
fallbackURL: fileURL,
|
|
errorDescription: errorDescription
|
|
)
|
|
}
|
|
}
|
|
}
|