import Foundation import IndieSync import os import WatchConnectivity /// Watch side of the recording hand-off. The watch has no iCloud; a finished /// recording becomes durable on the phone, which ingests and transcribes it. /// /// Each queued recording is a `.m4a` plus a `.meta.plist` sidecar in /// `Documents/Recordings/`. `transferFile` hands the audio to WatchConnectivity /// with an `AudioNotePayload` as metadata. Local files are deleted **only** when /// the phone sends an `IngestAck` — the end-to-end confirmation that the /// recording is durably in the iCloud container (or permanently vetoed there). /// WC-level `didFinish` success is not enough: the phone can still fail after /// delivery (staging, iCloud write), and this local copy is the only recovery. /// Anything never acked is re-enqueued by `reconcileQueue()` from the persisted /// sidecar on the next activation/foreground; the phone dedupes and re-acks. /// `pendingCount` (files still on disk) drives the "N waiting for iPhone" footer. /// /// Strict concurrency: the delegate callbacks are nonisolated and run on /// WatchConnectivity's background queue — they extract Sendable values (a file /// name string) before hopping to the main actor. `WCSessionFile*` are not /// Sendable and never cross the hop. @Observable @MainActor final class WatchTransferService: NSObject { private nonisolated static let log = Logger( subsystem: "dev.rzen.indie.Notes.watchkitapp", category: "watch-transfer") /// Recordings still on disk (queued or in-flight) — the count the phone has /// not yet confirmed receiving. private(set) var pendingCount = 0 private var session: WCSession? /// Recordings WC reported delivered this session but the phone hasn't yet /// acked as ingested. Skipped by `reconcileQueue` so a foreground pass right /// after delivery doesn't re-send a whole audio file the phone is busy /// ingesting. In-memory only: after a relaunch these fall back into the /// reconcile pool, and a redundant redelivery is harmless (phone dedupes). private var deliveredAwaitingAck: Set = [] func activate() { guard WCSession.isSupported() else { return } let session = WCSession.default session.delegate = self session.activate() self.session = session refreshPendingCount() } /// Queue a finished recording for delivery. Writes the sidecar first (so a /// relaunch before delivery can re-enqueue it), then hands the file to /// `transferFile`. func enqueue(_ recording: WatchAudioRecorder.Recording) { let tz = TimeZone.current.identifier writeSidecar(base: recording.id.stringValue, recordedAt: recording.recordedAt, duration: recording.duration, timeZoneID: tz) startTransfer(id: recording.id.stringValue, url: recording.url, recordedAt: recording.recordedAt, duration: recording.duration, timeZoneID: tz) refreshPendingCount() } /// Re-enqueue any recording still on disk that the session isn't already /// transferring — a file whose delivery we never confirmed. Runs when the /// session activates (relaunch) and when the app returns to the foreground. func reconcileQueue() { guard let session else { refreshPendingCount(); return } let inFlight = Set(session.outstandingFileTransfers.map { $0.file.fileURL.lastPathComponent }) let fm = FileManager.default let dir = WatchAudioRecorder.recordingsDirectory let entries = (try? fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil)) ?? [] for m4a in entries where m4a.pathExtension == "m4a" { if inFlight.contains(m4a.lastPathComponent) { continue } if deliveredAwaitingAck.contains(m4a.lastPathComponent) { continue } let base = m4a.deletingPathExtension().lastPathComponent guard ULID(string: base) != nil else { continue } let meta = readSidecar(base: base) startTransfer(id: base, url: m4a, recordedAt: meta?.recordedAt ?? Date(), duration: meta?.duration ?? 0, timeZoneID: meta?.timeZoneID) } // Sweep sidecars whose audio file is gone (e.g. a simulator "recording" // that never produced a file) so they don't linger forever. let audioBases = Set(entries.filter { $0.pathExtension == "m4a" } .map { $0.deletingPathExtension().lastPathComponent }) for sidecar in entries where sidecar.lastPathComponent.hasSuffix(".meta.plist") { let base = sidecar.lastPathComponent.replacingOccurrences(of: ".meta.plist", with: "") if !audioBases.contains(base) { try? fm.removeItem(at: sidecar) } } refreshPendingCount() } // MARK: - Transfer private func startTransfer(id: String, url: URL, recordedAt: Date, duration: TimeInterval, timeZoneID: String?) { guard let session, session.activationState == .activated, FileManager.default.fileExists(atPath: url.path) else { return } let payload = AudioNotePayload( id: id, recordedAt: recordedAt, duration: duration, timeZoneID: timeZoneID) session.transferFile(url, metadata: payload.metadata()) } /// Delete an **acked** recording and its sidecar — called only when the /// phone's `IngestAck` arrives, never on mere WC delivery success. private func completeTransfer(fileName: String) { let dir = WatchAudioRecorder.recordingsDirectory let base = (fileName as NSString).deletingPathExtension try? FileManager.default.removeItem(at: dir.appending(path: fileName)) try? FileManager.default.removeItem(at: sidecarURL(base: base)) deliveredAwaitingAck.remove(fileName) refreshPendingCount() } /// WC delivered the file to the phone; park it until the ingest ack. private func markDelivered(fileName: String) { deliveredAwaitingAck.insert(fileName) refreshPendingCount() } // MARK: - Pending count private func refreshPendingCount() { let dir = WatchAudioRecorder.recordingsDirectory let entries = (try? FileManager.default.contentsOfDirectory( at: dir, includingPropertiesForKeys: nil)) ?? [] pendingCount = entries.filter { $0.pathExtension == "m4a" }.count } // MARK: - Sidecar (persisted metadata for re-enqueue after relaunch) private func sidecarURL(base: String) -> URL { WatchAudioRecorder.recordingsDirectory.appending(path: "\(base).meta.plist") } private func writeSidecar(base: String, recordedAt: Date, duration: TimeInterval, timeZoneID: String?) { var dict: [String: Any] = ["recordedAt": recordedAt, "duration": duration] if let timeZoneID { dict["timeZoneID"] = timeZoneID } if let data = try? PropertyListSerialization.data(fromPropertyList: dict, format: .binary, options: 0) { try? data.write(to: sidecarURL(base: base)) } } private func readSidecar(base: String) -> (recordedAt: Date, duration: TimeInterval, timeZoneID: String?)? { guard let data = try? Data(contentsOf: sidecarURL(base: base)), let dict = try? PropertyListSerialization.propertyList(from: data, options: [], format: nil) as? [String: Any], let recordedAt = dict["recordedAt"] as? Date, let duration = dict["duration"] as? Double else { return nil } return (recordedAt, duration, dict["timeZoneID"] as? String) } } // MARK: - WCSessionDelegate extension WatchTransferService: WCSessionDelegate { nonisolated func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: (any Error)?) { if let error { Self.log.error("watch WC activation failed: \(error, privacy: .public)") } Task { @MainActor in self.reconcileQueue() } } nonisolated func sessionReachabilityDidChange(_ session: WCSession) { // A queued file whose transfer never registered (session wasn't yet // activated at enqueue) gets re-driven once the phone is reachable again. if session.isReachable { Task { @MainActor in self.reconcileQueue() } } } nonisolated func session(_ session: WCSession, didFinish fileTransfer: WCSessionFileTransfer, error: (any Error)?) { // Extract the Sendable file name before hopping — WCSessionFileTransfer / // WCSessionFile are not Sendable. let fileName = fileTransfer.file.fileURL.lastPathComponent if let error { Self.log.warning("file transfer failed (\(fileName, privacy: .public)): \(error, privacy: .public)") Task { @MainActor in self.refreshPendingCount() } return } // Delivered ≠ ingested: keep the local copy until the phone's ack. Task { @MainActor in self.markDelivered(fileName: fileName) } } nonisolated func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) { // The phone's ingest ack — the recording is durable in iCloud (or // permanently vetoed); the local copy has done its job. guard let ack = IngestAck(userInfo: userInfo) else { return } Task { @MainActor in self.completeTransfer(fileName: "\(ack.id).m4a") } } }