LiveActivityManager.swift 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790
  1. // LoopFollow
  2. // LiveActivityManager.swift
  3. // swiftformat:disable indent
  4. #if !targetEnvironment(macCatalyst)
  5. @preconcurrency import ActivityKit
  6. import Foundation
  7. import os
  8. import UIKit
  9. import UserNotifications
  10. // Live Activity manager for LoopFollow.
  11. final class LiveActivityManager {
  12. static let shared = LiveActivityManager()
  13. private init() {
  14. NotificationCenter.default.addObserver(
  15. self,
  16. selector: #selector(handleForeground),
  17. name: UIApplication.willEnterForegroundNotification,
  18. object: nil,
  19. )
  20. NotificationCenter.default.addObserver(
  21. self,
  22. selector: #selector(handleDidBecomeActive),
  23. name: UIApplication.didBecomeActiveNotification,
  24. object: nil,
  25. )
  26. NotificationCenter.default.addObserver(
  27. self,
  28. selector: #selector(handleWillResignActive),
  29. name: UIApplication.willResignActiveNotification,
  30. object: nil,
  31. )
  32. NotificationCenter.default.addObserver(
  33. self,
  34. selector: #selector(handleBackgroundAudioFailed),
  35. name: .backgroundAudioFailed,
  36. object: nil,
  37. )
  38. }
  39. /// Fires before the app loses focus (lock screen, home button, etc.).
  40. /// Cancels any pending debounced refresh and pushes the latest snapshot
  41. /// directly to the Live Activity while the app is still foreground-active,
  42. /// ensuring the LA is up to date the moment the lock screen appears.
  43. @objc private func handleWillResignActive() {
  44. guard Storage.shared.laEnabled.value, let activity = current else { return }
  45. refreshWorkItem?.cancel()
  46. refreshWorkItem = nil
  47. let provider = StorageCurrentGlucoseStateProvider()
  48. guard let snapshot = GlucoseSnapshotBuilder.build(from: provider) else { return }
  49. LAAppGroupSettings.setThresholds(
  50. lowMgdl: Storage.shared.lowLine.value,
  51. highMgdl: Storage.shared.highLine.value,
  52. )
  53. GlucoseSnapshotStore.shared.save(snapshot)
  54. seq += 1
  55. let nextSeq = seq
  56. let state = GlucoseLiveActivityAttributes.ContentState(
  57. snapshot: snapshot,
  58. seq: nextSeq,
  59. reason: "resign-active",
  60. producedAt: Date(),
  61. )
  62. let content = ActivityContent(
  63. state: state,
  64. staleDate: Date(timeIntervalSince1970: Storage.shared.laRenewBy.value),
  65. relevanceScore: 100.0,
  66. )
  67. Task {
  68. // Direct ActivityKit update — app is still active at this point.
  69. await activity.update(content)
  70. LogManager.shared.log(category: .general, message: "[LA] resign-active flush sent seq=\(nextSeq)", isDebug: true)
  71. // Also send APNs so the extension receives the latest token-based update.
  72. if let token = pushToken {
  73. await APNSClient.shared.sendLiveActivityUpdate(pushToken: token, state: state)
  74. }
  75. }
  76. }
  77. @objc private func handleDidBecomeActive() {
  78. guard Storage.shared.laEnabled.value else { return }
  79. if skipNextDidBecomeActive {
  80. LogManager.shared.log(category: .general, message: "[LA] didBecomeActive: skipped (handleForeground owns restart)", isDebug: true)
  81. skipNextDidBecomeActive = false
  82. return
  83. }
  84. LogManager.shared.log(category: .general, message: "[LA] didBecomeActive: calling startFromCurrentState, dismissedByUser=\(dismissedByUser)", isDebug: true)
  85. Task { @MainActor in
  86. self.startFromCurrentState()
  87. }
  88. }
  89. @objc private func handleForeground() {
  90. guard Storage.shared.laEnabled.value else { return }
  91. let renewalFailed = Storage.shared.laRenewalFailed.value
  92. let renewBy = Storage.shared.laRenewBy.value
  93. let now = Date().timeIntervalSince1970
  94. let overlayIsShowing = renewBy > 0 && now >= renewBy - LiveActivityManager.renewalWarning
  95. LogManager.shared.log(
  96. category: .general,
  97. message: "[LA] foreground: renewalFailed=\(renewalFailed), overlayShowing=\(overlayIsShowing), current=\(current?.id ?? "nil"), dismissedByUser=\(dismissedByUser), renewBy=\(renewBy), now=\(now)"
  98. )
  99. guard renewalFailed || overlayIsShowing else {
  100. LogManager.shared.log(category: .general, message: "[LA] foreground: no action needed (not in renewal window)")
  101. return
  102. }
  103. LogManager.shared.log(
  104. category: .general,
  105. message: "[LA] ending stale LA and restarting (renewalFailed=\(renewalFailed), overlayShowing=\(overlayIsShowing))"
  106. )
  107. skipNextDidBecomeActive = true
  108. // Mark restart intent BEFORE clearing storage flags, so any late .dismissed
  109. // from the old activity is never misclassified as a user swipe.
  110. endingForRestart = true
  111. dismissedByUser = false
  112. // Stop any observers/tasks tied to the previous activity instance. In the
  113. // current=nil branch below, the old observer can otherwise deliver a late
  114. // .dismissed and poison dismissedByUser.
  115. updateTask?.cancel()
  116. updateTask = nil
  117. tokenObservationTask?.cancel()
  118. tokenObservationTask = nil
  119. stateObserverTask?.cancel()
  120. stateObserverTask = nil
  121. pushToken = nil
  122. // Clear renewal state so the new snapshot does not show the renewal overlay.
  123. Storage.shared.laRenewBy.value = 0
  124. Storage.shared.laRenewalFailed.value = false
  125. cancelRenewalFailedNotification()
  126. guard let activity = current else {
  127. LogManager.shared.log(
  128. category: .general,
  129. message: "[LA] foreground restart: current=nil (old activity not bound locally), ending all existing LAs before restart"
  130. )
  131. current = nil
  132. Task {
  133. for activity in Activity<GlucoseLiveActivityAttributes>.activities {
  134. await activity.end(nil, dismissalPolicy: .immediate)
  135. }
  136. await MainActor.run {
  137. self.dismissedByUser = false
  138. self.startFromCurrentState(cleanupOrphans: false)
  139. LogManager.shared.log(
  140. category: .general,
  141. message: "[LA] foreground restart: fresh LA started after ending unbound existing activity"
  142. )
  143. }
  144. }
  145. return
  146. }
  147. current = nil
  148. Task {
  149. await activity.end(nil, dismissalPolicy: .immediate)
  150. await MainActor.run {
  151. self.dismissedByUser = false
  152. self.startFromCurrentState(cleanupOrphans: false)
  153. LogManager.shared.log(category: .general, message: "[LA] Live Activity restarted after foreground retry")
  154. }
  155. }
  156. }
  157. @objc private func handleBackgroundAudioFailed() {
  158. guard Storage.shared.laEnabled.value, current != nil else { return }
  159. // The background audio session has permanently failed — the app will lose its
  160. // background keep-alive. Immediately push the renewal overlay so the user sees
  161. // "Tap to update" on the lock screen and knows to foreground the app.
  162. LogManager.shared.log(category: .general, message: "[LA] background audio failed — forcing renewal overlay")
  163. Storage.shared.laRenewBy.value = Date().timeIntervalSince1970
  164. refreshFromCurrentState(reason: "audio-session-failed")
  165. }
  166. private func shouldRestartBecauseExtensionLooksStuck() -> Bool {
  167. guard Storage.shared.laEnabled.value else { return false }
  168. guard !dismissedByUser else { return false }
  169. guard let activity = current ?? Activity<GlucoseLiveActivityAttributes>.activities.first else {
  170. return false
  171. }
  172. let now = Date().timeIntervalSince1970
  173. let staleDatePassed = activity.content.staleDate.map { $0 <= Date() } ?? false
  174. if staleDatePassed {
  175. LogManager.shared.log(
  176. category: .general,
  177. message: "[LA] liveness check: staleDate already passed"
  178. )
  179. return true
  180. }
  181. let expectedSeq = activity.content.state.seq
  182. let seenSeq = LALivenessStore.lastExtensionSeq
  183. let lastSeenAt = LALivenessStore.lastExtensionSeenAt
  184. let lastProducedAt = LALivenessStore.lastExtensionProducedAt
  185. let extensionHasNeverCheckedIn = lastSeenAt <= 0
  186. let extensionLooksBehind = seenSeq < expectedSeq
  187. let noRecentExtensionTouch = extensionHasNeverCheckedIn || (now - lastSeenAt > LiveActivityManager.extensionLivenessGrace)
  188. LogManager.shared.log(
  189. category: .general,
  190. message: "[LA] liveness check: expectedSeq=\(expectedSeq), seenSeq=\(seenSeq), lastSeenAt=\(lastSeenAt), lastProducedAt=\(lastProducedAt), behind=\(extensionLooksBehind), noRecentTouch=\(noRecentExtensionTouch)",
  191. isDebug: true
  192. )
  193. // Conservative rule:
  194. // only suspect "stuck" if the extension is both behind AND has not checked in recently.
  195. return extensionLooksBehind && noRecentExtensionTouch
  196. }
  197. static let renewalThreshold: TimeInterval = 7.5 * 3600
  198. static let renewalWarning: TimeInterval = 30 * 60
  199. static let extensionLivenessGrace: TimeInterval = 15 * 60
  200. private(set) var current: Activity<GlucoseLiveActivityAttributes>?
  201. private var stateObserverTask: Task<Void, Never>?
  202. private var updateTask: Task<Void, Never>?
  203. private var seq: Int = 0
  204. private var lastUpdateTime: Date?
  205. private var pushToken: String?
  206. private var tokenObservationTask: Task<Void, Never>?
  207. private var refreshWorkItem: DispatchWorkItem?
  208. /// Set when the user manually swipes away the LA. Blocks auto-restart until
  209. /// an explicit user action (Restart button, App Intent) clears it.
  210. /// In-memory only — resets to false on app relaunch, so a kill + relaunch
  211. /// starts fresh as expected.
  212. private var dismissedByUser = false
  213. /// Set to true immediately before we call activity.end() as part of a planned restart.
  214. /// Cleared after the restart completes. The state observer checks this flag so that
  215. /// a .dismissed delivery triggered by our own end() call is never misclassified as a
  216. /// user swipe — regardless of the order in which the MainActor executes the two writes.
  217. private var endingForRestart = false
  218. /// Set by handleForeground() when it takes ownership of the restart sequence.
  219. /// Prevents handleDidBecomeActive() from racing with an in-flight end+restart.
  220. private var skipNextDidBecomeActive = false
  221. // MARK: - Public API
  222. func startIfNeeded() {
  223. guard ActivityAuthorizationInfo().areActivitiesEnabled else {
  224. LogManager.shared.log(category: .general, message: "Live Activity not authorized")
  225. return
  226. }
  227. if let existing = Activity<GlucoseLiveActivityAttributes>.activities.first {
  228. // Before reusing, check whether this activity needs a restart. This covers cold
  229. // starts (app was killed while the overlay was showing — willEnterForeground is
  230. // never sent, so handleForeground never runs) and any other path that lands here
  231. // without first going through handleForeground.
  232. let renewBy = Storage.shared.laRenewBy.value
  233. let now = Date().timeIntervalSince1970
  234. let staleDatePassed = existing.content.staleDate.map { $0 <= Date() } ?? false
  235. let inRenewalWindow = renewBy > 0 && now >= renewBy - LiveActivityManager.renewalWarning
  236. let needsRestart = Storage.shared.laRenewalFailed.value || inRenewalWindow || staleDatePassed
  237. if needsRestart {
  238. LogManager.shared.log(
  239. category: .general,
  240. message: "[LA] existing activity is stale on startIfNeeded — ending and restarting (staleDatePassed=\(staleDatePassed), inRenewalWindow=\(inRenewalWindow))"
  241. )
  242. endingForRestart = true
  243. dismissedByUser = false
  244. Storage.shared.laRenewBy.value = 0
  245. Storage.shared.laRenewalFailed.value = false
  246. cancelRenewalFailedNotification()
  247. Task {
  248. await existing.end(nil, dismissalPolicy: .immediate)
  249. await MainActor.run { self.startIfNeeded() }
  250. }
  251. return
  252. }
  253. bind(to: existing, logReason: "reuse")
  254. Storage.shared.laRenewalFailed.value = false
  255. return
  256. }
  257. do {
  258. let attributes = GlucoseLiveActivityAttributes(title: "LoopFollow")
  259. // Prefer a freshly built snapshot so all extended fields are populated.
  260. // Fall back to the persisted store (covers cold-start with real data),
  261. // then to a zero seed (true first-ever launch with no data yet).
  262. let provider = StorageCurrentGlucoseStateProvider()
  263. let seedSnapshot = GlucoseSnapshotBuilder.build(from: provider)
  264. ?? GlucoseSnapshotStore.shared.load()
  265. ?? GlucoseSnapshot(
  266. glucose: 0,
  267. delta: 0,
  268. trend: .unknown,
  269. updatedAt: Date(),
  270. iob: nil,
  271. cob: nil,
  272. projected: nil,
  273. unit: .mgdl,
  274. isNotLooping: false,
  275. )
  276. let initialState = GlucoseLiveActivityAttributes.ContentState(
  277. snapshot: seedSnapshot,
  278. seq: 0,
  279. reason: "start",
  280. producedAt: Date(),
  281. )
  282. let renewDeadline = Date().addingTimeInterval(LiveActivityManager.renewalThreshold)
  283. let content = ActivityContent(state: initialState, staleDate: renewDeadline)
  284. LALivenessStore.clear()
  285. let activity = try Activity.request(attributes: attributes, content: content, pushType: .token)
  286. bind(to: activity, logReason: "start-new")
  287. Storage.shared.laRenewBy.value = renewDeadline.timeIntervalSince1970
  288. Storage.shared.laRenewalFailed.value = false
  289. LogManager.shared.log(category: .general, message: "Live Activity started id=\(activity.id)")
  290. } catch {
  291. LogManager.shared.log(category: .general, message: "Live Activity failed to start: \(error)")
  292. }
  293. }
  294. /// Called from applicationWillTerminate. Ends the LA synchronously (blocking
  295. /// up to 3 s) so it clears from the lock screen before the process exits.
  296. /// Does not clear laEnabled — the user's preference is preserved for relaunch.
  297. func endOnTerminate() {
  298. guard let activity = current else { return }
  299. current = nil
  300. Storage.shared.laRenewBy.value = 0
  301. LALivenessStore.clear()
  302. let semaphore = DispatchSemaphore(value: 0)
  303. Task.detached {
  304. await activity.end(nil, dismissalPolicy: .immediate)
  305. semaphore.signal()
  306. }
  307. _ = semaphore.wait(timeout: .now() + 3)
  308. LogManager.shared.log(category: .general, message: "[LA] ended on app terminate")
  309. }
  310. func end(dismissalPolicy: ActivityUIDismissalPolicy = .default) {
  311. updateTask?.cancel()
  312. updateTask = nil
  313. guard let activity = current else { return }
  314. Task {
  315. let finalState = GlucoseLiveActivityAttributes.ContentState(
  316. snapshot: GlucoseSnapshotStore.shared.load() ?? GlucoseSnapshot(
  317. glucose: 0,
  318. delta: 0,
  319. trend: .unknown,
  320. updatedAt: Date(),
  321. iob: nil,
  322. cob: nil,
  323. projected: nil,
  324. unit: .mgdl,
  325. isNotLooping: false,
  326. ),
  327. seq: seq,
  328. reason: "end",
  329. producedAt: Date(),
  330. )
  331. let content = ActivityContent(state: finalState, staleDate: nil)
  332. await activity.end(content, dismissalPolicy: dismissalPolicy)
  333. LogManager.shared.log(category: .general, message: "Live Activity ended id=\(activity.id)", isDebug: true)
  334. if current?.id == activity.id {
  335. current = nil
  336. Storage.shared.laRenewBy.value = 0
  337. LALivenessStore.clear()
  338. }
  339. }
  340. }
  341. /// Ends all running Live Activities and starts a fresh one from the current state.
  342. /// Intended for the "Restart Live Activity" button and the AppIntent.
  343. @MainActor
  344. func forceRestart() {
  345. guard Storage.shared.laEnabled.value else { return }
  346. LogManager.shared.log(category: .general, message: "[LA] forceRestart called")
  347. dismissedByUser = false
  348. Storage.shared.laRenewBy.value = 0
  349. Storage.shared.laRenewalFailed.value = false
  350. LALivenessStore.clear()
  351. cancelRenewalFailedNotification()
  352. current = nil
  353. updateTask?.cancel(); updateTask = nil
  354. tokenObservationTask?.cancel(); tokenObservationTask = nil
  355. stateObserverTask?.cancel(); stateObserverTask = nil
  356. pushToken = nil
  357. Task {
  358. for activity in Activity<GlucoseLiveActivityAttributes>.activities {
  359. await activity.end(nil, dismissalPolicy: .immediate)
  360. }
  361. await MainActor.run {
  362. self.startFromCurrentState(cleanupOrphans: false)
  363. LogManager.shared.log(category: .general, message: "[LA] forceRestart: Live Activity restarted")
  364. }
  365. }
  366. }
  367. func startFromCurrentState(cleanupOrphans: Bool = false) {
  368. guard Storage.shared.laEnabled.value, !dismissedByUser else { return }
  369. if cleanupOrphans {
  370. endOrphanedActivities()
  371. }
  372. let provider = StorageCurrentGlucoseStateProvider()
  373. if let snapshot = GlucoseSnapshotBuilder.build(from: provider) {
  374. LAAppGroupSettings.setThresholds(
  375. lowMgdl: Storage.shared.lowLine.value,
  376. highMgdl: Storage.shared.highLine.value,
  377. )
  378. LAAppGroupSettings.setDisplayName(
  379. Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String ?? "LoopFollow",
  380. show: Storage.shared.showDisplayName.value
  381. )
  382. GlucoseSnapshotStore.shared.save(snapshot)
  383. }
  384. startIfNeeded()
  385. }
  386. func refreshFromCurrentState(reason: String) {
  387. // No LA guard here — Watch and store must update regardless of LA state.
  388. // LA-specific gating (laEnabled, dismissedByUser) is applied inside performRefresh.
  389. refreshWorkItem?.cancel()
  390. let workItem = DispatchWorkItem { [weak self] in
  391. self?.performRefresh(reason: reason)
  392. }
  393. refreshWorkItem = workItem
  394. DispatchQueue.main.asyncAfter(deadline: .now() + 20.0, execute: workItem)
  395. }
  396. // MARK: - Renewal
  397. /// Requests a fresh Live Activity to replace the current one when the renewal
  398. /// deadline has passed, working around Apple's 8-hour maximum LA lifetime.
  399. /// The new LA is requested FIRST — the old one is only ended if that succeeds,
  400. /// so the user keeps live data if Activity.request() throws.
  401. /// Returns true if renewal was performed (caller should return early).
  402. private func renewIfNeeded(snapshot: GlucoseSnapshot) -> Bool {
  403. guard let oldActivity = current else { return false }
  404. let renewBy = Storage.shared.laRenewBy.value
  405. guard renewBy > 0, Date().timeIntervalSince1970 >= renewBy else { return false }
  406. let overdueBy = Date().timeIntervalSince1970 - renewBy
  407. LogManager.shared.log(category: .general, message: "[LA] renewal deadline passed by \(Int(overdueBy))s, requesting new LA")
  408. let renewDeadline = Date().addingTimeInterval(LiveActivityManager.renewalThreshold)
  409. let attributes = GlucoseLiveActivityAttributes(title: "LoopFollow")
  410. // Build the fresh snapshot with showRenewalOverlay: false — the new LA has a
  411. // fresh deadline so no overlay is needed from the first frame. We pass the
  412. // deadline as staleDate to ActivityContent below, not to Storage yet; Storage
  413. // is only updated after Activity.request succeeds so a crash between the two
  414. // can't leave the deadline permanently stuck in the future.
  415. let freshSnapshot = snapshot.withRenewalOverlay(false)
  416. let state = GlucoseLiveActivityAttributes.ContentState(
  417. snapshot: freshSnapshot,
  418. seq: seq,
  419. reason: "renew",
  420. producedAt: Date(),
  421. )
  422. let content = ActivityContent(state: state, staleDate: renewDeadline)
  423. do {
  424. let newActivity = try Activity.request(attributes: attributes, content: content, pushType: .token)
  425. Task {
  426. await oldActivity.end(nil, dismissalPolicy: .immediate)
  427. }
  428. updateTask?.cancel()
  429. updateTask = nil
  430. tokenObservationTask?.cancel()
  431. tokenObservationTask = nil
  432. stateObserverTask?.cancel()
  433. stateObserverTask = nil
  434. pushToken = nil
  435. // Write deadline only on success — avoids a stuck future deadline if we crash
  436. // between the write and the Activity.request call.
  437. Storage.shared.laRenewBy.value = renewDeadline.timeIntervalSince1970
  438. bind(to: newActivity, logReason: "renew")
  439. Storage.shared.laRenewalFailed.value = false
  440. cancelRenewalFailedNotification()
  441. GlucoseSnapshotStore.shared.save(freshSnapshot)
  442. LogManager.shared.log(category: .general, message: "[LA] Live Activity renewed successfully id=\(newActivity.id)")
  443. return true
  444. } catch {
  445. // Renewal failed — deadline was never written, so no rollback needed.
  446. let isFirstFailure = !Storage.shared.laRenewalFailed.value
  447. Storage.shared.laRenewalFailed.value = true
  448. LogManager.shared.log(category: .general, message: "[LA] renewal failed, keeping existing LA: \(error)")
  449. if isFirstFailure {
  450. scheduleRenewalFailedNotification()
  451. }
  452. return false
  453. }
  454. }
  455. private func performRefresh(reason: String) {
  456. let provider = StorageCurrentGlucoseStateProvider()
  457. guard let snapshot = GlucoseSnapshotBuilder.build(from: provider) else {
  458. return
  459. }
  460. LogManager.shared.log(category: .general, message: "[LA] refresh g=\(snapshot.glucose) reason=\(reason)", isDebug: true)
  461. let fingerprint =
  462. "g=\(snapshot.glucose) d=\(snapshot.delta) t=\(snapshot.trend.rawValue) " +
  463. "at=\(snapshot.updatedAt.timeIntervalSince1970) iob=\(snapshot.iob?.description ?? "nil") " +
  464. "cob=\(snapshot.cob?.description ?? "nil") proj=\(snapshot.projected?.description ?? "nil") u=\(snapshot.unit.rawValue)"
  465. LogManager.shared.log(category: .general, message: "[LA] snapshot \(fingerprint) reason=\(reason)", isDebug: true)
  466. // Check if the Live Activity is approaching Apple's 8-hour limit and renew if so.
  467. if renewIfNeeded(snapshot: snapshot) { return }
  468. if snapshot.showRenewalOverlay {
  469. LogManager.shared.log(category: .general, message: "[LA] sending update with renewal overlay visible")
  470. }
  471. let now = Date()
  472. let timeSinceLastUpdate = now.timeIntervalSince(lastUpdateTime ?? .distantPast)
  473. let forceRefreshNeeded = timeSinceLastUpdate >= 5 * 60
  474. // Capture dedup result BEFORE saving so the store comparison is valid.
  475. let snapshotUnchanged = GlucoseSnapshotStore.shared.load() == snapshot
  476. // Store + Watch: always update, independent of LA state.
  477. LAAppGroupSettings.setThresholds(
  478. lowMgdl: Storage.shared.lowLine.value,
  479. highMgdl: Storage.shared.highLine.value,
  480. )
  481. GlucoseSnapshotStore.shared.save(snapshot)
  482. // WatchConnectivityManager.shared.send(snapshot: snapshot)
  483. // LA update: gated on LA being active, snapshot having changed, and activities enabled.
  484. guard Storage.shared.laEnabled.value, !dismissedByUser else { return }
  485. guard !snapshotUnchanged || forceRefreshNeeded else { return }
  486. guard ActivityAuthorizationInfo().areActivitiesEnabled else {
  487. return
  488. }
  489. if current == nil, let existing = Activity<GlucoseLiveActivityAttributes>.activities.first {
  490. bind(to: existing, logReason: "bind-existing")
  491. }
  492. if let _ = current {
  493. update(snapshot: snapshot, reason: reason)
  494. return
  495. }
  496. if isAppVisibleForLiveActivityStart() {
  497. startIfNeeded()
  498. if current != nil {
  499. update(snapshot: snapshot, reason: reason)
  500. }
  501. } else {
  502. LogManager.shared.log(category: .general, message: "LA start suppressed (not visible) reason=\(reason)", isDebug: true)
  503. }
  504. }
  505. private func isAppVisibleForLiveActivityStart() -> Bool {
  506. let scenes = UIApplication.shared.connectedScenes
  507. return scenes.contains { $0.activationState == .foregroundActive }
  508. }
  509. func update(snapshot: GlucoseSnapshot, reason: String) {
  510. if current == nil, let existing = Activity<GlucoseLiveActivityAttributes>.activities.first {
  511. bind(to: existing, logReason: "bind-existing")
  512. }
  513. guard let activity = current else { return }
  514. updateTask?.cancel()
  515. seq += 1
  516. let nextSeq = seq
  517. let activityID = activity.id
  518. let state = GlucoseLiveActivityAttributes.ContentState(
  519. snapshot: snapshot,
  520. seq: nextSeq,
  521. reason: reason,
  522. producedAt: Date(),
  523. )
  524. updateTask = Task { [weak self] in
  525. guard let self else { return }
  526. if activity.activityState == .ended || activity.activityState == .dismissed {
  527. if current?.id == activityID { current = nil }
  528. return
  529. }
  530. let content = ActivityContent(
  531. state: state,
  532. staleDate: Date(timeIntervalSince1970: Storage.shared.laRenewBy.value),
  533. relevanceScore: 100.0,
  534. )
  535. if Task.isCancelled { return }
  536. // Dual-path update strategy:
  537. // - Foreground: direct ActivityKit update works reliably.
  538. // - Background: direct update silently fails due to the audio session
  539. // limitation. APNs self-push is the only reliable delivery path.
  540. // Both paths are attempted when applicable; APNs is the authoritative
  541. // background mechanism.
  542. let isForeground = await MainActor.run {
  543. UIApplication.shared.applicationState == .active
  544. }
  545. if isForeground {
  546. await activity.update(content)
  547. }
  548. if Task.isCancelled { return }
  549. guard current?.id == activityID else {
  550. LogManager.shared.log(category: .general, message: "Live Activity update — activity ID mismatch, discarding")
  551. return
  552. }
  553. lastUpdateTime = Date()
  554. LogManager.shared.log(category: .general, message: "[LA] updated id=\(activityID) seq=\(nextSeq) reason=\(reason)", isDebug: true)
  555. if let token = pushToken {
  556. await APNSClient.shared.sendLiveActivityUpdate(pushToken: token, state: state)
  557. }
  558. }
  559. }
  560. // MARK: - Binding / Lifecycle
  561. /// Ends any Live Activities of this type that are not the one currently tracked.
  562. /// Called on app launch to clean up cards left behind by a previous crash.
  563. private func endOrphanedActivities() {
  564. for activity in Activity<GlucoseLiveActivityAttributes>.activities {
  565. guard activity.id != current?.id else { continue }
  566. let orphanID = activity.id
  567. Task {
  568. await activity.end(nil, dismissalPolicy: .immediate)
  569. LogManager.shared.log(category: .general, message: "Ended orphaned Live Activity id=\(orphanID)")
  570. }
  571. }
  572. }
  573. private func bind(to activity: Activity<GlucoseLiveActivityAttributes>, logReason: String) {
  574. if current?.id == activity.id { return }
  575. current = activity
  576. dismissedByUser = false
  577. endingForRestart = false
  578. attachStateObserver(to: activity)
  579. LogManager.shared.log(category: .general, message: "Live Activity bound id=\(activity.id) (\(logReason))", isDebug: true)
  580. observePushToken(for: activity)
  581. }
  582. private func observePushToken(for activity: Activity<GlucoseLiveActivityAttributes>) {
  583. tokenObservationTask?.cancel()
  584. tokenObservationTask = Task {
  585. for await tokenData in activity.pushTokenUpdates {
  586. let token = tokenData.map { String(format: "%02x", $0) }.joined()
  587. self.pushToken = token
  588. LogManager.shared.log(category: .general, message: "Live Activity push token received", isDebug: true)
  589. }
  590. }
  591. }
  592. func handleExpiredToken() {
  593. end()
  594. // Activity will restart on next BG refresh via refreshFromCurrentState()
  595. }
  596. // MARK: - Renewal Notifications
  597. private static let renewalNotificationID = "\(Bundle.main.bundleIdentifier ?? "loopfollow").la.renewal.failed"
  598. private func scheduleRenewalFailedNotification() {
  599. let content = UNMutableNotificationContent()
  600. content.title = "Live Activity Expiring"
  601. content.body = "Live Activity will expire soon. Open LoopFollow to restart."
  602. content.sound = .default
  603. let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false)
  604. let request = UNNotificationRequest(
  605. identifier: LiveActivityManager.renewalNotificationID,
  606. content: content,
  607. trigger: trigger,
  608. )
  609. UNUserNotificationCenter.current().add(request) { error in
  610. if let error {
  611. LogManager.shared.log(category: .general, message: "[LA] failed to schedule renewal notification: \(error)")
  612. }
  613. }
  614. LogManager.shared.log(category: .general, message: "[LA] renewal failed notification scheduled")
  615. }
  616. private func cancelRenewalFailedNotification() {
  617. let id = LiveActivityManager.renewalNotificationID
  618. UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [id])
  619. UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [id])
  620. }
  621. private func attachStateObserver(to activity: Activity<GlucoseLiveActivityAttributes>) {
  622. stateObserverTask?.cancel()
  623. stateObserverTask = Task {
  624. for await state in activity.activityStateUpdates {
  625. LogManager.shared.log(category: .general, message: "Live Activity state id=\(activity.id) -> \(state)", isDebug: true)
  626. if state == .ended || state == .dismissed {
  627. if current?.id == activity.id {
  628. current = nil
  629. // Do NOT clear laRenewBy here. Preserving it means handleForeground()
  630. // can detect the renewal window on the next foreground event and restart
  631. // automatically — whether the LA ended normally (.ended) or was
  632. // system-dismissed (.dismissed). laRenewBy is only set to 0 when:
  633. // • the user explicitly swipes (below) — renewal intent cancelled
  634. // • a new LA starts (startIfNeeded writes the new deadline)
  635. // • handleForeground() clears it synchronously before restarting
  636. // • the user disables LA or calls forceRestart
  637. LogManager.shared.log(category: .general, message: "[LA] activity cleared id=\(activity.id) state=\(state)", isDebug: true)
  638. }
  639. if state == .dismissed {
  640. // Three possible sources of .dismissed — only the third blocks restart:
  641. //
  642. // (a) endingForRestart: our own end() during a planned restart.
  643. // Must be checked first: handleForeground() clears laRenewalFailed
  644. // and laRenewBy synchronously before calling end(), so those flags
  645. // would read as "no problem" even though we initiated the dismissal.
  646. //
  647. // (b) iOS system force-dismiss: either laRenewalFailed is set (our 8-hour
  648. // renewal logic marked it) or the renewal deadline has already passed
  649. // (laRenewBy > 0 && now >= laRenewBy). In both cases iOS acted, not
  650. // the user. laRenewBy is preserved so handleForeground() restarts on
  651. // the next foreground.
  652. //
  653. // (c) User decision: the user explicitly swiped the LA away. Block
  654. // auto-restart until forceRestart() is called. Clear laRenewBy so
  655. // handleForeground() does NOT re-enter the renewal path on the next
  656. // foreground — the renewal intent is cancelled by the user's choice.
  657. let now = Date().timeIntervalSince1970
  658. let renewBy = Storage.shared.laRenewBy.value
  659. let renewalFailed = Storage.shared.laRenewalFailed.value
  660. let pastDeadline = renewBy > 0 && now >= renewBy
  661. LogManager.shared.log(category: .general, message: "[LA] .dismissed: endingForRestart=\(endingForRestart), renewalFailed=\(renewalFailed), pastDeadline=\(pastDeadline), renewBy=\(renewBy), now=\(now)")
  662. if endingForRestart {
  663. // (a) Our own restart — do nothing, Task handles the rest.
  664. LogManager.shared.log(category: .general, message: "[LA] dismissed by self (endingForRestart) — restart in-flight, no action")
  665. } else if renewalFailed || pastDeadline {
  666. // (b) iOS system force-dismiss — allow auto-restart on next foreground.
  667. LogManager.shared.log(category: .general, message: "[LA] dismissed by iOS (renewalFailed=\(renewalFailed), pastDeadline=\(pastDeadline)) — auto-restart on next foreground")
  668. } else {
  669. // (c) User decision — cancel renewal intent, block auto-restart.
  670. dismissedByUser = true
  671. Storage.shared.laRenewBy.value = 0
  672. LogManager.shared.log(category: .general, message: "[LA] dismissed by USER (renewBy=\(renewBy), now=\(now)) — laRenewBy cleared, auto-restart BLOCKED until forceRestart")
  673. }
  674. }
  675. }
  676. }
  677. }
  678. }
  679. }
  680. #endif
  681. extension Notification.Name {
  682. /// Posted when the user taps the Live Activity or Dynamic Island.
  683. /// Observers navigate to the Home or Snoozer tab as appropriate.
  684. static let liveActivityDidForeground = Notification.Name("liveActivityDidForeground")
  685. }