Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
19 changes: 18 additions & 1 deletion LoopFollow.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,11 @@
65E153C32E4BB69100693A4F /* URLTokenValidationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E153C22E4BB69100693A4F /* URLTokenValidationView.swift */; };
65E8A2862E44B0300065037B /* VolumeButtonHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E8A2852E44B0300065037B /* VolumeButtonHandler.swift */; };
66E3D12E66AA4534A144A54B /* BackgroundRefreshManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8CA8BE0B3D247408FE088B4 /* BackgroundRefreshManager.swift */; };
87F526CE28FD073330D2F516 /* BannerManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 098BA6FC4279B166734A0E13 /* BannerManager.swift */; };
8897B18D53EA2F1963B8B4E0 /* AppBannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8000BC181BCE5AA8553FB3B6 /* AppBannerView.swift */; };
9C7FB98C98BE4FF98F4815EE /* Telemetry.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDFBE69CEF18416D84959974 /* Telemetry.swift */; };
92664FA270DA9905E1F08676 /* BGChartStubs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C508E0E86BEC0B13280F34B /* BGChartStubs.swift */; };
9727B75402723679BD39CDE5 /* DBSizeTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBE9ECA96BEA20DBD21B570D /* DBSizeTask.swift */; };
9C7FB98C98BE4FF98F4815EE /* Telemetry.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDFBE69CEF18416D84959974 /* Telemetry.swift */; };
9F24B79D2722BB040AE3311F /* BGChartModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C8CB992D98689FC7D019CEC /* BGChartModel.swift */; };
A1A1A10001000000A0CFEED1 /* APNsCredentialValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1A1A10001000000A0CFEED2 /* APNsCredentialValidator.swift */; };
A1A1A10002000000A0CFEED1 /* LogRedactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1A1A10002000000A0CFEED2 /* LogRedactor.swift */; };
Expand Down Expand Up @@ -474,6 +476,7 @@

/* Begin PBXFileReference section */
059B0FA59AABFE72FE13DDDA /* Pods-LoopFollow.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-LoopFollow.release.xcconfig"; path = "Target Support Files/Pods-LoopFollow/Pods-LoopFollow.release.xcconfig"; sourceTree = "<group>"; };
098BA6FC4279B166734A0E13 /* BannerManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BannerManager.swift; sourceTree = "<group>"; };
21EBF6ACF259782445DA1A7E /* DBSizeAlarmEditor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DBSizeAlarmEditor.swift; sourceTree = "<group>"; };
2B9BEC26E4E48EF9B811A372 /* PendingFutureCarb.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingFutureCarb.swift; sourceTree = "<group>"; };
2EBAB9EECE7095238A558060 /* FutureCarbsCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FutureCarbsCondition.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -528,6 +531,7 @@
65A100022F5AA00000AA1002 /* UnitsConfigurationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnitsConfigurationView.swift; sourceTree = "<group>"; };
65E153C22E4BB69100693A4F /* URLTokenValidationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLTokenValidationView.swift; sourceTree = "<group>"; };
65E8A2852E44B0300065037B /* VolumeButtonHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VolumeButtonHandler.swift; sourceTree = "<group>"; };
8000BC181BCE5AA8553FB3B6 /* AppBannerView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AppBannerView.swift; sourceTree = "<group>"; };
6C508E0E86BEC0B13280F34B /* BGChartStubs.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BGChartStubs.swift; sourceTree = "<group>"; };
7C8CB992D98689FC7D019CEC /* BGChartModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BGChartModel.swift; sourceTree = "<group>"; };
8B81E18A2489A364AFC0A9E4 /* DBSize.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DBSize.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1042,6 +1046,16 @@
path = Settings;
sourceTree = "<group>";
};
6A3463E271ADFF0E844E3E61 /* Banner */ = {
isa = PBXGroup;
children = (
098BA6FC4279B166734A0E13 /* BannerManager.swift */,
8000BC181BCE5AA8553FB3B6 /* AppBannerView.swift */,
);
name = Banner;
path = Banner;
sourceTree = "<group>";
};
6A5880E0B811AF443B05AB02 /* Frameworks */ = {
isa = PBXGroup;
children = (
Expand Down Expand Up @@ -1717,6 +1731,7 @@
DDC7E5CD2DC6637800EB1127 /* Storage */,
DDEF503D2D32753A00999A5D /* Task */,
FCC68871248A736700A0279D /* ViewControllers */,
6A3463E271ADFF0E844E3E61 /* Banner */,
CC1E389FE3B6E6EA3184474B /* Charts */,
);
path = LoopFollow;
Expand Down Expand Up @@ -2480,6 +2495,8 @@
DDEF50422D479BAA00884336 /* LoopAPNSCarbsView.swift in Sources */,
DDEF50432D479BBA00884336 /* LoopAPNSBolusView.swift in Sources */,
DDEF50452D479BDA00884336 /* LoopAPNSRemoteView.swift in Sources */,
87F526CE28FD073330D2F516 /* BannerManager.swift in Sources */,
8897B18D53EA2F1963B8B4E0 /* AppBannerView.swift in Sources */,
9F24B79D2722BB040AE3311F /* BGChartModel.swift in Sources */,
2F99F2B5C902F3018383C086 /* BGChartView.swift in Sources */,
92664FA270DA9905E1F08676 /* BGChartStubs.swift in Sources */,
Expand Down
16 changes: 16 additions & 0 deletions LoopFollow/Application/MainTabView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ struct MainTabView: View {
@ObservedObject private var snoozerPosition = Storage.shared.snoozerPosition
@ObservedObject private var statisticsPosition = Storage.shared.statisticsPosition
@ObservedObject private var treatmentsPosition = Storage.shared.treatmentsPosition
@ObservedObject private var activeBanner = Observable.shared.activeBanner

@State private var showTelemetryConsent = false

Expand All @@ -21,6 +22,21 @@ struct MainTabView: View {
}

var body: some View {
// The banner sits in a VStack above the TabView (rather than a
// .safeAreaInset) so the UIKit-hosted Home content is physically
// pushed down too — safe-area changes don't propagate into
// UIViewControllerRepresentable.
VStack(spacing: 0) {
if let message = activeBanner.value {
AppBannerView(message: message)
.transition(.move(edge: .top).combined(with: .opacity))
}
tabView
}
.animation(.easeInOut(duration: 0.25), value: activeBanner.value)
}

private var tabView: some View {
TabView(selection: $selectedTab.value) {
ForEach(Array(orderedItems.prefix(4).enumerated()), id: \.element) { index, item in
tabContent(for: item)
Expand Down
67 changes: 67 additions & 0 deletions LoopFollow/BackgroundRefresh/BT/BLEManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,21 @@ class BLEManager: NSObject, ObservableObject {
private var centralManager: CBCentralManager!
private var activeDevice: BluetoothDevice?

/// Arrival times of recent heartbeat dropouts. Main-queue-confined
/// (centralManager delivers on .main) and deliberately not persisted:
/// stale history must not resurrect the banner after a relaunch.
private var heartbeatDropoutTimes: [Date] = []

/// A beat this much later than expected counts as one dropout event.
/// Stricter than the 15% logging margin - normal jitter must not count.
private let dropoutFactor = 1.5

/// Sliding window over which dropout events are counted.
private let dropoutWindow: TimeInterval = 60 * 60

/// Dropout events within the window needed to raise the banner.
private let dropoutTriggerCount = 5

override private init() {
super.init()

Expand Down Expand Up @@ -49,6 +64,8 @@ class BLEManager: NSObject, ObservableObject {
activeDevice = nil
device.lastHeartbeatTime = nil
}
heartbeatDropoutTimes.removeAll()
BannerManager.shared.clear(.heartbeat)
Storage.shared.selectedBLEDevice.value = nil
}

Expand Down Expand Up @@ -208,6 +225,7 @@ extension BLEManager: BluetoothDeviceDelegate {
let delay = elapsedTime - expectedInterval
LogManager.shared.log(category: .bluetooth, message: "Heartbeat triggered (Delayed by \(String(format: "%.1f", delay)) seconds)")
}
recordHeartbeatOutcome(elapsed: elapsedTime, expectedInterval: expectedInterval, now: now)
} else {
LogManager.shared.log(category: .bluetooth, message: "Heartbeat triggered (First heartbeat)")
}
Expand All @@ -216,6 +234,55 @@ extension BLEManager: BluetoothDeviceDelegate {

TaskScheduler.shared.checkTasksNow()
}

/// Counts late heartbeats over a sliding window and raises a banner when
/// dropouts become frequent - the typical symptom of a dying transmitter
/// battery. Detection is arrival-based on purpose: a struggling battery
/// still delivers (late) beats, whereas total silence usually means
/// out-of-range or a dead device and is already surfaced by the
/// connection status in Background Refresh settings and by BG alarms.
private func recordHeartbeatOutcome(elapsed: TimeInterval, expectedInterval: TimeInterval, now: Date) {
heartbeatDropoutTimes.removeAll { now.timeIntervalSince($0) > dropoutWindow }

// A gap this large means out-of-range, Bluetooth off or a suspended
// app - not a struggling battery. Discard the history collected
// before the blind spot instead of counting it.
let resetGap = max(6 * expectedInterval, 30 * 60)
if elapsed > resetGap {
if !heartbeatDropoutTimes.isEmpty {
heartbeatDropoutTimes.removeAll()
LogManager.shared.log(category: .bluetooth, message: "Heartbeat gap of \(Int(elapsed)) seconds, resetting dropout history")
}
} else if elapsed > expectedInterval * dropoutFactor {
heartbeatDropoutTimes.append(now)
LogManager.shared.log(category: .bluetooth, message: "Heartbeat dropout recorded (\(heartbeatDropoutTimes.count) in the last hour)")
}

// Hysteresis: raise at the trigger count, clear only once the window
// is completely clean, so the banner doesn't flap at the boundary.
if heartbeatDropoutTimes.count >= dropoutTriggerCount {
BannerManager.shared.report(source: .heartbeat, severity: .warning, text: heartbeatDropoutBannerText())
} else if heartbeatDropoutTimes.isEmpty {
BannerManager.shared.clear(.heartbeat)
}
}

/// The text must stay stable while the condition persists (no counts or
/// durations) so BannerManager's dedupe keeps the banner from
/// re-animating on every beat and user dismissal keeps working.
private func heartbeatDropoutBannerText() -> String {
let name = activeDevice?.deviceName ?? "heartbeat device"
let cause: String
switch Storage.shared.backgroundRefreshType.value {
case .rileyLink:
cause = "a low RileyLink battery"
case .omnipodDash:
cause = "a low pod battery"
default:
cause = "a low transmitter battery"
}
return "Heartbeat: repeated Bluetooth dropouts from \(name). This can be a sign of \(cause) or a weak Bluetooth connection."
}
}

extension BLEManager {
Expand Down
75 changes: 75 additions & 0 deletions LoopFollow/Banner/AppBannerView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// LoopFollow
// AppBannerView.swift

import SwiftUI

/// Dismissable banner shown at the top of the app, above the tab content.
struct AppBannerView: View {
let message: BannerMessage

var body: some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: iconName)
.font(.title3)
.foregroundColor(iconColor)

Text(message.text)
.font(.callout)
.lineLimit(3)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)

Button {
BannerManager.shared.dismissCurrent()
} label: {
Image(systemName: "xmark.circle.fill")
.font(.title3)
.foregroundColor(.secondary)
}
.accessibilityLabel("Dismiss")
}
.padding()
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(tint)
)
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(border, lineWidth: 1)
)
.padding(.horizontal, 10)
.padding(.vertical, 4)
}

private var iconName: String {
switch message.severity {
case .info: return "info.circle.fill"
case .warning: return "exclamationmark.triangle.fill"
case .error: return "xmark.octagon.fill"
}
}

private var iconColor: Color {
switch message.severity {
case .info: return .blue
case .warning: return .orange
case .error: return .red
}
}

private var tint: Color {
switch message.severity {
case .info: return Color.blue.opacity(0.20)
case .warning: return Color.orange.opacity(0.20)
case .error: return Color.red.opacity(0.20)
}
}

private var border: Color {
switch message.severity {
case .info: return Color.blue.opacity(0.40)
case .warning: return Color.orange.opacity(0.40)
case .error: return Color.red.opacity(0.40)
}
}
}
Loading
Loading