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
4 changes: 4 additions & 0 deletions LoopFollow.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
6541341A2E1DC27900BDBE08 /* OverridePresetData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654134192E1DC27900BDBE08 /* OverridePresetData.swift */; };
6541341C2E1DC28000BDBE08 /* DateExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6541341B2E1DC28000BDBE08 /* DateExtensions.swift */; };
6584B1012E4A263900135D4D /* TOTPService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6584B1002E4A263900135D4D /* TOTPService.swift */; };
65E8A2862E44B0300065037B /* VolumeButtonHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E8A2852E44B0300065037B /* VolumeButtonHandler.swift */; };
DD0247592DB2E89600FCADF6 /* AlarmCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0247582DB2E89600FCADF6 /* AlarmCondition.swift */; };
DD0247712DB4337700FCADF6 /* BuildExpireCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD02475B2DB2E8FB00FCADF6 /* BuildExpireCondition.swift */; };
DD0650A92DCA8A10004D3B41 /* AlarmBGSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0650A82DCA8A10004D3B41 /* AlarmBGSection.swift */; };
Expand Down Expand Up @@ -398,6 +399,7 @@
654134192E1DC27900BDBE08 /* OverridePresetData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverridePresetData.swift; sourceTree = "<group>"; };
6541341B2E1DC28000BDBE08 /* DateExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateExtensions.swift; sourceTree = "<group>"; };
6584B1002E4A263900135D4D /* TOTPService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TOTPService.swift; sourceTree = "<group>"; };
65E8A2852E44B0300065037B /* VolumeButtonHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VolumeButtonHandler.swift; sourceTree = "<group>"; };
A7D55B42A22051DAD69E89D0 /* Pods_LoopFollow.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_LoopFollow.framework; sourceTree = BUILT_PRODUCTS_DIR; };
DD0247582DB2E89600FCADF6 /* AlarmCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmCondition.swift; sourceTree = "<group>"; };
DD02475B2DB2E8FB00FCADF6 /* BuildExpireCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuildExpireCondition.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1278,6 +1280,7 @@
FC1BDD2C24A23204001B652C /* MainViewController+updateStats.swift */,
FCA2DDE52501095000254A8C /* Timers.swift */,
DD608A0B2C27415C00F91132 /* BackgroundAlertManager.swift */,
65E8A2852E44B0300065037B /* VolumeButtonHandler.swift */,
);
path = Controllers;
sourceTree = "<group>";
Expand Down Expand Up @@ -1947,6 +1950,7 @@
DDC7E5162DBCFA7F00EB1127 /* SnoozerView.swift in Sources */,
FCFEECA2248857A600402A7F /* SettingsViewController.swift in Sources */,
DD7F4C232DD7A62200D449E9 /* AlarmType+SortDirection.swift in Sources */,
65E8A2862E44B0300065037B /* VolumeButtonHandler.swift in Sources */,
DD0650F72DCFDA26004D3B41 /* InfoBanner.swift in Sources */,
DDE75D292DE5E56C007C1FC1 /* LinkRow.swift in Sources */,
DD4AFB612DB68BBC00BB593F /* AlarmListView.swift in Sources */,
Expand Down
4 changes: 3 additions & 1 deletion LoopFollow/Alarm/AlarmConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ struct AlarmConfiguration: Codable, Equatable {
var audioDuringCalls: Bool
var ignoreZeroBG: Bool
var autoSnoozeCGMStart: Bool
var enableVolumeButtonSnooze: Bool

static let `default` = AlarmConfiguration(
muteUntil: nil,
Expand All @@ -27,6 +28,7 @@ struct AlarmConfiguration: Codable, Equatable {
forcedOutputVolume: 0.5,
audioDuringCalls: true,
ignoreZeroBG: true,
autoSnoozeCGMStart: false
autoSnoozeCGMStart: false,
enableVolumeButtonSnooze: false
)
}
1 change: 1 addition & 0 deletions LoopFollow/Alarm/AlarmManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ class AlarmManager {
alarms[idx].snoozedUntil = Date().addingTimeInterval(snoozeSeconds)
Storage.shared.alarms.value = alarms
}
Observable.shared.alarmSoundPlaying.value = false
stopAlarm()
}
}
Expand Down
10 changes: 9 additions & 1 deletion LoopFollow/Alarm/AlarmSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -177,12 +177,20 @@ struct AlarmSettingsView: View {
)

Toggle(
"Auto‑Snooze CGM Start",
"Auto‑Snooze CGM Start",
isOn: Binding(
get: { cfgStore.value.autoSnoozeCGMStart },
set: { cfgStore.value.autoSnoozeCGMStart = $0 }
)
)

Toggle(
"Volume Buttons Snooze Alarms",
isOn: Binding(
get: { cfgStore.value.enableVolumeButtonSnooze },
set: { cfgStore.value.enableVolumeButtonSnooze = $0 }
)
)
}
}
}
Expand Down
3 changes: 3 additions & 0 deletions LoopFollow/Application/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate {

_ = BLEManager.shared

// Ensure VolumeButtonHandler is initialized so it can receive alarm notifications
_ = VolumeButtonHandler.shared

return true
}

Expand Down
10 changes: 6 additions & 4 deletions LoopFollow/Controllers/AlarmSound.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,6 @@ class AlarmSound {

fileprivate static var systemOutputVolumeBeforeOverride: Float?

fileprivate static var playingTimer: Timer?

fileprivate static var soundURL = Bundle.main.url(forResource: "Indeed", withExtension: "caf")!
fileprivate static var audioPlayer: AVAudioPlayer?
fileprivate static let audioPlayerDelegate = AudioPlayerDelegate()
Expand Down Expand Up @@ -70,8 +68,7 @@ class AlarmSound {
}

static func stop() {
playingTimer?.invalidate()
playingTimer = nil
Observable.shared.alarmSoundPlaying.value = false

audioPlayer?.stop()
audioPlayer = nil
Expand Down Expand Up @@ -140,6 +137,8 @@ class AlarmSound {
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)")
} else {
Observable.shared.alarmSoundPlaying.value = true
}
} else {
LogManager.shared.log(category: .alarm, message: "AlarmSound - audio player failed to play")
Expand Down Expand Up @@ -223,6 +222,7 @@ class AudioPlayerDelegate: NSObject, AVAudioPlayerDelegate {
/* audioPlayerDidFinishPlaying:successfully: is called when a sound has finished playing. This method is NOT called if the player is stopped due to an interruption. */
func audioPlayerDidFinishPlaying(_: AVAudioPlayer, successfully flag: Bool) {
LogManager.shared.log(category: .alarm, message: "AlarmRule - audioPlayerDidFinishPlaying (\(flag))", isDebug: true)
Observable.shared.alarmSoundPlaying.value = false
}

/* if an error occurs while decoding it will be reported to the delegate. */
Expand All @@ -239,12 +239,14 @@ class AudioPlayerDelegate: NSObject, AVAudioPlayerDelegate {
/* audioPlayerBeginInterruption: is called when the audio session has been interrupted while the player was playing. The player will have been paused. */
func audioPlayerBeginInterruption(_: AVAudioPlayer) {
LogManager.shared.log(category: .alarm, message: "AlarmRule - audioPlayerBeginInterruption")
Observable.shared.alarmSoundPlaying.value = false
}

/* audioPlayerEndInterruption:withOptions: is called when the audio session interruption has ended and this player had been interrupted while playing. */
/* Currently the only flag is AVAudioSessionInterruptionFlags_ShouldResume. */
func audioPlayerEndInterruption(_: AVAudioPlayer, withOptions flags: Int) {
LogManager.shared.log(category: .alarm, message: "AlarmRule - audioPlayerEndInterruption withOptions: \(flags)")
Observable.shared.alarmSoundPlaying.value = false
}
}

Expand Down
182 changes: 182 additions & 0 deletions LoopFollow/Controllers/VolumeButtonHandler.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
// LoopFollow
// VolumeButtonHandler.swift

import AVFoundation
import Combine
import Foundation
import UIKit

class VolumeButtonHandler: NSObject {
static let shared = VolumeButtonHandler()

// Volume button snoozer activation delay in seconds
private let volumeButtonActivationDelay: TimeInterval = 0.9

// Volume button detection parameters
private let volumeButtonPressThreshold: Float = 0.02
private let volumeButtonPressTimeWindow: TimeInterval = 0.3
private let volumeButtonCooldown: TimeInterval = 0.5

// KVO observer for system volume
private var volumeObserver: NSKeyValueObservation?

private var lastVolume: Float = 0.0
private var isMonitoring = false
private var alarmStartTime: Date?
private var lastVolumeButtonPressTime: Date?

// Button press detection
private var recentVolumeChanges: [(volume: Float, timestamp: Date)] = []
private var lastSignificantVolumeChange: Date?
private var volumeChangePattern: [TimeInterval] = []

private var cancellables = Set<AnyCancellable>()

override private init() {
super.init()

Observable.shared.alarmSoundPlaying.$value
.removeDuplicates()
.sink { [weak self] alarmSoundPlaying in
guard let self = self else { return }
if alarmSoundPlaying {
self.alarmStarted()
} else {
self.alarmStopped()
}
}
.store(in: &cancellables)
}

private func recordVolumeChange(currentVolume: Float, timestamp: Date) {
recentVolumeChanges.append((volume: currentVolume, timestamp: timestamp))

let cutoffTime = timestamp.timeIntervalSinceReferenceDate - volumeButtonPressTimeWindow
recentVolumeChanges = recentVolumeChanges.filter { $0.timestamp.timeIntervalSinceReferenceDate > cutoffTime }

if let lastChange = lastSignificantVolumeChange {
let timeSinceLastChange = timestamp.timeIntervalSince(lastChange)
volumeChangePattern.append(timeSinceLastChange)

if volumeChangePattern.count > 5 {
volumeChangePattern.removeFirst()
}
}
lastSignificantVolumeChange = timestamp
}

private func isLikelyVolumeButtonPress(volumeDifference: Float, timestamp: Date) -> Bool {
let isReasonableChange = volumeDifference >= 0.03 && volumeDifference <= 0.12
let isDiscreteChange = recentVolumeChanges.count <= 2
let hasConsistentTiming = volumeChangePattern.isEmpty || volumeChangePattern.last! >= 0.15
let isNotRapidSequence = recentVolumeChanges.count < 3 ||
(recentVolumeChanges.count >= 3 &&
recentVolumeChanges.suffix(3).map { $0.timestamp.timeIntervalSinceReferenceDate }.enumerated().dropFirst().allSatisfy { index, timestamp in
let previousTimestamp = recentVolumeChanges.suffix(3).map { $0.timestamp.timeIntervalSinceReferenceDate }[index - 1]
return timestamp - previousTimestamp > 0.08
})

return isReasonableChange && isDiscreteChange && hasConsistentTiming && isNotRapidSequence
}

private func snoozeActiveAlarm() {
LogManager.shared.log(category: .volumeButtonSnooze, message: "Snoozing alarm")

lastVolumeButtonPressTime = Date()
AlarmManager.shared.performSnooze()

let impactFeedback = UIImpactFeedbackGenerator(style: .medium)
impactFeedback.impactOccurred()
}

private func alarmStarted() {
guard Storage.shared.alarmConfiguration.value.enableVolumeButtonSnooze else { return }
LogManager.shared.log(category: .volumeButtonSnooze, message: "Alarm start detected, setting up volume observer.")

alarmStartTime = Date()
recentVolumeChanges.removeAll()
lastSignificantVolumeChange = nil
volumeChangePattern.removeAll()

startMonitoring()
}

private func alarmStopped() {
LogManager.shared.log(category: .volumeButtonSnooze, message: "Alarm stop detected")

alarmStartTime = nil
stopMonitoring()

recentVolumeChanges.removeAll()
lastSignificantVolumeChange = nil
volumeChangePattern.removeAll()
}

func startMonitoring() {
guard !isMonitoring else { return }

isMonitoring = true

volumeObserver = AVAudioSession.sharedInstance().observe(\.outputVolume, options: [.new]) { [weak self] session, _ in
guard let self = self, let alarmStartTime = self.alarmStartTime else { return }

let currentVolume = session.outputVolume
let now = Date()

// On the first observation, capture the initial volume when the audio session
// becomes active. This solves the race condition. We then return to avoid
// treating this initial setup as a user-initiated button press.
if self.lastVolume == 0.0, currentVolume > 0.0 {
LogManager.shared.log(category: .volumeButtonSnooze, message: "Observer received initial valid volume: \(currentVolume)")
self.lastVolume = currentVolume
return
}

guard self.lastVolume > 0.0 else { return }

let volumeDifference = abs(currentVolume - self.lastVolume)

if volumeDifference > self.volumeButtonPressThreshold {
let timeSinceAlarmStart = now.timeIntervalSince(alarmStartTime)

// Ignore volume changes from the alarm system's own ramp-up.
if timeSinceAlarmStart < 2.0, currentVolume > self.lastVolume {
if volumeDifference <= 0.15, timeSinceAlarmStart < 1.5 {
self.lastVolume = currentVolume
return
}
}

self.recordVolumeChange(currentVolume: currentVolume, timestamp: now)

if timeSinceAlarmStart > self.volumeButtonActivationDelay {
if let lastPress = self.lastVolumeButtonPressTime {
let timeSinceLastPress = now.timeIntervalSince(lastPress)
if timeSinceLastPress < self.volumeButtonCooldown {
self.lastVolume = currentVolume
return
}
}

if self.isLikelyVolumeButtonPress(volumeDifference: volumeDifference, timestamp: now) {
self.snoozeActiveAlarm()
}
}
}
self.lastVolume = currentVolume
}
}

func stopMonitoring() {
guard isMonitoring else { return }

LogManager.shared.log(category: .volumeButtonSnooze, message: "Invalidating volume observer.")

// Invalidate the observer to stop receiving notifications and prevent memory leaks.
volumeObserver?.invalidate()
volumeObserver = nil

isMonitoring = false
lastVolume = 0.0 // Reset for the next alarm.
}
}
1 change: 1 addition & 0 deletions LoopFollow/Log/LogManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ class LogManager {
case taskScheduler = "Task Scheduler"
case dexcom = "Dexcom"
case alarm = "Alarm"
case volumeButtonSnooze = "Volume Button Snooze"
case calendar = "Calendar"
case deviceStatus = "Device Status"
}
Expand Down
1 change: 1 addition & 0 deletions LoopFollow/Storage/Observable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ class Observable {
var deltaText = ObservableValue<String>(default: "+0")

var currentAlarm = ObservableValue<UUID?>(default: nil)
var alarmSoundPlaying = ObservableValue<Bool>(default: false)

var debug = ObservableValue<Bool>(default: false)

Expand Down