Skip to content

Commit e45b240

Browse files
committed
Merge PR #594: Apple Watch app
# Conflicts: # LoopFollow/Log/LogManager.swift # LoopFollow/ViewControllers/MainViewController.swift
2 parents 84e7bfd + 024e0ee commit e45b240

27 files changed

Lines changed: 1656 additions & 57 deletions

LoopFollow.xcodeproj/project.pbxproj

Lines changed: 219 additions & 1 deletion
Large diffs are not rendered by default.

LoopFollow/Application/AppDelegate.swift

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,19 +42,28 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
4242
// Ensure VolumeButtonHandler is initialized so it can receive alarm notifications
4343
_ = VolumeButtonHandler.shared
4444

45+
WatchConnectivityManager.shared.activate()
46+
4547
// Register for remote notifications
4648
DispatchQueue.main.async {
4749
UIApplication.shared.registerForRemoteNotifications()
4850
}
4951

5052
BackgroundRefreshManager.shared.register()
5153

52-
// Detect Before-First-Unlock launch. If protected data is unavailable here,
53-
// StorageValues were cached from encrypted UserDefaults and need a reload
54-
// on the first foreground after the user unlocks.
55-
let bfu = !UIApplication.shared.isProtectedDataAvailable
54+
// Detect Before-First-Unlock launch. isProtectedDataAvailable returns false
55+
// for ANY locked-screen background launch, not only post-reboot. Standard
56+
// UserDefaults use NSFileProtectionCompleteUntilFirstUserAuthentication —
57+
// they stay readable after the first unlock even when the screen is locked.
58+
// True BFU (boot before first unlock) is the only case where UserDefaults
59+
// is actually inaccessible; in that state every StorageValue reads as its
60+
// default — including migrationStep, which is always ≥ 1 for existing users.
61+
// Guard against false positives by checking that migrationStep is still 0
62+
// (its default), meaning the real value couldn't be read from disk.
63+
let protectedDataUnavailable = !UIApplication.shared.isProtectedDataAvailable
64+
let bfu = protectedDataUnavailable && Storage.shared.migrationStep.value == 0
5665
Storage.shared.needsBFUReload = bfu
57-
LogManager.shared.log(category: .general, message: "BFU check: isProtectedDataAvailable=\(!bfu), needsBFUReload=\(bfu)")
66+
LogManager.shared.log(category: .general, message: "BFU check: isProtectedDataAvailable=\(!protectedDataUnavailable), migrationStep=\(Storage.shared.migrationStep.value), needsBFUReload=\(bfu)")
5867

5968
return true
6069
}

LoopFollow/Controllers/Nightscout/BGData.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,7 @@ extension MainViewController {
272272

273273
// Live Activity storage
274274
Storage.shared.lastBgReadingTimeSeconds.value = lastBGTime
275+
Storage.shared.lastBgMgdl.value = Double(latestBG)
275276
Storage.shared.lastDeltaMgdl.value = Double(deltaBG)
276277
Storage.shared.lastTrendCode.value = entries[latestEntryIndex].direction
277278

LoopFollow/LiveActivity/AppGroupID.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ enum AppGroupID {
5858
".WidgetExtension",
5959
".Widgets",
6060
".WidgetsExtension",
61+
".watchkitapp",
6162
".Watch",
6263
".WatchExtension",
6364
".CarPlay",

LoopFollow/LiveActivity/GlucoseSnapshotStore.swift

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
// GlucoseSnapshotStore.swift
33

44
import Foundation
5+
import os.log
6+
7+
private let storeLog = OSLog(subsystem: Bundle.main.bundleIdentifier ?? "com.loopfollow", category: "GlucoseSnapshotStore")
58

69
/// Persists the latest GlucoseSnapshot into the App Group container so that:
710
/// - the Live Activity extension can read it
@@ -17,31 +20,39 @@ final class GlucoseSnapshotStore {
1720

1821
// MARK: - Public API
1922

20-
func save(_ snapshot: GlucoseSnapshot) {
23+
func save(_ snapshot: GlucoseSnapshot, completion: (() -> Void)? = nil) {
2124
queue.async {
2225
do {
2326
let url = try self.fileURL()
27+
// GlucoseSnapshot writes `updatedAt` as a Double via its custom
28+
// encoder, so no JSONEncoder date strategy is required.
2429
let encoder = JSONEncoder()
25-
encoder.dateEncodingStrategy = .iso8601
2630
let data = try encoder.encode(snapshot)
2731
try data.write(to: url, options: [.atomic])
32+
os_log("GlucoseSnapshotStore: saved snapshot g=%d to %{public}@", log: storeLog, type: .debug, Int(snapshot.glucose), url.lastPathComponent)
2833
} catch {
29-
// Intentionally silent (extension-safe, no dependencies).
34+
os_log("GlucoseSnapshotStore: save failed — %{public}@", log: storeLog, type: .error, error.localizedDescription)
3035
}
36+
completion?()
3137
}
3238
}
3339

3440
func load() -> GlucoseSnapshot? {
3541
do {
3642
let url = try fileURL()
37-
guard FileManager.default.fileExists(atPath: url.path) else { return nil }
43+
guard FileManager.default.fileExists(atPath: url.path) else {
44+
os_log("GlucoseSnapshotStore: file not found at %{public}@", log: storeLog, type: .debug, url.lastPathComponent)
45+
return nil
46+
}
3847

3948
let data = try Data(contentsOf: url)
49+
// GlucoseSnapshot reads `updatedAt` as a Double via its custom decoder,
50+
// so no JSONDecoder date strategy is required.
4051
let decoder = JSONDecoder()
41-
decoder.dateDecodingStrategy = .iso8601
42-
return try decoder.decode(GlucoseSnapshot.self, from: data)
52+
let snapshot = try decoder.decode(GlucoseSnapshot.self, from: data)
53+
return snapshot
4354
} catch {
44-
// Intentionally silent (extension-safe, no dependencies).
55+
os_log("GlucoseSnapshotStore: load failed — %{public}@", log: storeLog, type: .error, error.localizedDescription)
4556
return nil
4657
}
4758
}

LoopFollow/LiveActivity/LAAppGroupSettings.swift

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ enum LAAppGroupSettings {
149149
static let smallWidgetSlot = "la.smallWidgetSlot"
150150
static let displayName = "la.displayName"
151151
static let showDisplayName = "la.showDisplayName"
152+
static let watchSelectedSlots = "watch.selectedSlots"
152153
}
153154

154155
private static var defaults: UserDefaults? {
@@ -206,6 +207,22 @@ enum LAAppGroupSettings {
206207
return LiveActivitySlotOption(rawValue: raw) ?? LiveActivitySlotDefaults.smallWidgetSlot
207208
}
208209

210+
// MARK: - Watch selected slots (ordered, variable-length)
211+
212+
/// Persists the user's ordered list of selected Watch data slots.
213+
static func setWatchSelectedSlots(_ slots: [LiveActivitySlotOption]) {
214+
defaults?.set(slots.map(\.rawValue), forKey: Keys.watchSelectedSlots)
215+
}
216+
217+
/// Returns the ordered list of selected Watch data slots.
218+
/// Falls back to a sensible default if nothing is saved.
219+
static func watchSelectedSlots() -> [LiveActivitySlotOption] {
220+
guard let raw = defaults?.stringArray(forKey: Keys.watchSelectedSlots) else {
221+
return [.iob, .cob, .projectedBG, .battery]
222+
}
223+
return raw.compactMap { LiveActivitySlotOption(rawValue: $0) }
224+
}
225+
209226
// MARK: - Display Name
210227

211228
static func setDisplayName(_ name: String, show: Bool) {

LoopFollow/LiveActivity/LiveActivityManager.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1052,7 +1052,7 @@ final class LiveActivityManager {
10521052
highMgdl: Storage.shared.highLine.value,
10531053
)
10541054
GlucoseSnapshotStore.shared.save(snapshot)
1055-
// WatchConnectivityManager.shared.send(snapshot: snapshot)
1055+
WatchConnectivityManager.shared.send(snapshot: snapshot)
10561056

10571057
// LA update: gated on LA being active, snapshot having changed, and activities enabled.
10581058
if !Storage.shared.laEnabled.value {

LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ struct StorageCurrentGlucoseStateProvider: CurrentGlucoseStateProviding {
1010
// MARK: - Core Glucose
1111

1212
var glucoseMgdl: Double? {
13-
guard let bg = Observable.shared.bg.value, bg > 0 else { return nil }
14-
return Double(bg)
13+
guard let bg = Storage.shared.lastBgMgdl.value, bg > 0 else { return nil }
14+
return bg
1515
}
1616

1717
var deltaMgdl: Double? {
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
// LoopFollow
2+
// WatchConnectivityManager.swift
3+
4+
import Foundation
5+
import WatchConnectivity
6+
7+
final class WatchConnectivityManager: NSObject {
8+
// MARK: - Shared Instance
9+
10+
static let shared = WatchConnectivityManager()
11+
12+
// MARK: - Init
13+
14+
/// Serial queue protecting mutable state (the last-ack timestamp) from
15+
/// concurrent access: `send(snapshot:)` may be called from any thread, and
16+
/// WCSession delegate callbacks arrive on an arbitrary background queue.
17+
private let stateQueue = DispatchQueue(label: "com.loopfollow.WatchConnectivityManager.state")
18+
19+
/// Backing storage for `lastWatchAckTimestamp`. Always access via the
20+
/// thread-safe accessor below.
21+
private var _lastWatchAckTimestamp: TimeInterval = 0
22+
23+
/// Timestamp of the last snapshot the Watch ACK'd. Read/write is serialized
24+
/// through `stateQueue`.
25+
private var lastWatchAckTimestamp: TimeInterval {
26+
get { stateQueue.sync { _lastWatchAckTimestamp } }
27+
set { stateQueue.sync { _lastWatchAckTimestamp = newValue } }
28+
}
29+
30+
override private init() {
31+
super.init()
32+
}
33+
34+
// MARK: - Setup
35+
36+
/// Call once from AppDelegate after app launch.
37+
func activate() {
38+
guard WCSession.isSupported() else {
39+
LogManager.shared.log(category: .watch, message: "WatchConnectivityManager: WCSession not supported on this device")
40+
return
41+
}
42+
WCSession.default.delegate = self
43+
WCSession.default.activate()
44+
LogManager.shared.log(category: .watch, message: "WatchConnectivityManager: WCSession activation requested")
45+
}
46+
47+
// MARK: - Send Snapshot
48+
49+
/// Sends the latest GlucoseSnapshot to the Watch via transferUserInfo.
50+
/// Safe to call from any thread.
51+
/// No-ops silently if Watch is not paired or reachable.
52+
func send(snapshot: GlucoseSnapshot) {
53+
guard WCSession.isSupported() else { return }
54+
55+
let session = WCSession.default
56+
57+
guard session.activationState == .activated else {
58+
LogManager.shared.log(category: .watch, message: "WatchConnectivityManager: session not activated, skipping send")
59+
return
60+
}
61+
62+
guard session.isPaired else {
63+
LogManager.shared.log(category: .watch, message: "WatchConnectivityManager: no paired Watch, skipping send")
64+
return
65+
}
66+
67+
do {
68+
// GlucoseSnapshot has a custom encoder that writes updatedAt as a
69+
// Double (timeIntervalSince1970), so no date strategy needs to be set.
70+
let encoder = JSONEncoder()
71+
let data = try encoder.encode(snapshot)
72+
let payload: [String: Any] = ["snapshot": data]
73+
74+
// Warn if Watch hasn't ACK'd this or a recent snapshot.
75+
let behindBy = snapshot.updatedAt.timeIntervalSince1970 - lastWatchAckTimestamp
76+
if lastWatchAckTimestamp > 0, behindBy > 600 {
77+
LogManager.shared.log(category: .watch, message: "WatchConnectivityManager: Watch ACK is \(Int(behindBy))s behind — Watch may be missing deliveries")
78+
}
79+
80+
// sendMessage: immediate delivery when Watch app is in foreground.
81+
if session.isReachable {
82+
session.sendMessage(payload, replyHandler: nil, errorHandler: nil)
83+
LogManager.shared.log(category: .watch, message: "WatchConnectivityManager: snapshot sent via sendMessage (reachable)")
84+
}
85+
86+
// Cancel outstanding transfers before queuing — only the latest snapshot matters.
87+
// session.outstandingUserInfoTransfers.forEach { $0.cancel() }
88+
89+
// transferUserInfo: guaranteed queued delivery for background wakes.
90+
session.transferUserInfo(payload)
91+
92+
// applicationContext: latest-state mirror for next launch / scheduled refresh.
93+
do {
94+
try session.updateApplicationContext(payload)
95+
} catch {
96+
LogManager.shared.log(
97+
category: .watch,
98+
message: "WatchConnectivityManager: failed to update applicationContext — \(error)"
99+
)
100+
}
101+
102+
LogManager.shared.log(category: .watch, message: "WatchConnectivityManager: snapshot queued via transferUserInfo")
103+
} catch {
104+
LogManager.shared.log(category: .watch, message: "WatchConnectivityManager: failed to encode snapshot — \(error)")
105+
}
106+
}
107+
}
108+
109+
// MARK: - WCSessionDelegate
110+
111+
extension WatchConnectivityManager: WCSessionDelegate {
112+
func session(
113+
_: WCSession,
114+
activationDidCompleteWith activationState: WCSessionActivationState,
115+
error: Error?
116+
) {
117+
if let error = error {
118+
LogManager.shared.log(category: .watch, message: "WatchConnectivityManager: activation failed — \(error)")
119+
} else {
120+
LogManager.shared.log(category: .watch, message: "WatchConnectivityManager: activation complete — state \(activationState.rawValue)")
121+
}
122+
}
123+
124+
/// When the Watch app comes to the foreground, send the latest snapshot immediately
125+
/// so the Watch app has fresh data without waiting for the next BG poll.
126+
/// Receives ACKs from the Watch (sent after each snapshot is saved).
127+
func session(_: WCSession, didReceiveMessage message: [String: Any]) {
128+
if let ackTimestamp = message["watchAck"] as? TimeInterval {
129+
lastWatchAckTimestamp = ackTimestamp
130+
LogManager.shared.log(category: .watch, message: "WatchConnectivityManager: Watch ACK received for snapshot at \(ackTimestamp)")
131+
}
132+
}
133+
134+
func session(_: WCSession, didReceiveUserInfo userInfo: [String: Any]) {
135+
if let ackTimestamp = userInfo["watchAck"] as? TimeInterval {
136+
lastWatchAckTimestamp = ackTimestamp
137+
LogManager.shared.log(category: .watch, message: "WatchConnectivityManager: Watch ACK (userInfo) received for snapshot at \(ackTimestamp)")
138+
}
139+
}
140+
141+
func sessionReachabilityDidChange(_ session: WCSession) {
142+
guard session.isReachable else { return }
143+
if let snapshot = GlucoseSnapshotStore.shared.load() {
144+
send(snapshot: snapshot)
145+
LogManager.shared.log(category: .watch, message: "WatchConnectivityManager: Watch became reachable — snapshot pushed")
146+
}
147+
}
148+
149+
func sessionDidBecomeInactive(_: WCSession) {
150+
LogManager.shared.log(category: .watch, message: "WatchConnectivityManager: session became inactive")
151+
}
152+
153+
func sessionDidDeactivate(_: WCSession) {
154+
LogManager.shared.log(category: .watch, message: "WatchConnectivityManager: session deactivated — reactivating")
155+
WCSession.default.activate()
156+
}
157+
}

LoopFollow/Log/LogManager.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ class LogManager {
3030
case deviceStatus = "Device Status"
3131
case remote = "Remote"
3232
case websocket = "WebSocket"
33+
case watch = "Watch"
3334
}
3435

3536
init() {

0 commit comments

Comments
 (0)