Skip to content

Commit 05bd6bd

Browse files
authored
Remove pre-iOS 18 Live Activity code paths (#711)
* Remove pre-iOS 18 Live Activity code paths With the deployment target now at iOS 18, delete the legacy fallbacks and availability guards that only ran on older systems: - LiveActivityManager: remove startIfNeededLegacy and attemptLegacyRenewal (the Activity.request() start/renewal paths), unwrap the iOS 17.2 push-to-start guards, and drop the now-redundant @available attributes. - LoopFollowLiveActivity: collapse the dual ActivityConfiguration into the family-adaptive layout and inline contentMargins. - StorageCurrentGlucoseStateProvider: the renewal overlay is driven solely by laRenewalFailed. - EKEventStore: request full calendar access directly. - RestartLiveActivityIntent: drop the redundant iOS 16.4 availability attributes. Also delete the orphaned duplicate LoopFollow/LiveActivity/RestartLiveActivityIntent.swift, which was not referenced by any target and shadowed the maintained root copy. * Tighten Live Activity comments to describe end state
1 parent 0537a14 commit 05bd6bd

6 files changed

Lines changed: 74 additions & 349 deletions

File tree

LoopFollow/Extensions/EKEventStore+Extensions.swift

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,8 @@ import Foundation
66

77
extension EKEventStore {
88
func requestCalendarAccess(completion: @escaping (Bool, Error?) -> Void) {
9-
if #available(iOS 17, *) {
10-
requestFullAccessToEvents { granted, error in
11-
completion(granted, error)
12-
}
13-
} else {
14-
requestAccess(to: .event) { granted, error in
15-
completion(granted, error)
16-
}
9+
requestFullAccessToEvents { granted, error in
10+
completion(granted, error)
1711
}
1812
}
1913
}

LoopFollow/LiveActivity/LiveActivityManager.swift

Lines changed: 51 additions & 235 deletions
Original file line numberDiff line numberDiff line change
@@ -10,19 +10,8 @@ import os
1010
import UIKit
1111
import UserNotifications
1212

13-
// Live Activity manager for LoopFollow.
14-
//
15-
// iOS 17.2+: every LA creation (initial start, renewal, forced
16-
// restart) goes through APNs push-to-start. Updates
17-
// ride the same APNs transport. One transport, one
18-
// credential failure mode that surfaces in settings.
19-
//
20-
// iOS 16.6 – 17.1: legacy Activity.request() for everything;
21-
// renewal-failed notification when backgrounded.
22-
// The entry-point `if #available(iOS 17.2, *)` checks
23-
// isolate every iOS 17.2 code path, so the legacy
24-
// helpers can be deleted in one commit when the
25-
// deployment target reaches 17.2.
13+
// Live Activity manager for LoopFollow. Every LA creation (start, renewal,
14+
// restart) and update goes through APNs push-to-start.
2615

2716
final class LiveActivityManager {
2817
static let shared = LiveActivityManager()
@@ -55,50 +44,42 @@ final class LiveActivityManager {
5544
startActivityUpdatesObservation()
5645
}
5746

58-
// MARK: - Push-to-start observation (iOS 17.2+)
47+
// MARK: - Push-to-start observation
5948

60-
/// Observes the type-level push-to-start token (iOS 17.2+) and persists it.
49+
/// Observes the type-level push-to-start token and persists it.
6150
/// The token survives app relaunches but is reissued by iOS periodically or when
6251
/// the user toggles LA permissions — each new delivery overwrites the stored value.
6352
private func startPushToStartTokenObservation() {
64-
if #available(iOS 17.2, *) {
65-
pushToStartObservationTask?.cancel()
66-
LogManager.shared.log(
67-
category: .general,
68-
message: "[LA] pushToStartTokenUpdates observation starting (iOS 17.2+)"
69-
)
70-
pushToStartObservationTask = Task {
71-
var deliveries = 0
72-
for await tokenData in Activity<GlucoseLiveActivityAttributes>.pushToStartTokenUpdates {
73-
deliveries += 1
74-
let token = tokenData.map { String(format: "%02x", $0) }.joined()
75-
let previousTail = Storage.shared.laPushToStartToken.value.isEmpty
76-
? "nil"
77-
: String(Storage.shared.laPushToStartToken.value.suffix(8))
78-
let tail = String(token.suffix(8))
79-
let changed = tail != previousTail
80-
Storage.shared.laPushToStartToken.value = token
81-
LogManager.shared.log(
82-
category: .general,
83-
message: "[LA] push-to-start token received #\(deliveries) token=…\(tail) (prev=…\(previousTail))\(changed ? " CHANGED" : " same")"
84-
)
85-
}
53+
pushToStartObservationTask?.cancel()
54+
LogManager.shared.log(
55+
category: .general,
56+
message: "[LA] pushToStartTokenUpdates observation starting"
57+
)
58+
pushToStartObservationTask = Task {
59+
var deliveries = 0
60+
for await tokenData in Activity<GlucoseLiveActivityAttributes>.pushToStartTokenUpdates {
61+
deliveries += 1
62+
let token = tokenData.map { String(format: "%02x", $0) }.joined()
63+
let previousTail = Storage.shared.laPushToStartToken.value.isEmpty
64+
? "nil"
65+
: String(Storage.shared.laPushToStartToken.value.suffix(8))
66+
let tail = String(token.suffix(8))
67+
let changed = tail != previousTail
68+
Storage.shared.laPushToStartToken.value = token
8669
LogManager.shared.log(
8770
category: .general,
88-
message: "[LA] pushToStartTokenUpdates stream ended after \(deliveries) deliveries — no further tokens will arrive"
71+
message: "[LA] push-to-start token received #\(deliveries) token=…\(tail) (prev=…\(previousTail))\(changed ? " CHANGED" : " same")"
8972
)
9073
}
91-
} else {
9274
LogManager.shared.log(
9375
category: .general,
94-
message: "[LA] pushToStartTokenUpdates unavailable (iOS <17.2) — push-to-start will never fire"
76+
message: "[LA] pushToStartTokenUpdates stream ended after \(deliveries) deliveries — no further tokens will arrive"
9577
)
9678
}
9779
}
9880

99-
/// Observes new Activity creations. When an activity is started by
100-
/// push-to-start (iOS 17.2+), the app discovers it through this stream and
101-
/// adopts it via the same bind/update path as an app-initiated start.
81+
/// Observes new Activity creations so push-to-start activities are adopted
82+
/// via the same bind/update path as an app-initiated start.
10283
private func startActivityUpdatesObservation() {
10384
activityUpdatesObservationTask?.cancel()
10485
LogManager.shared.log(
@@ -471,7 +452,7 @@ final class LiveActivityManager {
471452
/// The actual end+restart is run from handleDidBecomeActive() because
472453
/// Activity.request() returns `visibility` during willEnterForeground.
473454
private var pendingForegroundRestart = false
474-
/// Observes `pushToStartTokenUpdates` (iOS 17.2+) and persists the token.
455+
/// Observes `pushToStartTokenUpdates` and persists the token.
475456
/// Long-lived — started once at init and never cancelled.
476457
private var pushToStartObservationTask: Task<Void, Never>?
477458
/// Observes `Activity<>.activityUpdates` so activities started out-of-band
@@ -515,118 +496,28 @@ final class LiveActivityManager {
515496
let startReason = nextStartReasonOverride ?? "user-start"
516497
nextStartReasonOverride = nil
517498

518-
if #available(iOS 17.2, *) {
519-
// iOS 17.2+ uses push-to-start for every creation path. If an
520-
// activity is already running and not stale we adopt/reuse it
521-
// (covers warm starts where the LA survived a relaunch); only
522-
// truly new starts dispatch APNs.
523-
if let existing = Activity<GlucoseLiveActivityAttributes>.activities.first {
524-
let renewBy = Storage.shared.laRenewBy.value
525-
let now = Date().timeIntervalSince1970
526-
let staleDatePassed = existing.content.staleDate.map { $0 <= Date() } ?? false
527-
let inRenewalWindow = renewBy > 0 && now >= renewBy - LiveActivityManager.renewalWarning
528-
let needsRestart = Storage.shared.laRenewalFailed.value || inRenewalWindow || staleDatePassed
529-
if !needsRestart {
530-
bind(to: existing, logReason: "reuse")
531-
Storage.shared.laRenewalFailed.value = false
532-
return
533-
}
534-
LogManager.shared.log(
535-
category: .general,
536-
message: "[LA] existing activity is stale on startIfNeeded (iOS 17.2+) — push-to-start replace (staleDatePassed=\(staleDatePassed), inRenewalWindow=\(inRenewalWindow))"
537-
)
538-
attemptPushToStartCreate(reason: startReason, oldActivity: existing)
539-
return
540-
}
541-
attemptPushToStartCreate(reason: startReason, oldActivity: nil)
542-
} else {
543-
startIfNeededLegacy()
544-
}
545-
}
546-
547-
/// Pre-17.2 path (iOS 16.6 – 17.1). Identical to dev's `startIfNeeded` —
548-
/// Activity.request() for everything. Removable when the deployment target
549-
/// reaches 17.2.
550-
@MainActor
551-
private func startIfNeededLegacy() {
499+
// Push-to-start is used for every creation path. If an activity is
500+
// already running and not stale we adopt/reuse it (covers warm starts
501+
// where the LA survived a relaunch); only truly new starts dispatch APNs.
552502
if let existing = Activity<GlucoseLiveActivityAttributes>.activities.first {
553-
// Before reusing, check whether this activity needs a restart. This covers cold
554-
// starts (app was killed while the overlay was showing — willEnterForeground is
555-
// never sent, so handleForeground never runs) and any other path that lands here
556-
// without first going through handleForeground.
557503
let renewBy = Storage.shared.laRenewBy.value
558504
let now = Date().timeIntervalSince1970
559505
let staleDatePassed = existing.content.staleDate.map { $0 <= Date() } ?? false
560506
let inRenewalWindow = renewBy > 0 && now >= renewBy - LiveActivityManager.renewalWarning
561507
let needsRestart = Storage.shared.laRenewalFailed.value || inRenewalWindow || staleDatePassed
562-
563-
if needsRestart {
564-
LogManager.shared.log(
565-
category: .general,
566-
message: "[LA] existing activity is stale on startIfNeeded — ending and restarting (staleDatePassed=\(staleDatePassed), inRenewalWindow=\(inRenewalWindow))"
567-
)
568-
569-
endingForRestart = true
570-
dismissedByUser = false
571-
572-
Storage.shared.laRenewBy.value = 0
508+
if !needsRestart {
509+
bind(to: existing, logReason: "reuse")
573510
Storage.shared.laRenewalFailed.value = false
574-
cancelRenewalFailedNotification()
575-
576-
Task {
577-
await existing.end(nil, dismissalPolicy: .immediate)
578-
await MainActor.run { self.startIfNeededLegacy() }
579-
}
580511
return
581512
}
582-
583-
bind(to: existing, logReason: "reuse")
584-
Storage.shared.laRenewalFailed.value = false
585-
return
586-
}
587-
588-
do {
589-
let attributes = GlucoseLiveActivityAttributes(title: "LoopFollow")
590-
591-
let provider = StorageCurrentGlucoseStateProvider()
592-
let seedSnapshot = GlucoseSnapshotBuilder.build(from: provider)
593-
?? GlucoseSnapshotStore.shared.load()
594-
?? GlucoseSnapshot(
595-
glucose: 0,
596-
delta: 0,
597-
trend: .unknown,
598-
updatedAt: Date(),
599-
iob: nil,
600-
cob: nil,
601-
projected: nil,
602-
unit: .mgdl,
603-
isNotLooping: false,
604-
)
605-
606-
let initialState = GlucoseLiveActivityAttributes.ContentState(
607-
snapshot: seedSnapshot,
608-
seq: 0,
609-
reason: "start",
610-
producedAt: Date(),
611-
)
612-
613-
let renewDeadline = Date().addingTimeInterval(LiveActivityManager.renewalThreshold)
614-
let content = ActivityContent(state: initialState, staleDate: renewDeadline)
615-
LALivenessStore.clear()
616-
let activity = try Activity.request(attributes: attributes, content: content, pushType: .token)
617-
618-
bind(to: activity, logReason: "start-new")
619-
Storage.shared.laRenewBy.value = renewDeadline.timeIntervalSince1970
620-
Storage.shared.laRenewalFailed.value = false
621-
LogManager.shared.log(category: .general, message: "Live Activity started id=\(activity.id)")
622-
} catch {
623-
let ns = error as NSError
624-
let scene = isAppVisibleForLiveActivityStart()
625513
LogManager.shared.log(
626514
category: .general,
627-
message: "Live Activity failed to start: \(error) domain=\(ns.domain) code=\(ns.code) — authorized=\(ActivityAuthorizationInfo().areActivitiesEnabled), sceneActive=\(scene), activities=\(Activity<GlucoseLiveActivityAttributes>.activities.count)"
515+
message: "[LA] existing activity is stale on startIfNeeded — push-to-start replace (staleDatePassed=\(staleDatePassed), inRenewalWindow=\(inRenewalWindow))"
628516
)
517+
attemptPushToStartCreate(reason: startReason, oldActivity: existing)
518+
return
629519
}
520+
attemptPushToStartCreate(reason: startReason, oldActivity: nil)
630521
}
631522

632523
/// Called from applicationWillTerminate. Ends the LA synchronously (blocking
@@ -771,102 +662,31 @@ final class LiveActivityManager {
771662
let overdueBy = Date().timeIntervalSince1970 - renewBy
772663
LogManager.shared.log(category: .general, message: "[LA] renewal deadline passed by \(Int(overdueBy))s, requesting new LA")
773664

774-
if #available(iOS 17.2, *) {
775-
// iOS 17.2+: renewal goes through push-to-start. The dispatch hops
776-
// to MainActor and returns immediately; adoption (or failure) lands
777-
// in the observer. Return true so performRefresh stops processing
778-
// this tick.
779-
Task { @MainActor [weak self] in
780-
self?.attemptPushToStartCreate(reason: "renew", oldActivity: oldActivity, snapshot: snapshot)
781-
}
782-
return true
783-
} else {
784-
return attemptLegacyRenewal(snapshot: snapshot, oldActivity: oldActivity)
785-
}
786-
}
787-
788-
/// Pre-17.2 renewal (iOS 16.6 – 17.1): foreground Activity.request, mark
789-
/// renewal-failed if it throws. Removable when the deployment target
790-
/// reaches 17.2.
791-
private func attemptLegacyRenewal(
792-
snapshot: GlucoseSnapshot,
793-
oldActivity: Activity<GlucoseLiveActivityAttributes>
794-
) -> Bool {
795-
let renewDeadline = Date().addingTimeInterval(LiveActivityManager.renewalThreshold)
796-
let attributes = GlucoseLiveActivityAttributes(title: "LoopFollow")
797-
798-
// Build the fresh snapshot with showRenewalOverlay: false — the new LA has a
799-
// fresh deadline so no overlay is needed from the first frame. We pass the
800-
// deadline as staleDate to ActivityContent below, not to Storage yet; Storage
801-
// is only updated after Activity.request succeeds so a crash between the two
802-
// can't leave the deadline permanently stuck in the future.
803-
let freshSnapshot = snapshot.withRenewalOverlay(false)
804-
805-
let state = GlucoseLiveActivityAttributes.ContentState(
806-
snapshot: freshSnapshot,
807-
seq: seq,
808-
reason: "renew",
809-
producedAt: Date(),
810-
)
811-
let content = ActivityContent(state: state, staleDate: renewDeadline)
812-
813-
do {
814-
let newActivity = try Activity.request(attributes: attributes, content: content, pushType: .token)
815-
816-
Task {
817-
await oldActivity.end(nil, dismissalPolicy: .immediate)
818-
}
819-
820-
updateTask?.cancel()
821-
updateTask = nil
822-
tokenObservationTask?.cancel()
823-
tokenObservationTask = nil
824-
stateObserverTask?.cancel()
825-
stateObserverTask = nil
826-
pushToken = nil
827-
828-
// Write deadline only on success — avoids a stuck future deadline if we crash
829-
// between the write and the Activity.request call.
830-
Storage.shared.laRenewBy.value = renewDeadline.timeIntervalSince1970
831-
bind(to: newActivity, logReason: "renew")
832-
Storage.shared.laRenewalFailed.value = false
833-
cancelRenewalFailedNotification()
834-
GlucoseSnapshotStore.shared.save(freshSnapshot)
835-
LogManager.shared.log(category: .general, message: "[LA] Live Activity renewed successfully id=\(newActivity.id)")
836-
return true
837-
} catch {
838-
// Renewal failed — deadline was never written, so no rollback needed.
839-
let isFirstFailure = !Storage.shared.laRenewalFailed.value
840-
Storage.shared.laRenewalFailed.value = true
841-
let ns = error as NSError
842-
LogManager.shared.log(
843-
category: .general,
844-
message: "[LA] renewal failed, keeping existing LA: \(error) domain=\(ns.domain) code=\(ns.code) — authorized=\(ActivityAuthorizationInfo().areActivitiesEnabled), activities=\(Activity<GlucoseLiveActivityAttributes>.activities.count)"
845-
)
846-
if isFirstFailure {
847-
scheduleRenewalFailedNotification()
848-
}
849-
return false
665+
// Renewal goes through push-to-start. The dispatch hops to MainActor
666+
// and returns immediately; adoption (or failure) lands in the observer.
667+
// Return true so performRefresh stops processing this tick.
668+
Task { @MainActor [weak self] in
669+
self?.attemptPushToStartCreate(reason: "renew", oldActivity: oldActivity, snapshot: snapshot)
850670
}
671+
return true
851672
}
852673

853-
// MARK: - Push-to-start (iOS 17.2+)
674+
// MARK: - Push-to-start
854675

855-
/// Single creation path for iOS 17.2+. Handles initial start, renewal, and
856-
/// forced restart. Verifies token + APNs credentials, applies backoff, then
857-
/// dispatches the APNs push-to-start call. The old activity is only ended
858-
/// after a confirmed successful send, preserving it if the send fails.
859-
/// Adoption is delivered via the `activityUpdates` observer —
860-
/// `handlePushToStartResult` only updates backoff/state.
861-
@available(iOS 17.2, *)
676+
/// Single creation path. Handles initial start, renewal, and forced restart.
677+
/// Verifies token + APNs credentials, applies backoff, then dispatches the
678+
/// APNs push-to-start call. The old activity is only ended after a confirmed
679+
/// successful send, preserving it if the send fails. Adoption is delivered
680+
/// via the `activityUpdates` observer — `handlePushToStartResult` only
681+
/// updates backoff/state.
862682
@MainActor
863683
private func attemptPushToStartCreate(
864684
reason: String,
865685
oldActivity: Activity<GlucoseLiveActivityAttributes>?,
866686
snapshot: GlucoseSnapshot? = nil
867687
) {
868-
// Validate APNs credentials up-front — push-to-start is the ONLY transport
869-
// on iOS 17.2+, so missing/invalid creds mean the LA will never display.
688+
// Validate APNs credentials up-front — push-to-start is the only
689+
// transport, so missing/invalid creds mean the LA will never display.
870690
let keyId = Storage.shared.lfKeyId.value
871691
let apnsKey = Storage.shared.lfApnsKey.value
872692
guard APNsCredentialValidator.isFullyConfigured(keyId: keyId, apnsKey: apnsKey) else {
@@ -919,7 +739,6 @@ final class LiveActivityManager {
919739
}
920740
}
921741

922-
@available(iOS 17.2, *)
923742
private func dispatchPushToStart(
924743
reason: String,
925744
oldActivity: Activity<GlucoseLiveActivityAttributes>?,
@@ -1019,7 +838,6 @@ final class LiveActivityManager {
1019838
}
1020839
}
1021840

1022-
@available(iOS 17.2, *)
1023841
@MainActor
1024842
private func handlePushToStartResult(
1025843
_ result: APNSClient.PushToStartResult,
@@ -1306,10 +1124,8 @@ final class LiveActivityManager {
13061124
// bind-existing path rebinds to the just-ended activity — clearing
13071125
// endingForRestart and turning the eventual iOS dismissal into a misclassified
13081126
// user swipe. Drive the restart synchronously instead.
1309-
if #available(iOS 17.2, *) {
1310-
Task { @MainActor [weak self] in
1311-
self?.attemptPushToStartCreate(reason: "expired-token", oldActivity: nil)
1312-
}
1127+
Task { @MainActor [weak self] in
1128+
self?.attemptPushToStartCreate(reason: "expired-token", oldActivity: nil)
13131129
}
13141130
}
13151131

0 commit comments

Comments
 (0)