| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470 |
- // LoopFollow
- // LiveActivityManager.swift
- // swiftformat:disable indent
- #if !targetEnvironment(macCatalyst)
- @preconcurrency import ActivityKit
- import Foundation
- import os
- import UIKit
- import UserNotifications
- // Live Activity manager for LoopFollow.
- //
- // iOS 17.2+: every LA creation (initial start, renewal, forced
- // restart) goes through APNs push-to-start. Updates
- // ride the same APNs transport. One transport, one
- // credential failure mode that surfaces in settings.
- //
- // iOS 16.6 – 17.1: legacy Activity.request() for everything;
- // renewal-failed notification when backgrounded.
- // The entry-point `if #available(iOS 17.2, *)` checks
- // isolate every iOS 17.2 code path, so the legacy
- // helpers can be deleted in one commit when the
- // deployment target reaches 17.2.
- final class LiveActivityManager {
- static let shared = LiveActivityManager()
- private init() {
- NotificationCenter.default.addObserver(
- self,
- selector: #selector(handleForeground),
- name: UIApplication.willEnterForegroundNotification,
- object: nil,
- )
- NotificationCenter.default.addObserver(
- self,
- selector: #selector(handleDidBecomeActive),
- name: UIApplication.didBecomeActiveNotification,
- object: nil,
- )
- NotificationCenter.default.addObserver(
- self,
- selector: #selector(handleWillResignActive),
- name: UIApplication.willResignActiveNotification,
- object: nil,
- )
- NotificationCenter.default.addObserver(
- self,
- selector: #selector(handleBackgroundAudioFailed),
- name: .backgroundAudioFailed,
- object: nil,
- )
- startPushToStartTokenObservation()
- startActivityUpdatesObservation()
- }
- // MARK: - Push-to-start observation (iOS 17.2+)
- /// Observes the type-level push-to-start token (iOS 17.2+) and persists it.
- /// The token survives app relaunches but is reissued by iOS periodically or when
- /// the user toggles LA permissions — each new delivery overwrites the stored value.
- private func startPushToStartTokenObservation() {
- if #available(iOS 17.2, *) {
- pushToStartObservationTask?.cancel()
- LogManager.shared.log(
- category: .general,
- message: "[LA] pushToStartTokenUpdates observation starting (iOS 17.2+)"
- )
- pushToStartObservationTask = Task {
- var deliveries = 0
- for await tokenData in Activity<GlucoseLiveActivityAttributes>.pushToStartTokenUpdates {
- deliveries += 1
- let token = tokenData.map { String(format: "%02x", $0) }.joined()
- let previousTail = Storage.shared.laPushToStartToken.value.isEmpty
- ? "nil"
- : String(Storage.shared.laPushToStartToken.value.suffix(8))
- let tail = String(token.suffix(8))
- let changed = tail != previousTail
- Storage.shared.laPushToStartToken.value = token
- LogManager.shared.log(
- category: .general,
- message: "[LA] push-to-start token received #\(deliveries) token=…\(tail) (prev=…\(previousTail))\(changed ? " CHANGED" : " same")"
- )
- }
- LogManager.shared.log(
- category: .general,
- message: "[LA] pushToStartTokenUpdates stream ended after \(deliveries) deliveries — no further tokens will arrive"
- )
- }
- } else {
- LogManager.shared.log(
- category: .general,
- message: "[LA] pushToStartTokenUpdates unavailable (iOS <17.2) — push-to-start will never fire"
- )
- }
- }
- /// Observes new Activity creations. When an activity is started by
- /// push-to-start (iOS 17.2+), the app discovers it through this stream and
- /// adopts it via the same bind/update path as an app-initiated start.
- private func startActivityUpdatesObservation() {
- activityUpdatesObservationTask?.cancel()
- LogManager.shared.log(
- category: .general,
- message: "[LA] activityUpdates observation starting"
- )
- activityUpdatesObservationTask = Task { [weak self] in
- var deliveries = 0
- for await activity in Activity<GlucoseLiveActivityAttributes>.activityUpdates {
- deliveries += 1
- let incomingID = activity.id
- LogManager.shared.log(
- category: .general,
- message: "[LA] activityUpdates delivery #\(deliveries) id=\(incomingID) — dispatching to MainActor"
- )
- await MainActor.run {
- self?.adoptPushToStartActivity(activity)
- }
- }
- LogManager.shared.log(
- category: .general,
- message: "[LA] activityUpdates stream ended after \(deliveries) deliveries — push-to-start adoption will no longer work until app relaunch"
- )
- }
- }
- @MainActor
- private func adoptPushToStartActivity(_ activity: Activity<GlucoseLiveActivityAttributes>) {
- // Skip if it's the activity we already track (app-initiated path binds it directly).
- if current?.id == activity.id {
- LogManager.shared.log(
- category: .general,
- message: "[LA] activityUpdates: ignoring own activity id=\(activity.id) (already current)"
- )
- return
- }
- let adoptDelay = lastPushToStartSuccessAt.map { Int(Date().timeIntervalSince($0)) }
- let delayDescription = adoptDelay.map { "\($0)s after last push-to-start success" } ?? "no prior push-to-start this session"
- let totalActivities = Activity<GlucoseLiveActivityAttributes>.activities.count
- let staleDate = activity.content.staleDate
- let staleDesc = staleDate.map { String(format: "%.0f", $0.timeIntervalSinceNow) + "s" } ?? "nil"
- let incomingSeq = activity.content.state.seq
- LogManager.shared.log(
- category: .general,
- message: "[LA] adopt: id=\(activity.id) seq=\(incomingSeq) staleIn=\(staleDesc) totalActivities=\(totalActivities) (\(delayDescription))"
- )
- lastPushToStartSuccessAt = nil
- pushToStartSendsWithoutAdoption = 0
- // The new LA is confirmed — clear any post-send backoff so a legitimate
- // near-term renewal isn't silently blocked by the 5-minute base interval.
- Storage.shared.laPushToStartBackoff.value = 0
- // If we already have a current activity and this is a different one, it's likely
- // the new push-to-start LA replacing an old one. End the old, then bind the new.
- if let old = current, old.id != activity.id {
- LogManager.shared.log(
- category: .general,
- message: "[LA] activityUpdates: replacing old=\(old.id) with new=\(activity.id)"
- )
- let oldActivity = old
- Task {
- await oldActivity.end(nil, dismissalPolicy: .immediate)
- }
- } else {
- LogManager.shared.log(
- category: .general,
- message: "[LA] activityUpdates: adopting new activity id=\(activity.id) (no prior current)"
- )
- }
- // Fresh deadline — push-to-start-initiated LAs reset the 8-hour clock.
- Storage.shared.laRenewBy.value = Date().timeIntervalSince1970 + LiveActivityManager.renewalThreshold
- Storage.shared.laRenewalFailed.value = false
- cancelRenewalFailedNotification()
- dismissedByUser = false
- // A fresh LA invalidates any latched foreground-restart intent — the
- // condition that prompted the latch (overlay showing / renewal failed)
- // is resolved by adoption itself, so a deferred restart on the next
- // didBecomeActive would needlessly tear down the just-adopted LA.
- if pendingForegroundRestart {
- LogManager.shared.log(
- category: .general,
- message: "[LA] adoption clears stale pendingForegroundRestart (LA already replaced via push-to-start)"
- )
- pendingForegroundRestart = false
- }
- bind(to: activity, logReason: "push-to-start-adopt")
- }
- /// Fires before the app loses focus (lock screen, home button, etc.).
- /// Cancels any pending debounced refresh and pushes the latest snapshot
- /// directly to the Live Activity while the app is still foreground-active,
- /// ensuring the LA is up to date the moment the lock screen appears.
- @objc private func handleWillResignActive() {
- guard Storage.shared.laEnabled.value, let activity = current else { return }
- refreshWorkItem?.cancel()
- refreshWorkItem = nil
- let provider = StorageCurrentGlucoseStateProvider()
- guard let snapshot = GlucoseSnapshotBuilder.build(from: provider) else { return }
- LAAppGroupSettings.setThresholds(
- lowMgdl: Storage.shared.lowLine.value,
- highMgdl: Storage.shared.highLine.value,
- )
- GlucoseSnapshotStore.shared.save(snapshot)
- seq += 1
- let nextSeq = seq
- let state = GlucoseLiveActivityAttributes.ContentState(
- snapshot: snapshot,
- seq: nextSeq,
- reason: "resign-active",
- producedAt: Date(),
- )
- let content = ActivityContent(
- state: state,
- staleDate: Date(timeIntervalSince1970: Storage.shared.laRenewBy.value),
- relevanceScore: 100.0,
- )
- Task {
- // Direct ActivityKit update — app is still active at this point.
- await activity.update(content)
- LogManager.shared.log(category: .general, message: "[LA] resign-active flush sent seq=\(nextSeq)", isDebug: true)
- // Also send APNs so the extension receives the latest token-based update.
- if let token = pushToken {
- await APNSClient.shared.sendLiveActivityUpdate(pushToken: token, state: state)
- }
- }
- }
- @objc private func handleDidBecomeActive() {
- guard Storage.shared.laEnabled.value else { return }
- let appState = UIApplication.shared.applicationState.rawValue
- let existing = Activity<GlucoseLiveActivityAttributes>.activities.count
- if pendingForegroundRestart {
- pendingForegroundRestart = false
- LogManager.shared.log(
- category: .general,
- message: "[LA] didBecomeActive: running deferred foreground restart (appState=\(appState), activities=\(existing))"
- )
- performForegroundRestart()
- return
- }
- LogManager.shared.log(category: .general, message: "[LA] didBecomeActive: startFromCurrentState (appState=\(appState), activities=\(existing), current=\(current?.id ?? "nil"), dismissedByUser=\(dismissedByUser))", isDebug: true)
- Task { @MainActor in
- self.startFromCurrentState()
- }
- }
- @objc private func handleForeground() {
- guard Storage.shared.laEnabled.value else { return }
- let renewalFailed = Storage.shared.laRenewalFailed.value
- let renewBy = Storage.shared.laRenewBy.value
- let now = Date().timeIntervalSince1970
- let overlayIsShowing = renewBy > 0 && now >= renewBy - LiveActivityManager.renewalWarning
- let stuckSends = pushToStartSendsWithoutAdoption
- let pushToStartLooksStuck = stuckSends >= LiveActivityManager.pushToStartForceRestartThreshold
- let appState = UIApplication.shared.applicationState.rawValue
- let existing = Activity<GlucoseLiveActivityAttributes>.activities.count
- LogManager.shared.log(
- category: .general,
- message: "[LA] foreground: appState=\(appState), activities=\(existing), renewalFailed=\(renewalFailed), overlayShowing=\(overlayIsShowing), current=\(current?.id ?? "nil"), dismissedByUser=\(dismissedByUser), renewBy=\(renewBy), now=\(now), pushToStartSendsWithoutAdoption=\(stuckSends)"
- )
- guard renewalFailed || overlayIsShowing || pushToStartLooksStuck else {
- LogManager.shared.log(category: .general, message: "[LA] foreground: no action needed (not in renewal window)")
- return
- }
- if pushToStartLooksStuck {
- // Reset the counter now so we don't re-trigger on every foreground
- // entry until the next round of silently-failed sends actually
- // builds up again. The restart itself ends the current LA and
- // starts a fresh one, which (per Apple's docs) should cause iOS to
- // emit a new pushToStartToken — the workaround for FB21158660.
- pushToStartSendsWithoutAdoption = 0
- LogManager.shared.log(
- category: .general,
- message: "[LA] foreground: push-to-start looks stuck (sendsWithoutAdoption=\(stuckSends) ≥ \(LiveActivityManager.pushToStartForceRestartThreshold)) — forcing local restart to nudge token rotation"
- )
- } else {
- LogManager.shared.log(
- category: .general,
- message: "[LA] ending stale LA and restarting (renewalFailed=\(renewalFailed), overlayShowing=\(overlayIsShowing))"
- )
- }
- // willEnterForegroundNotification fires before the scene reaches
- // foregroundActive — Activity.request() returns `visibility` during
- // this window. Defer the actual restart to didBecomeActive.
- pendingForegroundRestart = true
- LogManager.shared.log(
- category: .general,
- message: "[LA] foreground: scheduling restart on next didBecomeActive"
- )
- }
- private func performForegroundRestart() {
- // Re-check the conditions that latched the intent. The latch can outlive its
- // trigger — e.g. if the user briefly foregrounds the app while the renewal
- // overlay is up, then backgrounds before didBecomeActive runs, the background
- // renewal can replace the LA before the next foreground entry. By the time
- // didBecomeActive eventually fires, the freshly-renewed LA is healthy and a
- // restart would be gratuitous.
- let renewalFailed = Storage.shared.laRenewalFailed.value
- let renewBy = Storage.shared.laRenewBy.value
- let now = Date().timeIntervalSince1970
- let overlayIsShowing = renewBy > 0 && now >= renewBy - LiveActivityManager.renewalWarning
- let pushToStartLooksStuck = pushToStartSendsWithoutAdoption >= LiveActivityManager.pushToStartForceRestartThreshold
- guard renewalFailed || overlayIsShowing || pushToStartLooksStuck else {
- LogManager.shared.log(
- category: .general,
- message: "[LA] deferred foreground restart skipped — conditions no longer hold (renewalFailed=\(renewalFailed), overlayShowing=\(overlayIsShowing), pushToStartLooksStuck=\(pushToStartLooksStuck))"
- )
- return
- }
- // Mark restart intent BEFORE clearing storage flags, so any late .dismissed
- // from the old activity is never misclassified as a user swipe.
- endingForRestart = true
- dismissedByUser = false
- nextStartReasonOverride = "deferred-foreground-restart"
- // Stop any observers/tasks tied to the previous activity instance. In the
- // current=nil branch below, the old observer can otherwise deliver a late
- // .dismissed and poison dismissedByUser.
- updateTask?.cancel()
- updateTask = nil
- tokenObservationTask?.cancel()
- tokenObservationTask = nil
- stateObserverTask?.cancel()
- stateObserverTask = nil
- pushToken = nil
- // Clear renewal state so the new snapshot does not show the renewal overlay.
- Storage.shared.laRenewBy.value = 0
- Storage.shared.laRenewalFailed.value = false
- cancelRenewalFailedNotification()
- guard let activity = current else {
- LogManager.shared.log(
- category: .general,
- message: "[LA] foreground restart: current=nil (old activity not bound locally), ending all existing LAs before restart"
- )
- current = nil
- Task {
- for activity in Activity<GlucoseLiveActivityAttributes>.activities {
- await activity.end(nil, dismissalPolicy: .immediate)
- }
- await MainActor.run {
- self.dismissedByUser = false
- self.startFromCurrentState(cleanupOrphans: false)
- LogManager.shared.log(
- category: .general,
- message: "[LA] foreground restart: fresh LA started after ending unbound existing activity"
- )
- }
- }
- return
- }
- current = nil
- Task {
- await activity.end(nil, dismissalPolicy: .immediate)
- await MainActor.run {
- self.dismissedByUser = false
- self.startFromCurrentState(cleanupOrphans: false)
- LogManager.shared.log(category: .general, message: "[LA] Live Activity restarted after foreground retry")
- }
- }
- }
- @objc private func handleBackgroundAudioFailed() {
- guard Storage.shared.laEnabled.value, current != nil else { return }
- // The background audio session has permanently failed — the app will lose its
- // background keep-alive. Immediately push the renewal overlay so the user sees
- // "Tap to update" on the lock screen and knows to foreground the app.
- LogManager.shared.log(category: .general, message: "[LA] background audio failed — forcing renewal overlay")
- Storage.shared.laRenewBy.value = Date().timeIntervalSince1970
- refreshFromCurrentState(reason: "audio-session-failed")
- }
- private func shouldRestartBecauseExtensionLooksStuck() -> Bool {
- guard Storage.shared.laEnabled.value else { return false }
- guard !dismissedByUser else { return false }
- guard let activity = current ?? Activity<GlucoseLiveActivityAttributes>.activities.first else {
- return false
- }
- let now = Date().timeIntervalSince1970
- let staleDatePassed = activity.content.staleDate.map { $0 <= Date() } ?? false
- if staleDatePassed {
- LogManager.shared.log(
- category: .general,
- message: "[LA] liveness check: staleDate already passed"
- )
- return true
- }
- let expectedSeq = activity.content.state.seq
- let seenSeq = LALivenessStore.lastExtensionSeq
- let lastSeenAt = LALivenessStore.lastExtensionSeenAt
- let lastProducedAt = LALivenessStore.lastExtensionProducedAt
- let extensionHasNeverCheckedIn = lastSeenAt <= 0
- let extensionLooksBehind = seenSeq < expectedSeq
- let noRecentExtensionTouch = extensionHasNeverCheckedIn || (now - lastSeenAt > LiveActivityManager.extensionLivenessGrace)
- LogManager.shared.log(
- category: .general,
- message: "[LA] liveness check: expectedSeq=\(expectedSeq), seenSeq=\(seenSeq), lastSeenAt=\(lastSeenAt), lastProducedAt=\(lastProducedAt), behind=\(extensionLooksBehind), noRecentTouch=\(noRecentExtensionTouch)",
- isDebug: true
- )
- // Conservative rule:
- // only suspect "stuck" if the extension is both behind AND has not checked in recently.
- return extensionLooksBehind && noRecentExtensionTouch
- }
- static let renewalThreshold: TimeInterval = 7.5 * 3600
- static let renewalWarning: TimeInterval = 30 * 60
- static let extensionLivenessGrace: TimeInterval = 15 * 60
- /// Base backoff after a 429 for push-to-start; doubled on each subsequent 429,
- /// capped at `pushToStartMaxBackoff`. Reset to base after a successful send.
- private static let pushToStartBaseBackoff: TimeInterval = 300 // 5 min
- private static let pushToStartMaxBackoff: TimeInterval = 3600 // 60 min
- /// When a successful APNs push-to-start does not result in an `activityUpdates`
- /// adoption, count those orphaned sends. After this threshold, the next
- /// foreground entry forces a local restart to nudge iOS to issue a new
- /// pushToStartToken — Apple FB21158660 workaround. Set to 4 (not 2) to avoid
- /// false positives on slow connections where the activityUpdates delivery lags.
- private static let pushToStartForceRestartThreshold: Int = 4
- /// Polling timeout for the push-to-start token to arrive after a fresh install.
- /// `pushToStartTokenUpdates` typically delivers within a couple of seconds.
- private static let pushToStartTokenWaitTimeout: TimeInterval = 5
- private static let pushToStartTokenPollInterval: TimeInterval = 0.5
- /// Delay before the single automatic retry when the push-to-start token is not
- /// yet available after the initial wait. The token is almost always en route
- /// and arrives within a few seconds of the first request.
- private static let pushToStartTokenRetryDelay: TimeInterval = 10
- private(set) var current: Activity<GlucoseLiveActivityAttributes>?
- private var stateObserverTask: Task<Void, Never>?
- private var updateTask: Task<Void, Never>?
- private var seq: Int = 0
- private var lastUpdateTime: Date?
- private var pushToken: String?
- private var tokenObservationTask: Task<Void, Never>?
- private var refreshWorkItem: DispatchWorkItem?
- /// Set when the user manually swipes away the LA. Blocks auto-restart until
- /// an explicit user action (Restart button, App Intent) clears it.
- /// In-memory only — resets to false on app relaunch, so a kill + relaunch
- /// starts fresh as expected.
- private var dismissedByUser = false
- /// Set to true immediately before we call activity.end() as part of a planned restart.
- /// Cleared after the restart completes. The state observer checks this flag so that
- /// a .dismissed delivery triggered by our own end() call is never misclassified as a
- /// user swipe — regardless of the order in which the MainActor executes the two writes.
- private var endingForRestart = false
- /// Set by handleForeground() when the renewal window has been detected.
- /// The actual end+restart is run from handleDidBecomeActive() because
- /// Activity.request() returns `visibility` during willEnterForeground.
- private var pendingForegroundRestart = false
- /// Observes `pushToStartTokenUpdates` (iOS 17.2+) and persists the token.
- /// Long-lived — started once at init and never cancelled.
- private var pushToStartObservationTask: Task<Void, Never>?
- /// Observes `Activity<>.activityUpdates` so activities started out-of-band
- /// (push-to-start) are adopted automatically.
- private var activityUpdatesObservationTask: Task<Void, Never>?
- /// Timestamp of the last successful push-to-start APNs dispatch. Used to log
- /// the delay until iOS delivers the new activity via `activityUpdates`. If
- /// adoption never happens, a growing gap here is the fingerprint.
- private var lastPushToStartSuccessAt: Date?
- /// Number of consecutive successful push-to-start APNs sends that have NOT
- /// been followed by an `activityUpdates` adoption. When this reaches
- /// `pushToStartForceRestartThreshold`, the next foreground entry forces a
- /// local restart even outside the renewal window — ending the existing LA
- /// and starting a fresh one is the only known way to nudge iOS to issue a
- /// new `pushToStartToken` when the current one has gone silent
- /// (Apple FB21158660).
- private var pushToStartSendsWithoutAdoption: Int = 0
- /// Single-shot override for the next push-to-start reason tag. Consumed by
- /// `startIfNeeded`. Lets the deferred-foreground-restart path tag its
- /// push-to-start with a distinct label instead of "user-start", which made
- /// the 8:25 stale-latch event indistinguishable from a real user start in
- /// the log.
- private var nextStartReasonOverride: String?
- // MARK: - Public API
- @MainActor
- func startIfNeeded() {
- let authorized = ActivityAuthorizationInfo().areActivitiesEnabled
- let existingCount = Activity<GlucoseLiveActivityAttributes>.activities.count
- LogManager.shared.log(
- category: .general,
- message: "[LA] startIfNeeded: authorized=\(authorized), activities=\(existingCount), current=\(current?.id ?? "nil"), dismissedByUser=\(dismissedByUser), laEnabled=\(Storage.shared.laEnabled.value)",
- isDebug: true
- )
- guard authorized else {
- LogManager.shared.log(category: .general, message: "Live Activity not authorized")
- return
- }
- let startReason = nextStartReasonOverride ?? "user-start"
- nextStartReasonOverride = nil
- if #available(iOS 17.2, *) {
- // iOS 17.2+ uses push-to-start for every creation path. If an
- // activity is already running and not stale we adopt/reuse it
- // (covers warm starts where the LA survived a relaunch); only
- // truly new starts dispatch APNs.
- if let existing = Activity<GlucoseLiveActivityAttributes>.activities.first {
- let renewBy = Storage.shared.laRenewBy.value
- let now = Date().timeIntervalSince1970
- let staleDatePassed = existing.content.staleDate.map { $0 <= Date() } ?? false
- let inRenewalWindow = renewBy > 0 && now >= renewBy - LiveActivityManager.renewalWarning
- let needsRestart = Storage.shared.laRenewalFailed.value || inRenewalWindow || staleDatePassed
- if !needsRestart {
- bind(to: existing, logReason: "reuse")
- Storage.shared.laRenewalFailed.value = false
- return
- }
- LogManager.shared.log(
- category: .general,
- message: "[LA] existing activity is stale on startIfNeeded (iOS 17.2+) — push-to-start replace (staleDatePassed=\(staleDatePassed), inRenewalWindow=\(inRenewalWindow))"
- )
- attemptPushToStartCreate(reason: startReason, oldActivity: existing)
- return
- }
- attemptPushToStartCreate(reason: startReason, oldActivity: nil)
- } else {
- startIfNeededLegacy()
- }
- }
- /// Pre-17.2 path (iOS 16.6 – 17.1). Identical to dev's `startIfNeeded` —
- /// Activity.request() for everything. Removable when the deployment target
- /// reaches 17.2.
- @MainActor
- private func startIfNeededLegacy() {
- if let existing = Activity<GlucoseLiveActivityAttributes>.activities.first {
- // Before reusing, check whether this activity needs a restart. This covers cold
- // starts (app was killed while the overlay was showing — willEnterForeground is
- // never sent, so handleForeground never runs) and any other path that lands here
- // without first going through handleForeground.
- let renewBy = Storage.shared.laRenewBy.value
- let now = Date().timeIntervalSince1970
- let staleDatePassed = existing.content.staleDate.map { $0 <= Date() } ?? false
- let inRenewalWindow = renewBy > 0 && now >= renewBy - LiveActivityManager.renewalWarning
- let needsRestart = Storage.shared.laRenewalFailed.value || inRenewalWindow || staleDatePassed
- if needsRestart {
- LogManager.shared.log(
- category: .general,
- message: "[LA] existing activity is stale on startIfNeeded — ending and restarting (staleDatePassed=\(staleDatePassed), inRenewalWindow=\(inRenewalWindow))"
- )
- endingForRestart = true
- dismissedByUser = false
- Storage.shared.laRenewBy.value = 0
- Storage.shared.laRenewalFailed.value = false
- cancelRenewalFailedNotification()
- Task {
- await existing.end(nil, dismissalPolicy: .immediate)
- await MainActor.run { self.startIfNeededLegacy() }
- }
- return
- }
- bind(to: existing, logReason: "reuse")
- Storage.shared.laRenewalFailed.value = false
- return
- }
- do {
- let attributes = GlucoseLiveActivityAttributes(title: "LoopFollow")
- let provider = StorageCurrentGlucoseStateProvider()
- let seedSnapshot = GlucoseSnapshotBuilder.build(from: provider)
- ?? GlucoseSnapshotStore.shared.load()
- ?? GlucoseSnapshot(
- glucose: 0,
- delta: 0,
- trend: .unknown,
- updatedAt: Date(),
- iob: nil,
- cob: nil,
- projected: nil,
- unit: .mgdl,
- isNotLooping: false,
- )
- let initialState = GlucoseLiveActivityAttributes.ContentState(
- snapshot: seedSnapshot,
- seq: 0,
- reason: "start",
- producedAt: Date(),
- )
- let renewDeadline = Date().addingTimeInterval(LiveActivityManager.renewalThreshold)
- let content = ActivityContent(state: initialState, staleDate: renewDeadline)
- LALivenessStore.clear()
- let activity = try Activity.request(attributes: attributes, content: content, pushType: .token)
- bind(to: activity, logReason: "start-new")
- Storage.shared.laRenewBy.value = renewDeadline.timeIntervalSince1970
- Storage.shared.laRenewalFailed.value = false
- LogManager.shared.log(category: .general, message: "Live Activity started id=\(activity.id)")
- } catch {
- let ns = error as NSError
- let scene = isAppVisibleForLiveActivityStart()
- LogManager.shared.log(
- category: .general,
- message: "Live Activity failed to start: \(error) domain=\(ns.domain) code=\(ns.code) — authorized=\(ActivityAuthorizationInfo().areActivitiesEnabled), sceneActive=\(scene), activities=\(Activity<GlucoseLiveActivityAttributes>.activities.count)"
- )
- }
- }
- /// Called from applicationWillTerminate. Ends the LA synchronously (blocking
- /// up to 3 s) so it clears from the lock screen before the process exits.
- /// Does not clear laEnabled — the user's preference is preserved for relaunch.
- func endOnTerminate() {
- guard let activity = current else { return }
- // Flag the end as system-initiated so the state observer does not
- // classify the resulting `.dismissed` as a user swipe (laRenewBy is
- // cleared below, which would otherwise make pastDeadline=false).
- endingForRestart = true
- current = nil
- Storage.shared.laRenewBy.value = 0
- LALivenessStore.clear()
- let semaphore = DispatchSemaphore(value: 0)
- Task.detached {
- await activity.end(nil, dismissalPolicy: .immediate)
- semaphore.signal()
- }
- _ = semaphore.wait(timeout: .now() + 3)
- LogManager.shared.log(category: .general, message: "[LA] ended on app terminate")
- }
- func end(dismissalPolicy: ActivityUIDismissalPolicy = .default) {
- updateTask?.cancel()
- updateTask = nil
- guard let activity = current else { return }
- Task {
- let finalState = GlucoseLiveActivityAttributes.ContentState(
- snapshot: GlucoseSnapshotStore.shared.load() ?? GlucoseSnapshot(
- glucose: 0,
- delta: 0,
- trend: .unknown,
- updatedAt: Date(),
- iob: nil,
- cob: nil,
- projected: nil,
- unit: .mgdl,
- isNotLooping: false,
- ),
- seq: seq,
- reason: "end",
- producedAt: Date(),
- )
- let content = ActivityContent(state: finalState, staleDate: nil)
- await activity.end(content, dismissalPolicy: dismissalPolicy)
- LogManager.shared.log(category: .general, message: "Live Activity ended id=\(activity.id)", isDebug: true)
- if current?.id == activity.id {
- current = nil
- Storage.shared.laRenewBy.value = 0
- LALivenessStore.clear()
- }
- }
- }
- /// Ends all running Live Activities and starts a fresh one from the current state.
- /// Intended for the "Restart Live Activity" button and the AppIntent.
- @MainActor
- func forceRestart() {
- guard Storage.shared.laEnabled.value else { return }
- LogManager.shared.log(category: .general, message: "[LA] forceRestart called")
- // Mark as system-initiated so any residual `.dismissed` delivered from
- // the cancelled state observer stream cannot flip dismissedByUser=true
- // and spoil the freshly started LA.
- endingForRestart = true
- dismissedByUser = false
- Storage.shared.laRenewBy.value = 0
- Storage.shared.laRenewalFailed.value = false
- // The user explicitly asked for a fresh LA — clear any push-to-start
- // backoff that would otherwise rate-limit the Restart button silently.
- Storage.shared.laLastPushToStartAt.value = 0
- Storage.shared.laPushToStartBackoff.value = 0
- pushToStartSendsWithoutAdoption = 0
- LALivenessStore.clear()
- cancelRenewalFailedNotification()
- current = nil
- updateTask?.cancel(); updateTask = nil
- tokenObservationTask?.cancel(); tokenObservationTask = nil
- stateObserverTask?.cancel(); stateObserverTask = nil
- pushToken = nil
- Task {
- for activity in Activity<GlucoseLiveActivityAttributes>.activities {
- await activity.end(nil, dismissalPolicy: .immediate)
- }
- await MainActor.run {
- self.startFromCurrentState(cleanupOrphans: false)
- LogManager.shared.log(category: .general, message: "[LA] forceRestart: Live Activity restarted")
- }
- }
- }
- @MainActor
- func startFromCurrentState(cleanupOrphans: Bool = false) {
- guard Storage.shared.laEnabled.value, !dismissedByUser else { return }
- if cleanupOrphans {
- endOrphanedActivities()
- }
- let provider = StorageCurrentGlucoseStateProvider()
- if let snapshot = GlucoseSnapshotBuilder.build(from: provider) {
- LAAppGroupSettings.setThresholds(
- lowMgdl: Storage.shared.lowLine.value,
- highMgdl: Storage.shared.highLine.value,
- )
- LAAppGroupSettings.setDisplayName(
- Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String ?? "LoopFollow",
- show: Storage.shared.showDisplayName.value
- )
- GlucoseSnapshotStore.shared.save(snapshot)
- }
- startIfNeeded()
- }
- func refreshFromCurrentState(reason: String) {
- // No LA guard here — Watch and store must update regardless of LA state.
- // LA-specific gating (laEnabled, dismissedByUser) is applied inside performRefresh.
- refreshWorkItem?.cancel()
- let workItem = DispatchWorkItem { [weak self] in
- self?.performRefresh(reason: reason)
- }
- refreshWorkItem = workItem
- DispatchQueue.main.asyncAfter(deadline: .now() + 20.0, execute: workItem)
- }
- // MARK: - Renewal
- /// Requests a fresh Live Activity to replace the current one when the renewal
- /// deadline has passed, working around Apple's 8-hour maximum LA lifetime.
- /// Returns true if renewal was performed (caller should return early).
- private func renewIfNeeded(snapshot: GlucoseSnapshot) -> Bool {
- guard let oldActivity = current else { return false }
- let renewBy = Storage.shared.laRenewBy.value
- guard renewBy > 0, Date().timeIntervalSince1970 >= renewBy else { return false }
- let overdueBy = Date().timeIntervalSince1970 - renewBy
- LogManager.shared.log(category: .general, message: "[LA] renewal deadline passed by \(Int(overdueBy))s, requesting new LA")
- if #available(iOS 17.2, *) {
- // iOS 17.2+: renewal goes through push-to-start. The dispatch hops
- // to MainActor and returns immediately; adoption (or failure) lands
- // in the observer. Return true so performRefresh stops processing
- // this tick.
- Task { @MainActor [weak self] in
- self?.attemptPushToStartCreate(reason: "renew", oldActivity: oldActivity, snapshot: snapshot)
- }
- return true
- } else {
- return attemptLegacyRenewal(snapshot: snapshot, oldActivity: oldActivity)
- }
- }
- /// Pre-17.2 renewal (iOS 16.6 – 17.1): foreground Activity.request, mark
- /// renewal-failed if it throws. Removable when the deployment target
- /// reaches 17.2.
- private func attemptLegacyRenewal(
- snapshot: GlucoseSnapshot,
- oldActivity: Activity<GlucoseLiveActivityAttributes>
- ) -> Bool {
- let renewDeadline = Date().addingTimeInterval(LiveActivityManager.renewalThreshold)
- let attributes = GlucoseLiveActivityAttributes(title: "LoopFollow")
- // Build the fresh snapshot with showRenewalOverlay: false — the new LA has a
- // fresh deadline so no overlay is needed from the first frame. We pass the
- // deadline as staleDate to ActivityContent below, not to Storage yet; Storage
- // is only updated after Activity.request succeeds so a crash between the two
- // can't leave the deadline permanently stuck in the future.
- let freshSnapshot = snapshot.withRenewalOverlay(false)
- let state = GlucoseLiveActivityAttributes.ContentState(
- snapshot: freshSnapshot,
- seq: seq,
- reason: "renew",
- producedAt: Date(),
- )
- let content = ActivityContent(state: state, staleDate: renewDeadline)
- do {
- let newActivity = try Activity.request(attributes: attributes, content: content, pushType: .token)
- Task {
- await oldActivity.end(nil, dismissalPolicy: .immediate)
- }
- updateTask?.cancel()
- updateTask = nil
- tokenObservationTask?.cancel()
- tokenObservationTask = nil
- stateObserverTask?.cancel()
- stateObserverTask = nil
- pushToken = nil
- // Write deadline only on success — avoids a stuck future deadline if we crash
- // between the write and the Activity.request call.
- Storage.shared.laRenewBy.value = renewDeadline.timeIntervalSince1970
- bind(to: newActivity, logReason: "renew")
- Storage.shared.laRenewalFailed.value = false
- cancelRenewalFailedNotification()
- GlucoseSnapshotStore.shared.save(freshSnapshot)
- LogManager.shared.log(category: .general, message: "[LA] Live Activity renewed successfully id=\(newActivity.id)")
- return true
- } catch {
- // Renewal failed — deadline was never written, so no rollback needed.
- let isFirstFailure = !Storage.shared.laRenewalFailed.value
- Storage.shared.laRenewalFailed.value = true
- let ns = error as NSError
- LogManager.shared.log(
- category: .general,
- message: "[LA] renewal failed, keeping existing LA: \(error) domain=\(ns.domain) code=\(ns.code) — authorized=\(ActivityAuthorizationInfo().areActivitiesEnabled), activities=\(Activity<GlucoseLiveActivityAttributes>.activities.count)"
- )
- if isFirstFailure {
- scheduleRenewalFailedNotification()
- }
- return false
- }
- }
- // MARK: - Push-to-start (iOS 17.2+)
- /// Single creation path for iOS 17.2+. Handles initial start, renewal, and
- /// forced restart. Verifies token + APNs credentials, applies backoff, then
- /// dispatches the APNs push-to-start call. The old activity is only ended
- /// after a confirmed successful send, preserving it if the send fails.
- /// Adoption is delivered via the `activityUpdates` observer —
- /// `handlePushToStartResult` only updates backoff/state.
- @available(iOS 17.2, *)
- @MainActor
- private func attemptPushToStartCreate(
- reason: String,
- oldActivity: Activity<GlucoseLiveActivityAttributes>?,
- snapshot: GlucoseSnapshot? = nil
- ) {
- // Validate APNs credentials up-front — push-to-start is the ONLY transport
- // on iOS 17.2+, so missing/invalid creds mean the LA will never display.
- let keyId = Storage.shared.lfKeyId.value
- let apnsKey = Storage.shared.lfApnsKey.value
- guard APNsCredentialValidator.isFullyConfigured(keyId: keyId, apnsKey: apnsKey) else {
- LogManager.shared.log(
- category: .general,
- message: "[LA] push-to-start (\(reason)) blocked — APNs credentials missing or invalid (keyId valid=\(APNsCredentialValidator.isValidKeyId(keyId)), apnsKey valid=\(APNsCredentialValidator.isValidApnsKey(apnsKey)))"
- )
- scheduleApnsCredentialsMissingNotification()
- return
- }
- let now = Date().timeIntervalSince1970
- let lastAt = Storage.shared.laLastPushToStartAt.value
- let backoff = Storage.shared.laPushToStartBackoff.value
- if lastAt > 0, now < lastAt + backoff {
- let wait = Int(lastAt + backoff - now)
- LogManager.shared.log(
- category: .general,
- message: "[LA] push-to-start (\(reason)) rate-limited: next allowed in \(wait)s (backoff=\(Int(backoff))s)"
- )
- return
- }
- // Build snapshot if caller didn't supply one (initial start path).
- let workingSnapshot: GlucoseSnapshot = {
- if let snapshot { return snapshot }
- let provider = StorageCurrentGlucoseStateProvider()
- return GlucoseSnapshotBuilder.build(from: provider)
- ?? GlucoseSnapshotStore.shared.load()
- ?? GlucoseSnapshot(
- glucose: 0,
- delta: 0,
- trend: .unknown,
- updatedAt: Date(),
- iob: nil,
- cob: nil,
- projected: nil,
- unit: .mgdl,
- isNotLooping: false,
- )
- }()
- Task { [weak self] in
- guard let self else { return }
- await self.dispatchPushToStart(
- reason: reason,
- oldActivity: oldActivity,
- snapshot: workingSnapshot
- )
- }
- }
- @available(iOS 17.2, *)
- private func dispatchPushToStart(
- reason: String,
- oldActivity: Activity<GlucoseLiveActivityAttributes>?,
- snapshot: GlucoseSnapshot,
- isRetry: Bool = false
- ) async {
- // Wait briefly for the push-to-start token to arrive — covers the
- // fresh-install case where the user toggles LA on before iOS has
- // delivered the first token via pushToStartTokenUpdates.
- var token = Storage.shared.laPushToStartToken.value
- if token.isEmpty {
- let pollIntervalNs = UInt64(LiveActivityManager.pushToStartTokenPollInterval * 1_000_000_000)
- let maxAttempts = Int(LiveActivityManager.pushToStartTokenWaitTimeout / LiveActivityManager.pushToStartTokenPollInterval)
- for attempt in 1 ... maxAttempts {
- try? await Task.sleep(nanoseconds: pollIntervalNs)
- token = Storage.shared.laPushToStartToken.value
- if !token.isEmpty {
- LogManager.shared.log(
- category: .general,
- message: "[LA] push-to-start (\(reason)) token arrived after \(attempt) poll(s)"
- )
- break
- }
- }
- }
- guard !token.isEmpty else {
- if isRetry {
- // Token still absent after retry — give up and notify the user.
- LogManager.shared.log(
- category: .general,
- message: "[LA] push-to-start (\(reason)) aborted — no token after retry (iOS hasn't issued one yet)"
- )
- await MainActor.run { self.schedulePushToStartTokenMissingNotification() }
- } else {
- // Token likely en route — wait briefly and make a single automatic
- // retry before surfacing an error to the user.
- LogManager.shared.log(
- category: .general,
- message: "[LA] push-to-start (\(reason)) no token after \(Int(LiveActivityManager.pushToStartTokenWaitTimeout))s — retrying in \(Int(LiveActivityManager.pushToStartTokenRetryDelay))s"
- )
- try? await Task.sleep(nanoseconds: UInt64(LiveActivityManager.pushToStartTokenRetryDelay * 1_000_000_000))
- await dispatchPushToStart(reason: reason, oldActivity: oldActivity, snapshot: snapshot, isRetry: true)
- }
- return
- }
- // Record attempt time up-front so two refresh ticks can't double-fire.
- await MainActor.run {
- Storage.shared.laLastPushToStartAt.value = Date().timeIntervalSince1970
- }
- let nextSeq = await MainActor.run { () -> Int in
- self.seq += 1
- return self.seq
- }
- let freshSnapshot = snapshot.withRenewalOverlay(false)
- let state = GlucoseLiveActivityAttributes.ContentState(
- snapshot: freshSnapshot,
- seq: nextSeq,
- reason: reason,
- producedAt: Date(),
- )
- let staleDate = Date().addingTimeInterval(LiveActivityManager.renewalThreshold)
- let tail = String(token.suffix(8))
- LogManager.shared.log(
- category: .general,
- message: "[LA] push-to-start (\(reason)) firing token=…\(tail) seq=\(nextSeq) staleIn=\(Int(staleDate.timeIntervalSinceNow))s"
- )
- let sendStart = Date()
- let result = await APNSClient.shared.sendLiveActivityStart(
- pushToStartToken: token,
- attributesTitle: "LoopFollow",
- state: state,
- staleDate: staleDate,
- )
- let elapsedMs = Int(Date().timeIntervalSince(sendStart) * 1000)
- LogManager.shared.log(
- category: .general,
- message: "[LA] push-to-start (\(reason)) APNs round-trip result=\(result) elapsed=\(elapsedMs)ms"
- )
- // End the old activity only after a confirmed successful send — if the
- // send fails the user keeps their existing LA rather than losing data
- // with nothing to replace it.
- if result == .success, let oldActivity {
- LogManager.shared.log(
- category: .general,
- message: "[LA] push-to-start (\(reason)) send succeeded — ending oldActivity=\(oldActivity.id)"
- )
- await oldActivity.end(nil, dismissalPolicy: .immediate)
- }
- await MainActor.run {
- self.handlePushToStartResult(result, reason: reason)
- }
- }
- @available(iOS 17.2, *)
- @MainActor
- private func handlePushToStartResult(
- _ result: APNSClient.PushToStartResult,
- reason: String
- ) {
- switch result {
- case .success:
- // Adoption of the new LA runs via `activityUpdates` observation,
- // which ends the old activity, resets the renewal deadline and
- // clears `laRenewalFailed`. Apply base backoff so refresh ticks
- // between now and adoption don't re-fire push-to-start.
- Storage.shared.laPushToStartBackoff.value = LiveActivityManager.pushToStartBaseBackoff
- lastPushToStartSuccessAt = Date()
- pushToStartSendsWithoutAdoption += 1
- LogManager.shared.log(
- category: .general,
- message: "[LA] push-to-start (\(reason)) succeeded — awaiting activityUpdates to adopt new LA (backoff=\(Int(LiveActivityManager.pushToStartBaseBackoff))s, sendsWithoutAdoption=\(pushToStartSendsWithoutAdoption))"
- )
- case .rateLimited:
- let currentBackoff = Storage.shared.laPushToStartBackoff.value
- let next = min(
- LiveActivityManager.pushToStartMaxBackoff,
- max(LiveActivityManager.pushToStartBaseBackoff, currentBackoff * 2)
- )
- Storage.shared.laPushToStartBackoff.value = next
- LogManager.shared.log(
- category: .general,
- message: "[LA] push-to-start (\(reason)) 429 — backoff raised to \(Int(next))s"
- )
- if reason == "renew" { markRenewalFailedFromBackground() }
- case .tokenInvalid:
- // Clear the stored token so the next `pushToStartTokenUpdates`
- // delivery overwrites it. Reset backoff — no point holding off
- // while we wait for iOS to reissue.
- Storage.shared.laPushToStartToken.value = ""
- Storage.shared.laPushToStartBackoff.value = 0
- LogManager.shared.log(
- category: .general,
- message: "[LA] push-to-start (\(reason)) token invalid — cleared, awaiting new token"
- )
- if reason == "renew" { markRenewalFailedFromBackground() }
- case .failed:
- let currentBackoff = Storage.shared.laPushToStartBackoff.value
- if currentBackoff < LiveActivityManager.pushToStartBaseBackoff {
- Storage.shared.laPushToStartBackoff.value = LiveActivityManager.pushToStartBaseBackoff
- }
- if reason == "renew" { markRenewalFailedFromBackground() }
- }
- }
- /// Background renewal couldn't restart the LA via push-to-start (rate-limited,
- /// invalid token, etc.). Mark the state so the renewal overlay shows on the
- /// lock screen, and post a local notification on the first failure so the
- /// user knows to foreground the app.
- private func markRenewalFailedFromBackground() {
- let isFirstFailure = !Storage.shared.laRenewalFailed.value
- Storage.shared.laRenewalFailed.value = true
- LogManager.shared.log(
- category: .general,
- message: "[LA] push-to-start renewal failed — renewal marked failed"
- )
- if isFirstFailure {
- scheduleRenewalFailedNotification()
- }
- }
- private func performRefresh(reason: String) {
- let provider = StorageCurrentGlucoseStateProvider()
- guard let snapshot = GlucoseSnapshotBuilder.build(from: provider) else {
- return
- }
- LogManager.shared.log(category: .general, message: "[LA] refresh g=\(snapshot.glucose) reason=\(reason)", isDebug: true)
- let fingerprint =
- "g=\(snapshot.glucose) d=\(snapshot.delta) t=\(snapshot.trend.rawValue) " +
- "at=\(snapshot.updatedAt.timeIntervalSince1970) iob=\(snapshot.iob?.description ?? "nil") " +
- "cob=\(snapshot.cob?.description ?? "nil") proj=\(snapshot.projected?.description ?? "nil") u=\(snapshot.unit.rawValue)"
- LogManager.shared.log(category: .general, message: "[LA] snapshot \(fingerprint) reason=\(reason)", isDebug: true)
- // Check if the Live Activity is approaching Apple's 8-hour limit and renew if so.
- if renewIfNeeded(snapshot: snapshot) { return }
- if snapshot.showRenewalOverlay {
- LogManager.shared.log(category: .general, message: "[LA] sending update with renewal overlay visible")
- }
- let now = Date()
- let timeSinceLastUpdate = now.timeIntervalSince(lastUpdateTime ?? .distantPast)
- let forceRefreshNeeded = timeSinceLastUpdate >= 5 * 60
- // Capture dedup result BEFORE saving so the store comparison is valid.
- let snapshotUnchanged = GlucoseSnapshotStore.shared.load() == snapshot
- // Store + Watch: always update, independent of LA state.
- LAAppGroupSettings.setThresholds(
- lowMgdl: Storage.shared.lowLine.value,
- highMgdl: Storage.shared.highLine.value,
- )
- GlucoseSnapshotStore.shared.save(snapshot)
- // WatchConnectivityManager.shared.send(snapshot: snapshot)
- // LA update: gated on LA being active, snapshot having changed, and activities enabled.
- if !Storage.shared.laEnabled.value {
- LogManager.shared.log(category: .general, message: "[LA] refresh: LA update skipped — laEnabled=false reason=\(reason)", isDebug: true)
- return
- }
- if dismissedByUser {
- LogManager.shared.log(category: .general, message: "[LA] refresh: LA update skipped — dismissedByUser=true reason=\(reason)")
- return
- }
- guard !snapshotUnchanged || forceRefreshNeeded else { return }
- guard ActivityAuthorizationInfo().areActivitiesEnabled else {
- LogManager.shared.log(category: .general, message: "[LA] refresh: LA update skipped — areActivitiesEnabled=false reason=\(reason)")
- return
- }
- if current == nil,
- let existing = Activity<GlucoseLiveActivityAttributes>.activities.first(where: { $0.activityState == .active })
- {
- // Skip activities already in .ended/.dismissed — those are corpses
- // (typically post-410 ends pending iOS dismissal). Binding to them
- // would clear endingForRestart and turn the eventual iOS dismissal
- // into a misclassified user swipe.
- bind(to: existing, logReason: "bind-existing")
- }
- if let _ = current {
- update(snapshot: snapshot, reason: reason)
- return
- }
- if isAppVisibleForLiveActivityStart() {
- Task { @MainActor in
- self.startIfNeeded()
- if self.current != nil {
- self.update(snapshot: snapshot, reason: reason)
- }
- }
- } else {
- LogManager.shared.log(category: .general, message: "LA start suppressed (not visible) reason=\(reason)", isDebug: true)
- }
- }
- private func isAppVisibleForLiveActivityStart() -> Bool {
- let scenes = UIApplication.shared.connectedScenes
- return scenes.contains { $0.activationState == .foregroundActive }
- }
- func update(snapshot: GlucoseSnapshot, reason: String) {
- if current == nil,
- let existing = Activity<GlucoseLiveActivityAttributes>.activities.first(where: { $0.activityState == .active })
- {
- bind(to: existing, logReason: "bind-existing")
- }
- guard let activity = current else { return }
- updateTask?.cancel()
- seq += 1
- let nextSeq = seq
- let activityID = activity.id
- let state = GlucoseLiveActivityAttributes.ContentState(
- snapshot: snapshot,
- seq: nextSeq,
- reason: reason,
- producedAt: Date(),
- )
- updateTask = Task { [weak self] in
- guard let self else { return }
- if activity.activityState == .ended || activity.activityState == .dismissed {
- if current?.id == activityID { current = nil }
- return
- }
- let content = ActivityContent(
- state: state,
- staleDate: Date(timeIntervalSince1970: Storage.shared.laRenewBy.value),
- relevanceScore: 100.0,
- )
- if Task.isCancelled { return }
- // Dual-path update strategy:
- // - Foreground: direct ActivityKit update works reliably.
- // - Background: direct update silently fails due to the audio session
- // limitation. APNs self-push is the only reliable delivery path.
- // Both paths are attempted when applicable; APNs is the authoritative
- // background mechanism.
- let isForeground = await MainActor.run {
- UIApplication.shared.applicationState == .active
- }
- if isForeground {
- await activity.update(content)
- } else {
- LogManager.shared.log(
- category: .general,
- message: "[LA] update seq=\(nextSeq) — app backgrounded, direct ActivityKit update skipped, relying on APNs",
- isDebug: true
- )
- }
- if Task.isCancelled { return }
- guard current?.id == activityID else {
- LogManager.shared.log(category: .general, message: "Live Activity update — activity ID mismatch, discarding")
- return
- }
- lastUpdateTime = Date()
- LogManager.shared.log(category: .general, message: "[LA] updated id=\(activityID) seq=\(nextSeq) reason=\(reason)", isDebug: true)
- if let token = pushToken {
- await APNSClient.shared.sendLiveActivityUpdate(pushToken: token, state: state)
- } else {
- LogManager.shared.log(
- category: .general,
- message: "[LA] update seq=\(nextSeq) reason=\(reason) — no push token yet, APNs skipped"
- )
- }
- }
- }
- // MARK: - Binding / Lifecycle
- /// Ends any Live Activities of this type that are not the one currently tracked.
- /// Called on app launch to clean up cards left behind by a previous crash.
- private func endOrphanedActivities() {
- for activity in Activity<GlucoseLiveActivityAttributes>.activities {
- guard activity.id != current?.id else { continue }
- let orphanID = activity.id
- Task {
- await activity.end(nil, dismissalPolicy: .immediate)
- LogManager.shared.log(category: .general, message: "Ended orphaned Live Activity id=\(orphanID)")
- }
- }
- }
- private func bind(to activity: Activity<GlucoseLiveActivityAttributes>, logReason: String) {
- if current?.id == activity.id { return }
- current = activity
- let wasEndingForRestart = endingForRestart
- dismissedByUser = false
- endingForRestart = false
- attachStateObserver(to: activity)
- LogManager.shared.log(
- category: .general,
- message: "Live Activity bound id=\(activity.id) state=\(activity.activityState) (\(logReason)) — endingForRestart cleared (was \(wasEndingForRestart))",
- isDebug: true
- )
- observePushToken(for: activity)
- }
- private func observePushToken(for activity: Activity<GlucoseLiveActivityAttributes>) {
- tokenObservationTask?.cancel()
- let activityID = activity.id
- tokenObservationTask = Task {
- for await tokenData in activity.pushTokenUpdates {
- let token = tokenData.map { String(format: "%02x", $0) }.joined()
- let previousTail = self.pushToken.map { String($0.suffix(8)) } ?? "nil"
- let tail = String(token.suffix(8))
- self.pushToken = token
- LogManager.shared.log(
- category: .general,
- message: "[LA] push token received id=\(activityID) token=…\(tail) (prev=…\(previousTail))"
- )
- }
- }
- }
- func handleExpiredToken() {
- let existing = Activity<GlucoseLiveActivityAttributes>.activities.count
- LogManager.shared.log(
- category: .general,
- message: "[LA] handleExpiredToken: current=\(current?.id ?? "nil"), activities=\(existing), dismissedByUser=\(dismissedByUser) — marking endingForRestart and ending"
- )
- // Mark as system-initiated so the `.dismissed` delivered by end()
- // is not classified as a user swipe — that would set dismissedByUser=true
- // and block the restart kicked off below.
- endingForRestart = true
- end()
- // Waiting for the next BG refresh is unreliable: end() nulls `current`
- // and clears laRenewBy, so renewIfNeeded short-circuits and performRefresh's
- // bind-existing path rebinds to the just-ended activity — clearing
- // endingForRestart and turning the eventual iOS dismissal into a misclassified
- // user swipe. Drive the restart synchronously instead.
- if #available(iOS 17.2, *) {
- Task { @MainActor [weak self] in
- self?.attemptPushToStartCreate(reason: "expired-token", oldActivity: nil)
- }
- }
- }
- // MARK: - Renewal Notifications
- private static let renewalNotificationID = "\(Bundle.main.bundleIdentifier ?? "loopfollow").la.renewal.failed"
- private static let apnsCredentialsNotificationID = "\(Bundle.main.bundleIdentifier ?? "loopfollow").la.apns.missing"
- private static let pushToStartTokenNotificationID = "\(Bundle.main.bundleIdentifier ?? "loopfollow").la.token.missing"
- private func scheduleRenewalFailedNotification() {
- let content = UNMutableNotificationContent()
- content.title = "Live Activity Expiring"
- content.body = "Live Activity will expire soon. Open LoopFollow to restart."
- content.sound = .default
- let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false)
- let request = UNNotificationRequest(
- identifier: LiveActivityManager.renewalNotificationID,
- content: content,
- trigger: trigger,
- )
- UNUserNotificationCenter.current().add(request) { error in
- if let error {
- LogManager.shared.log(category: .general, message: "[LA] failed to schedule renewal notification: \(error)")
- }
- }
- LogManager.shared.log(category: .general, message: "[LA] renewal failed notification scheduled")
- }
- private func cancelRenewalFailedNotification() {
- let id = LiveActivityManager.renewalNotificationID
- UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [id])
- UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [id])
- }
- private func scheduleApnsCredentialsMissingNotification() {
- let content = UNMutableNotificationContent()
- content.title = "Live Activity Setup Needed"
- content.body = "APNs credentials are missing or invalid. Configure them in Settings → APN."
- content.sound = .default
- let request = UNNotificationRequest(
- identifier: LiveActivityManager.apnsCredentialsNotificationID,
- content: content,
- trigger: UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false),
- )
- UNUserNotificationCenter.current().add(request, withCompletionHandler: nil)
- }
- private func schedulePushToStartTokenMissingNotification() {
- let content = UNMutableNotificationContent()
- content.title = "Live Activity Could Not Start"
- content.body = "Live Activity could not start — try again in a moment."
- content.sound = .default
- let request = UNNotificationRequest(
- identifier: LiveActivityManager.pushToStartTokenNotificationID,
- content: content,
- trigger: UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false),
- )
- UNUserNotificationCenter.current().add(request, withCompletionHandler: nil)
- }
- private func attachStateObserver(to activity: Activity<GlucoseLiveActivityAttributes>) {
- stateObserverTask?.cancel()
- stateObserverTask = Task {
- for await state in activity.activityStateUpdates {
- LogManager.shared.log(category: .general, message: "Live Activity state id=\(activity.id) -> \(state)", isDebug: true)
- if state == .ended || state == .dismissed {
- // Capture whether this delivery is for the activity we currently track
- // BEFORE clearing `current` below. The classifier needs this signal to
- // distinguish a real user swipe of the foreground LA from a late
- // .dismissed delivered by a stale observer for an activity we already
- // ended programmatically.
- let wasCurrentActivity = current?.id == activity.id
- if wasCurrentActivity {
- current = nil
- // Do NOT clear laRenewBy here. Preserving it means handleForeground()
- // can detect the renewal window on the next foreground event and restart
- // automatically — whether the LA ended normally (.ended) or was
- // system-dismissed (.dismissed). laRenewBy is only set to 0 when:
- // • the user explicitly swipes (below) — renewal intent cancelled
- // • a new LA starts (startIfNeeded writes the new deadline)
- // • handleForeground() clears it synchronously before restarting
- // • the user disables LA or calls forceRestart
- LogManager.shared.log(category: .general, message: "[LA] activity cleared id=\(activity.id) state=\(state)", isDebug: true)
- }
- if state == .ended, wasCurrentActivity, !endingForRestart {
- // iOS terminated the activity itself — typically the ~8h lifetime
- // cap reached before renewal fired. The .dismissed path below
- // already handles iOS-initiated dismissals via renewalFailed /
- // pastDeadline, but .ended bypasses that branch entirely. Without
- // a signal here, handleForeground() sees `renewalFailed=false` and
- // `renewBy` still in the future, returns "no action needed", and
- // startIfNeeded keeps re-binding the corpse — the LA stays dark
- // until the user manually force-restarts. Mark renewalFailed so
- // the next foreground entry runs performForegroundRestart, which
- // sweeps any leftover ended activity and pushes a fresh one.
- Storage.shared.laRenewalFailed.value = true
- LogManager.shared.log(category: .general, message: "[LA] ended by iOS (not our restart) — marked renewalFailed=true, auto-restart on next foreground")
- }
- if state == .dismissed {
- // Three possible sources of .dismissed — only the third blocks restart:
- //
- // (a) endingForRestart: our own end() during a planned restart.
- // Must be checked first: handleForeground() clears laRenewalFailed
- // and laRenewBy synchronously before calling end(), so those flags
- // would read as "no problem" even though we initiated the dismissal.
- //
- // (b) iOS system force-dismiss: either laRenewalFailed is set (our 8-hour
- // renewal logic marked it) or the renewal deadline has already passed
- // (laRenewBy > 0 && now >= laRenewBy). In both cases iOS acted, not
- // the user. laRenewBy is preserved so handleForeground() restarts on
- // the next foreground.
- //
- // (c) User decision: the user explicitly swiped the LA away. Block
- // auto-restart until forceRestart() is called. Clear laRenewBy so
- // handleForeground() does NOT re-enter the renewal path on the next
- // foreground — the renewal intent is cancelled by the user's choice.
- //
- // Gated on `wasCurrentActivity`: the user can only swipe the
- // foreground LA. A .dismissed for an activity we no longer track is a
- // stale observer (the activity was ended programmatically and iOS is
- // just now cleaning up) — must not latch dismissedByUser=true.
- let now = Date().timeIntervalSince1970
- let renewBy = Storage.shared.laRenewBy.value
- let renewalFailed = Storage.shared.laRenewalFailed.value
- let pastDeadline = renewBy > 0 && now >= renewBy
- LogManager.shared.log(category: .general, message: "[LA] .dismissed: endingForRestart=\(endingForRestart), renewalFailed=\(renewalFailed), pastDeadline=\(pastDeadline), wasCurrent=\(wasCurrentActivity), renewBy=\(renewBy), now=\(now)")
- if endingForRestart {
- // (a) Our own restart — do nothing, Task handles the rest.
- LogManager.shared.log(category: .general, message: "[LA] dismissed by self (endingForRestart) — restart in-flight, no action")
- } else if renewalFailed || pastDeadline {
- // (b) iOS system force-dismiss — allow auto-restart on next foreground.
- LogManager.shared.log(category: .general, message: "[LA] dismissed by iOS (renewalFailed=\(renewalFailed), pastDeadline=\(pastDeadline)) — auto-restart on next foreground")
- } else if !wasCurrentActivity {
- // (d) Stale observer for an activity we no longer track (e.g. a
- // post-410 end whose iOS-side dismissal landed hours later).
- // Not a user swipe — no flags to set.
- LogManager.shared.log(category: .general, message: "[LA] dismissed by stale observer (id=\(activity.id) is not current) — no action")
- } else {
- // (c) User decision — cancel renewal intent, block auto-restart.
- dismissedByUser = true
- Storage.shared.laRenewBy.value = 0
- LogManager.shared.log(category: .general, message: "[LA] dismissed by USER (renewBy=\(renewBy), now=\(now)) — laRenewBy cleared, auto-restart BLOCKED until forceRestart")
- }
- }
- }
- }
- }
- }
- }
- extension Notification.Name {
- /// Posted when the user taps the Live Activity or Dynamic Island.
- /// Observers navigate to the Home or Snoozer tab as appropriate.
- static let liveActivityDidForeground = Notification.Name("liveActivityDidForeground")
- }
- #endif
|