LiveActivityManager.swift 71 KB

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