diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index ea3a54623..20ac791c8 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -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 */; }; @@ -398,6 +399,7 @@ 654134192E1DC27900BDBE08 /* OverridePresetData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverridePresetData.swift; sourceTree = ""; }; 6541341B2E1DC28000BDBE08 /* DateExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateExtensions.swift; sourceTree = ""; }; 6584B1002E4A263900135D4D /* TOTPService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TOTPService.swift; sourceTree = ""; }; + 65E8A2852E44B0300065037B /* VolumeButtonHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VolumeButtonHandler.swift; sourceTree = ""; }; 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 = ""; }; DD02475B2DB2E8FB00FCADF6 /* BuildExpireCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuildExpireCondition.swift; sourceTree = ""; }; @@ -1278,6 +1280,7 @@ FC1BDD2C24A23204001B652C /* MainViewController+updateStats.swift */, FCA2DDE52501095000254A8C /* Timers.swift */, DD608A0B2C27415C00F91132 /* BackgroundAlertManager.swift */, + 65E8A2852E44B0300065037B /* VolumeButtonHandler.swift */, ); path = Controllers; sourceTree = ""; @@ -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 */, diff --git a/LoopFollow/Alarm/AlarmConfiguration.swift b/LoopFollow/Alarm/AlarmConfiguration.swift index 915bfaa5d..5c3e9b290 100644 --- a/LoopFollow/Alarm/AlarmConfiguration.swift +++ b/LoopFollow/Alarm/AlarmConfiguration.swift @@ -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, @@ -27,6 +28,7 @@ struct AlarmConfiguration: Codable, Equatable { forcedOutputVolume: 0.5, audioDuringCalls: true, ignoreZeroBG: true, - autoSnoozeCGMStart: false + autoSnoozeCGMStart: false, + enableVolumeButtonSnooze: false ) } diff --git a/LoopFollow/Alarm/AlarmManager.swift b/LoopFollow/Alarm/AlarmManager.swift index 9ac72a26e..09ff8d610 100644 --- a/LoopFollow/Alarm/AlarmManager.swift +++ b/LoopFollow/Alarm/AlarmManager.swift @@ -163,6 +163,7 @@ class AlarmManager { alarms[idx].snoozedUntil = Date().addingTimeInterval(snoozeSeconds) Storage.shared.alarms.value = alarms } + Observable.shared.alarmSoundPlaying.value = false stopAlarm() } } diff --git a/LoopFollow/Alarm/AlarmSettingsView.swift b/LoopFollow/Alarm/AlarmSettingsView.swift index 027434638..85df90ff0 100644 --- a/LoopFollow/Alarm/AlarmSettingsView.swift +++ b/LoopFollow/Alarm/AlarmSettingsView.swift @@ -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 } + ) + ) } } } diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index fafe98fe2..a459c2f25 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/LoopFollow/Application/AppDelegate.swift @@ -39,6 +39,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate { _ = BLEManager.shared + // Ensure VolumeButtonHandler is initialized so it can receive alarm notifications + _ = VolumeButtonHandler.shared + return true } diff --git a/LoopFollow/Controllers/AlarmSound.swift b/LoopFollow/Controllers/AlarmSound.swift index 2c076a740..13657d2bd 100644 --- a/LoopFollow/Controllers/AlarmSound.swift +++ b/LoopFollow/Controllers/AlarmSound.swift @@ -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() @@ -70,8 +68,7 @@ class AlarmSound { } static func stop() { - playingTimer?.invalidate() - playingTimer = nil + Observable.shared.alarmSoundPlaying.value = false audioPlayer?.stop() audioPlayer = nil @@ -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") @@ -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. */ @@ -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 } } diff --git a/LoopFollow/Controllers/VolumeButtonHandler.swift b/LoopFollow/Controllers/VolumeButtonHandler.swift new file mode 100644 index 000000000..7f4d16de6 --- /dev/null +++ b/LoopFollow/Controllers/VolumeButtonHandler.swift @@ -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() + + 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. + } +} diff --git a/LoopFollow/Log/LogManager.swift b/LoopFollow/Log/LogManager.swift index a8593063a..eb4112c16 100644 --- a/LoopFollow/Log/LogManager.swift +++ b/LoopFollow/Log/LogManager.swift @@ -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" } diff --git a/LoopFollow/Storage/Observable.swift b/LoopFollow/Storage/Observable.swift index b20615d5f..03576644f 100644 --- a/LoopFollow/Storage/Observable.swift +++ b/LoopFollow/Storage/Observable.swift @@ -24,6 +24,7 @@ class Observable { var deltaText = ObservableValue(default: "+0") var currentAlarm = ObservableValue(default: nil) + var alarmSoundPlaying = ObservableValue(default: false) var debug = ObservableValue(default: false)