| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382 |
- // 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
- 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() {
- // 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
- // 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
- // 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
- }
- 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: "user-start", oldActivity: existing)
- return
- }
- attemptPushToStartCreate(reason: "user-start", 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 {
- 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 {
- 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 auto-restart promised by the comment below.
- endingForRestart = true
- end()
- // Activity will restart on next BG refresh via refreshFromCurrentState()
- }
- // 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 {
- if current?.id == activity.id {
- 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 == .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.
- 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), 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 {
- // (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
|