Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
bf6c44f
Fix duplicate chevron on Import/Export Settings row (#628)
bjorkert Apr 29, 2026
c9f74f4
CI: Bump dev version to 6.0.9 [skip ci]
github-actions[bot] Apr 29, 2026
8b5ce73
Add anonymous telemetry (#626)
bjorkert May 2, 2026
3f45cd0
CI: Bump dev version to 6.0.10 [skip ci]
github-actions[bot] May 2, 2026
2e72194
Deduplicate Nightscout treatment entries by id field (#569)
bjorkert May 3, 2026
8f1b9ac
CI: Bump dev version to 6.0.11 [skip ci]
github-actions[bot] May 3, 2026
fff2295
Units selection (#558)
codebymini May 3, 2026
b68598b
CI: Bump dev version to 6.0.12 [skip ci]
github-actions[bot] May 3, 2026
204e3cc
Fix alarm sound session activation failures in background (#596)
bjorkert May 3, 2026
f7c4155
CI: Bump dev version to 6.0.13 [skip ci]
github-actions[bot] May 3, 2026
22dc308
Fix Live Activity restart classification, foreground race, and add tr…
bjorkert May 4, 2026
06b314f
CI: Bump dev version to 6.0.14 [skip ci]
github-actions[bot] May 4, 2026
23e1f44
update to fastlane 2.233.1 (#632)
bjorkert May 4, 2026
46218ed
CI: Bump dev version to 6.0.15 [skip ci]
github-actions[bot] May 4, 2026
fd81a69
Recognize Atlas DASH pod in Omnipod heartbeat scan (#633)
bjorkert May 4, 2026
8b2c4a1
CI: Bump dev version to 6.0.16 [skip ci]
github-actions[bot] May 4, 2026
2fa57b2
Default migrationStep to latest so fresh installs skip migrations (#631)
bjorkert May 4, 2026
3ffae49
CI: Bump dev version to 6.0.17 [skip ci]
github-actions[bot] May 4, 2026
5ab6209
Fix/stats inclusive range - replaces #621 (#629)
MtlPhil May 4, 2026
57f5f11
CI: Bump dev version to 6.0.18 [skip ci]
github-actions[bot] May 4, 2026
2e8607f
Redact secrets from log output (#623)
bjorkert May 4, 2026
1cd1f60
CI: Bump dev version to 6.0.19 [skip ci]
github-actions[bot] May 4, 2026
e595114
Add iOS 17.2+ push-to-start for Live Activity renewal (#622)
bjorkert May 4, 2026
c0f703c
CI: Bump dev version to 6.0.20 [skip ci]
github-actions[bot] May 4, 2026
0d91b95
update version to 6.1.0 [skip ci]
marionbarker May 4, 2026
bccd5b8
Add Apple Watch app with complications, haptic alerts, and remote com…
claude May 6, 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
2 changes: 1 addition & 1 deletion Config.xcconfig
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@
unique_id = ${DEVELOPMENT_TEAM}

//Version (DEFAULT)
LOOP_FOLLOW_MARKETING_VERSION = 6.0.8
LOOP_FOLLOW_MARKETING_VERSION = 6.1.0
4 changes: 3 additions & 1 deletion Gemfile
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
source "https://rubygems.org"
gem "fastlane", "2.232.2"
gem "fastlane", "2.233.1"
gem "json", ">=2.19.2"
gem "addressable", ">=2.9.0"
12 changes: 6 additions & 6 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ GEM
faraday_middleware (1.2.1)
faraday (~> 1.0)
fastimage (2.4.0)
fastlane (2.232.2)
fastlane (2.233.1)
CFPropertyList (>= 2.3, < 4.0.0)
abbrev (~> 0.1.2)
addressable (>= 2.8, < 3.0.0)
Expand All @@ -92,7 +92,7 @@ GEM
faraday-cookie_jar (~> 0.0.6)
faraday_middleware (~> 1.0)
fastimage (>= 2.1.0, < 3.0.0)
fastlane-sirp (>= 1.0.0)
fastlane-sirp (>= 1.1.0)
gh_inspector (>= 1.1.2, < 2.0.0)
google-apis-androidpublisher_v3 (~> 0.3)
google-apis-playcustomapp_v1 (~> 0.1)
Expand Down Expand Up @@ -122,8 +122,7 @@ GEM
xcodeproj (>= 1.13.0, < 2.0.0)
xcpretty (~> 0.4.1)
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
fastlane-sirp (1.0.0)
sysrandom (~> 1.0)
fastlane-sirp (1.1.0)
gh_inspector (1.1.3)
google-apis-androidpublisher_v3 (0.54.0)
google-apis-core (>= 0.11.0, < 2.a)
Expand Down Expand Up @@ -203,7 +202,6 @@ GEM
simctl (1.6.10)
CFPropertyList
naturally
sysrandom (1.0.5)
terminal-notifier (2.0.0)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
Expand Down Expand Up @@ -232,7 +230,9 @@ PLATFORMS
ruby

DEPENDENCIES
fastlane (= 2.232.2)
addressable (>= 2.9.0)
fastlane (= 2.233.1)
json (>= 2.19.2)

BUNDLED WITH
4.0.6
252 changes: 251 additions & 1 deletion LoopFollow.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

28 changes: 25 additions & 3 deletions LoopFollow/Application/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
_ = BLEManager.shared
// Ensure VolumeButtonHandler is initialized so it can receive alarm notifications
_ = VolumeButtonHandler.shared

WatchConnectivityManager.shared.activate()

// Register for remote notifications
DispatchQueue.main.async {
Expand All @@ -48,6 +50,21 @@ class AppDelegate: UIResponder, UIApplicationDelegate {

BackgroundRefreshManager.shared.register()

// Telemetry: record this cold launch (used by the rolling
// coldLaunches7d signal). If the running build's SHA differs from
// the one we last sent for, fire an immediate ping — the scheduler
// alone can't notice an app update. Otherwise let the 24h scheduler
// handle cadence: its first run is lastSentAt + 24h, so a relaunch
// a few hours after the previous send simply waits out the
// remainder. See Helpers/Telemetry.swift.
TelemetryClient.shared.recordColdLaunch()
Task.detached {
if TelemetryClient.shared.buildShaChangedSinceLastSend() {
await TelemetryClient.shared.maybeSend()
}
TelemetryClient.shared.scheduleRecurring()
}

// Detect Before-First-Unlock launch. If protected data is unavailable here,
// StorageValues were cached from encrypted UserDefaults and need a reload
// on the first foreground after the user unlocks.
Expand All @@ -72,7 +89,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {

Observable.shared.loopFollowDeviceToken.value = tokenString

LogManager.shared.log(category: .apns, message: "Successfully registered for remote notifications with token: \(tokenString)")
LogManager.shared.log(category: .apns, message: "Successfully registered for remote notifications with token: \(LogRedactor.tail(tokenString))")
}

/// Called when failed to register for remote notifications
Expand All @@ -82,7 +99,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate {

/// Called when a remote notification is received
func application(_: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
LogManager.shared.log(category: .apns, message: "Received remote notification: \(userInfo)")
let userInfoKeys = userInfo.keys.compactMap { $0 as? String }.sorted()
LogManager.shared.log(category: .apns, message: "Received remote notification: keys=\(userInfoKeys)")

// Check if this is a response notification from Loop or Trio
if let aps = userInfo["aps"] as? [String: Any] {
Expand All @@ -107,6 +125,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
}
}

// Forward Loop command return notifications to the Watch
WatchConnectivityManager.shared.forwardCommandReturnToWatch(userInfo: userInfo)

// Call completion handler
completionHandler(.newData)
}
Expand Down Expand Up @@ -217,7 +238,8 @@ extension AppDelegate: UNUserNotificationCenterDelegate {
{
// Log the notification
let userInfo = notification.request.content.userInfo
LogManager.shared.log(category: .general, message: "Will present notification: \(userInfo)")
let userInfoKeys = userInfo.keys.compactMap { $0 as? String }.sorted()
LogManager.shared.log(category: .general, message: "Will present notification: keys=\(userInfoKeys)")

// Show the notification even when app is in foreground
completionHandler([.banner, .sound, .badge])
Expand Down
4 changes: 3 additions & 1 deletion LoopFollow/Application/Base.lproj/Main.storyboard
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" alignment="center" translatesAutoresizingMaskIntoConstraints="NO" id="xGF-Pj-QE0">
<rect key="frame" x="102.66666666666666" y="0.0" width="92.666666666666657" height="45"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Est A1C:" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="WV0-Jy-FPs" userLabel="Est A1C:">
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Est. A1C:" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="WV0-Jy-FPs" userLabel="Est. A1C:">
<rect key="frame" x="18.333333333333346" y="0.0" width="56.333333333333343" height="18"/>
<fontDescription key="fontDescription" type="system" pointSize="15"/>
<nil key="textColor"/>
Expand Down Expand Up @@ -306,11 +306,13 @@
<outlet property="smallGraphHeightConstraint" destination="qmO-ga-QWl" id="VsZ-zJ-LsJ"/>
<outlet property="statsAvgBG" destination="jpA-Nb-pU7" id="Uo8-a4-Aus"/>
<outlet property="statsEstA1C" destination="7Jx-XF-1vS" id="4RD-nm-JxO"/>
<outlet property="statsEstA1CTitle" destination="WV0-Jy-FPs" id="WnU-h8-2hf"/>
<outlet property="statsHighPercent" destination="HON-rt-8pC" id="283-3S-PCR"/>
<outlet property="statsInRangePercent" destination="7mH-Np-j0L" id="vUp-Pv-Mva"/>
<outlet property="statsLowPercent" destination="TzL-hn-9qu" id="0QR-Mz-KJe"/>
<outlet property="statsPieChart" destination="Hhh-F1-s1p" id="Rhh-Up-Kr0"/>
<outlet property="statsStdDev" destination="wAI-Tp-784" id="BUZ-lS-JfA"/>
<outlet property="statsStdDevTitle" destination="iXC-Mz-I09" id="QHf-Q8-J7B"/>
<outlet property="statsView" destination="ikj-at-auF" id="7AQ-VA-Pw2"/>
</connections>
</viewController>
Expand Down
36 changes: 36 additions & 0 deletions LoopFollow/Application/SceneDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,17 @@
// SceneDelegate.swift

import AVFoundation
import SwiftUI
import UIKit

class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
let synthesizer = AVSpeechSynthesizer()

/// One-shot guard so the consent prompt is only attempted once per
/// process lifetime even if the scene activates repeatedly.
private var consentPromptShownThisProcess = false

func scene(_ scene: UIScene, willConnectTo _: UISceneSession, options _: UIScene.ConnectionOptions) {
// Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
// If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
Expand All @@ -32,6 +37,37 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
func sceneDidBecomeActive(_: UIScene) {
// Called when the scene has moved from an inactive state to an active state.
// Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive.
runTelemetryFirstForegroundHook()
}

/// Presents the one-time consent sheet on first foreground. Sending is
/// handled by AppDelegate at launch and by TaskScheduler thereafter —
/// firing maybeSend here would duplicate the launch-time send.
private func runTelemetryFirstForegroundHook() {
if !Storage.shared.telemetryConsentDecisionMade.value,
!consentPromptShownThisProcess
{
consentPromptShownThisProcess = true
presentTelemetryConsentSheet()
}
}

private func presentTelemetryConsentSheet() {
guard let root = window?.rootViewController else { return }
// Find the topmost presented controller so we don't try to present
// over a sheet that's already up.
var top = root
while let presented = top.presentedViewController {
top = presented
}

let host = UIHostingController(rootView: TelemetryConsentView())
host.isModalInPresentation = true // user must explicitly choose
// Defer to the next runloop so view hierarchy is settled when the
// scene first becomes active on a fresh install.
DispatchQueue.main.async {
top.present(host, animated: true)
}
}

func scene(_: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
Expand Down
6 changes: 4 additions & 2 deletions LoopFollow/BackgroundRefresh/BackgroundRefreshType.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,10 @@ enum BackgroundRefreshType: String, Codable, CaseIterable {

case .omnipodDash:
if let name = device.name {
// actual DASH or rPi DASH simulator
return name == "TWI BOARD" || name == " :: Fake POD ::"
// "TWI BOARD": original DASH pod
// "InPlay BLE": newer Atlas DASH pod
// " :: Fake POD ::": rPi DASH simulator
return name == "TWI BOARD" || name == "InPlay BLE" || name == " :: Fake POD ::"
}
return false

Expand Down
52 changes: 33 additions & 19 deletions LoopFollow/Controllers/AlarmSound.swift
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,7 @@ class AlarmSound {
audioPlayer = try AVAudioPlayer(contentsOf: soundURL)
audioPlayer!.delegate = audioPlayerDelegate

try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: [])
try AVAudioSession.sharedInstance().setActive(true)
activateAudioSessionWithFallback()

audioPlayer?.numberOfLoops = 0

Expand All @@ -116,8 +115,6 @@ class AlarmSound {
return
}

enableAudio()

// If repeating with delay, we'll handle it manually via the delegate
// Only set repeatDelay if both repeating and delay > 0
repeatDelay = (repeating && delay > 0) ? TimeInterval(delay) : 0
Expand All @@ -126,8 +123,7 @@ class AlarmSound {
audioPlayer = try AVAudioPlayer(contentsOf: soundURL)
audioPlayer!.delegate = audioPlayerDelegate

try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: [])
try AVAudioSession.sharedInstance().setActive(true)
activateAudioSessionWithFallback()

// Only use numberOfLoops if we're not using delay-based repeating
// When repeatDelay > 0, we play once and then use the delegate to schedule the next play with delay
Expand All @@ -145,8 +141,7 @@ class AlarmSound {
// First sound plays immediately - delay only applies between repeated sounds
if audioPlayer!.play() {
if !isPlaying {
LogManager.shared.log(category: .alarm, message: "AlarmSound - not playing after calling play")
LogManager.shared.log(category: .alarm, message: "AlarmSound - rate value: \(audioPlayer!.rate)")
LogManager.shared.log(category: .alarm, message: "AlarmSound - not playing after calling play (rate \(audioPlayer!.rate))")
} else {
Observable.shared.alarmSoundPlaying.value = true
if repeatDelay > 0 {
Expand Down Expand Up @@ -184,8 +179,7 @@ class AlarmSound {
audioPlayer = try AVAudioPlayer(contentsOf: soundURL)
audioPlayer!.delegate = audioPlayerDelegate

try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: [])
try AVAudioSession.sharedInstance().setActive(true)
activateAudioSessionWithFallback()

audioPlayer!.numberOfLoops = 0

Expand Down Expand Up @@ -213,8 +207,7 @@ class AlarmSound {
audioPlayer = try AVAudioPlayer(contentsOf: soundURL)
audioPlayer!.delegate = audioPlayerDelegate

try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: [])
try AVAudioSession.sharedInstance().setActive(true)
activateAudioSessionWithFallback()

// Play endless loops
audioPlayer!.numberOfLoops = 2
Expand Down Expand Up @@ -260,14 +253,35 @@ class AlarmSound {
systemOutputVolumeBeforeOverride = nil
}

fileprivate static func enableAudio() {
do {
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: [])
try AVAudioSession.sharedInstance().setActive(true)
LogManager.shared.log(category: .alarm, message: "Audio session configured for alarm playback")
} catch {
LogManager.shared.log(category: .alarm, message: "Enable audio error: \(error)")
// Background activation of a non-mixable .playback session is denied by iOS
// (cannotInterruptOthers, 560557684) unless the app is already actively playing
// audio. In foreground, or with Silent Tune holding a mixable session alive,
// options: [] succeeds and lets the alarm dominate other audio. For
// Bluetooth-heartbeat users with no Silent Tune we skip [] (it would always
// be denied) and ladder through mixable options so activation is still
// permitted from background. Each attempt is logged so we can see in the
// field which fallback (if any) the user landed on.
fileprivate static func activateAudioSessionWithFallback() {
let isBackgroundWithoutSilentTune = UIApplication.shared.applicationState == .background
&& Storage.shared.backgroundRefreshType.value != .silentTune

let dominate: (label: String, options: AVAudioSession.CategoryOptions) = ("[]", [])
let duck: (label: String, options: AVAudioSession.CategoryOptions) = (".duckOthers", .duckOthers)
let mix: (label: String, options: AVAudioSession.CategoryOptions) = (".mixWithOthers", .mixWithOthers)

let candidates = isBackgroundWithoutSilentTune ? [duck, mix] : [dominate, duck, mix]
for candidate in candidates {
do {
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: candidate.options)
try AVAudioSession.sharedInstance().setActive(true)
LogManager.shared.log(category: .alarm, message: "AlarmSound - audio session active (options: \(candidate.label))")
return
} catch {
let nsError = error as NSError
LogManager.shared.log(category: .alarm, message: "AlarmSound - audio session activation failed (options: \(candidate.label)) [code \(nsError.code)]: \(error.localizedDescription)")
}
}
LogManager.shared.log(category: .alarm, message: "AlarmSound - all audio session option fallbacks exhausted")
}
}

Expand Down
29 changes: 19 additions & 10 deletions LoopFollow/Controllers/Graphs.swift
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,10 @@ class TempTargetRenderer: LineChartRenderer {

let ScaleXMax: Double = 150.0
extension MainViewController {
private func graphRangeThresholds() -> (low: Double, high: Double) {
UnitSettingsStore.shared.effectiveThresholds()
}

func updateChartRenderers() {
let tempTargetDataIndex = GraphDataIndex.tempTarget.rawValue
let smbDataIndex = GraphDataIndex.smb.rawValue
Expand Down Expand Up @@ -627,15 +631,17 @@ extension MainViewController {
// Clear limit lines so they don't add multiples when changing the settings
BGChart.rightAxis.removeAllLimitLines()

// Add lower red line based on low alert value
let thresholds = graphRangeThresholds()

// Add lower red line
let ll = ChartLimitLine()
ll.limit = Storage.shared.lowLine.value
ll.limit = thresholds.low
ll.lineColor = NSUIColor.systemRed.withAlphaComponent(0.5)
BGChart.rightAxis.addLimitLine(ll)

// Add upper yellow line based on low alert value
// Add upper yellow line
let ul = ChartLimitLine()
ul.limit = Storage.shared.highLine.value
ul.limit = thresholds.high
ul.lineColor = NSUIColor.systemYellow.withAlphaComponent(0.5)
BGChart.rightAxis.addLimitLine(ul)

Expand Down Expand Up @@ -786,15 +792,17 @@ extension MainViewController {
// Clear limit lines so they don't add multiples when changing the settings
BGChart.rightAxis.removeAllLimitLines()

// Add lower red line based on low alert value
let thresholds = graphRangeThresholds()

// Add lower red line
let ll = ChartLimitLine()
ll.limit = Storage.shared.lowLine.value
ll.limit = thresholds.low
ll.lineColor = NSUIColor.systemRed.withAlphaComponent(0.5)
BGChart.rightAxis.addLimitLine(ll)

// Add upper yellow line based on low alert value
// Add upper yellow line
let ul = ChartLimitLine()
ul.limit = Storage.shared.highLine.value
ul.limit = thresholds.high
ul.lineColor = NSUIColor.systemYellow.withAlphaComponent(0.5)
BGChart.rightAxis.addLimitLine(ul)

Expand Down Expand Up @@ -824,6 +832,7 @@ extension MainViewController {
var colors = [NSUIColor]()

topBG = Storage.shared.minBGScale.value
let thresholds = graphRangeThresholds()
for i in 0 ..< entries.count {
// Clamp the plotted y-value to the same bounds the header text uses
// (HIGH/LOW), so the graph stays consistent with the main display.
Expand All @@ -836,9 +845,9 @@ extension MainViewController {
mainChart.append(value)
smallChart.append(value)

if Double(entries[i].sgv) >= Storage.shared.highLine.value {
if Double(entries[i].sgv) >= thresholds.high {
colors.append(NSUIColor.systemYellow)
} else if Double(entries[i].sgv) <= Storage.shared.lowLine.value {
} else if Double(entries[i].sgv) <= thresholds.low {
colors.append(NSUIColor.systemRed)
} else {
colors.append(NSUIColor.systemGreen)
Expand Down
Loading