LiveActivityManager.swift 65 KB

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