LiveActivityManager.swift 39 KB

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