@@ -10,19 +10,8 @@ import os
1010import UIKit
1111import 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
2716final 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