|
@@ -0,0 +1,364 @@
|
|
|
|
|
+import Foundation
|
|
|
|
|
+import SwiftData
|
|
|
|
|
+import UniformTypeIdentifiers
|
|
|
|
|
+
|
|
|
|
|
+struct RemoteAsset: Decodable {
|
|
|
|
|
+ let id: String
|
|
|
|
|
+ let clientId: String
|
|
|
|
|
+ let kind: String
|
|
|
|
|
+ let fileName: String
|
|
|
|
|
+ let mimeType: String
|
|
|
|
|
+ let sizeBytes: Int64
|
|
|
|
|
+ let sha256: String
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+struct RemoteEvent: Decodable {
|
|
|
|
|
+ let id: String
|
|
|
|
|
+ let clientId: String?
|
|
|
|
|
+ let relativeTimeMs: Int64
|
|
|
|
|
+ let eventType: String
|
|
|
|
|
+ let textContent: String?
|
|
|
|
|
+ let voiceStartOffsetMs: Int64?
|
|
|
|
|
+ let voiceEndOffsetMs: Int64?
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+struct RemoteSession: Decodable {
|
|
|
|
|
+ let id: String
|
|
|
|
|
+ let clientId: String?
|
|
|
|
|
+ let title: String
|
|
|
|
|
+ let startTime: Date
|
|
|
|
|
+ let endTime: Date?
|
|
|
|
|
+ let durationMs: Int64
|
|
|
|
|
+ let revision: Int64
|
|
|
|
|
+ let deletedAt: Date?
|
|
|
|
|
+ let events: [RemoteEvent]?
|
|
|
|
|
+ let assets: [RemoteAsset]?
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+private struct SessionUpload: Encodable {
|
|
|
|
|
+ let clientId: String
|
|
|
|
|
+ let title: String
|
|
|
|
|
+ let startTime: Date
|
|
|
|
|
+ let endTime: Date?
|
|
|
|
|
+ let durationMs: Int64
|
|
|
|
|
+ let events: [EventUpload]
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+private struct EventUpload: Encodable {
|
|
|
|
|
+ let clientId: String
|
|
|
|
|
+ let relativeTimeMs: Int64
|
|
|
|
|
+ let eventType: String
|
|
|
|
|
+ let textContent: String?
|
|
|
|
|
+ let voiceStartOffsetMs: Int64?
|
|
|
|
|
+ let voiceEndOffsetMs: Int64?
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+private struct SyncCheckpoint: Decodable {
|
|
|
|
|
+ let syncedAt: Date
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+final class RemoteNetworkService: NetworkServiceProtocol {
|
|
|
|
|
+ private let client: APIClient
|
|
|
|
|
+
|
|
|
|
|
+ init(client: APIClient = .shared) {
|
|
|
|
|
+ self.client = client
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ func fetchSessions() async throws -> [RemoteSession] {
|
|
|
|
|
+ try await client.request("sessions?includeDeleted=true", authenticated: true)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ func syncSession(_ session: CelestiaSession) async throws -> RemoteSession {
|
|
|
|
|
+ let payload = SessionUpload(
|
|
|
|
|
+ clientId: session.id.uuidString,
|
|
|
|
|
+ title: session.title,
|
|
|
|
|
+ startTime: session.startTime,
|
|
|
|
|
+ endTime: session.endTime,
|
|
|
|
|
+ durationMs: session.durationMs,
|
|
|
|
|
+ events: session.events.map {
|
|
|
|
|
+ EventUpload(
|
|
|
|
|
+ clientId: $0.id.uuidString,
|
|
|
|
|
+ relativeTimeMs: $0.relativeTimeMs,
|
|
|
|
|
+ eventType: $0.eventType,
|
|
|
|
|
+ textContent: $0.textContent,
|
|
|
|
|
+ voiceStartOffsetMs: $0.voiceStartOffsetMs,
|
|
|
|
|
+ voiceEndOffsetMs: $0.voiceEndOffsetMs
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+ )
|
|
|
|
|
+ let encoder = JSONEncoder()
|
|
|
|
|
+ encoder.dateEncodingStrategy = .iso8601
|
|
|
|
|
+ return try await client.request("sessions", method: .post, body: try encoder.encode(payload), authenticated: true)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ func uploadAsset(sessionID: String, clientID: String, kind: String, fileURL: URL) async throws -> RemoteAsset {
|
|
|
|
|
+ let mimeType = UTType(filenameExtension: fileURL.pathExtension)?.preferredMIMEType ?? "application/octet-stream"
|
|
|
|
|
+ return try await client.upload(
|
|
|
|
|
+ "sessions/\(sessionID)/assets",
|
|
|
|
|
+ fileURL: fileURL,
|
|
|
|
|
+ fileName: fileURL.lastPathComponent,
|
|
|
|
|
+ mimeType: mimeType,
|
|
|
|
|
+ fields: ["clientId": clientID, "kind": kind]
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ func downloadAsset(sessionID: String, asset: RemoteAsset, destinationURL: URL) async throws {
|
|
|
|
|
+ try await client.download("sessions/\(sessionID)/assets/\(asset.id)", to: destinationURL)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ func recordSyncCheckpoint() async throws {
|
|
|
|
|
+ let _: SyncCheckpoint = try await client.request("sync/trigger", method: .post, authenticated: true)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+struct RemoteBoundDevice: Decodable {
|
|
|
|
|
+ let id: String
|
|
|
|
|
+ let name: String
|
|
|
|
|
+ let peripheralUUID: String
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+private struct BindDeviceBody: Encodable {
|
|
|
|
|
+ let name: String
|
|
|
|
|
+ let peripheralUUID: String
|
|
|
|
|
+ let hardwareMAC: String?
|
|
|
|
|
+ let firmwareVersion: String?
|
|
|
|
|
+ let batteryLevel: Int?
|
|
|
|
|
+ let freeStorageMB: Int?
|
|
|
|
|
+ let totalStorageMB: Int?
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+private struct UpdateDeviceBody: Encodable {
|
|
|
|
|
+ let name: String?
|
|
|
|
|
+ let batteryLevel: Int?
|
|
|
|
|
+ let isConnected: Bool?
|
|
|
|
|
+ let firmwareVersion: String?
|
|
|
|
|
+ let freeStorageMB: Int?
|
|
|
|
|
+ let totalStorageMB: Int?
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+final class DeviceCloudService {
|
|
|
|
|
+ static let shared = DeviceCloudService()
|
|
|
|
|
+ private let client: APIClient
|
|
|
|
|
+
|
|
|
|
|
+ init(client: APIClient = .shared) {
|
|
|
|
|
+ self.client = client
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ func register(_ device: BoundDevice) async throws -> RemoteBoundDevice {
|
|
|
|
|
+ let body = BindDeviceBody(
|
|
|
|
|
+ name: device.name,
|
|
|
|
|
+ peripheralUUID: device.peripheralUUID,
|
|
|
|
|
+ hardwareMAC: device.hardwareMAC,
|
|
|
|
|
+ firmwareVersion: device.firmwareVersion,
|
|
|
|
|
+ batteryLevel: device.batteryLevel,
|
|
|
|
|
+ freeStorageMB: device.freeStorageMB,
|
|
|
|
|
+ totalStorageMB: device.totalStorageMB
|
|
|
|
|
+ )
|
|
|
|
|
+ return try await client.request("devices", method: .post, body: try JSONEncoder().encode(body), authenticated: true)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ func update(_ device: BoundDevice) async throws -> RemoteBoundDevice {
|
|
|
|
|
+ guard let cloudID = device.cloudID else { return try await register(device) }
|
|
|
|
|
+ let body = UpdateDeviceBody(
|
|
|
|
|
+ name: device.name,
|
|
|
|
|
+ batteryLevel: device.batteryLevel,
|
|
|
|
|
+ isConnected: device.isConnected,
|
|
|
|
|
+ firmwareVersion: device.firmwareVersion,
|
|
|
|
|
+ freeStorageMB: device.freeStorageMB,
|
|
|
|
|
+ totalStorageMB: device.totalStorageMB
|
|
|
|
|
+ )
|
|
|
|
|
+ return try await client.request("devices/\(cloudID)", method: .put, body: try JSONEncoder().encode(body), authenticated: true)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ func remove(cloudID: String) async throws {
|
|
|
|
|
+ try await client.requestVoid("devices/\(cloudID)", method: .delete, authenticated: true)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+@MainActor
|
|
|
|
|
+final class SyncManager: ObservableObject {
|
|
|
|
|
+ static let shared = SyncManager()
|
|
|
|
|
+
|
|
|
|
|
+ @Published private(set) var isSyncing = false
|
|
|
|
|
+ @Published private(set) var lastSyncDate: Date?
|
|
|
|
|
+ @Published private(set) var lastErrorMessage: String?
|
|
|
|
|
+ @Published private(set) var completedCount = 0
|
|
|
|
|
+ @Published private(set) var totalCount = 0
|
|
|
|
|
+
|
|
|
|
|
+ private let service: NetworkServiceProtocol
|
|
|
|
|
+
|
|
|
|
|
+ init(service: NetworkServiceProtocol = RemoteNetworkService()) {
|
|
|
|
|
+ self.service = service
|
|
|
|
|
+ lastSyncDate = UserDefaults.standard.object(forKey: "com.celestia.trace.last_server_sync") as? Date
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ func sync(
|
|
|
|
|
+ sessions: [CelestiaSession],
|
|
|
|
|
+ modelContext: ModelContext,
|
|
|
|
|
+ userID: String
|
|
|
|
|
+ ) async -> Bool {
|
|
|
|
|
+ guard !isSyncing else { return false }
|
|
|
|
|
+ isSyncing = true
|
|
|
|
|
+ lastErrorMessage = nil
|
|
|
|
|
+ completedCount = 0
|
|
|
|
|
+ totalCount = sessions.filter {
|
|
|
|
|
+ ($0.ownerUserID == nil || $0.ownerUserID == userID) && (!$0.isSynced || $0.cloudSessionId == nil)
|
|
|
|
|
+ }.count
|
|
|
|
|
+ defer { isSyncing = false }
|
|
|
|
|
+
|
|
|
|
|
+ do {
|
|
|
|
|
+ let remoteSessions = try await service.fetchSessions()
|
|
|
|
|
+ let mergedSessions = merge(remoteSessions, into: sessions, modelContext: modelContext, userID: userID)
|
|
|
|
|
+ for (remote, local) in mergedSessions {
|
|
|
|
|
+ try await downloadMissingAssets(for: local, remote: remote)
|
|
|
|
|
+ }
|
|
|
|
|
+ try modelContext.save()
|
|
|
|
|
+
|
|
|
|
|
+ let eligible = sessions.filter {
|
|
|
|
|
+ ($0.ownerUserID == nil || $0.ownerUserID == userID) && (!$0.isSynced || $0.cloudSessionId == nil)
|
|
|
|
|
+ }
|
|
|
|
|
+ var failures: [String] = []
|
|
|
|
|
+ for session in eligible {
|
|
|
|
|
+ session.ownerUserID = userID
|
|
|
|
|
+ session.syncState = .syncing
|
|
|
|
|
+ session.lastSyncError = nil
|
|
|
|
|
+ do {
|
|
|
|
|
+ let remote = try await service.syncSession(session)
|
|
|
|
|
+ session.cloudSessionId = remote.id
|
|
|
|
|
+ session.serverRevision = remote.revision
|
|
|
|
|
+ try await uploadLocalAssets(for: session, remote: remote)
|
|
|
|
|
+ session.isSynced = true
|
|
|
|
|
+ session.syncState = .synced
|
|
|
|
|
+ session.lastSyncedAt = Date()
|
|
|
|
|
+ completedCount += 1
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ session.isSynced = false
|
|
|
|
|
+ session.syncState = .failed
|
|
|
|
|
+ session.lastSyncError = error.localizedDescription
|
|
|
|
|
+ failures.append("\(session.title):\(error.localizedDescription)")
|
|
|
|
|
+ }
|
|
|
|
|
+ try modelContext.save()
|
|
|
|
|
+ }
|
|
|
|
|
+ guard failures.isEmpty else {
|
|
|
|
|
+ lastErrorMessage = failures.joined(separator: "\n")
|
|
|
|
|
+ return false
|
|
|
|
|
+ }
|
|
|
|
|
+ try await service.recordSyncCheckpoint()
|
|
|
|
|
+ let now = Date()
|
|
|
|
|
+ lastSyncDate = now
|
|
|
|
|
+ UserDefaults.standard.set(now, forKey: "com.celestia.trace.last_server_sync")
|
|
|
|
|
+ return true
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ lastErrorMessage = error.localizedDescription
|
|
|
|
|
+ return false
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func uploadLocalAssets(for session: CelestiaSession, remote: RemoteSession) async throws {
|
|
|
|
|
+ guard let cloudID = session.cloudSessionId else { throw APIError.missingData }
|
|
|
|
|
+ let existingIDs = Set((remote.assets ?? []).map(\.clientId))
|
|
|
|
|
+ if let path = session.localAudioPath {
|
|
|
|
|
+ guard let url = AudioPathHelper.resolveURL(for: path) else {
|
|
|
|
|
+ throw APIError.transport("本地录音文件不存在:\(path)")
|
|
|
|
|
+ }
|
|
|
|
|
+ let clientID = "\(session.id.uuidString)-audio"
|
|
|
|
|
+ if !existingIDs.contains(clientID) {
|
|
|
|
|
+ _ = try await service.uploadAsset(sessionID: cloudID, clientID: clientID, kind: "AUDIO", fileURL: url)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ for event in session.events where event.eventType == "PHOTO" {
|
|
|
|
|
+ guard let path = event.localFilePath else { continue }
|
|
|
|
|
+ guard let url = AudioPathHelper.resolveURL(for: path) else {
|
|
|
|
|
+ throw APIError.transport("照片文件不存在:\(path)")
|
|
|
|
|
+ }
|
|
|
|
|
+ let clientID = "\(event.id.uuidString)-photo"
|
|
|
|
|
+ if !existingIDs.contains(clientID) {
|
|
|
|
|
+ _ = try await service.uploadAsset(sessionID: cloudID, clientID: clientID, kind: "PHOTO", fileURL: url)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func downloadMissingAssets(for session: CelestiaSession, remote: RemoteSession) async throws {
|
|
|
|
|
+ let assets = remote.assets ?? []
|
|
|
|
|
+ if (session.localAudioPath == nil || AudioPathHelper.resolveURL(for: session.localAudioPath) == nil),
|
|
|
|
|
+ let audio = assets.first(where: { $0.kind.uppercased() == "AUDIO" }) {
|
|
|
|
|
+ let destination = downloadDestination(for: audio)
|
|
|
|
|
+ try await service.downloadAsset(sessionID: remote.id, asset: audio, destinationURL: destination)
|
|
|
|
|
+ session.localAudioPath = AudioPathHelper.relativePath(from: destination.path)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let photoAssets = Dictionary(uniqueKeysWithValues: assets
|
|
|
|
|
+ .filter { $0.kind.uppercased() == "PHOTO" }
|
|
|
|
|
+ .map { ($0.clientId.lowercased(), $0) })
|
|
|
|
|
+ for event in session.events where event.eventType == "PHOTO" {
|
|
|
|
|
+ guard event.localFilePath == nil || AudioPathHelper.resolveURL(for: event.localFilePath) == nil else { continue }
|
|
|
|
|
+ let key = "\(event.id.uuidString)-photo".lowercased()
|
|
|
|
|
+ guard let asset = photoAssets[key] else { continue }
|
|
|
|
|
+ let destination = downloadDestination(for: asset)
|
|
|
|
|
+ try await service.downloadAsset(sessionID: remote.id, asset: asset, destinationURL: destination)
|
|
|
|
|
+ event.localFilePath = AudioPathHelper.relativePath(from: destination.path)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func downloadDestination(for asset: RemoteAsset) -> URL {
|
|
|
|
|
+ let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
|
|
|
|
+ let fileExtension = (asset.fileName as NSString).pathExtension
|
|
|
|
|
+ let suffix = fileExtension.isEmpty ? "" : ".\(fileExtension.lowercased())"
|
|
|
|
|
+ return documents.appendingPathComponent("cloud_\(asset.id)\(suffix)")
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func merge(
|
|
|
|
|
+ _ remoteSessions: [RemoteSession],
|
|
|
|
|
+ into localSessions: [CelestiaSession],
|
|
|
|
|
+ modelContext: ModelContext,
|
|
|
|
|
+ userID: String
|
|
|
|
|
+ ) -> [(RemoteSession, CelestiaSession)] {
|
|
|
|
|
+ var byClientID = Dictionary(uniqueKeysWithValues: localSessions.map { ($0.id.uuidString.lowercased(), $0) })
|
|
|
|
|
+ var merged: [(RemoteSession, CelestiaSession)] = []
|
|
|
|
|
+ for remote in remoteSessions {
|
|
|
|
|
+ guard let clientID = remote.clientId?.lowercased() else { continue }
|
|
|
|
|
+ if let local = byClientID[clientID] {
|
|
|
|
|
+ if remote.deletedAt != nil, local.isSynced {
|
|
|
|
|
+ modelContext.delete(local)
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
+ if local.isSynced, remote.revision > local.serverRevision {
|
|
|
|
|
+ apply(remote, to: local, userID: userID)
|
|
|
|
|
+ }
|
|
|
|
|
+ if remote.deletedAt == nil {
|
|
|
|
|
+ merged.append((remote, local))
|
|
|
|
|
+ }
|
|
|
|
|
+ } else if remote.deletedAt == nil {
|
|
|
|
|
+ let id = UUID(uuidString: clientID) ?? UUID()
|
|
|
|
|
+ let session = CelestiaSession(id: id, title: remote.title, startTime: remote.startTime)
|
|
|
|
|
+ apply(remote, to: session, userID: userID)
|
|
|
|
|
+ modelContext.insert(session)
|
|
|
|
|
+ byClientID[clientID] = session
|
|
|
|
|
+ merged.append((remote, session))
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ try? modelContext.save()
|
|
|
|
|
+ return merged
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func apply(_ remote: RemoteSession, to local: CelestiaSession, userID: String) {
|
|
|
|
|
+ local.title = remote.title
|
|
|
|
|
+ local.startTime = remote.startTime
|
|
|
|
|
+ local.endTime = remote.endTime
|
|
|
|
|
+ local.cloudSessionId = remote.id
|
|
|
|
|
+ local.ownerUserID = userID
|
|
|
|
|
+ local.serverRevision = remote.revision
|
|
|
|
|
+ local.isSynced = true
|
|
|
|
|
+ local.syncState = .synced
|
|
|
|
|
+ local.lastSyncError = nil
|
|
|
|
|
+ local.events.removeAll()
|
|
|
|
|
+ for remoteEvent in remote.events ?? [] {
|
|
|
|
|
+ let id = remoteEvent.clientId.flatMap(UUID.init(uuidString:)) ?? UUID()
|
|
|
|
|
+ let event = CelestiaTimelineEvent(id: id, relativeTimeMs: remoteEvent.relativeTimeMs, eventType: remoteEvent.eventType)
|
|
|
|
|
+ event.textContent = remoteEvent.textContent
|
|
|
|
|
+ event.voiceStartOffsetMs = remoteEvent.voiceStartOffsetMs
|
|
|
|
|
+ event.voiceEndOffsetMs = remoteEvent.voiceEndOffsetMs
|
|
|
|
|
+ local.events.append(event)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|