Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions LoopFollow/LiveActivity/APNSClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ class APNSClient {
]

snapshotDict["isNotLooping"] = snapshot.isNotLooping
snapshotDict["showRenewalOverlay"] = snapshot.showRenewalOverlay
if let iob = snapshot.iob { snapshotDict["iob"] = iob }
if let cob = snapshot.cob { snapshotDict["cob"] = cob }
if let projected = snapshot.projected { snapshotDict["projected"] = projected }
Expand Down
14 changes: 12 additions & 2 deletions LoopFollow/LiveActivity/GlucoseSnapshot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable {
/// True when LoopFollow detects the loop has not reported in 15+ minutes (Nightscout only).
let isNotLooping: Bool

// MARK: - Renewal

/// True when the Live Activity is within 30 minutes of its renewal deadline.
/// The extension renders a "Tap to update" overlay so the user knows renewal is imminent.
let showRenewalOverlay: Bool

init(
glucose: Double,
delta: Double,
Expand All @@ -59,7 +65,8 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable {
cob: Double?,
projected: Double?,
unit: Unit,
isNotLooping: Bool
isNotLooping: Bool,
showRenewalOverlay: Bool = false
) {
self.glucose = glucose
self.delta = delta
Expand All @@ -70,6 +77,7 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable {
self.projected = projected
self.unit = unit
self.isNotLooping = isNotLooping
self.showRenewalOverlay = showRenewalOverlay
}

func encode(to encoder: Encoder) throws {
Expand All @@ -83,10 +91,11 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable {
try container.encodeIfPresent(projected, forKey: .projected)
try container.encode(unit, forKey: .unit)
try container.encode(isNotLooping, forKey: .isNotLooping)
try container.encode(showRenewalOverlay, forKey: .showRenewalOverlay)
}

private enum CodingKeys: String, CodingKey {
case glucose, delta, trend, updatedAt, iob, cob, projected, unit, isNotLooping
case glucose, delta, trend, updatedAt, iob, cob, projected, unit, isNotLooping, showRenewalOverlay
}

// MARK: - Codable
Expand All @@ -102,6 +111,7 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable {
projected = try container.decodeIfPresent(Double.self, forKey: .projected)
unit = try container.decode(Unit.self, forKey: .unit)
isNotLooping = try container.decodeIfPresent(Bool.self, forKey: .isNotLooping) ?? false
showRenewalOverlay = try container.decodeIfPresent(Bool.self, forKey: .showRenewalOverlay) ?? false
}

// MARK: - Derived Convenience
Expand Down
14 changes: 13 additions & 1 deletion LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,17 @@ enum GlucoseSnapshotBuilder {
// Not Looping — read from Observable, set by evaluateNotLooping() in DeviceStatus.swift
let isNotLooping = Observable.shared.isNotLooping.value

// Renewal overlay — show renewalWarning seconds before the renewal deadline
// so the user knows the LA is about to be replaced.
let renewBy = Storage.shared.laRenewBy.value
let now = Date().timeIntervalSince1970
let showRenewalOverlay = renewBy > 0 && now >= renewBy - LiveActivityManager.renewalWarning

if showRenewalOverlay {
let timeLeft = max(renewBy - now, 0)
LogManager.shared.log(category: .general, message: "[LA] renewal overlay ON — \(Int(timeLeft))s until deadline")
}

LogManager.shared.log(
category: .general,
message: "LA snapshot built: updatedAt=\(updatedAt) interval=\(updatedAt.timeIntervalSince1970)",
Expand All @@ -68,7 +79,8 @@ enum GlucoseSnapshotBuilder {
cob: provider.cob,
projected: provider.projectedMgdl,
unit: preferredUnit,
isNotLooping: isNotLooping
isNotLooping: isNotLooping,
showRenewalOverlay: showRenewalOverlay
)
}

Expand Down
156 changes: 153 additions & 3 deletions LoopFollow/LiveActivity/LiveActivityManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,59 @@ import UIKit
@available(iOS 16.1, *)
final class LiveActivityManager {
static let shared = LiveActivityManager()
private init() {}
private init() {
NotificationCenter.default.addObserver(
self,
selector: #selector(handleForeground),
name: UIApplication.willEnterForegroundNotification,
object: nil
)
}

@objc private func handleForeground() {
LogManager.shared.log(category: .general, message: "[LA] foreground notification received, laRenewalFailed=\(Storage.shared.laRenewalFailed.value)")
guard Storage.shared.laRenewalFailed.value else { return }

// Renewal previously failed — end the stale LA and start a fresh one.
// We cannot call startIfNeeded() here: it finds the existing activity in
// Activity.activities and reuses it rather than replacing it.
LogManager.shared.log(category: .general, message: "[LA] ending stale LA and restarting after renewal failure")
// Clear state synchronously so any snapshot built between now and when the
// new LA is started computes showRenewalOverlay = false.
Storage.shared.laRenewBy.value = 0
Storage.shared.laRenewalFailed.value = false

guard let activity = current else {
startFromCurrentState()
return
}

current = nil
updateTask?.cancel()
updateTask = nil
tokenObservationTask?.cancel()
tokenObservationTask = nil
stateObserverTask?.cancel()
stateObserverTask = nil
pushToken = nil

Task {
// Await end so the activity is removed from Activity.activities before
// startIfNeeded() runs — otherwise it hits the reuse path and skips
// writing a new laRenewBy deadline.
await activity.end(nil, dismissalPolicy: .immediate)
await MainActor.run {
// startFromCurrentState rebuilds the snapshot (showRenewalOverlay = false
// since laRenewBy is 0), saves it to the store, then calls startIfNeeded()
// which finds no existing activity and requests a fresh LA with a new deadline.
self.startFromCurrentState()
LogManager.shared.log(category: .general, message: "[LA] Live Activity restarted after foreground retry")
}
}
}

static let renewalThreshold: TimeInterval = 7.5 * 3600
static let renewalWarning: TimeInterval = 20 * 60

private(set) var current: Activity<GlucoseLiveActivityAttributes>?
private var stateObserverTask: Task<Void, Never>?
Expand All @@ -32,6 +84,7 @@ final class LiveActivityManager {

if let existing = Activity<GlucoseLiveActivityAttributes>.activities.first {
bind(to: existing, logReason: "reuse")
Storage.shared.laRenewalFailed.value = false
return
}

Expand All @@ -57,10 +110,13 @@ final class LiveActivityManager {
producedAt: Date()
)

let content = ActivityContent(state: initialState, staleDate: Date().addingTimeInterval(15 * 60))
let renewDeadline = Date().addingTimeInterval(LiveActivityManager.renewalThreshold)
let content = ActivityContent(state: initialState, staleDate: renewDeadline)
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 {
LogManager.shared.log(category: .general, message: "Live Activity failed to start: \(error)")
Expand Down Expand Up @@ -98,11 +154,13 @@ final class LiveActivityManager {

if current?.id == activity.id {
current = nil
Storage.shared.laRenewBy.value = 0
}
}
}

func startFromCurrentState() {
endOrphanedActivities()
let provider = StorageCurrentGlucoseStateProvider()
if let snapshot = GlucoseSnapshotBuilder.build(from: provider) {
LAAppGroupSettings.setThresholds(
Expand All @@ -123,6 +181,77 @@ final class LiveActivityManager {
DispatchQueue.main.asyncAfter(deadline: .now() + 5.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.
/// The new LA is requested FIRST — the old one is only ended if that succeeds,
/// so the user keeps live data if Activity.request() throws.
/// 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")

let renewDeadline = Date().addingTimeInterval(LiveActivityManager.renewalThreshold)
let attributes = GlucoseLiveActivityAttributes(title: "LoopFollow")

// Strip the overlay flag — the new LA has a fresh deadline so it should
// open clean, without the warning visible from the first frame.
let freshSnapshot = GlucoseSnapshot(
glucose: snapshot.glucose,
delta: snapshot.delta,
trend: snapshot.trend,
updatedAt: snapshot.updatedAt,
iob: snapshot.iob,
cob: snapshot.cob,
projected: snapshot.projected,
unit: snapshot.unit,
isNotLooping: snapshot.isNotLooping,
showRenewalOverlay: 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)

// New LA is live — now it's safe to remove the old card.
Task {
await oldActivity.end(nil, dismissalPolicy: .immediate)
}

updateTask?.cancel()
updateTask = nil
tokenObservationTask?.cancel()
tokenObservationTask = nil
stateObserverTask?.cancel()
stateObserverTask = nil
pushToken = nil

bind(to: newActivity, logReason: "renew")
Storage.shared.laRenewBy.value = renewDeadline.timeIntervalSince1970
Storage.shared.laRenewalFailed.value = false
// Update the store so the next duplicate check has the correct baseline.
GlucoseSnapshotStore.shared.save(freshSnapshot)
LogManager.shared.log(category: .general, message: "[LA] Live Activity renewed successfully id=\(newActivity.id)")
return true
} catch {
Storage.shared.laRenewalFailed.value = true
LogManager.shared.log(category: .general, message: "[LA] renewal failed, keeping existing LA: \(error)")
return false
}
}

private func performRefresh(reason: String) {
let provider = StorageCurrentGlucoseStateProvider()
guard let snapshot = GlucoseSnapshotBuilder.build(from: provider) else {
Expand All @@ -134,6 +263,14 @@ final class LiveActivityManager {
"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
Expand Down Expand Up @@ -200,7 +337,7 @@ final class LiveActivityManager {

let content = ActivityContent(
state: state,
staleDate: Date().addingTimeInterval(15 * 60),
staleDate: Date(timeIntervalSince1970: Storage.shared.laRenewBy.value),
relevanceScore: 100.0
)

Expand Down Expand Up @@ -238,6 +375,19 @@ final class LiveActivityManager {

// 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
Expand Down
4 changes: 4 additions & 0 deletions LoopFollow/Storage/Storage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,10 @@ class Storage {
var lastCOB = StorageValue<Double?>(key: "lastCOB", defaultValue: nil)
var projectedBgMgdl = StorageValue<Double?>(key: "projectedBgMgdl", defaultValue: nil)

// Live Activity renewal
var laRenewBy = StorageValue<TimeInterval>(key: "laRenewBy", defaultValue: 0)
var laRenewalFailed = StorageValue<Bool>(key: "laRenewalFailed", defaultValue: false)

// Graph Settings [BEGIN]
var showDots = StorageValue<Bool>(key: "showDots", defaultValue: true)
var showLines = StorageValue<Bool>(key: "showLines", defaultValue: true)
Expand Down
32 changes: 32 additions & 0 deletions LoopFollowLAExtension/LoopFollowLiveActivity.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,17 @@ struct LoopFollowLiveActivityWidget: Widget {
DynamicIslandExpandedRegion(.leading) {
DynamicIslandLeadingView(snapshot: context.state.snapshot)
.id(context.state.seq)
.overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay))
}
DynamicIslandExpandedRegion(.trailing) {
DynamicIslandTrailingView(snapshot: context.state.snapshot)
.id(context.state.seq)
.overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay))
}
DynamicIslandExpandedRegion(.bottom) {
DynamicIslandBottomView(snapshot: context.state.snapshot)
.id(context.state.seq)
.overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay, showText: true))
}
} compactLeading: {
DynamicIslandCompactLeadingView(snapshot: context.state.snapshot)
Expand Down Expand Up @@ -130,6 +133,35 @@ private struct LockScreenLiveActivityView: View {
}
}
)
.overlay(
ZStack {
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(Color.gray.opacity(0.6))
Text("Tap to update")
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(.white)
}
.opacity(state.snapshot.showRenewalOverlay ? 1 : 0)
)
}
}

/// Full-size gray overlay shown 30 minutes before the LA renewal deadline.
/// Applied to both the lock screen view and each expanded Dynamic Island region.
private struct RenewalOverlayView: View {
let show: Bool
var showText: Bool = false

var body: some View {
ZStack {
Color.gray.opacity(0.6)
if showText {
Text("Tap to update")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.white)
}
}
.opacity(show ? 1 : 0)
}
}

Expand Down