LiveActivityManager.swift 62 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286
  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. Every LA creation (start, renewal,
  11. // restart) and update goes through APNs push-to-start.
  12. final class LiveActivityManager {
  13. static let shared = LiveActivityManager()
  14. private init() {
  15. NotificationCenter.default.addObserver(
  16. self,
  17. selector: #selector(handleForeground),
  18. name: UIApplication.willEnterForegroundNotification,
  19. object: nil,
  20. )
  21. NotificationCenter.default.addObserver(
  22. self,
  23. selector: #selector(handleDidBecomeActive),
  24. name: UIApplication.didBecomeActiveNotification,
  25. object: nil,
  26. )
  27. NotificationCenter.default.addObserver(
  28. self,
  29. selector: #selector(handleWillResignActive),
  30. name: UIApplication.willResignActiveNotification,
  31. object: nil,
  32. )
  33. NotificationCenter.default.addObserver(
  34. self,
  35. selector: #selector(handleBackgroundAudioFailed),
  36. name: .backgroundAudioFailed,
  37. object: nil,
  38. )
  39. startPushToStartTokenObservation()
  40. startActivityUpdatesObservation()
  41. }
  42. // MARK: - Push-to-start observation
  43. /// Observes the type-level push-to-start token and persists it.
  44. /// The token survives app relaunches but is reissued by iOS periodically or when
  45. /// the user toggles LA permissions — each new delivery overwrites the stored value.
  46. private func startPushToStartTokenObservation() {
  47. pushToStartObservationTask?.cancel()
  48. LogManager.shared.log(
  49. category: .general,
  50. message: "[LA] pushToStartTokenUpdates observation starting"
  51. )
  52. pushToStartObservationTask = Task {
  53. var deliveries = 0
  54. for await tokenData in Activity<GlucoseLiveActivityAttributes>.pushToStartTokenUpdates {
  55. deliveries += 1
  56. let token = tokenData.map { String(format: "%02x", $0) }.joined()
  57. let previousTail = Storage.shared.laPushToStartToken.value.isEmpty
  58. ? "nil"
  59. : String(Storage.shared.laPushToStartToken.value.suffix(8))
  60. let tail = String(token.suffix(8))
  61. let changed = tail != previousTail
  62. Storage.shared.laPushToStartToken.value = token
  63. LogManager.shared.log(
  64. category: .general,
  65. message: "[LA] push-to-start token received #\(deliveries) token=…\(tail) (prev=…\(previousTail))\(changed ? " CHANGED" : " same")"
  66. )
  67. }
  68. LogManager.shared.log(
  69. category: .general,
  70. message: "[LA] pushToStartTokenUpdates stream ended after \(deliveries) deliveries — no further tokens will arrive"
  71. )
  72. }
  73. }
  74. /// Observes new Activity creations so push-to-start activities are adopted
  75. /// via the same bind/update path as an app-initiated start.
  76. private func startActivityUpdatesObservation() {
  77. activityUpdatesObservationTask?.cancel()
  78. LogManager.shared.log(
  79. category: .general,
  80. message: "[LA] activityUpdates observation starting"
  81. )
  82. activityUpdatesObservationTask = Task { [weak self] in
  83. var deliveries = 0
  84. for await activity in Activity<GlucoseLiveActivityAttributes>.activityUpdates {
  85. deliveries += 1
  86. let incomingID = activity.id
  87. LogManager.shared.log(
  88. category: .general,
  89. message: "[LA] activityUpdates delivery #\(deliveries) id=\(incomingID) — dispatching to MainActor"
  90. )
  91. await MainActor.run {
  92. self?.adoptPushToStartActivity(activity)
  93. }
  94. }
  95. LogManager.shared.log(
  96. category: .general,
  97. message: "[LA] activityUpdates stream ended after \(deliveries) deliveries — push-to-start adoption will no longer work until app relaunch"
  98. )
  99. }
  100. }
  101. @MainActor
  102. private func adoptPushToStartActivity(_ activity: Activity<GlucoseLiveActivityAttributes>) {
  103. // Skip if it's the activity we already track (app-initiated path binds it directly).
  104. if current?.id == activity.id {
  105. LogManager.shared.log(
  106. category: .general,
  107. message: "[LA] activityUpdates: ignoring own activity id=\(activity.id) (already current)"
  108. )
  109. return
  110. }
  111. let adoptDelay = lastPushToStartSuccessAt.map { Int(Date().timeIntervalSince($0)) }
  112. let delayDescription = adoptDelay.map { "\($0)s after last push-to-start success" } ?? "no prior push-to-start this session"
  113. let totalActivities = Activity<GlucoseLiveActivityAttributes>.activities.count
  114. let staleDate = activity.content.staleDate
  115. let staleDesc = staleDate.map { String(format: "%.0f", $0.timeIntervalSinceNow) + "s" } ?? "nil"
  116. let incomingSeq = activity.content.state.seq
  117. LogManager.shared.log(
  118. category: .general,
  119. message: "[LA] adopt: id=\(activity.id) seq=\(incomingSeq) staleIn=\(staleDesc) totalActivities=\(totalActivities) (\(delayDescription))"
  120. )
  121. lastPushToStartSuccessAt = nil
  122. pushToStartSendsWithoutAdoption = 0
  123. // The new LA is confirmed — clear any post-send backoff so a legitimate
  124. // near-term renewal isn't silently blocked by the 5-minute base interval.
  125. Storage.shared.laPushToStartBackoff.value = 0
  126. // If we already have a current activity and this is a different one, it's likely
  127. // the new push-to-start LA replacing an old one. End the old, then bind the new.
  128. if let old = current, old.id != activity.id {
  129. LogManager.shared.log(
  130. category: .general,
  131. message: "[LA] activityUpdates: replacing old=\(old.id) with new=\(activity.id)"
  132. )
  133. let oldActivity = old
  134. Task {
  135. await oldActivity.end(nil, dismissalPolicy: .immediate)
  136. }
  137. } else {
  138. LogManager.shared.log(
  139. category: .general,
  140. message: "[LA] activityUpdates: adopting new activity id=\(activity.id) (no prior current)"
  141. )
  142. }
  143. // Fresh deadline — push-to-start-initiated LAs reset the 8-hour clock.
  144. Storage.shared.laRenewBy.value = Date().timeIntervalSince1970 + LiveActivityManager.renewalThreshold
  145. Storage.shared.laRenewalFailed.value = false
  146. cancelRenewalFailedNotification()
  147. dismissedByUser = false
  148. // A fresh LA invalidates any latched foreground-restart intent — the
  149. // condition that prompted the latch (overlay showing / renewal failed)
  150. // is resolved by adoption itself, so a deferred restart on the next
  151. // didBecomeActive would needlessly tear down the just-adopted LA.
  152. if pendingForegroundRestart {
  153. LogManager.shared.log(
  154. category: .general,
  155. message: "[LA] adoption clears stale pendingForegroundRestart (LA already replaced via push-to-start)"
  156. )
  157. pendingForegroundRestart = false
  158. }
  159. bind(to: activity, logReason: "push-to-start-adopt")
  160. }
  161. /// Fires before the app loses focus (lock screen, home button, etc.).
  162. /// Cancels any pending debounced refresh and pushes the latest snapshot
  163. /// directly to the Live Activity while the app is still foreground-active,
  164. /// ensuring the LA is up to date the moment the lock screen appears.
  165. @objc private func handleWillResignActive() {
  166. guard Storage.shared.laEnabled.value, let activity = current else { return }
  167. refreshWorkItem?.cancel()
  168. refreshWorkItem = nil
  169. let provider = StorageCurrentGlucoseStateProvider()
  170. guard let snapshot = GlucoseSnapshotBuilder.build(from: provider) else { return }
  171. LAAppGroupSettings.setThresholds(
  172. lowMgdl: Storage.shared.lowLine.value,
  173. highMgdl: Storage.shared.highLine.value,
  174. )
  175. GlucoseSnapshotStore.shared.save(snapshot)
  176. seq += 1
  177. let nextSeq = seq
  178. let state = GlucoseLiveActivityAttributes.ContentState(
  179. snapshot: snapshot,
  180. seq: nextSeq,
  181. reason: "resign-active",
  182. producedAt: Date(),
  183. )
  184. let content = ActivityContent(
  185. state: state,
  186. staleDate: Date(timeIntervalSince1970: Storage.shared.laRenewBy.value),
  187. relevanceScore: 100.0,
  188. )
  189. Task {
  190. // Direct ActivityKit update — app is still active at this point.
  191. await activity.update(content)
  192. LogManager.shared.log(category: .general, message: "[LA] resign-active flush sent seq=\(nextSeq)", isDebug: true)
  193. // Also send APNs so the extension receives the latest token-based update.
  194. if let token = pushToken {
  195. await APNSClient.shared.sendLiveActivityUpdate(pushToken: token, state: state)
  196. }
  197. }
  198. }
  199. @objc private func handleDidBecomeActive() {
  200. guard Storage.shared.laEnabled.value else { return }
  201. let appState = UIApplication.shared.applicationState.rawValue
  202. let existing = Activity<GlucoseLiveActivityAttributes>.activities.count
  203. if pendingForegroundRestart {
  204. pendingForegroundRestart = false
  205. LogManager.shared.log(
  206. category: .general,
  207. message: "[LA] didBecomeActive: running deferred foreground restart (appState=\(appState), activities=\(existing))"
  208. )
  209. performForegroundRestart()
  210. return
  211. }
  212. LogManager.shared.log(category: .general, message: "[LA] didBecomeActive: startFromCurrentState (appState=\(appState), activities=\(existing), current=\(current?.id ?? "nil"), dismissedByUser=\(dismissedByUser))", isDebug: true)
  213. Task { @MainActor in
  214. self.startFromCurrentState()
  215. }
  216. }
  217. @objc private func handleForeground() {
  218. guard Storage.shared.laEnabled.value else { return }
  219. let renewalFailed = Storage.shared.laRenewalFailed.value
  220. let renewBy = Storage.shared.laRenewBy.value
  221. let now = Date().timeIntervalSince1970
  222. let overlayIsShowing = renewBy > 0 && now >= renewBy - LiveActivityManager.renewalWarning
  223. let stuckSends = pushToStartSendsWithoutAdoption
  224. let pushToStartLooksStuck = stuckSends >= LiveActivityManager.pushToStartForceRestartThreshold
  225. let appState = UIApplication.shared.applicationState.rawValue
  226. let existing = Activity<GlucoseLiveActivityAttributes>.activities.count
  227. LogManager.shared.log(
  228. category: .general,
  229. message: "[LA] foreground: appState=\(appState), activities=\(existing), renewalFailed=\(renewalFailed), overlayShowing=\(overlayIsShowing), current=\(current?.id ?? "nil"), dismissedByUser=\(dismissedByUser), renewBy=\(renewBy), now=\(now), pushToStartSendsWithoutAdoption=\(stuckSends)"
  230. )
  231. guard renewalFailed || overlayIsShowing || pushToStartLooksStuck else {
  232. LogManager.shared.log(category: .general, message: "[LA] foreground: no action needed (not in renewal window)")
  233. return
  234. }
  235. if pushToStartLooksStuck {
  236. // Reset the counter now so we don't re-trigger on every foreground
  237. // entry until the next round of silently-failed sends actually
  238. // builds up again. The restart itself ends the current LA and
  239. // starts a fresh one, which (per Apple's docs) should cause iOS to
  240. // emit a new pushToStartToken — the workaround for FB21158660.
  241. pushToStartSendsWithoutAdoption = 0
  242. LogManager.shared.log(
  243. category: .general,
  244. message: "[LA] foreground: push-to-start looks stuck (sendsWithoutAdoption=\(stuckSends) ≥ \(LiveActivityManager.pushToStartForceRestartThreshold)) — forcing local restart to nudge token rotation"
  245. )
  246. } else {
  247. LogManager.shared.log(
  248. category: .general,
  249. message: "[LA] ending stale LA and restarting (renewalFailed=\(renewalFailed), overlayShowing=\(overlayIsShowing))"
  250. )
  251. }
  252. // willEnterForegroundNotification fires before the scene reaches
  253. // foregroundActive — Activity.request() returns `visibility` during
  254. // this window. Defer the actual restart to didBecomeActive.
  255. pendingForegroundRestart = true
  256. LogManager.shared.log(
  257. category: .general,
  258. message: "[LA] foreground: scheduling restart on next didBecomeActive"
  259. )
  260. }
  261. private func performForegroundRestart() {
  262. // Re-check the conditions that latched the intent. The latch can outlive its
  263. // trigger — e.g. if the user briefly foregrounds the app while the renewal
  264. // overlay is up, then backgrounds before didBecomeActive runs, the background
  265. // renewal can replace the LA before the next foreground entry. By the time
  266. // didBecomeActive eventually fires, the freshly-renewed LA is healthy and a
  267. // restart would be gratuitous.
  268. let renewalFailed = Storage.shared.laRenewalFailed.value
  269. let renewBy = Storage.shared.laRenewBy.value
  270. let now = Date().timeIntervalSince1970
  271. let overlayIsShowing = renewBy > 0 && now >= renewBy - LiveActivityManager.renewalWarning
  272. let pushToStartLooksStuck = pushToStartSendsWithoutAdoption >= LiveActivityManager.pushToStartForceRestartThreshold
  273. guard renewalFailed || overlayIsShowing || pushToStartLooksStuck else {
  274. LogManager.shared.log(
  275. category: .general,
  276. message: "[LA] deferred foreground restart skipped — conditions no longer hold (renewalFailed=\(renewalFailed), overlayShowing=\(overlayIsShowing), pushToStartLooksStuck=\(pushToStartLooksStuck))"
  277. )
  278. return
  279. }
  280. // Mark restart intent BEFORE clearing storage flags, so any late .dismissed
  281. // from the old activity is never misclassified as a user swipe.
  282. endingForRestart = true
  283. dismissedByUser = false
  284. nextStartReasonOverride = "deferred-foreground-restart"
  285. // Stop any observers/tasks tied to the previous activity instance. In the
  286. // current=nil branch below, the old observer can otherwise deliver a late
  287. // .dismissed and poison dismissedByUser.
  288. updateTask?.cancel()
  289. updateTask = nil
  290. tokenObservationTask?.cancel()
  291. tokenObservationTask = nil
  292. stateObserverTask?.cancel()
  293. stateObserverTask = nil
  294. pushToken = nil
  295. // Clear renewal state so the new snapshot does not show the renewal overlay.
  296. Storage.shared.laRenewBy.value = 0
  297. Storage.shared.laRenewalFailed.value = false
  298. cancelRenewalFailedNotification()
  299. guard let activity = current else {
  300. LogManager.shared.log(
  301. category: .general,
  302. message: "[LA] foreground restart: current=nil (old activity not bound locally), ending all existing LAs before restart"
  303. )
  304. current = nil
  305. Task {
  306. for activity in Activity<GlucoseLiveActivityAttributes>.activities {
  307. await activity.end(nil, dismissalPolicy: .immediate)
  308. }
  309. await MainActor.run {
  310. self.dismissedByUser = false
  311. self.startFromCurrentState(cleanupOrphans: false)
  312. LogManager.shared.log(
  313. category: .general,
  314. message: "[LA] foreground restart: fresh LA started after ending unbound existing activity"
  315. )
  316. }
  317. }
  318. return
  319. }
  320. current = nil
  321. Task {
  322. await activity.end(nil, dismissalPolicy: .immediate)
  323. await MainActor.run {
  324. self.dismissedByUser = false
  325. self.startFromCurrentState(cleanupOrphans: false)
  326. LogManager.shared.log(category: .general, message: "[LA] Live Activity restarted after foreground retry")
  327. }
  328. }
  329. }
  330. @objc private func handleBackgroundAudioFailed() {
  331. guard Storage.shared.laEnabled.value, current != nil else { return }
  332. // The background audio session has permanently failed — the app will lose its
  333. // background keep-alive. Immediately push the renewal overlay so the user sees
  334. // "Tap to update" on the lock screen and knows to foreground the app.
  335. LogManager.shared.log(category: .general, message: "[LA] background audio failed — forcing renewal overlay")
  336. Storage.shared.laRenewBy.value = Date().timeIntervalSince1970
  337. refreshFromCurrentState(reason: "audio-session-failed")
  338. }
  339. private func shouldRestartBecauseExtensionLooksStuck() -> Bool {
  340. guard Storage.shared.laEnabled.value else { return false }
  341. guard !dismissedByUser else { return false }
  342. guard let activity = current ?? Activity<GlucoseLiveActivityAttributes>.activities.first else {
  343. return false
  344. }
  345. let now = Date().timeIntervalSince1970
  346. let staleDatePassed = activity.content.staleDate.map { $0 <= Date() } ?? false
  347. if staleDatePassed {
  348. LogManager.shared.log(
  349. category: .general,
  350. message: "[LA] liveness check: staleDate already passed"
  351. )
  352. return true
  353. }
  354. let expectedSeq = activity.content.state.seq
  355. let seenSeq = LALivenessStore.lastExtensionSeq
  356. let lastSeenAt = LALivenessStore.lastExtensionSeenAt
  357. let lastProducedAt = LALivenessStore.lastExtensionProducedAt
  358. let extensionHasNeverCheckedIn = lastSeenAt <= 0
  359. let extensionLooksBehind = seenSeq < expectedSeq
  360. let noRecentExtensionTouch = extensionHasNeverCheckedIn || (now - lastSeenAt > LiveActivityManager.extensionLivenessGrace)
  361. LogManager.shared.log(
  362. category: .general,
  363. message: "[LA] liveness check: expectedSeq=\(expectedSeq), seenSeq=\(seenSeq), lastSeenAt=\(lastSeenAt), lastProducedAt=\(lastProducedAt), behind=\(extensionLooksBehind), noRecentTouch=\(noRecentExtensionTouch)",
  364. isDebug: true
  365. )
  366. // Conservative rule:
  367. // only suspect "stuck" if the extension is both behind AND has not checked in recently.
  368. return extensionLooksBehind && noRecentExtensionTouch
  369. }
  370. static let renewalThreshold: TimeInterval = 7.5 * 3600
  371. static let renewalWarning: TimeInterval = 30 * 60
  372. static let extensionLivenessGrace: TimeInterval = 15 * 60
  373. /// Base backoff after a 429 for push-to-start; doubled on each subsequent 429,
  374. /// capped at `pushToStartMaxBackoff`. Reset to base after a successful send.
  375. private static let pushToStartBaseBackoff: TimeInterval = 300 // 5 min
  376. private static let pushToStartMaxBackoff: TimeInterval = 3600 // 60 min
  377. /// When a successful APNs push-to-start does not result in an `activityUpdates`
  378. /// adoption, count those orphaned sends. After this threshold, the next
  379. /// foreground entry forces a local restart to nudge iOS to issue a new
  380. /// pushToStartToken — Apple FB21158660 workaround. Set to 4 (not 2) to avoid
  381. /// false positives on slow connections where the activityUpdates delivery lags.
  382. private static let pushToStartForceRestartThreshold: Int = 4
  383. /// Polling timeout for the push-to-start token to arrive after a fresh install.
  384. /// `pushToStartTokenUpdates` typically delivers within a couple of seconds.
  385. private static let pushToStartTokenWaitTimeout: TimeInterval = 5
  386. private static let pushToStartTokenPollInterval: TimeInterval = 0.5
  387. /// Delay before the single automatic retry when the push-to-start token is not
  388. /// yet available after the initial wait. The token is almost always en route
  389. /// and arrives within a few seconds of the first request.
  390. private static let pushToStartTokenRetryDelay: TimeInterval = 10
  391. private(set) var current: Activity<GlucoseLiveActivityAttributes>?
  392. private var stateObserverTask: Task<Void, Never>?
  393. private var updateTask: Task<Void, Never>?
  394. private var seq: Int = 0
  395. private var lastUpdateTime: Date?
  396. private var pushToken: String?
  397. private var tokenObservationTask: Task<Void, Never>?
  398. private var refreshWorkItem: DispatchWorkItem?
  399. /// Set when the user manually swipes away the LA. Blocks auto-restart until
  400. /// an explicit user action (Restart button, App Intent) clears it.
  401. /// In-memory only — resets to false on app relaunch, so a kill + relaunch
  402. /// starts fresh as expected.
  403. private var dismissedByUser = false
  404. /// Set to true immediately before we call activity.end() as part of a planned restart.
  405. /// Cleared after the restart completes. The state observer checks this flag so that
  406. /// a .dismissed delivery triggered by our own end() call is never misclassified as a
  407. /// user swipe — regardless of the order in which the MainActor executes the two writes.
  408. private var endingForRestart = false
  409. /// Set by handleForeground() when the renewal window has been detected.
  410. /// The actual end+restart is run from handleDidBecomeActive() because
  411. /// Activity.request() returns `visibility` during willEnterForeground.
  412. private var pendingForegroundRestart = false
  413. /// Observes `pushToStartTokenUpdates` and persists the token.
  414. /// Long-lived — started once at init and never cancelled.
  415. private var pushToStartObservationTask: Task<Void, Never>?
  416. /// Observes `Activity<>.activityUpdates` so activities started out-of-band
  417. /// (push-to-start) are adopted automatically.
  418. private var activityUpdatesObservationTask: Task<Void, Never>?
  419. /// Timestamp of the last successful push-to-start APNs dispatch. Used to log
  420. /// the delay until iOS delivers the new activity via `activityUpdates`. If
  421. /// adoption never happens, a growing gap here is the fingerprint.
  422. private var lastPushToStartSuccessAt: Date?
  423. /// Number of consecutive successful push-to-start APNs sends that have NOT
  424. /// been followed by an `activityUpdates` adoption. When this reaches
  425. /// `pushToStartForceRestartThreshold`, the next foreground entry forces a
  426. /// local restart even outside the renewal window — ending the existing LA
  427. /// and starting a fresh one is the only known way to nudge iOS to issue a
  428. /// new `pushToStartToken` when the current one has gone silent
  429. /// (Apple FB21158660).
  430. private var pushToStartSendsWithoutAdoption: Int = 0
  431. /// Single-shot override for the next push-to-start reason tag. Consumed by
  432. /// `startIfNeeded`. Lets the deferred-foreground-restart path tag its
  433. /// push-to-start with a distinct label instead of "user-start", which made
  434. /// the 8:25 stale-latch event indistinguishable from a real user start in
  435. /// the log.
  436. private var nextStartReasonOverride: String?
  437. // MARK: - Public API
  438. @MainActor
  439. func startIfNeeded() {
  440. let authorized = ActivityAuthorizationInfo().areActivitiesEnabled
  441. let existingCount = Activity<GlucoseLiveActivityAttributes>.activities.count
  442. LogManager.shared.log(
  443. category: .general,
  444. message: "[LA] startIfNeeded: authorized=\(authorized), activities=\(existingCount), current=\(current?.id ?? "nil"), dismissedByUser=\(dismissedByUser), laEnabled=\(Storage.shared.laEnabled.value)",
  445. isDebug: true
  446. )
  447. guard authorized else {
  448. LogManager.shared.log(category: .general, message: "Live Activity not authorized")
  449. return
  450. }
  451. let startReason = nextStartReasonOverride ?? "user-start"
  452. nextStartReasonOverride = nil
  453. // Push-to-start is used for every creation path. If an activity is
  454. // already running and not stale we adopt/reuse it (covers warm starts
  455. // where the LA survived a relaunch); only truly new starts dispatch APNs.
  456. if let existing = Activity<GlucoseLiveActivityAttributes>.activities.first {
  457. let renewBy = Storage.shared.laRenewBy.value
  458. let now = Date().timeIntervalSince1970
  459. let staleDatePassed = existing.content.staleDate.map { $0 <= Date() } ?? false
  460. let inRenewalWindow = renewBy > 0 && now >= renewBy - LiveActivityManager.renewalWarning
  461. let needsRestart = Storage.shared.laRenewalFailed.value || inRenewalWindow || staleDatePassed
  462. if !needsRestart {
  463. bind(to: existing, logReason: "reuse")
  464. Storage.shared.laRenewalFailed.value = false
  465. return
  466. }
  467. LogManager.shared.log(
  468. category: .general,
  469. message: "[LA] existing activity is stale on startIfNeeded — push-to-start replace (staleDatePassed=\(staleDatePassed), inRenewalWindow=\(inRenewalWindow))"
  470. )
  471. attemptPushToStartCreate(reason: startReason, oldActivity: existing)
  472. return
  473. }
  474. attemptPushToStartCreate(reason: startReason, oldActivity: nil)
  475. }
  476. /// Called from applicationWillTerminate. Ends the LA synchronously (blocking
  477. /// up to 3 s) so it clears from the lock screen before the process exits.
  478. /// Does not clear laEnabled — the user's preference is preserved for relaunch.
  479. func endOnTerminate() {
  480. guard let activity = current else { return }
  481. // Flag the end as system-initiated so the state observer does not
  482. // classify the resulting `.dismissed` as a user swipe (laRenewBy is
  483. // cleared below, which would otherwise make pastDeadline=false).
  484. endingForRestart = true
  485. current = nil
  486. Storage.shared.laRenewBy.value = 0
  487. LALivenessStore.clear()
  488. let semaphore = DispatchSemaphore(value: 0)
  489. Task.detached {
  490. await activity.end(nil, dismissalPolicy: .immediate)
  491. semaphore.signal()
  492. }
  493. _ = semaphore.wait(timeout: .now() + 3)
  494. LogManager.shared.log(category: .general, message: "[LA] ended on app terminate")
  495. }
  496. func end(dismissalPolicy: ActivityUIDismissalPolicy = .default) {
  497. updateTask?.cancel()
  498. updateTask = nil
  499. guard let activity = current else { return }
  500. Task {
  501. let finalState = GlucoseLiveActivityAttributes.ContentState(
  502. snapshot: GlucoseSnapshotStore.shared.load() ?? GlucoseSnapshot(
  503. glucose: 0,
  504. delta: 0,
  505. trend: .unknown,
  506. updatedAt: Date(),
  507. iob: nil,
  508. cob: nil,
  509. projected: nil,
  510. unit: .mgdl,
  511. isNotLooping: false,
  512. ),
  513. seq: seq,
  514. reason: "end",
  515. producedAt: Date(),
  516. )
  517. let content = ActivityContent(state: finalState, staleDate: nil)
  518. await activity.end(content, dismissalPolicy: dismissalPolicy)
  519. LogManager.shared.log(category: .general, message: "Live Activity ended id=\(activity.id)", isDebug: true)
  520. if current?.id == activity.id {
  521. current = nil
  522. Storage.shared.laRenewBy.value = 0
  523. LALivenessStore.clear()
  524. }
  525. }
  526. }
  527. /// Ends all running Live Activities and starts a fresh one from the current state.
  528. /// Intended for the "Restart Live Activity" button and the AppIntent.
  529. @MainActor
  530. func forceRestart() {
  531. guard Storage.shared.laEnabled.value else { return }
  532. LogManager.shared.log(category: .general, message: "[LA] forceRestart called")
  533. // Mark as system-initiated so any residual `.dismissed` delivered from
  534. // the cancelled state observer stream cannot flip dismissedByUser=true
  535. // and spoil the freshly started LA.
  536. endingForRestart = true
  537. dismissedByUser = false
  538. Storage.shared.laRenewBy.value = 0
  539. Storage.shared.laRenewalFailed.value = false
  540. // The user explicitly asked for a fresh LA — clear any push-to-start
  541. // backoff that would otherwise rate-limit the Restart button silently.
  542. Storage.shared.laLastPushToStartAt.value = 0
  543. Storage.shared.laPushToStartBackoff.value = 0
  544. pushToStartSendsWithoutAdoption = 0
  545. LALivenessStore.clear()
  546. cancelRenewalFailedNotification()
  547. current = nil
  548. updateTask?.cancel(); updateTask = nil
  549. tokenObservationTask?.cancel(); tokenObservationTask = nil
  550. stateObserverTask?.cancel(); stateObserverTask = nil
  551. pushToken = nil
  552. Task {
  553. for activity in Activity<GlucoseLiveActivityAttributes>.activities {
  554. await activity.end(nil, dismissalPolicy: .immediate)
  555. }
  556. await MainActor.run {
  557. self.startFromCurrentState(cleanupOrphans: false)
  558. LogManager.shared.log(category: .general, message: "[LA] forceRestart: Live Activity restarted")
  559. }
  560. }
  561. }
  562. @MainActor
  563. func startFromCurrentState(cleanupOrphans: Bool = false) {
  564. guard Storage.shared.laEnabled.value, !dismissedByUser else { return }
  565. if cleanupOrphans {
  566. endOrphanedActivities()
  567. }
  568. let provider = StorageCurrentGlucoseStateProvider()
  569. if let snapshot = GlucoseSnapshotBuilder.build(from: provider) {
  570. LAAppGroupSettings.setThresholds(
  571. lowMgdl: Storage.shared.lowLine.value,
  572. highMgdl: Storage.shared.highLine.value,
  573. )
  574. LAAppGroupSettings.setDisplayName(
  575. Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String ?? "LoopFollow",
  576. show: Storage.shared.showDisplayName.value
  577. )
  578. GlucoseSnapshotStore.shared.save(snapshot)
  579. }
  580. startIfNeeded()
  581. }
  582. func refreshFromCurrentState(reason: String) {
  583. // No LA guard here — Watch and store must update regardless of LA state.
  584. // LA-specific gating (laEnabled, dismissedByUser) is applied inside performRefresh.
  585. refreshWorkItem?.cancel()
  586. let workItem = DispatchWorkItem { [weak self] in
  587. self?.performRefresh(reason: reason)
  588. }
  589. refreshWorkItem = workItem
  590. DispatchQueue.main.asyncAfter(deadline: .now() + 20.0, execute: workItem)
  591. }
  592. // MARK: - Renewal
  593. /// Requests a fresh Live Activity to replace the current one when the renewal
  594. /// deadline has passed, working around Apple's 8-hour maximum LA lifetime.
  595. /// Returns true if renewal was performed (caller should return early).
  596. private func renewIfNeeded(snapshot: GlucoseSnapshot) -> Bool {
  597. guard let oldActivity = current else { return false }
  598. let renewBy = Storage.shared.laRenewBy.value
  599. guard renewBy > 0, Date().timeIntervalSince1970 >= renewBy else { return false }
  600. let overdueBy = Date().timeIntervalSince1970 - renewBy
  601. LogManager.shared.log(category: .general, message: "[LA] renewal deadline passed by \(Int(overdueBy))s, requesting new LA")
  602. // Renewal goes through push-to-start. The dispatch hops to MainActor
  603. // and returns immediately; adoption (or failure) lands in the observer.
  604. // Return true so performRefresh stops processing this tick.
  605. Task { @MainActor [weak self] in
  606. self?.attemptPushToStartCreate(reason: "renew", oldActivity: oldActivity, snapshot: snapshot)
  607. }
  608. return true
  609. }
  610. // MARK: - Push-to-start
  611. /// Single creation path. Handles initial start, renewal, and forced restart.
  612. /// Verifies token + APNs credentials, applies backoff, then dispatches the
  613. /// APNs push-to-start call. The old activity is only ended after a confirmed
  614. /// successful send, preserving it if the send fails. Adoption is delivered
  615. /// via the `activityUpdates` observer — `handlePushToStartResult` only
  616. /// updates backoff/state.
  617. @MainActor
  618. private func attemptPushToStartCreate(
  619. reason: String,
  620. oldActivity: Activity<GlucoseLiveActivityAttributes>?,
  621. snapshot: GlucoseSnapshot? = nil
  622. ) {
  623. // Validate APNs credentials up-front — push-to-start is the only
  624. // transport, so missing/invalid creds mean the LA will never display.
  625. let keyId = Storage.shared.lfKeyId.value
  626. let apnsKey = Storage.shared.lfApnsKey.value
  627. guard APNsCredentialValidator.isFullyConfigured(keyId: keyId, apnsKey: apnsKey) else {
  628. LogManager.shared.log(
  629. category: .general,
  630. message: "[LA] push-to-start (\(reason)) blocked — APNs credentials missing or invalid (keyId valid=\(APNsCredentialValidator.isValidKeyId(keyId)), apnsKey valid=\(APNsCredentialValidator.isValidApnsKey(apnsKey)))"
  631. )
  632. scheduleApnsCredentialsMissingNotification()
  633. return
  634. }
  635. let now = Date().timeIntervalSince1970
  636. let lastAt = Storage.shared.laLastPushToStartAt.value
  637. let backoff = Storage.shared.laPushToStartBackoff.value
  638. if lastAt > 0, now < lastAt + backoff {
  639. let wait = Int(lastAt + backoff - now)
  640. LogManager.shared.log(
  641. category: .general,
  642. message: "[LA] push-to-start (\(reason)) rate-limited: next allowed in \(wait)s (backoff=\(Int(backoff))s)"
  643. )
  644. return
  645. }
  646. // Build snapshot if caller didn't supply one (initial start path).
  647. let workingSnapshot: GlucoseSnapshot = {
  648. if let snapshot { return snapshot }
  649. let provider = StorageCurrentGlucoseStateProvider()
  650. return GlucoseSnapshotBuilder.build(from: provider)
  651. ?? GlucoseSnapshotStore.shared.load()
  652. ?? GlucoseSnapshot(
  653. glucose: 0,
  654. delta: 0,
  655. trend: .unknown,
  656. updatedAt: Date(),
  657. iob: nil,
  658. cob: nil,
  659. projected: nil,
  660. unit: .mgdl,
  661. isNotLooping: false,
  662. )
  663. }()
  664. Task { [weak self] in
  665. guard let self else { return }
  666. await self.dispatchPushToStart(
  667. reason: reason,
  668. oldActivity: oldActivity,
  669. snapshot: workingSnapshot
  670. )
  671. }
  672. }
  673. private func dispatchPushToStart(
  674. reason: String,
  675. oldActivity: Activity<GlucoseLiveActivityAttributes>?,
  676. snapshot: GlucoseSnapshot,
  677. isRetry: Bool = false
  678. ) async {
  679. // Wait briefly for the push-to-start token to arrive — covers the
  680. // fresh-install case where the user toggles LA on before iOS has
  681. // delivered the first token via pushToStartTokenUpdates.
  682. var token = Storage.shared.laPushToStartToken.value
  683. if token.isEmpty {
  684. let pollIntervalNs = UInt64(LiveActivityManager.pushToStartTokenPollInterval * 1_000_000_000)
  685. let maxAttempts = Int(LiveActivityManager.pushToStartTokenWaitTimeout / LiveActivityManager.pushToStartTokenPollInterval)
  686. for attempt in 1 ... maxAttempts {
  687. try? await Task.sleep(nanoseconds: pollIntervalNs)
  688. token = Storage.shared.laPushToStartToken.value
  689. if !token.isEmpty {
  690. LogManager.shared.log(
  691. category: .general,
  692. message: "[LA] push-to-start (\(reason)) token arrived after \(attempt) poll(s)"
  693. )
  694. break
  695. }
  696. }
  697. }
  698. guard !token.isEmpty else {
  699. if isRetry {
  700. // Token still absent after retry — give up and notify the user.
  701. LogManager.shared.log(
  702. category: .general,
  703. message: "[LA] push-to-start (\(reason)) aborted — no token after retry (iOS hasn't issued one yet)"
  704. )
  705. await MainActor.run { self.schedulePushToStartTokenMissingNotification() }
  706. } else {
  707. // Token likely en route — wait briefly and make a single automatic
  708. // retry before surfacing an error to the user.
  709. LogManager.shared.log(
  710. category: .general,
  711. message: "[LA] push-to-start (\(reason)) no token after \(Int(LiveActivityManager.pushToStartTokenWaitTimeout))s — retrying in \(Int(LiveActivityManager.pushToStartTokenRetryDelay))s"
  712. )
  713. try? await Task.sleep(nanoseconds: UInt64(LiveActivityManager.pushToStartTokenRetryDelay * 1_000_000_000))
  714. await dispatchPushToStart(reason: reason, oldActivity: oldActivity, snapshot: snapshot, isRetry: true)
  715. }
  716. return
  717. }
  718. // Record attempt time up-front so two refresh ticks can't double-fire.
  719. await MainActor.run {
  720. Storage.shared.laLastPushToStartAt.value = Date().timeIntervalSince1970
  721. }
  722. let nextSeq = await MainActor.run { () -> Int in
  723. self.seq += 1
  724. return self.seq
  725. }
  726. let freshSnapshot = snapshot.withRenewalOverlay(false)
  727. let state = GlucoseLiveActivityAttributes.ContentState(
  728. snapshot: freshSnapshot,
  729. seq: nextSeq,
  730. reason: reason,
  731. producedAt: Date(),
  732. )
  733. let staleDate = Date().addingTimeInterval(LiveActivityManager.renewalThreshold)
  734. let tail = String(token.suffix(8))
  735. LogManager.shared.log(
  736. category: .general,
  737. message: "[LA] push-to-start (\(reason)) firing token=…\(tail) seq=\(nextSeq) staleIn=\(Int(staleDate.timeIntervalSinceNow))s"
  738. )
  739. let sendStart = Date()
  740. let result = await APNSClient.shared.sendLiveActivityStart(
  741. pushToStartToken: token,
  742. attributesTitle: "LoopFollow",
  743. state: state,
  744. staleDate: staleDate,
  745. )
  746. let elapsedMs = Int(Date().timeIntervalSince(sendStart) * 1000)
  747. LogManager.shared.log(
  748. category: .general,
  749. message: "[LA] push-to-start (\(reason)) APNs round-trip result=\(result) elapsed=\(elapsedMs)ms"
  750. )
  751. // End the old activity only after a confirmed successful send — if the
  752. // send fails the user keeps their existing LA rather than losing data
  753. // with nothing to replace it.
  754. if result == .success, let oldActivity {
  755. LogManager.shared.log(
  756. category: .general,
  757. message: "[LA] push-to-start (\(reason)) send succeeded — ending oldActivity=\(oldActivity.id)"
  758. )
  759. await oldActivity.end(nil, dismissalPolicy: .immediate)
  760. }
  761. await MainActor.run {
  762. self.handlePushToStartResult(result, reason: reason)
  763. }
  764. }
  765. @MainActor
  766. private func handlePushToStartResult(
  767. _ result: APNSClient.PushToStartResult,
  768. reason: String
  769. ) {
  770. switch result {
  771. case .success:
  772. // Adoption of the new LA runs via `activityUpdates` observation,
  773. // which ends the old activity, resets the renewal deadline and
  774. // clears `laRenewalFailed`. Apply base backoff so refresh ticks
  775. // between now and adoption don't re-fire push-to-start.
  776. Storage.shared.laPushToStartBackoff.value = LiveActivityManager.pushToStartBaseBackoff
  777. lastPushToStartSuccessAt = Date()
  778. pushToStartSendsWithoutAdoption += 1
  779. LogManager.shared.log(
  780. category: .general,
  781. message: "[LA] push-to-start (\(reason)) succeeded — awaiting activityUpdates to adopt new LA (backoff=\(Int(LiveActivityManager.pushToStartBaseBackoff))s, sendsWithoutAdoption=\(pushToStartSendsWithoutAdoption))"
  782. )
  783. case .rateLimited:
  784. let currentBackoff = Storage.shared.laPushToStartBackoff.value
  785. let next = min(
  786. LiveActivityManager.pushToStartMaxBackoff,
  787. max(LiveActivityManager.pushToStartBaseBackoff, currentBackoff * 2)
  788. )
  789. Storage.shared.laPushToStartBackoff.value = next
  790. LogManager.shared.log(
  791. category: .general,
  792. message: "[LA] push-to-start (\(reason)) 429 — backoff raised to \(Int(next))s"
  793. )
  794. if reason == "renew" { markRenewalFailedFromBackground() }
  795. case .tokenInvalid:
  796. // Clear the stored token so the next `pushToStartTokenUpdates`
  797. // delivery overwrites it. Reset backoff — no point holding off
  798. // while we wait for iOS to reissue.
  799. Storage.shared.laPushToStartToken.value = ""
  800. Storage.shared.laPushToStartBackoff.value = 0
  801. LogManager.shared.log(
  802. category: .general,
  803. message: "[LA] push-to-start (\(reason)) token invalid — cleared, awaiting new token"
  804. )
  805. if reason == "renew" { markRenewalFailedFromBackground() }
  806. case .failed:
  807. let currentBackoff = Storage.shared.laPushToStartBackoff.value
  808. if currentBackoff < LiveActivityManager.pushToStartBaseBackoff {
  809. Storage.shared.laPushToStartBackoff.value = LiveActivityManager.pushToStartBaseBackoff
  810. }
  811. if reason == "renew" { markRenewalFailedFromBackground() }
  812. }
  813. }
  814. /// Background renewal couldn't restart the LA via push-to-start (rate-limited,
  815. /// invalid token, etc.). Mark the state so the renewal overlay shows on the
  816. /// lock screen, and post a local notification on the first failure so the
  817. /// user knows to foreground the app.
  818. private func markRenewalFailedFromBackground() {
  819. let isFirstFailure = !Storage.shared.laRenewalFailed.value
  820. Storage.shared.laRenewalFailed.value = true
  821. LogManager.shared.log(
  822. category: .general,
  823. message: "[LA] push-to-start renewal failed — renewal marked failed"
  824. )
  825. if isFirstFailure {
  826. scheduleRenewalFailedNotification()
  827. }
  828. }
  829. private func performRefresh(reason: String) {
  830. let provider = StorageCurrentGlucoseStateProvider()
  831. guard let snapshot = GlucoseSnapshotBuilder.build(from: provider) else {
  832. return
  833. }
  834. LogManager.shared.log(category: .general, message: "[LA] refresh g=\(snapshot.glucose) reason=\(reason)", isDebug: true)
  835. let fingerprint =
  836. "g=\(snapshot.glucose) d=\(snapshot.delta) t=\(snapshot.trend.rawValue) " +
  837. "at=\(snapshot.updatedAt.timeIntervalSince1970) iob=\(snapshot.iob?.description ?? "nil") " +
  838. "cob=\(snapshot.cob?.description ?? "nil") proj=\(snapshot.projected?.description ?? "nil") u=\(snapshot.unit.rawValue)"
  839. LogManager.shared.log(category: .general, message: "[LA] snapshot \(fingerprint) reason=\(reason)", isDebug: true)
  840. // Check if the Live Activity is approaching Apple's 8-hour limit and renew if so.
  841. if renewIfNeeded(snapshot: snapshot) { return }
  842. if snapshot.showRenewalOverlay {
  843. LogManager.shared.log(category: .general, message: "[LA] sending update with renewal overlay visible")
  844. }
  845. let now = Date()
  846. let timeSinceLastUpdate = now.timeIntervalSince(lastUpdateTime ?? .distantPast)
  847. let forceRefreshNeeded = timeSinceLastUpdate >= 5 * 60
  848. // Capture dedup result BEFORE saving so the store comparison is valid.
  849. let snapshotUnchanged = GlucoseSnapshotStore.shared.load() == snapshot
  850. // Store + Watch: always update, independent of LA state.
  851. LAAppGroupSettings.setThresholds(
  852. lowMgdl: Storage.shared.lowLine.value,
  853. highMgdl: Storage.shared.highLine.value,
  854. )
  855. GlucoseSnapshotStore.shared.save(snapshot)
  856. // WatchConnectivityManager.shared.send(snapshot: snapshot)
  857. // LA update: gated on LA being active, snapshot having changed, and activities enabled.
  858. if !Storage.shared.laEnabled.value {
  859. LogManager.shared.log(category: .general, message: "[LA] refresh: LA update skipped — laEnabled=false reason=\(reason)", isDebug: true)
  860. return
  861. }
  862. if dismissedByUser {
  863. LogManager.shared.log(category: .general, message: "[LA] refresh: LA update skipped — dismissedByUser=true reason=\(reason)")
  864. return
  865. }
  866. guard !snapshotUnchanged || forceRefreshNeeded else { return }
  867. guard ActivityAuthorizationInfo().areActivitiesEnabled else {
  868. LogManager.shared.log(category: .general, message: "[LA] refresh: LA update skipped — areActivitiesEnabled=false reason=\(reason)")
  869. return
  870. }
  871. if current == nil,
  872. let existing = Activity<GlucoseLiveActivityAttributes>.activities.first(where: { $0.activityState == .active })
  873. {
  874. // Skip activities already in .ended/.dismissed — those are corpses
  875. // (typically post-410 ends pending iOS dismissal). Binding to them
  876. // would clear endingForRestart and turn the eventual iOS dismissal
  877. // into a misclassified user swipe.
  878. bind(to: existing, logReason: "bind-existing")
  879. }
  880. if let _ = current {
  881. update(snapshot: snapshot, reason: reason)
  882. return
  883. }
  884. if isAppVisibleForLiveActivityStart() {
  885. Task { @MainActor in
  886. self.startIfNeeded()
  887. if self.current != nil {
  888. self.update(snapshot: snapshot, reason: reason)
  889. }
  890. }
  891. } else {
  892. LogManager.shared.log(category: .general, message: "LA start suppressed (not visible) reason=\(reason)", isDebug: true)
  893. }
  894. }
  895. private func isAppVisibleForLiveActivityStart() -> Bool {
  896. let scenes = UIApplication.shared.connectedScenes
  897. return scenes.contains { $0.activationState == .foregroundActive }
  898. }
  899. func update(snapshot: GlucoseSnapshot, reason: String) {
  900. if current == nil,
  901. let existing = Activity<GlucoseLiveActivityAttributes>.activities.first(where: { $0.activityState == .active })
  902. {
  903. bind(to: existing, logReason: "bind-existing")
  904. }
  905. guard let activity = current else { return }
  906. updateTask?.cancel()
  907. seq += 1
  908. let nextSeq = seq
  909. let activityID = activity.id
  910. let state = GlucoseLiveActivityAttributes.ContentState(
  911. snapshot: snapshot,
  912. seq: nextSeq,
  913. reason: reason,
  914. producedAt: Date(),
  915. )
  916. updateTask = Task { [weak self] in
  917. guard let self else { return }
  918. if activity.activityState == .ended || activity.activityState == .dismissed {
  919. if current?.id == activityID { current = nil }
  920. return
  921. }
  922. let content = ActivityContent(
  923. state: state,
  924. staleDate: Date(timeIntervalSince1970: Storage.shared.laRenewBy.value),
  925. relevanceScore: 100.0,
  926. )
  927. if Task.isCancelled { return }
  928. // Dual-path update strategy:
  929. // - Foreground: direct ActivityKit update works reliably.
  930. // - Background: direct update silently fails due to the audio session
  931. // limitation. APNs self-push is the only reliable delivery path.
  932. // Both paths are attempted when applicable; APNs is the authoritative
  933. // background mechanism.
  934. let isForeground = await MainActor.run {
  935. UIApplication.shared.applicationState == .active
  936. }
  937. if isForeground {
  938. await activity.update(content)
  939. } else {
  940. LogManager.shared.log(
  941. category: .general,
  942. message: "[LA] update seq=\(nextSeq) — app backgrounded, direct ActivityKit update skipped, relying on APNs",
  943. isDebug: true
  944. )
  945. }
  946. if Task.isCancelled { return }
  947. guard current?.id == activityID else {
  948. LogManager.shared.log(category: .general, message: "Live Activity update — activity ID mismatch, discarding")
  949. return
  950. }
  951. lastUpdateTime = Date()
  952. LogManager.shared.log(category: .general, message: "[LA] updated id=\(activityID) seq=\(nextSeq) reason=\(reason)", isDebug: true)
  953. if let token = pushToken {
  954. await APNSClient.shared.sendLiveActivityUpdate(pushToken: token, state: state)
  955. } else {
  956. LogManager.shared.log(
  957. category: .general,
  958. message: "[LA] update seq=\(nextSeq) reason=\(reason) — no push token yet, APNs skipped"
  959. )
  960. }
  961. }
  962. }
  963. // MARK: - Binding / Lifecycle
  964. /// Ends any Live Activities of this type that are not the one currently tracked.
  965. /// Called on app launch to clean up cards left behind by a previous crash.
  966. private func endOrphanedActivities() {
  967. for activity in Activity<GlucoseLiveActivityAttributes>.activities {
  968. guard activity.id != current?.id else { continue }
  969. let orphanID = activity.id
  970. Task {
  971. await activity.end(nil, dismissalPolicy: .immediate)
  972. LogManager.shared.log(category: .general, message: "Ended orphaned Live Activity id=\(orphanID)")
  973. }
  974. }
  975. }
  976. private func bind(to activity: Activity<GlucoseLiveActivityAttributes>, logReason: String) {
  977. if current?.id == activity.id { return }
  978. current = activity
  979. let wasEndingForRestart = endingForRestart
  980. dismissedByUser = false
  981. endingForRestart = false
  982. attachStateObserver(to: activity)
  983. LogManager.shared.log(
  984. category: .general,
  985. message: "Live Activity bound id=\(activity.id) state=\(activity.activityState) (\(logReason)) — endingForRestart cleared (was \(wasEndingForRestart))",
  986. isDebug: true
  987. )
  988. observePushToken(for: activity)
  989. }
  990. private func observePushToken(for activity: Activity<GlucoseLiveActivityAttributes>) {
  991. tokenObservationTask?.cancel()
  992. let activityID = activity.id
  993. tokenObservationTask = Task {
  994. for await tokenData in activity.pushTokenUpdates {
  995. let token = tokenData.map { String(format: "%02x", $0) }.joined()
  996. let previousTail = self.pushToken.map { String($0.suffix(8)) } ?? "nil"
  997. let tail = String(token.suffix(8))
  998. self.pushToken = token
  999. LogManager.shared.log(
  1000. category: .general,
  1001. message: "[LA] push token received id=\(activityID) token=…\(tail) (prev=…\(previousTail))"
  1002. )
  1003. }
  1004. }
  1005. }
  1006. func handleExpiredToken() {
  1007. let existing = Activity<GlucoseLiveActivityAttributes>.activities.count
  1008. LogManager.shared.log(
  1009. category: .general,
  1010. message: "[LA] handleExpiredToken: current=\(current?.id ?? "nil"), activities=\(existing), dismissedByUser=\(dismissedByUser) — marking endingForRestart and ending"
  1011. )
  1012. // Mark as system-initiated so the `.dismissed` delivered by end()
  1013. // is not classified as a user swipe — that would set dismissedByUser=true
  1014. // and block the restart kicked off below.
  1015. endingForRestart = true
  1016. end()
  1017. // Waiting for the next BG refresh is unreliable: end() nulls `current`
  1018. // and clears laRenewBy, so renewIfNeeded short-circuits and performRefresh's
  1019. // bind-existing path rebinds to the just-ended activity — clearing
  1020. // endingForRestart and turning the eventual iOS dismissal into a misclassified
  1021. // user swipe. Drive the restart synchronously instead.
  1022. Task { @MainActor [weak self] in
  1023. self?.attemptPushToStartCreate(reason: "expired-token", oldActivity: nil)
  1024. }
  1025. }
  1026. // MARK: - Renewal Notifications
  1027. private static let renewalNotificationID = "\(Bundle.main.bundleIdentifier ?? "loopfollow").la.renewal.failed"
  1028. private static let apnsCredentialsNotificationID = "\(Bundle.main.bundleIdentifier ?? "loopfollow").la.apns.missing"
  1029. private static let pushToStartTokenNotificationID = "\(Bundle.main.bundleIdentifier ?? "loopfollow").la.token.missing"
  1030. private func scheduleRenewalFailedNotification() {
  1031. let content = UNMutableNotificationContent()
  1032. content.title = "Live Activity Expiring"
  1033. content.body = "Live Activity will expire soon. Open LoopFollow to restart."
  1034. content.sound = .default
  1035. let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false)
  1036. let request = UNNotificationRequest(
  1037. identifier: LiveActivityManager.renewalNotificationID,
  1038. content: content,
  1039. trigger: trigger,
  1040. )
  1041. UNUserNotificationCenter.current().add(request) { error in
  1042. if let error {
  1043. LogManager.shared.log(category: .general, message: "[LA] failed to schedule renewal notification: \(error)")
  1044. }
  1045. }
  1046. LogManager.shared.log(category: .general, message: "[LA] renewal failed notification scheduled")
  1047. }
  1048. private func cancelRenewalFailedNotification() {
  1049. let id = LiveActivityManager.renewalNotificationID
  1050. UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [id])
  1051. UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [id])
  1052. }
  1053. private func scheduleApnsCredentialsMissingNotification() {
  1054. let content = UNMutableNotificationContent()
  1055. content.title = "Live Activity Setup Needed"
  1056. content.body = "APNs credentials are missing or invalid. Configure them in Settings → APN."
  1057. content.sound = .default
  1058. let request = UNNotificationRequest(
  1059. identifier: LiveActivityManager.apnsCredentialsNotificationID,
  1060. content: content,
  1061. trigger: UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false),
  1062. )
  1063. UNUserNotificationCenter.current().add(request, withCompletionHandler: nil)
  1064. }
  1065. private func schedulePushToStartTokenMissingNotification() {
  1066. let content = UNMutableNotificationContent()
  1067. content.title = "Live Activity Could Not Start"
  1068. content.body = "Live Activity could not start — try again in a moment."
  1069. content.sound = .default
  1070. let request = UNNotificationRequest(
  1071. identifier: LiveActivityManager.pushToStartTokenNotificationID,
  1072. content: content,
  1073. trigger: UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false),
  1074. )
  1075. UNUserNotificationCenter.current().add(request, withCompletionHandler: nil)
  1076. }
  1077. private func attachStateObserver(to activity: Activity<GlucoseLiveActivityAttributes>) {
  1078. stateObserverTask?.cancel()
  1079. stateObserverTask = Task {
  1080. for await state in activity.activityStateUpdates {
  1081. LogManager.shared.log(category: .general, message: "Live Activity state id=\(activity.id) -> \(state)", isDebug: true)
  1082. if state == .ended || state == .dismissed {
  1083. // Capture whether this delivery is for the activity we currently track
  1084. // BEFORE clearing `current` below. The classifier needs this signal to
  1085. // distinguish a real user swipe of the foreground LA from a late
  1086. // .dismissed delivered by a stale observer for an activity we already
  1087. // ended programmatically.
  1088. let wasCurrentActivity = current?.id == activity.id
  1089. if wasCurrentActivity {
  1090. current = nil
  1091. // Do NOT clear laRenewBy here. Preserving it means handleForeground()
  1092. // can detect the renewal window on the next foreground event and restart
  1093. // automatically — whether the LA ended normally (.ended) or was
  1094. // system-dismissed (.dismissed). laRenewBy is only set to 0 when:
  1095. // • the user explicitly swipes (below) — renewal intent cancelled
  1096. // • a new LA starts (startIfNeeded writes the new deadline)
  1097. // • handleForeground() clears it synchronously before restarting
  1098. // • the user disables LA or calls forceRestart
  1099. LogManager.shared.log(category: .general, message: "[LA] activity cleared id=\(activity.id) state=\(state)", isDebug: true)
  1100. }
  1101. if state == .ended, wasCurrentActivity, !endingForRestart {
  1102. // iOS terminated the activity itself — typically the ~8h lifetime
  1103. // cap reached before renewal fired. The .dismissed path below
  1104. // already handles iOS-initiated dismissals via renewalFailed /
  1105. // pastDeadline, but .ended bypasses that branch entirely. Without
  1106. // a signal here, handleForeground() sees `renewalFailed=false` and
  1107. // `renewBy` still in the future, returns "no action needed", and
  1108. // startIfNeeded keeps re-binding the corpse — the LA stays dark
  1109. // until the user manually force-restarts. Mark renewalFailed so
  1110. // the next foreground entry runs performForegroundRestart, which
  1111. // sweeps any leftover ended activity and pushes a fresh one.
  1112. Storage.shared.laRenewalFailed.value = true
  1113. LogManager.shared.log(category: .general, message: "[LA] ended by iOS (not our restart) — marked renewalFailed=true, auto-restart on next foreground")
  1114. }
  1115. if state == .dismissed {
  1116. // Three possible sources of .dismissed — only the third blocks restart:
  1117. //
  1118. // (a) endingForRestart: our own end() during a planned restart.
  1119. // Must be checked first: handleForeground() clears laRenewalFailed
  1120. // and laRenewBy synchronously before calling end(), so those flags
  1121. // would read as "no problem" even though we initiated the dismissal.
  1122. //
  1123. // (b) iOS system force-dismiss: either laRenewalFailed is set (our 8-hour
  1124. // renewal logic marked it) or the renewal deadline has already passed
  1125. // (laRenewBy > 0 && now >= laRenewBy). In both cases iOS acted, not
  1126. // the user. laRenewBy is preserved so handleForeground() restarts on
  1127. // the next foreground.
  1128. //
  1129. // (c) User decision: the user explicitly swiped the LA away. Block
  1130. // auto-restart until forceRestart() is called. Clear laRenewBy so
  1131. // handleForeground() does NOT re-enter the renewal path on the next
  1132. // foreground — the renewal intent is cancelled by the user's choice.
  1133. //
  1134. // Gated on `wasCurrentActivity`: the user can only swipe the
  1135. // foreground LA. A .dismissed for an activity we no longer track is a
  1136. // stale observer (the activity was ended programmatically and iOS is
  1137. // just now cleaning up) — must not latch dismissedByUser=true.
  1138. let now = Date().timeIntervalSince1970
  1139. let renewBy = Storage.shared.laRenewBy.value
  1140. let renewalFailed = Storage.shared.laRenewalFailed.value
  1141. let pastDeadline = renewBy > 0 && now >= renewBy
  1142. LogManager.shared.log(category: .general, message: "[LA] .dismissed: endingForRestart=\(endingForRestart), renewalFailed=\(renewalFailed), pastDeadline=\(pastDeadline), wasCurrent=\(wasCurrentActivity), renewBy=\(renewBy), now=\(now)")
  1143. if endingForRestart {
  1144. // (a) Our own restart — do nothing, Task handles the rest.
  1145. LogManager.shared.log(category: .general, message: "[LA] dismissed by self (endingForRestart) — restart in-flight, no action")
  1146. } else if renewalFailed || pastDeadline {
  1147. // (b) iOS system force-dismiss — allow auto-restart on next foreground.
  1148. LogManager.shared.log(category: .general, message: "[LA] dismissed by iOS (renewalFailed=\(renewalFailed), pastDeadline=\(pastDeadline)) — auto-restart on next foreground")
  1149. } else if !wasCurrentActivity {
  1150. // (d) Stale observer for an activity we no longer track (e.g. a
  1151. // post-410 end whose iOS-side dismissal landed hours later).
  1152. // Not a user swipe — no flags to set.
  1153. LogManager.shared.log(category: .general, message: "[LA] dismissed by stale observer (id=\(activity.id) is not current) — no action")
  1154. } else {
  1155. // (c) User decision — cancel renewal intent, block auto-restart.
  1156. dismissedByUser = true
  1157. Storage.shared.laRenewBy.value = 0
  1158. LogManager.shared.log(category: .general, message: "[LA] dismissed by USER (renewBy=\(renewBy), now=\(now)) — laRenewBy cleared, auto-restart BLOCKED until forceRestart")
  1159. }
  1160. }
  1161. }
  1162. }
  1163. }
  1164. }
  1165. }
  1166. extension Notification.Name {
  1167. /// Posted when the user taps the Live Activity or Dynamic Island.
  1168. /// Observers navigate to the Home or Snoozer tab as appropriate.
  1169. static let liveActivityDidForeground = Notification.Name("liveActivityDidForeground")
  1170. }
  1171. #endif