Skip to content

Add LF Hub - #796

Open
bjorkert wants to merge 24 commits into
devfrom
feature/lfhub
Open

bjorkert wants to merge 24 commits into
devfrom
feature/lfhub

Conversation

@bjorkert

Copy link
Copy Markdown
Member

Adds LF Hub, a way to follow a family's push server instead of polling Nightscout or Dexcom from each phone. The hub drives the badge, Live Activity and Speak BG, and alarms are rung as system alarms by a new notification service extension, with a fallback to alarming in the app. Phones join through invites with roles, and LF Hub is offered as the Background Refresh mode right after connecting.

Hub failures are surfaced on Home and in the Ready list: a removed phone leaves at once, rejected pushes and outages are shown, failed source changes can be undone, and snoozes reach the hub before they are marked handled. Fastlane is updated for the new extension target.

…larms rung as system alarms by a notification service extension, and Speak BG, with invites, roles and a fallback to alarming in the app
…at once, rejected pushes and outages show on Home and in the Ready list, failed source changes can be undone, snoozes reach the hub before handled, and errors read plainly
codebymini
codebymini previously approved these changes Oct 27, 2026

@codebymini codebymini left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

# Conflicts:
#	LoopFollow.xcodeproj/project.pbxproj
#	fastlane/Fastfile
@marionbarker

Copy link
Copy Markdown
Collaborator

Test

This appears to work.
I'm using the hub and token provided for me to test.
I'm not sure what's involved in setting up the hub and whether this will be common.
I'm not sure it should be the first thing that shows up on the Settings screen.
This adds yet another identifier that needs to be documented for the browser build.
I have not test the browser build side of this PR.

IMG_9561

bjorkert and others added 7 commits November 5, 2026 08:31
…arm locally until it does, and offer to disconnect
The body was one long expression: the Form followed by about twenty
modifiers, among them four alerts and two confirmation dialogs, each with
generic closures and inline Binding(get:set:) values. Swift infers a
modifier chain as a single expression, so every added modifier multiplies
the work for the type checker. It gave up with "The compiler is unable to
type-check this expression in reasonable time" and the build failed.

The chain is now three pieces that body puts together:
- form: the Form with its task, refresh, notification and sheet modifiers
- deviceAlerts(_:): the error, replace, remove and make-member alerts
- sourceAndConnectionDialogs(_:): the data-source dialog, the LF Hub mode
  offer and the disconnect dialog

Each piece is checked on its own, which keeps every expression small.
Two smaller changes help as well: the data-source dialog's title is worked
out beforehand as a typed String, and its action uses if/else instead of
a ternary of two awaits. The invite sheet now uses the existing
patientDisplayName instead of repeating the same expression.

Nothing changes in what the screen shows or does.

@marionbarker marionbarker left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review notes from reading the code (not built or run). The first five are about how hub alarms are adopted and handled.

🤖 Generated with Claude Code

#if !targetEnvironment(macCatalyst)
if #available(iOS 26, *) { HubAlarmKit.stop(alarmID: alarmID) }
#endif
HubClient.shared.alarmHandled(alarmID)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Auto-stop reports a hub alarm as handled. stopAlarm() stops the AlarmKit alarm and POSTs /handled for whatever alarm is current whenever isHubMode. That includes when checkAlarms stops an adopted hub alarm because its condition isn't met locally.

Scenario: Hub mode is on but not confirmed (for example AlarmKit is denied, so drivesAlarms is false while the hub still delivers alarms because settings.alarms = isHubMode). A hub Low alarm arrives as a normal notification and is adopted as currentAlarm. The phone's own data lags the hub's, so the local Low check returns false, which leads to stopAlarm(), then HubAlarmKit.stop and alarmHandled. The hub stops repeating a Low nobody acknowledged.

Suggestion: report /handled only for a user snooze/acknowledge (e.g. from performSnooze), not for every stopAlarm().

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Real, though the effect is smaller than described: on the hub, /handled counts as a 2-minute snooze and clears the current alarm, so a Low that still holds fires again after that. It also only happens while the hub isn't driving the alarms, when the phone alarms itself anyway. Fixed: when the local check clears an alarm, the app no longer reports it handled or stops its system alarm; the hub stops that alarm itself once its own check clears it. Snooze, Acknowledge and Snooze All still report as before. (dee1724)

// the Live Activity push-to-start uses interruption-level: passive with empty
// title/body and must not produce a banner or sound when LF is foregrounded.
// The hub's badge push is also silent, so only its badge is applied.
if content.interruptionLevel == .passive || (content.title.isEmpty && content.body.isEmpty) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hub system alarms aren't adopted while the app is in the foreground. The NSE sets interruptionLevel = .passive on every hub alarm that rings through AlarmKit, so this early return runs before the lfhubAlarmId adoption below. With the app open, the in-app Snooze/Snoozer doesn't act on the ringing alarm until a later status poll (up to about 60s) adopts it. Consider moving the adoptHubAlarm call above the passive check.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: the alarm is adopted before the passive check (dee1724). Adoption there only happens while the hub drives the alarms, since the NSE also delivers passively when the phone has left the hub, and those alarms shouldn't be adopted. The system alarm's own Snooze button worked in this case all along.

// them; this app checks the rest. A sound this app started for an alarm the hub
// checks stops; the hub's alarm rings instead.
var alarms = Storage.shared.alarms.value
let hubDrives = HubClient.shared.drivesAlarms

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A local alarm can replace the adopted hub alarm. While the hub drives alarms, a local-only alarm type that fires sets currentAlarm to its own id (line 157). That replaces the adopted hub alarm, which is still ringing as a system alarm. Tapping in-app Snooze then snoozes the local alarm and calls alarmHandled for it, so the hub's alarm is never reported handled and keeps repeating.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can't happen with the current hub: API 1 checks every alarm type the app has, so while the hub drives, the list of alarms checked locally is empty and checkAlarms returns before that line. HubAlarmTypeTests.parity fails if a type is added that the hub doesn't check, which forces this to be handled then.

Comment thread LoopFollow/Alarm/AlarmManager.swift Outdated
alarms = HubClient.shared.alarmsCheckedHere(alarms)
let soundsHere = Observable.shared.currentAlarm.value.map { id in alarms.contains { $0.id == id } } ?? false
if Observable.shared.alarmSoundPlaying.value, !soundsHere {
AlarmSound.stop()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

currentAlarm stays set after the sound stops. This stops the local sound for an alarm the hub now checks, but currentAlarm and currentAlarmTitleOverride aren't cleared. Because that alarm is now filtered out of alarms, no later tick clears it either. The Snooze UI stays up, and a later snooze sends /handled for an alarm the hub may not be sounding. Consider clearing currentAlarm here too.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed (dee1724), with one adjustment: currentAlarm is cleared only when the hub hasn't adopted that alarm. Clearing it unconditionally would drop an alarm the hub is sounding from the Snoozer until the next status read, and going through stopAlarm() would report it handled.

if Observable.shared.alarmSoundPlaying.value, !soundsHere {
AlarmSound.stop()
}
if alarms.isEmpty { return }

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Snooze All is skipped when every alarm is hub-checked. This early return runs before the Snooze All check below. If the user enables Snooze All while an adopted hub alarm rings, stopAlarm() is never called on this phone, and the AlarmKit alarm keeps ringing until the hub gets the uploaded config and sends a stop push (which depends on network and APNs). Consider moving the Snooze All check above this return.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed by moving the Snooze All check above the hub block (dee1724). Online, the hub already handled this: the config upload makes it send a stop push for the sounding alarm. The phone now also stops it locally, which covers the case where the upload or the push fails.

listening = Task { [weak self] in
// Coming to the foreground runs a check of its own.
var lastCheck = Date()
while !Task.isCancelled, let self, self.isActive {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The long poll can't restart after this loop exits. When isActive turns false the loop ends, but listening is never set back to nil, so startListening()'s listening == nil guard blocks every later start. stopListening() is only called from change(to:) and on entering the background, so a mode change from elsewhere (settings/QR import, restore) leaves a finished Task in listening, and the open app stops getting live hub updates. Consider resetting listening = nil when the loop exits on its own.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving this as is. Every way out of Hub mode goes through change(to:), which calls stopListening(), including disconnect. QR import and restore don't touch the background mode. If it ever did happen, the next background/foreground cycle restarts the listener, and alarms don't depend on the long poll. Setting listening = nil at loop exit would also race with a stop and start and could leave two loops running.

}
}

override func serviceExtensionTimeWillExpire() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Expiry can deliver the content mid-change. serviceExtensionTimeWillExpire delivers the shared content while the Task may still be setting sound / interruptionLevel on another thread. A slow receipt (10s timeout) plus a ringOnce wait of up to 25s can pass the roughly 30s budget. The notification then goes out with its sound and the system alarm also rings, and the concurrent access is a data race on UNMutableNotificationContent. Consider capping the ringOnce wait shorter, or delivering a passive copy on expiry once a ring was scheduled.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving this as is. The receipt and the ring run concurrently (async let), and the ring-once deadline is absolute at 25 s from start, so they don't add up to 35 s. The sound is removed and the content made passive right after AlarmKit schedules the alarm, before the ring-once wait, so expiry during the wait already delivers the passive copy. The only remaining window is AlarmKit's schedule call hanging for about 30 s, where a notification with sound is the right fallback.

// app's own sources.
if HubBundle.shared.isActive {
Task {
if await HubBundle.shared.data(for: .entries) != nil {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant entries lookups (minor). In Hub mode the entries part is looked up here, then again in webLoadNSBGData (hasCurrent) and in NightscoutUtils.load (data(for:)). If the part goes stale between these checks, the tick ends early (Nightscout disabled) or fails with "No current LF Hub data", and that BG update is lost. Consider calling webLoadNSBGData directly and falling back to loadBGFromSources() on failure.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving this as is. The three checks run within milliseconds, so the part has to cross the 3-minute staleness boundary in exactly that gap, and only a follower without a Nightscout URL is affected. The reading is retried within 10–60 s, not lost. Falling back from webLoadNSBGData would change the Dexcom/Nightscout priority and could loop between them.

func render(_ text: String, language: String, completion: @escaping (String?) -> Void) {
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let old = (try? FileManager.default.contentsOfDirectory(atPath: directory.path)) ?? []
for name in old where name.hasPrefix("lfhub-speak-") {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The previous Speak BG sound file can be deleted too early (minor). Every lfhub-speak-* file is deleted before the new one is written, including the file a just-delivered Speak BG notification still points to. If two speak pushes arrive close together, the first announcement can be cut off or fall back to the default sound. Names are also only unique to the second. Consider keeping the last one or two files, or pruning only older ones.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving this as is. The hub sends at most one speak push per new reading, so at least a minute apart, and every speak push has the same apns-collapse-id, so a newer one replaces the older notification anyway. Two speak notifications don't overlap in practice.

…ive hub alarms in the foreground, and check Snooze All before the hub filter

@marionbarker marionbarker left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up review of dee1724, from reading the code (not run). The commit fixes four earlier findings: no /handled report when the local check clears an alarm, foreground adoption of passive hub alarms, clearing a leftover current alarm, and Snooze All ordering. Below are 3 new notes and 5 still open from the last review.

🤖 Generated with Claude Code

let hubDrives = HubClient.shared.drivesAlarms
if hubDrives {
alarms = HubClient.shared.alarmsCheckedHere(alarms)
if let current = Observable.shared.currentAlarm.value, !alarms.contains(where: { $0.id == current }) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still open: a local alarm can replace the adopted hub alarm (the assignment at line 164). While the hub drives alarms, a local-only alarm type that fires sets currentAlarm to its own id. That replaces the adopted hub alarm, which keeps ringing as a system alarm, while adoptedAlarm still holds the hub id. Tapping in-app Snooze then snoozes the local alarm and posts /handled for it, and the hub's alarm keeps repeating. It recovers only when a later status poll re-adopts it (up to about 60s in the foreground, and not while suspended).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as before, see #796 (comment). While the hub drives, every alarm type is hub-checked, so the local list is empty and checkAlarms returns before this assignment. HubAlarmTypeTests.parity fails the day a type is added that the hub doesn't check.

Comment thread LoopFollow/Alarm/AlarmManager.swift Outdated
func stopAlarm() {
/// Stops the current alarm. With `reportToHub`, in Hub mode its system alarm stops
/// too and the hub takes it as handled here.
func stopAlarm(reportToHub: Bool = true) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reportToHub defaults to true for paths the user didn't trigger. The onboarding guard (line 54) still calls stopAlarm(), so a hub alarm adopted while a phone joins during onboarding (the new hub step) gets its system alarm stopped and reported /handled without anyone acting. Defaulting to false and passing true only from performSnooze and Snooze All would keep "report only user handling" in one place. performSnooze also posts /handled for local-only alarm types the hub never fired.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 094abe2. stopAlarm now defaults to not reporting, and only Snooze (performSnooze) and Snooze All pass reportToHub: true, so the onboarding guard stops the sound without touching the hub. The /handled post for a local-only alarm type stays: on the hub it only clears a matching active alarm, so for an alarm the hub never fired it does nothing.

Observable.shared.selectedTabIndex.value = 0
}

if let hubAlarm = response.notification.request.content.userInfo["lfhubAlarmId"] as? String,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

didReceive still adopts hub alarms when the hub isn't driving. willPresent now adopts only while drivesAlarms, but this path adopts every time. Scenario: Hub mode is on but unconfirmed, so the app should alarm itself. The user taps the hub's High notification and it's adopted. The local High check then passes, but currentAlarm == alarm.id hits the "stays active" break, so the app never plays its own sound for that alarm. Consider the same drivesAlarms gate here, keeping adoption for the snooze action so the snooze still applies.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 094abe2. didReceive adopts the hub alarm only while the hub drives the alarms, or when the action is snooze so the snooze applies to that alarm.

Comment thread LoopFollow/Alarm/AlarmManager.swift Outdated
if Observable.shared.alarmSoundPlaying.value {
AlarmSound.stop()
}
if !MainActor.assumeIsolated({ HubClient.shared.isAdopted(current) }) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MainActor.assumeIsolated traps if checkAlarms ever runs off the main thread. It's safe today only because AlarmTask dispatches to main first. Marking checkAlarms (or AlarmManager) @MainActor would have the compiler enforce that, instead of a runtime crash if a future caller uses a background queue.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 094abe2: checkAlarms is @mainactor and calls isAdopted directly. Its only caller, AlarmTask, already runs it on main.

listening = Task { [weak self] in
// Coming to the foreground runs a check of its own.
var lastCheck = Date()
while !Task.isCancelled, let self, self.isActive {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still open: the long poll can't restart after this loop exits. When isActive turns false the loop ends, but listening is never set back to nil, so startListening()'s guard blocks every later start. A mode change that bypasses change(to:) (settings/QR import, restore) leaves the open app without live hub updates. Consider resetting listening = nil when the loop exits on its own.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as before, see #796 (comment). Every way out of Hub mode goes through change(to:), which calls stopListening(); QR import and restore don't change the background mode. The next background/foreground cycle restarts the listener, and alarms don't depend on the long poll.

}
}

override func serviceExtensionTimeWillExpire() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still open: expiry can deliver the content mid-change. serviceExtensionTimeWillExpire delivers the shared content while the Task may still be setting sound/interruptionLevel. A slow receipt (10s) plus a ringOnce wait of up to 25s can pass the roughly 30s budget, so the notification sound plays alongside the system alarm, and the concurrent writes are a data race on UNMutableNotificationContent.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as before, see #796 (comment). The receipt and the ring run concurrently and the ring-once deadline is 25 s from start, so they don't add up. The sound is removed and the content made passive right after AlarmKit schedules the alarm, before the ring-once wait.

// app's own sources.
if HubBundle.shared.isActive {
Task {
if await HubBundle.shared.data(for: .entries) != nil {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still open (minor): redundant entries lookups. The entries part is checked here, again in webLoadNSBGData (hasCurrent), and again in NightscoutUtils.load (data(for:)). If it goes stale between checks, that BG tick is lost. Consider calling webLoadNSBGData directly and falling back to loadBGFromSources() on failure.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as before, see #796 (comment). The checks run within milliseconds, it only affects a follower without a Nightscout URL, and the reading is retried within 10–60 s.

func render(_ text: String, language: String, completion: @escaping (String?) -> Void) {
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let old = (try? FileManager.default.contentsOfDirectory(atPath: directory.path)) ?? []
for name in old where name.hasPrefix("lfhub-speak-") {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still open (minor): the previous speech file is deleted too early. Every lfhub-speak-* file is removed before the new one is written, including the one a just-delivered Speak BG notification still references, and names are only unique to the second. If two speak pushes arrive close together, the first announcement can be cut off.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as before, see #796 (comment). The hub sends at most one speak push per new reading, and all speak pushes share an apns-collapse-id, so a newer one replaces the older notification.

…ed hub alarms only while the hub drives alarms, and run checkAlarms on the main actor
…nds on it

iOS removes a ringing AlarmKit alarm's banner when the app leaves the screen, and the alarm keeps sounding with no button to stop it. The app now rings its sounding hub alarms again on entering the background, and the notification service extension rings one again on the hub's next push when LoopFollow ended while on screen. A Live Activity's push token is also read when the app binds to it.
@bjorkert

Copy link
Copy Markdown
Member Author

Tested on real devices, over time and with several users.

Long-term use: LF Hub has been in daily use since 24 October on hubs for four families, each following a patient with LoopFollow on their own phone, with the hub driving the alarms, badge and Live Activity.

Real-device run (iPhone SE, iOS 26.2, real APNs, a hub and a Nightscout with simulated Loop data), all passed:

  • Set up as owner from the app, the Ready checklist, AlarmKit and notification permission
  • Background refresh through LF Hub: badge and Live Activity updated by the hub
  • Test alarm, now and delayed: push, notification service extension, system alarm, delivery receipt
  • Low, high, Not Looping and Missed Reading alarms from the hub with the app in the foreground, in the background and terminated
  • Snooze in the app, and from the system alarm's button with the app terminated
  • Alarm stopped by the hub when the condition clears
  • Snooze All, Mute (notification only, no system alarm) and Speak BG
  • Invite a family member, change role and remove the device from the app
  • Live Activity renewal through push-to-start
  • Hub does not recognize the phone (401): warning shown, the app alarms by itself and recovers when the hub answers again
  • Hub down: the app alarms by itself after 5 minutes, the watchdog alert after 20 minutes, recovery when the hub is back
  • Disconnect: the device is removed and the app returns to Silent Tune

Fixed during the run (8d232d6): a hub alarm ringing while LoopFollow was on screen lost its banner and stop button when the app left the screen or ended there; it is now rung again so the button comes back.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants