Files
Reflect/Shared/Services/WatchConnectivityManager.swift
Trey t c22d246865 Fix 25 audit issues: memory leaks, concurrency, performance, accessibility
Address findings from comprehensive audit across 5 workstreams:

- Memory: Token-based DataController listeners (prevent closure leaks),
  static DateFormatters, ImageCache observer cleanup, MotionManager
  reference counting, FoundationModels dedup guard
- Concurrency: Replace Task.detached with Task in FeelsApp (preserve
  MainActor isolation), wrap WatchConnectivity handler in MainActor
- Performance: Cache sortedGroupedData in DayViewViewModel, cache demo
  data in MonthView/YearView, remove broken ReduceMotionModifier
- Accessibility: VoiceOver support for LockScreen, DemoHeatmapCell
  labels, MonthCard button labels, InsightsView header traits,
  Smart Invert protection on neon headers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 09:11:48 -06:00

205 lines
6.8 KiB
Swift

//
// WatchConnectivityManager.swift
// Feels
//
// Central coordinator for Watch Connectivity.
// Used for immediate UI updates (Live Activity, widget refresh).
// Data persistence is handled by CloudKit sync.
//
import Foundation
import WatchConnectivity
import WidgetKit
import os.log
/// Manages Watch Connectivity between iOS and watchOS
/// Used for immediate notifications, not data persistence (CloudKit handles that)
final class WatchConnectivityManager: NSObject, ObservableObject {
static let shared = WatchConnectivityManager()
private static let logger = Logger(subsystem: "com.tt.feels", category: "WatchConnectivity")
private var session: WCSession?
#if os(watchOS)
/// Pending moods to send when session activates
private var pendingMoods: [(mood: Int, date: Date)] = []
#endif
/// Whether the paired device is currently reachable for immediate messaging
var isReachable: Bool {
session?.isReachable ?? false
}
private override init() {
super.init()
if WCSession.isSupported() {
session = WCSession.default
session?.delegate = self
session?.activate()
Self.logger.info("WCSession activation requested")
} else {
Self.logger.warning("WCSession not supported on this device")
}
}
// MARK: - iOS Watch
#if os(iOS)
/// Notify watch to reload its complications
func notifyWatchToReload() {
guard let session = session,
session.activationState == .activated,
session.isWatchAppInstalled else {
return
}
let message = ["action": "reloadWidgets"]
session.transferUserInfo(message)
Self.logger.info("Sent reload notification to watch")
}
#endif
// MARK: - Watch iOS
#if os(watchOS)
/// Send mood to iOS app for immediate side effects (Live Activity, etc.)
/// Data persistence is handled by CloudKit - this is just for immediate UI updates
func sendMoodToPhone(mood: Int, date: Date) -> Bool {
guard let session = session else {
Self.logger.warning("WCSession not ready - session is nil")
return false
}
guard session.activationState == .activated else {
pendingMoods.append((mood: mood, date: date))
if session.activationState == .notActivated {
session.activate()
}
Self.logger.warning("WCSession not activated, queued mood for later")
return false
}
let message: [String: Any] = [
"action": "logMood",
"mood": mood,
"date": date.timeIntervalSince1970
]
// Try immediate message first if iPhone is reachable
if session.isReachable {
session.sendMessage(message, replyHandler: nil) { error in
Self.logger.warning("sendMessage failed: \(error.localizedDescription), using transferUserInfo")
session.transferUserInfo(message)
}
Self.logger.info("Sent mood \(mood) to iPhone via sendMessage")
} else {
session.transferUserInfo(message)
Self.logger.info("Sent mood \(mood) to iPhone via transferUserInfo")
}
return true
}
#endif
}
// MARK: - WCSessionDelegate
extension WatchConnectivityManager: WCSessionDelegate {
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
if let error = error {
Self.logger.error("WCSession activation failed: \(error.localizedDescription)")
} else {
Self.logger.info("WCSession activation completed: \(activationState.rawValue)")
#if os(watchOS)
if activationState == .activated && !pendingMoods.isEmpty {
Self.logger.info("Sending \(self.pendingMoods.count) pending mood(s)")
for pending in pendingMoods {
_ = sendMoodToPhone(mood: pending.mood, date: pending.date)
}
pendingMoods.removeAll()
}
#endif
}
}
#if os(iOS)
func sessionDidBecomeInactive(_ session: WCSession) {
Self.logger.info("WCSession became inactive")
}
func sessionDidDeactivate(_ session: WCSession) {
Self.logger.info("WCSession deactivated, reactivating...")
session.activate()
}
func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
Self.logger.info("Received userInfo from watch")
handleReceivedMessage(userInfo)
}
func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
Self.logger.info("Received message from watch")
handleReceivedMessage(message)
}
func session(_ session: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) {
Self.logger.info("Received message from watch (with reply)")
handleReceivedMessage(message)
replyHandler(["status": "received"])
}
private func handleReceivedMessage(_ message: [String: Any]) {
Task { @MainActor in
guard let action = message["action"] as? String else {
Self.logger.error("No action in message")
return
}
switch action {
case "logMood":
guard let moodRaw = message["mood"] as? Int,
let mood = Mood(rawValue: moodRaw),
let timestamp = message["date"] as? TimeInterval else {
Self.logger.error("Invalid mood message format")
return
}
let date = Date(timeIntervalSince1970: timestamp)
Self.logger.info("Processing mood \(moodRaw) from watch for \(date)")
MoodLogger.shared.logMood(mood, for: date, entryType: .watch)
case "reloadWidgets":
Self.logger.info("Received reloadWidgets from watch")
WidgetCenter.shared.reloadAllTimelines()
default:
Self.logger.warning("Unknown action: \(action)")
}
}
}
#endif
#if os(watchOS)
func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
if userInfo["action"] as? String == "reloadWidgets" {
Self.logger.info("Received reload notification from iPhone")
Task { @MainActor in
WidgetCenter.shared.reloadAllTimelines()
}
}
}
func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
if message["action"] as? String == "reloadWidgets" {
Task { @MainActor in
WidgetCenter.shared.reloadAllTimelines()
}
}
}
#endif
}