Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
860eaf5
Add LF Hub: follow a family's push server for badge, Live Activity, a…
bjorkert Oct 25, 2026
9409a55
Offer LF Hub as the Background Refresh mode right after connecting to…
bjorkert Oct 25, 2026
161ef4a
Make LF Hub failures visible and recoverable: a removed phone leaves …
bjorkert Oct 26, 2026
1f36561
Merge remote-tracking branch 'origin/dev' into feature/lfhub
bjorkert Oct 27, 2026
f8ad6c4
Adopt a push-to-start Live Activity that a refresh already bound, kee…
bjorkert Oct 28, 2026
1345a6b
Clear the alarm title override when the hub stops its alarm
bjorkert Oct 28, 2026
26cc6f3
Let the watch fetch on its own while the hub drives the alarms
bjorkert Oct 28, 2026
5489ca7
Show the LF Hub version next to the newest one for the hub's channel,…
bjorkert Nov 1, 2026
799386a
Show a server icon before the source name in LF Hub mode, blue while …
bjorkert Nov 1, 2026
a7c944c
Merge remote-tracking branch 'origin/dev' into feature/lfhub
bjorkert Nov 1, 2026
0187fba
Apply the hub's badge push while LoopFollow is in the foreground
bjorkert Nov 1, 2026
87e9d14
Merge remote-tracking branch 'origin/dev' into feature/lfhub
bjorkert Nov 2, 2026
0fd22da
Drop the removed Download Prediction setting from the hub client sett…
bjorkert Nov 2, 2026
e723f84
Merge remote-tracking branch 'origin/dev' into feature/lfhub
bjorkert Nov 2, 2026
bd5be41
Link the notification service setup error to the docs
bjorkert Nov 5, 2026
3eed3eb
Point the notification service setup error only at App Group failures…
bjorkert Nov 5, 2026
01286fa
Keep the hub connection when the hub does not recognize the phone, al…
bjorkert Nov 5, 2026
67804f2
Drop the hub heartbeat log
bjorkert Nov 5, 2026
fc6a44f
Show LF Hub as the last row under Data Settings
bjorkert Nov 5, 2026
3f6a7dd
Explain that an admin's shared APN key works only for LoopFollow buil…
bjorkert Nov 5, 2026
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
98 changes: 98 additions & 0 deletions HubShared/BGAnnouncement.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// LoopFollow
// BGAnnouncement.swift

// Compiled into the app and into LoopFollowNotificationService: the app speaks it
// for Speak BG, the extension renders it for the hub's speech push.

import Foundation

/// The sentence Speak BG says for a new reading, in the chosen language.
enum BGAnnouncement {
struct Texts {
var stable: String
var increase: String
var decrease: String
var currentBGIs: String

static func forLanguage(_ language: String) -> Texts {
switch language {
case "fr":
return Texts(
stable: "et c'est stable",
increase: "et c'est monté de",
decrease: "et c'est descendu de",
currentBGIs: "La glycémie est"
)
case "it":
return Texts(
stable: "ed è stabile",
increase: "ed è salita di",
decrease: "ed è scesa di",
currentBGIs: "Glicemia attuale è"
)
case "sk":
return Texts(
stable: "a je stabilná",
increase: "a stúpla o",
decrease: "a klesla o",
currentBGIs: "Aktuálna glykémia je"
)
case "sv":
return Texts(
stable: "och det är stabilt",
increase: "och det har ökat med",
decrease: "och det har minskat med",
currentBGIs: "Blodsockret är"
)
case "en": fallthrough
default:
return Texts(
stable: "and it is stable",
increase: "and it is up",
decrease: "and it is down",
currentBGIs: "Glucose is"
)
}
}
}

static let voiceLanguageMap: [String: String] = [
"en": "en-US",
"fr": "fr-FR",
"it": "it-IT",
"sk": "sk-SK",
"sv": "sv-SE",
]

static func voiceLanguageCode(forAppLanguage appLanguage: String) -> String {
voiceLanguageMap[appLanguage, default: "en-US"]
}

/// A change of this many mg/dL or less is spoken as stable.
static let negligibleChange = 3

/// The announcement for `current` after `previous` (mg/dL), with values in the
/// display unit and a period as the decimal mark, which every voice reads as one.
static func text(current: Int, previous: Int, language: String, mmol: Bool,
locale: Locale = .current) -> String
{
let texts = Texts.forLanguage(language)
let difference = current - previous
let currentText = format(current, mmol: mmol, locale: locale)
if abs(difference) <= negligibleChange {
return "\(texts.currentBGIs) \(currentText) \(texts.stable)"
}
let direction = difference < 0 ? texts.decrease : texts.increase
return "\(texts.currentBGIs) \(currentText) \(direction) \(format(abs(difference), mmol: mmol, locale: locale))"
}

static func format(_ mgdl: Int, mmol: Bool, locale: Locale) -> String {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.locale = locale
formatter.minimumFractionDigits = mmol ? 1 : 0
formatter.maximumFractionDigits = mmol ? 1 : 0
let value = mmol ? Double(mgdl) * GlucoseConversion.mgDlToMmolL : Double(mgdl)
return (formatter.string(from: NSNumber(value: value)) ?? String(value)).replacingOccurrences(of: ",", with: ".")
}
}
174 changes: 174 additions & 0 deletions HubShared/HubAlarmKit.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
// LoopFollow
// HubAlarmKit.swift

// Compiled into the app and into LoopFollowNotificationService only: the extension
// rings hub alarms through it; the app stops them, handles their snooze intent and
// rings its own notices.

import Foundation

#if canImport(AlarmKit) && !targetEnvironment(macCatalyst)
import ActivityKit
import AlarmKit
import AppIntents
import struct SwiftUI.Color

/// Rings an alarm LF Hub delivered as an AlarmKit alarm, which breaks through
/// the silent switch and Focus. The notification service extension calls this
/// for every hub alarm push, so it rings even while LoopFollow is suspended
/// or not running. The AlarmKit alarm takes the LoopFollow alarm's id, so a
/// repeated push for an alarm that is still ringing does not ring twice.
@available(iOS 26, *)
enum HubAlarmKit {
/// A fixed alarm at or before now never fires, so it is set this far ahead.
private static let immediateFireDelay: TimeInterval = 2

static var isAuthorized: Bool {
AlarmKit.AlarmManager.shared.authorizationState == .authorized
}

/// The user has not been asked yet; once asked, only the Settings app changes it.
static var canAsk: Bool {
AlarmKit.AlarmManager.shared.authorizationState == .notDetermined
}

static func requestAuthorization() async -> Bool {
if AlarmKit.AlarmManager.shared.authorizationState == .notDetermined {
_ = try? await AlarmKit.AlarmManager.shared.requestAuthorization()
}
return isAuthorized
}

/// Whether the alarm is ringing or about to, when it starts if this call scheduled it,
/// and why. Not ringing when AlarmKit is not authorized or scheduling failed, so the
/// notification keeps its sound. Without `soundName` it rings with the default sound.
/// `symbol` is the SF Symbol on the alarm's button: the alarm type's icon. The button
/// reads Acknowledge for an alarm whose snooze is 0 (`acknowledge`), as in the app.
static func ring(alarmID: UUID, title: String, soundName: String?, symbol: String = "moon.zzz",
acknowledge: Bool = false) async -> (ringing: Bool, firesAt: Date?, detail: String)
{
// A denied state is final; notDetermined is what an extension reads even
// after the app was authorized, so scheduling is attempted then too.
let state = AlarmKit.AlarmManager.shared.authorizationState
guard state != .denied else { return (false, nil, "authorization denied") }
if ((try? AlarmKit.AlarmManager.shared.alarms) ?? []).contains(where: { $0.id == alarmID }) {
return (true, nil, "already scheduled")
}

let presentation = AlarmPresentation(
alert: AlarmPresentation.Alert(
title: LocalizedStringResource(stringLiteral: title),
stopButton: AlarmButton(
text: LocalizedStringResource(stringLiteral: buttonTitle(acknowledge: acknowledge)),
textColor: .white,
systemImageName: symbol
)
)
)
let firesAt = Date().addingTimeInterval(immediateFireDelay)
let configuration = AlarmKit.AlarmManager.AlarmConfiguration<HubAlarmMetadata>.alarm(
schedule: .fixed(firesAt),
attributes: AlarmAttributes(presentation: presentation, tintColor: .red),
stopIntent: SnoozeHubAlarmIntent(alarmID: alarmID),
sound: soundName.map { .named($0) } ?? .default
)
do {
let alarm = try await AlarmKit.AlarmManager.shared.schedule(id: alarmID, configuration: configuration)
return (true, firesAt, "scheduled, state \(alarm.state) (authorization read \(state))")
} catch {
return (false, nil, "schedule failed with authorization \(state): \(error)")
}
}

static func buttonTitle(acknowledge: Bool) -> String {
acknowledge ? "Acknowledge" : "Snooze"
}

/// Rings a notice from the app itself (not a LoopFollow alarm) as a system
/// alarm now, with an OK button and no snooze. Replaces a notice with the same id.
static func ringNotice(id: UUID, title: String, symbol: String) async -> Bool {
guard AlarmKit.AlarmManager.shared.authorizationState == .authorized else { return false }
stop(alarmID: id)
let presentation = AlarmPresentation(
alert: AlarmPresentation.Alert(
title: LocalizedStringResource(stringLiteral: title),
stopButton: AlarmButton(
text: LocalizedStringResource(stringLiteral: "OK"),
textColor: .white,
systemImageName: symbol
)
)
)
let configuration = AlarmKit.AlarmManager.AlarmConfiguration<HubAlarmMetadata>.alarm(
schedule: .fixed(Date().addingTimeInterval(immediateFireDelay)),
attributes: AlarmAttributes(presentation: presentation, tintColor: .orange),
sound: .default
)
return (try? await AlarmKit.AlarmManager.shared.schedule(id: id, configuration: configuration)) != nil
}

/// Whether the alarm is sounding now.
static func isAlerting(alarmID: UUID) -> Bool {
((try? AlarmKit.AlarmManager.shared.alarms) ?? []).contains { $0.id == alarmID && $0.state == .alerting }
}

static func stop(alarmID: UUID) {
try? AlarmKit.AlarmManager.shared.stop(id: alarmID)
try? AlarmKit.AlarmManager.shared.cancel(id: alarmID)
}
}

@available(iOS 26, *)
struct HubAlarmMetadata: AlarmMetadata {}

/// Runs in the app when the AlarmKit alarm's button is tapped, and snoozes
/// the LoopFollow alarm the way the notification's snooze action does.
@available(iOS 26, *)
struct SnoozeHubAlarmIntent: LiveActivityIntent {
static let title: LocalizedStringResource = "Snooze LoopFollow Alarm"
static let isDiscoverable = false

@Parameter(title: "Alarm ID") var alarmID: String

init() {}

init(alarmID: UUID) {
self.alarmID = alarmID.uuidString
}

func perform() async throws -> some IntentResult {
if let id = UUID(uuidString: alarmID) {
await HubAlarmSnoozeBridge.shared.snooze(id)
}
return .result()
}
}
#endif

/// Hands an AlarmKit snooze to the app's alarm handling. Taps that arrive
/// before the app registers its handler are queued until it does.
@MainActor
final class HubAlarmSnoozeBridge {
static let shared = HubAlarmSnoozeBridge()
private init() {}

private var handler: (@MainActor (UUID) async -> Void)?
private var pending: [UUID] = []

func setHandler(_ handler: @escaping @MainActor (UUID) async -> Void) {
self.handler = handler
let queued = pending
pending = []
Task { for id in queued {
await handler(id)
} }
}

func snooze(_ id: UUID) async {
if let handler {
await handler(id)
} else {
pending.append(id)
}
}
}
32 changes: 32 additions & 0 deletions HubShared/HubAppGroup.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// LoopFollow
// HubAppGroup.swift

/// What the app shares with LoopFollowNotificationService through the App Group's
/// defaults. The device key is not shared: the extension's one hub call, the
/// delivery receipt, needs no key.
enum HubAppGroup {
/// The hub's address while the app is connected to a hub; absent otherwise.
static let hubURLKey = "lfhubURL"

/// The notification category of a hub alarm whose snooze is 0: its buttons read
/// Acknowledge, as the app's own action for such an alarm does.
static let acknowledgeCategory = "lfhub-ack"

/// How the extension delivers a push from the hub.
enum Delivery: Equatable {
/// As a system alarm, when the hub asks for one.
case systemAlarm
/// As the notification the hub sent, with its sound.
case notification
/// Quietly to the notification list, with no sound: this phone has left the hub,
/// so nothing it cannot silence rings, and the push is still kept.
case quiet
}

/// A push reaching a phone that is not connected to a hub (`hubURL` absent) is
/// delivered quietly.
static func delivery(hubURL: String?, systemAlarm: Bool) -> Delivery {
guard let hubURL, !hubURL.isEmpty else { return .quiet }
return systemAlarm ? .systemAlarm : .notification
}
}
Loading