From adcb0fdbcd3b200033e79b5cb710e70699e570e1 Mon Sep 17 00:00:00 2001 From: codebymini Date: Thu, 7 Aug 2025 23:09:55 +0200 Subject: [PATCH 01/19] Add option to silence alarm with volume button --- LoopFollow.xcodeproj/project.pbxproj | 8 +- LoopFollow/Alarm/AlarmConfiguration.swift | 4 +- LoopFollow/Alarm/AlarmSettingsView.swift | 15 +- LoopFollow/Application/AppDelegate.swift | 16 + LoopFollow/Controllers/AlarmSound.swift | 20 +- .../Controllers/VolumeButtonHandler.swift | 327 ++++++++++++++++++ LoopFollow/Storage/Storage+Migrate.swift | 3 + 7 files changed, 387 insertions(+), 6 deletions(-) create mode 100644 LoopFollow/Controllers/VolumeButtonHandler.swift diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index cb8e910d8..b500d510e 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -10,9 +10,10 @@ 3F1335F351590E573D8E6962 /* Pods_LoopFollow.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A7D55B42A22051DAD69E89D0 /* Pods_LoopFollow.framework */; }; 654132E72E19EA7E00BDBE08 /* SimpleQRCodeScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654132E62E19EA7E00BDBE08 /* SimpleQRCodeScannerView.swift */; }; 654132EA2E19F24800BDBE08 /* TOTPGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654132E92E19F24800BDBE08 /* TOTPGenerator.swift */; }; - 6541341C2E1DC28000BDBE08 /* DateExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6541341B2E1DC28000BDBE08 /* DateExtensions.swift */; }; 654134182E1DC09700BDBE08 /* OverridePresetsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654134172E1DC09700BDBE08 /* OverridePresetsView.swift */; }; 6541341A2E1DC27900BDBE08 /* OverridePresetData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654134192E1DC27900BDBE08 /* OverridePresetData.swift */; }; + 6541341C2E1DC28000BDBE08 /* DateExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6541341B2E1DC28000BDBE08 /* DateExtensions.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 */; }; @@ -393,9 +394,10 @@ 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 = ""; }; 654132E62E19EA7E00BDBE08 /* SimpleQRCodeScannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleQRCodeScannerView.swift; sourceTree = ""; }; 654132E92E19F24800BDBE08 /* TOTPGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TOTPGenerator.swift; sourceTree = ""; }; - 6541341B2E1DC28000BDBE08 /* DateExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateExtensions.swift; sourceTree = ""; }; 654134172E1DC09700BDBE08 /* OverridePresetsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverridePresetsView.swift; sourceTree = ""; }; 654134192E1DC27900BDBE08 /* OverridePresetData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverridePresetData.swift; sourceTree = ""; }; + 6541341B2E1DC28000BDBE08 /* DateExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateExtensions.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 = ""; }; @@ -1275,6 +1277,7 @@ FC1BDD2C24A23204001B652C /* MainViewController+updateStats.swift */, FCA2DDE52501095000254A8C /* Timers.swift */, DD608A0B2C27415C00F91132 /* BackgroundAlertManager.swift */, + 65E8A2852E44B0300065037B /* VolumeButtonHandler.swift */, ); path = Controllers; sourceTree = ""; @@ -1943,6 +1946,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 aaf9f372e..0058d97e1 100644 --- a/LoopFollow/Alarm/AlarmConfiguration.swift +++ b/LoopFollow/Alarm/AlarmConfiguration.swift @@ -19,6 +19,7 @@ struct AlarmConfiguration: Codable, Equatable { var audioDuringCalls: Bool var ignoreZeroBG: Bool var autoSnoozeCGMStart: Bool + var enableVolumeButtonSilence: Bool static let `default` = AlarmConfiguration( muteUntil: nil, @@ -28,6 +29,7 @@ struct AlarmConfiguration: Codable, Equatable { forcedOutputVolume: 0.5, audioDuringCalls: true, ignoreZeroBG: true, - autoSnoozeCGMStart: false + autoSnoozeCGMStart: false, + enableVolumeButtonSilence: true ) } diff --git a/LoopFollow/Alarm/AlarmSettingsView.swift b/LoopFollow/Alarm/AlarmSettingsView.swift index 8f0d31c59..2963d4853 100644 --- a/LoopFollow/Alarm/AlarmSettingsView.swift +++ b/LoopFollow/Alarm/AlarmSettingsView.swift @@ -140,7 +140,10 @@ struct AlarmSettingsView: View { .datePickerStyle(.compact) } - Section(header: Text("Alarm Settings")) { + Section( + header: Text("Alarm Settings"), + footer: Text("When enabled, pressing the volume buttons will silence active alarms and snooze them.") + ) { Toggle( "Override System Volume", isOn: Binding( @@ -178,12 +181,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 Silence Alarms", + isOn: Binding( + get: { cfgStore.value.enableVolumeButtonSilence }, + set: { cfgStore.value.enableVolumeButtonSilence = $0 } + ) + ) } } } diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index 32db917a7..37ae64af5 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/LoopFollow/Application/AppDelegate.swift @@ -2,6 +2,7 @@ // AppDelegate.swift // Created by Jon Fawcett. +import AVFoundation import CoreData import EventKit import UIKit @@ -40,6 +41,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate { _ = BLEManager.shared + // Start volume button monitoring + VolumeButtonHandler.shared.startMonitoring() + return true } @@ -136,6 +140,18 @@ class AppDelegate: UIResponder, UIApplicationDelegate { return .all } } + + // MARK: - App Lifecycle + + func applicationWillResignActive(_: UIApplication) { + // Stop volume button monitoring when app goes to background + VolumeButtonHandler.shared.stopMonitoring() + } + + func applicationDidBecomeActive(_: UIApplication) { + // Restart volume button monitoring when app comes to foreground + VolumeButtonHandler.shared.startMonitoring() + } } extension AppDelegate: UNUserNotificationCenterDelegate { diff --git a/LoopFollow/Controllers/AlarmSound.swift b/LoopFollow/Controllers/AlarmSound.swift index e2575a5de..766ab7d5e 100644 --- a/LoopFollow/Controllers/AlarmSound.swift +++ b/LoopFollow/Controllers/AlarmSound.swift @@ -7,6 +7,11 @@ import Foundation import MediaPlayer import UIKit +extension Notification.Name { + static let alarmStarted = Notification.Name("alarmStarted") + static let alarmStopped = Notification.Name("alarmStopped") +} + /* * Class that handles the playing and the volume of the alarm sound. */ @@ -78,6 +83,9 @@ class AlarmSound { audioPlayer = nil restoreSystemOutputVolume() + + // Notify that alarm has stopped + NotificationCenter.default.post(name: .alarmStopped, object: nil) } static func playTest() { @@ -119,6 +127,9 @@ class AlarmSound { enableAudio() + // Notify that alarm is starting + NotificationCenter.default.post(name: .alarmStarted, object: nil) + do { audioPlayer = try AVAudioPlayer(contentsOf: soundURL) audioPlayer!.delegate = audioPlayerDelegate @@ -147,7 +158,14 @@ class AlarmSound { } if Storage.shared.alarmConfiguration.value.overrideSystemOutputVolume { - MPVolumeView.setVolume(Storage.shared.alarmConfiguration.value.forcedOutputVolume) + let targetVolume = Storage.shared.alarmConfiguration.value.forcedOutputVolume + LogManager.shared.log(category: .alarm, message: "Setting system volume to \(targetVolume) (was \(AVAudioSession.sharedInstance().outputVolume))") + + // Add a small delay to ensure the audio session is fully established + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + MPVolumeView.setVolume(targetVolume) + LogManager.shared.log(category: .alarm, message: "System volume set to \(targetVolume)") + } } } catch { LogManager.shared.log(category: .alarm, message: "AlarmSound - unable to play sound; error: \(error)") diff --git a/LoopFollow/Controllers/VolumeButtonHandler.swift b/LoopFollow/Controllers/VolumeButtonHandler.swift new file mode 100644 index 000000000..9a72f8af0 --- /dev/null +++ b/LoopFollow/Controllers/VolumeButtonHandler.swift @@ -0,0 +1,327 @@ +// LoopFollow +// VolumeButtonHandler.swift +// Created by codebymini. + +import AVFoundation +import Foundation +import UIKit + +class VolumeButtonHandler: NSObject { + static let shared = VolumeButtonHandler() + + // Volume button snoozer activation delay in seconds + private let volumeButtonActivationDelay: TimeInterval = 0.9 + + // Improved volume button detection parameters + private let volumeButtonPressThreshold: Float = 0.02 // Minimum volume change to consider a button press + private let volumeButtonPressTimeWindow: TimeInterval = 0.3 // Time window to detect rapid volume changes + private let volumeButtonCooldown: TimeInterval = 0.5 // Cooldown between button presses + + private var lastVolume: Float = 0.0 + private var isMonitoring = false + private var volumeMonitoringTimer: Timer? + private var volumeChangeTimer: Timer? + private var alarmStartTime: Date? + private var hasReceivedFirstVolumeAfterAlarm: Bool = false + private var lastVolumeButtonPressTime: Date? + private var consecutiveVolumeChanges: Int = 0 + + // Improved button press detection + private var recentVolumeChanges: [(volume: Float, timestamp: Date)] = [] + private var lastSignificantVolumeChange: Date? + private var volumeChangePattern: [TimeInterval] = [] // Track timing between changes + + override private init() { + super.init() + } + + func startMonitoring() { + guard !isMonitoring else { + LogManager.shared.log(category: .alarm, message: "Volume monitoring already active") + return + } + + do { + try AVAudioSession.sharedInstance().setActive(true) + lastVolume = AVAudioSession.sharedInstance().outputVolume + isMonitoring = true + + LogManager.shared.log(category: .alarm, message: "Initial volume: \(lastVolume)") + + // Start monitoring volume changes with a timer + volumeMonitoringTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in + self.checkVolumeChange() + } + + // Test volume monitoring after 2 seconds + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + let currentVol = AVAudioSession.sharedInstance().outputVolume + LogManager.shared.log(category: .alarm, message: "Volume monitoring test - current volume: \(currentVol)") + } + + // Listen for alarm start/stop notifications + NotificationCenter.default.addObserver( + self, + selector: #selector(alarmStarted), + name: .alarmStarted, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(alarmStopped), + name: .alarmStopped, + object: nil + ) + + LogManager.shared.log(category: .alarm, message: "Volume button monitoring started") + } catch { + LogManager.shared.log(category: .alarm, message: "Failed to start volume monitoring: \(error)") + } + } + + func stopMonitoring() { + guard isMonitoring else { return } + + isMonitoring = false + volumeMonitoringTimer?.invalidate() + volumeMonitoringTimer = nil + volumeChangeTimer?.invalidate() + volumeChangeTimer = nil + + // Remove notification observers + NotificationCenter.default.removeObserver(self, name: .alarmStarted, object: nil) + NotificationCenter.default.removeObserver(self, name: .alarmStopped, object: nil) + + LogManager.shared.log(category: .alarm, message: "Volume button monitoring stopped") + } + + private func checkVolumeChange() { + let currentVolume = AVAudioSession.sharedInstance().outputVolume + let volumeDifference = abs(currentVolume - lastVolume) + let now = Date() + + // Log volume changes for debugging + if volumeDifference > 0.01 { + LogManager.shared.log(category: .alarm, message: "Volume change: \(lastVolume) -> \(currentVolume) (diff: \(volumeDifference))") + } + + // Only respond to significant volume changes (likely from hardware buttons) + if volumeDifference > volumeButtonPressThreshold { + // Record this volume change for pattern analysis + recordVolumeChange(currentVolume: currentVolume, timestamp: now) + + // Additional check: ensure we're not just getting the initial volume reading + if lastVolume > 0 { + // Check if an alarm has been playing for at least the activation delay + if let startTime = alarmStartTime { + let timeSinceAlarmStart = now.timeIntervalSince(startTime) + if timeSinceAlarmStart > volumeButtonActivationDelay { + // Mark that we've received the first volume reading after alarm start + if !hasReceivedFirstVolumeAfterAlarm { + hasReceivedFirstVolumeAfterAlarm = true + LogManager.shared.log(category: .alarm, message: "First volume reading after alarm start - ignoring") + return + } + + // Check if we've pressed volume buttons recently (cooldown) + if let lastPress = lastVolumeButtonPressTime { + let timeSinceLastPress = now.timeIntervalSince(lastPress) + if timeSinceLastPress < volumeButtonCooldown { + LogManager.shared.log(category: .alarm, message: "Volume button pressed too recently (\(timeSinceLastPress)s ago), ignoring") + return + } + } + + // Improved button press detection + if isLikelyVolumeButtonPress(volumeDifference: volumeDifference, timestamp: now) { + LogManager.shared.log(category: .alarm, message: "Volume button press detected: \(volumeDifference), handling volume button press") + handleVolumeButtonPress() + } else { + LogManager.shared.log(category: .alarm, message: "Volume change detected but not recognized as button press: \(volumeDifference)") + } + } else { + LogManager.shared.log(category: .alarm, message: "Volume change detected but alarm hasn't been playing long enough: \(timeSinceAlarmStart)s (need \(volumeButtonActivationDelay)s)") + } + } else { + LogManager.shared.log(category: .alarm, message: "Volume change detected but no alarm start time recorded") + } + } else { + LogManager.shared.log(category: .alarm, message: "Volume change detected but lastVolume is 0") + } + } + + lastVolume = currentVolume + } + + // MARK: - Improved Button Press Detection + + private func recordVolumeChange(currentVolume: Float, timestamp: Date) { + // Add this volume change to our recent history + recentVolumeChanges.append((volume: currentVolume, timestamp: timestamp)) + + // Keep only recent changes (within the time window) + let cutoffTime = timestamp.timeIntervalSinceReferenceDate - volumeButtonPressTimeWindow + recentVolumeChanges = recentVolumeChanges.filter { $0.timestamp.timeIntervalSinceReferenceDate > cutoffTime } + + // Update pattern tracking + if let lastChange = lastSignificantVolumeChange { + let timeSinceLastChange = timestamp.timeIntervalSince(lastChange) + volumeChangePattern.append(timeSinceLastChange) + + // Keep only recent patterns + if volumeChangePattern.count > 5 { + volumeChangePattern.removeFirst() + } + } + + lastSignificantVolumeChange = timestamp + } + + private func isLikelyVolumeButtonPress(volumeDifference: Float, timestamp: Date) -> Bool { + // Criteria for identifying a volume button press: + + // 1. Volume change should be significant but not too large (typical button press range) + let isReasonableChange = volumeDifference >= 0.02 && volumeDifference <= 0.15 + + // 2. Should be a discrete change (not part of a continuous adjustment) + let isDiscreteChange = recentVolumeChanges.count <= 2 + + // 3. Timing should be consistent with button press patterns + let hasConsistentTiming = volumeChangePattern.isEmpty || + volumeChangePattern.last! >= 0.1 // At least 100ms between changes + + // 4. Should not be part of a rapid sequence (which might indicate slider usage) + 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.05 // At least 50ms between rapid changes + }) + + let isButtonPress = isReasonableChange && isDiscreteChange && hasConsistentTiming && isNotRapidSequence + + LogManager.shared.log(category: .alarm, message: "Button press analysis: change=\(volumeDifference), discrete=\(isDiscreteChange), timing=\(hasConsistentTiming), rapid=\(!isNotRapidSequence), result=\(isButtonPress)") + + return isButtonPress + } + + private func handleVolumeButtonPress() { + LogManager.shared.log(category: .alarm, message: "handleVolumeButtonPress called") + + // Check if volume button silencing is enabled + guard Storage.shared.alarmConfiguration.value.enableVolumeButtonSilence else { + LogManager.shared.log(category: .alarm, message: "Volume button silencing is disabled") + return + } + + // Check if there's an active alarm + guard AlarmSound.isPlaying else { + LogManager.shared.log(category: .alarm, message: "No alarm is currently playing") + return + } + + // Prevent multiple rapid triggers + guard volumeChangeTimer == nil else { + LogManager.shared.log(category: .alarm, message: "Volume change timer already active, ignoring") + return + } + + LogManager.shared.log(category: .alarm, message: "Immediately silencing alarm") + + // Silence the alarm immediately without delay + silenceActiveAlarm() + } + + private func silenceActiveAlarm() { + LogManager.shared.log(category: .alarm, message: "Volume button pressed - silencing active alarm") + + // Record the time of this volume button press + lastVolumeButtonPressTime = Date() + + // Check if alarm is still playing before stopping + let wasPlaying = AlarmSound.isPlaying + LogManager.shared.log(category: .alarm, message: "Alarm was playing: \(wasPlaying)") + + // Stop the alarm sound + AlarmSound.stop() + + // Check if alarm stopped + let isStillPlaying = AlarmSound.isPlaying + LogManager.shared.log(category: .alarm, message: "Alarm is still playing after stop: \(isStillPlaying)") + + // Perform snooze on the current alarm + AlarmManager.shared.performSnooze() + + // Log snooze details + if let currentAlarmID = Observable.shared.currentAlarm.value { + let alarms = Storage.shared.alarms.value + if let alarm = alarms.first(where: { $0.id == currentAlarmID }) { + LogManager.shared.log(category: .alarm, message: "Snoozed alarm: \(alarm.name), snooze duration: \(alarm.snoozeDuration) units") + } + } + + LogManager.shared.log(category: .alarm, message: "Alarm silenced and snoozed via volume button") + + // Check if snooze was successful + DispatchQueue.main.asyncAfter(deadline: .now() + volumeButtonActivationDelay) { + if let currentAlarm = Observable.shared.currentAlarm.value { + LogManager.shared.log(category: .alarm, message: "Current alarm after snooze: \(currentAlarm)") + } else { + LogManager.shared.log(category: .alarm, message: "No current alarm after snooze - snooze successful") + } + } + + // Provide haptic feedback to confirm the action + if #available(iOS 10.0, *) { + let impactFeedback = UIImpactFeedbackGenerator(style: .medium) + impactFeedback.impactOccurred() + } + } + + // MARK: - Notification Handlers + + @objc private func alarmStarted() { + alarmStartTime = Date() + hasReceivedFirstVolumeAfterAlarm = false + consecutiveVolumeChanges = 0 + + // Reset improved button press detection + recentVolumeChanges.removeAll() + lastSignificantVolumeChange = nil + volumeChangePattern.removeAll() + + LogManager.shared.log(category: .alarm, message: "Alarm started - volume button silencing enabled after \(volumeButtonActivationDelay) seconds") + + // Ignore volume changes for the first activation delay seconds after alarm starts + // This prevents false triggers from the alarm itself changing the volume + DispatchQueue.main.asyncAfter(deadline: .now() + volumeButtonActivationDelay) { + if let startTime = self.alarmStartTime { + let timeSince = Date().timeIntervalSince(startTime) + LogManager.shared.log(category: .alarm, message: "Alarm has been playing for \(timeSince)s - volume button silencing now active") + } + } + } + + @objc private func alarmStopped() { + alarmStartTime = nil + hasReceivedFirstVolumeAfterAlarm = false + consecutiveVolumeChanges = 0 + volumeChangeTimer?.invalidate() + volumeChangeTimer = nil + + // Reset improved button press detection + recentVolumeChanges.removeAll() + lastSignificantVolumeChange = nil + volumeChangePattern.removeAll() + + LogManager.shared.log(category: .alarm, message: "Alarm stopped - volume button silencing disabled") + } + + // MARK: - Testing + + func testSnoozeFunctionality() { + LogManager.shared.log(category: .alarm, message: "Testing snooze functionality manually") + silenceActiveAlarm() + } +} diff --git a/LoopFollow/Storage/Storage+Migrate.swift b/LoopFollow/Storage/Storage+Migrate.swift index 16c4a2b9b..643785ffd 100644 --- a/LoopFollow/Storage/Storage+Migrate.swift +++ b/LoopFollow/Storage/Storage+Migrate.swift @@ -293,6 +293,9 @@ extension Storage { $0.ignoreZeroBG = $1 } + // 10. Volume button silence (new feature, defaults to true) + // No migration needed as it defaults to true in the struct + // finally persist the whole struct Storage.shared.alarmConfiguration.value = cfg } From d7c4015ab12dac0a1b700261b7c3807c7a255b71 Mon Sep 17 00:00:00 2001 From: codebymini Date: Mon, 11 Aug 2025 09:43:22 +0200 Subject: [PATCH 02/19] Only listen for volume button presses when an alarm is fired --- .../Controllers/VolumeButtonHandler.swift | 41 ++++++++++++++----- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/LoopFollow/Controllers/VolumeButtonHandler.swift b/LoopFollow/Controllers/VolumeButtonHandler.swift index 9a72f8af0..5b0adf515 100644 --- a/LoopFollow/Controllers/VolumeButtonHandler.swift +++ b/LoopFollow/Controllers/VolumeButtonHandler.swift @@ -48,11 +48,6 @@ class VolumeButtonHandler: NSObject { LogManager.shared.log(category: .alarm, message: "Initial volume: \(lastVolume)") - // Start monitoring volume changes with a timer - volumeMonitoringTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in - self.checkVolumeChange() - } - // Test volume monitoring after 2 seconds DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { let currentVol = AVAudioSession.sharedInstance().outputVolume @@ -74,7 +69,7 @@ class VolumeButtonHandler: NSObject { object: nil ) - LogManager.shared.log(category: .alarm, message: "Volume button monitoring started") + LogManager.shared.log(category: .alarm, message: "Volume button monitoring started (waiting for alarm)") } catch { LogManager.shared.log(category: .alarm, message: "Failed to start volume monitoring: \(error)") } @@ -84,8 +79,7 @@ class VolumeButtonHandler: NSObject { guard isMonitoring else { return } isMonitoring = false - volumeMonitoringTimer?.invalidate() - volumeMonitoringTimer = nil + stopVolumeMonitoringTimer() volumeChangeTimer?.invalidate() volumeChangeTimer = nil @@ -293,12 +287,14 @@ class VolumeButtonHandler: NSObject { LogManager.shared.log(category: .alarm, message: "Alarm started - volume button silencing enabled after \(volumeButtonActivationDelay) seconds") - // Ignore volume changes for the first activation delay seconds after alarm starts - // This prevents false triggers from the alarm itself changing the volume + // Start volume monitoring after the activation delay DispatchQueue.main.asyncAfter(deadline: .now() + volumeButtonActivationDelay) { if let startTime = self.alarmStartTime { let timeSince = Date().timeIntervalSince(startTime) LogManager.shared.log(category: .alarm, message: "Alarm has been playing for \(timeSince)s - volume button silencing now active") + + // Start the volume monitoring timer now that the alarm is active + self.startVolumeMonitoringTimer() } } } @@ -310,6 +306,9 @@ class VolumeButtonHandler: NSObject { volumeChangeTimer?.invalidate() volumeChangeTimer = nil + // Stop the volume monitoring timer since no alarm is active + stopVolumeMonitoringTimer() + // Reset improved button press detection recentVolumeChanges.removeAll() lastSignificantVolumeChange = nil @@ -318,6 +317,28 @@ class VolumeButtonHandler: NSObject { LogManager.shared.log(category: .alarm, message: "Alarm stopped - volume button silencing disabled") } + // MARK: - Timer Management + + private func startVolumeMonitoringTimer() { + // Only start if not already running + guard volumeMonitoringTimer == nil else { + LogManager.shared.log(category: .alarm, message: "Volume monitoring timer already running") + return + } + + volumeMonitoringTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in + self.checkVolumeChange() + } + + LogManager.shared.log(category: .alarm, message: "Volume monitoring timer started") + } + + private func stopVolumeMonitoringTimer() { + volumeMonitoringTimer?.invalidate() + volumeMonitoringTimer = nil + LogManager.shared.log(category: .alarm, message: "Volume monitoring timer stopped") + } + // MARK: - Testing func testSnoozeFunctionality() { From 8d4973f8695729e8eb43561d5fb56b3df5924335 Mon Sep 17 00:00:00 2001 From: codebymini Date: Mon, 11 Aug 2025 14:52:33 +0200 Subject: [PATCH 03/19] Remove stray comment from storage --- LoopFollow/Storage/Storage+Migrate.swift | 3 --- 1 file changed, 3 deletions(-) diff --git a/LoopFollow/Storage/Storage+Migrate.swift b/LoopFollow/Storage/Storage+Migrate.swift index 643785ffd..16c4a2b9b 100644 --- a/LoopFollow/Storage/Storage+Migrate.swift +++ b/LoopFollow/Storage/Storage+Migrate.swift @@ -293,9 +293,6 @@ extension Storage { $0.ignoreZeroBG = $1 } - // 10. Volume button silence (new feature, defaults to true) - // No migration needed as it defaults to true in the struct - // finally persist the whole struct Storage.shared.alarmConfiguration.value = cfg } From 9c8a1fdb8a8c638d59e1c2c13ac0638189557f82 Mon Sep 17 00:00:00 2001 From: codebymini Date: Mon, 11 Aug 2025 15:14:40 +0200 Subject: [PATCH 04/19] Clean up unused code for volume button monitoring --- LoopFollow/Application/AppDelegate.swift | 12 ------------ LoopFollow/Controllers/VolumeButtonHandler.swift | 2 +- 2 files changed, 1 insertion(+), 13 deletions(-) diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index 37ae64af5..c95c1639a 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/LoopFollow/Application/AppDelegate.swift @@ -140,18 +140,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate { return .all } } - - // MARK: - App Lifecycle - - func applicationWillResignActive(_: UIApplication) { - // Stop volume button monitoring when app goes to background - VolumeButtonHandler.shared.stopMonitoring() - } - - func applicationDidBecomeActive(_: UIApplication) { - // Restart volume button monitoring when app comes to foreground - VolumeButtonHandler.shared.startMonitoring() - } } extension AppDelegate: UNUserNotificationCenterDelegate { diff --git a/LoopFollow/Controllers/VolumeButtonHandler.swift b/LoopFollow/Controllers/VolumeButtonHandler.swift index 5b0adf515..65b30b7eb 100644 --- a/LoopFollow/Controllers/VolumeButtonHandler.swift +++ b/LoopFollow/Controllers/VolumeButtonHandler.swift @@ -10,7 +10,7 @@ class VolumeButtonHandler: NSObject { static let shared = VolumeButtonHandler() // Volume button snoozer activation delay in seconds - private let volumeButtonActivationDelay: TimeInterval = 0.9 + private let volumeButtonActivationDelay: TimeInterval = 1.0 // Improved volume button detection parameters private let volumeButtonPressThreshold: Float = 0.02 // Minimum volume change to consider a button press From 68a3e141502119517acab2f286f223f53cac6e55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Mon, 11 Aug 2025 15:44:49 +0200 Subject: [PATCH 05/19] Removed unused import --- LoopFollow/Application/AppDelegate.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index c95c1639a..65daa8e8f 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/LoopFollow/Application/AppDelegate.swift @@ -2,7 +2,6 @@ // AppDelegate.swift // Created by Jon Fawcett. -import AVFoundation import CoreData import EventKit import UIKit From f8c7ad56d7c59004f7b4d7da32a80afc0db1fa3a Mon Sep 17 00:00:00 2001 From: codebymini Date: Mon, 11 Aug 2025 18:10:53 +0200 Subject: [PATCH 06/19] Fix volume button snoozing and clear up comments --- LoopFollow/Application/AppDelegate.swift | 13 +++++ .../Controllers/VolumeButtonHandler.swift | 48 ++++++++++++++----- 2 files changed, 48 insertions(+), 13 deletions(-) diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index 65daa8e8f..1cd7f4122 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/LoopFollow/Application/AppDelegate.swift @@ -139,6 +139,19 @@ class AppDelegate: UIResponder, UIApplicationDelegate { return .all } } + + // MARK: - App Lifecycle + + func applicationWillResignActive(_: UIApplication) { + // Note: Volume button monitoring may continue in background due to audio background mode + // This allows users to snooze alarms even when the app is not in foreground + VolumeButtonHandler.shared.stopMonitoring() + } + + func applicationDidBecomeActive(_: UIApplication) { + // Restart volume button monitoring when app comes to foreground + VolumeButtonHandler.shared.startMonitoring() + } } extension AppDelegate: UNUserNotificationCenterDelegate { diff --git a/LoopFollow/Controllers/VolumeButtonHandler.swift b/LoopFollow/Controllers/VolumeButtonHandler.swift index 65b30b7eb..99c54736b 100644 --- a/LoopFollow/Controllers/VolumeButtonHandler.swift +++ b/LoopFollow/Controllers/VolumeButtonHandler.swift @@ -10,7 +10,7 @@ class VolumeButtonHandler: NSObject { static let shared = VolumeButtonHandler() // Volume button snoozer activation delay in seconds - private let volumeButtonActivationDelay: TimeInterval = 1.0 + private let volumeButtonActivationDelay: TimeInterval = 0.9 // Improved volume button detection parameters private let volumeButtonPressThreshold: Float = 0.02 // Minimum volume change to consider a button press @@ -25,6 +25,7 @@ class VolumeButtonHandler: NSObject { private var hasReceivedFirstVolumeAfterAlarm: Bool = false private var lastVolumeButtonPressTime: Date? private var consecutiveVolumeChanges: Int = 0 + private var isAlarmSystemChangingVolume: Bool = false // Improved button press detection private var recentVolumeChanges: [(volume: Float, timestamp: Date)] = [] @@ -102,22 +103,36 @@ class VolumeButtonHandler: NSObject { // Only respond to significant volume changes (likely from hardware buttons) if volumeDifference > volumeButtonPressThreshold { + LogManager.shared.log(category: .alarm, message: "Significant volume change detected: \(volumeDifference) (threshold: \(volumeButtonPressThreshold))") + + // Check if this volume change is likely from the alarm system + if let startTime = alarmStartTime { + let timeSinceAlarmStart = now.timeIntervalSince(startTime) + + // If the alarm just started and this is a volume change in the expected direction (increase), + // it's likely from the alarm system setting the volume + if timeSinceAlarmStart < 2.0, currentVolume > lastVolume { + // Additional check: if this volume change matches the expected pattern from alarm system + // (typically a small increase to ensure alarm is audible) + if volumeDifference <= 0.15, timeSinceAlarmStart < 1.5 { + LogManager.shared.log(category: .alarm, message: "Ignoring volume change likely from alarm system: \(lastVolume) -> \(currentVolume) (alarm started \(timeSinceAlarmStart)s ago)") + lastVolume = currentVolume + return + } + } + } + // Record this volume change for pattern analysis recordVolumeChange(currentVolume: currentVolume, timestamp: now) // Additional check: ensure we're not just getting the initial volume reading if lastVolume > 0 { - // Check if an alarm has been playing for at least the activation delay + // Check if there's an active alarm if let startTime = alarmStartTime { let timeSinceAlarmStart = now.timeIntervalSince(startTime) - if timeSinceAlarmStart > volumeButtonActivationDelay { - // Mark that we've received the first volume reading after alarm start - if !hasReceivedFirstVolumeAfterAlarm { - hasReceivedFirstVolumeAfterAlarm = true - LogManager.shared.log(category: .alarm, message: "First volume reading after alarm start - ignoring") - return - } + LogManager.shared.log(category: .alarm, message: "Alarm active for \(timeSinceAlarmStart)s, activation delay: \(volumeButtonActivationDelay)s") + if timeSinceAlarmStart > volumeButtonActivationDelay { // Check if we've pressed volume buttons recently (cooldown) if let lastPress = lastVolumeButtonPressTime { let timeSinceLastPress = now.timeIntervalSince(lastPress) @@ -176,21 +191,22 @@ class VolumeButtonHandler: NSObject { // Criteria for identifying a volume button press: // 1. Volume change should be significant but not too large (typical button press range) - let isReasonableChange = volumeDifference >= 0.02 && volumeDifference <= 0.15 + // Make this more strict to avoid system volume changes + let isReasonableChange = volumeDifference >= 0.03 && volumeDifference <= 0.12 // 2. Should be a discrete change (not part of a continuous adjustment) let isDiscreteChange = recentVolumeChanges.count <= 2 // 3. Timing should be consistent with button press patterns let hasConsistentTiming = volumeChangePattern.isEmpty || - volumeChangePattern.last! >= 0.1 // At least 100ms between changes + volumeChangePattern.last! >= 0.15 // At least 150ms between changes (increased from 100ms) // 4. Should not be part of a rapid sequence (which might indicate slider usage) 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.05 // At least 50ms between rapid changes + return timestamp - previousTimestamp > 0.08 // At least 80ms between rapid changes (increased from 50ms) }) let isButtonPress = isReasonableChange && isDiscreteChange && hasConsistentTiming && isNotRapidSequence @@ -201,7 +217,10 @@ class VolumeButtonHandler: NSObject { } private func handleVolumeButtonPress() { - LogManager.shared.log(category: .alarm, message: "handleVolumeButtonPress called") + LogManager.shared.log(category: .alarm, message: "=== handleVolumeButtonPress called ===") + LogManager.shared.log(category: .alarm, message: "Current time: \(Date())") + LogManager.shared.log(category: .alarm, message: "Alarm start time: \(alarmStartTime?.description ?? "nil")") + LogManager.shared.log(category: .alarm, message: "AlarmSound.isPlaying: \(AlarmSound.isPlaying)") // Check if volume button silencing is enabled guard Storage.shared.alarmConfiguration.value.enableVolumeButtonSilence else { @@ -295,6 +314,9 @@ class VolumeButtonHandler: NSObject { // Start the volume monitoring timer now that the alarm is active self.startVolumeMonitoringTimer() + + // Mark that we're ready to receive volume button presses + self.hasReceivedFirstVolumeAfterAlarm = true } } } From 55741fa0b7670ac9934a8c02f96dc18f04913498 Mon Sep 17 00:00:00 2001 From: codebymini Date: Mon, 11 Aug 2025 19:55:58 +0200 Subject: [PATCH 07/19] Add warning for bluetooth users about limitation for volume button snoozer --- .../BackgroundRefreshSettingsView.swift | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/LoopFollow/BackgroundRefresh/BackgroundRefreshSettingsView.swift b/LoopFollow/BackgroundRefresh/BackgroundRefreshSettingsView.swift index bdde1a896..b807247da 100644 --- a/LoopFollow/BackgroundRefresh/BackgroundRefreshSettingsView.swift +++ b/LoopFollow/BackgroundRefresh/BackgroundRefreshSettingsView.swift @@ -75,6 +75,24 @@ struct BackgroundRefreshSettingsView: View { .foregroundColor(.secondary) } } + + // Warning about volume button snoozing with Bluetooth + if viewModel.backgroundRefreshType.isBluetooth { + Section { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + Text("Volume Button Snoozing Limitation") + .font(.headline) + .foregroundColor(.orange) + } + + Text("⚠️ When using Bluetooth devices for background refresh, volume button snoozing will NOT work while the app is in the background. You can still snooze alarms by opening the app or using push notifications.") + .font(.footnote) + .foregroundColor(.orange) + .padding(.top, 4) + } + } } } From 99247e9039a916ce09f1b892c43f337676e5246c Mon Sep 17 00:00:00 2001 From: codebymini Date: Mon, 11 Aug 2025 20:37:58 +0200 Subject: [PATCH 08/19] Add volume button snoozer for bluetooth updating apps --- LoopFollow/Application/AppDelegate.swift | 19 +++++-- .../BackgroundRefreshSettingsView.swift | 18 ------- LoopFollow/Controllers/AlarmSound.swift | 11 ++--- .../Controllers/VolumeButtonHandler.swift | 49 +++++++++++++++++++ 4 files changed, 68 insertions(+), 29 deletions(-) diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index 1cd7f4122..1dece5309 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/LoopFollow/Application/AppDelegate.swift @@ -143,13 +143,24 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // MARK: - App Lifecycle func applicationWillResignActive(_: UIApplication) { - // Note: Volume button monitoring may continue in background due to audio background mode - // This allows users to snooze alarms even when the app is not in foreground - VolumeButtonHandler.shared.stopMonitoring() + // Check if we should keep volume button monitoring active for Bluetooth background refresh + let backgroundType = Storage.shared.backgroundRefreshType.value + let volumeButtonEnabled = Storage.shared.alarmConfiguration.value.enableVolumeButtonSilence + + if backgroundType.isBluetooth || backgroundType == .silentTune, volumeButtonEnabled { + // Keep volume button monitoring active when using Bluetooth/Silent Tune AND volume button snoozing is enabled + let method = backgroundType == .silentTune ? "Silent Tune" : "Bluetooth" + LogManager.shared.log(category: .alarm, message: "App going to background with \(method) refresh and volume button snoozing enabled - keeping volume button monitoring active") + } else { + // Stop volume button monitoring for other background refresh types or when volume button snoozing is disabled + LogManager.shared.log(category: .alarm, message: "App going to background - stopping volume button monitoring") + VolumeButtonHandler.shared.stopMonitoring() + } } func applicationDidBecomeActive(_: UIApplication) { - // Restart volume button monitoring when app comes to foreground + // Always restart volume button monitoring when app comes to foreground + LogManager.shared.log(category: .alarm, message: "App becoming active - starting volume button monitoring") VolumeButtonHandler.shared.startMonitoring() } } diff --git a/LoopFollow/BackgroundRefresh/BackgroundRefreshSettingsView.swift b/LoopFollow/BackgroundRefresh/BackgroundRefreshSettingsView.swift index b807247da..bdde1a896 100644 --- a/LoopFollow/BackgroundRefresh/BackgroundRefreshSettingsView.swift +++ b/LoopFollow/BackgroundRefresh/BackgroundRefreshSettingsView.swift @@ -75,24 +75,6 @@ struct BackgroundRefreshSettingsView: View { .foregroundColor(.secondary) } } - - // Warning about volume button snoozing with Bluetooth - if viewModel.backgroundRefreshType.isBluetooth { - Section { - HStack { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundColor(.orange) - Text("Volume Button Snoozing Limitation") - .font(.headline) - .foregroundColor(.orange) - } - - Text("⚠️ When using Bluetooth devices for background refresh, volume button snoozing will NOT work while the app is in the background. You can still snooze alarms by opening the app or using push notifications.") - .font(.footnote) - .foregroundColor(.orange) - .padding(.top, 4) - } - } } } diff --git a/LoopFollow/Controllers/AlarmSound.swift b/LoopFollow/Controllers/AlarmSound.swift index 766ab7d5e..bd5a6fdac 100644 --- a/LoopFollow/Controllers/AlarmSound.swift +++ b/LoopFollow/Controllers/AlarmSound.swift @@ -134,9 +134,6 @@ class AlarmSound { audioPlayer = try AVAudioPlayer(contentsOf: soundURL) audioPlayer!.delegate = audioPlayerDelegate - try AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category(rawValue: convertFromAVAudioSessionCategory(AVAudioSession.Category.playback))) - try AVAudioSession.sharedInstance().setActive(true) - audioPlayer!.numberOfLoops = repeating ? -1 : 0 // Store existing volume @@ -177,13 +174,12 @@ class AlarmSound { return } + enableAudio() + do { audioPlayer = try AVAudioPlayer(contentsOf: soundURL) audioPlayer!.delegate = audioPlayerDelegate - try AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category(rawValue: convertFromAVAudioSessionCategory(AVAudioSession.Category.playback))) - try AVAudioSession.sharedInstance().setActive(true) - // Play endless loops audioPlayer!.numberOfLoops = 2 @@ -230,7 +226,8 @@ class AlarmSound { fileprivate static func enableAudio() { do { - try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: .mixWithOthers) + // Use playback category with options that work well in background and with Bluetooth + try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: [.mixWithOthers, .allowBluetooth]) try AVAudioSession.sharedInstance().setActive(true) } catch { LogManager.shared.log(category: .alarm, message: "Enable audio error: \(error)") diff --git a/LoopFollow/Controllers/VolumeButtonHandler.swift b/LoopFollow/Controllers/VolumeButtonHandler.swift index 99c54736b..3af20dc1f 100644 --- a/LoopFollow/Controllers/VolumeButtonHandler.swift +++ b/LoopFollow/Controllers/VolumeButtonHandler.swift @@ -70,6 +70,21 @@ class VolumeButtonHandler: NSObject { object: nil ) + // Listen for app state changes to handle background/foreground transitions + NotificationCenter.default.addObserver( + self, + selector: #selector(appDidEnterBackground), + name: UIApplication.didEnterBackgroundNotification, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(appWillEnterForeground), + name: UIApplication.willEnterForegroundNotification, + object: nil + ) + LogManager.shared.log(category: .alarm, message: "Volume button monitoring started (waiting for alarm)") } catch { LogManager.shared.log(category: .alarm, message: "Failed to start volume monitoring: \(error)") @@ -87,6 +102,8 @@ class VolumeButtonHandler: NSObject { // Remove notification observers NotificationCenter.default.removeObserver(self, name: .alarmStarted, object: nil) NotificationCenter.default.removeObserver(self, name: .alarmStopped, object: nil) + NotificationCenter.default.removeObserver(self, name: UIApplication.didEnterBackgroundNotification, object: nil) + NotificationCenter.default.removeObserver(self, name: UIApplication.willEnterForegroundNotification, object: nil) LogManager.shared.log(category: .alarm, message: "Volume button monitoring stopped") } @@ -339,6 +356,30 @@ class VolumeButtonHandler: NSObject { LogManager.shared.log(category: .alarm, message: "Alarm stopped - volume button silencing disabled") } + // MARK: - App State Change Handlers + + @objc private func appDidEnterBackground() { + let backgroundType = Storage.shared.backgroundRefreshType.value + let volumeButtonEnabled = Storage.shared.alarmConfiguration.value.enableVolumeButtonSilence + + if backgroundType.isBluetooth || backgroundType == .silentTune, volumeButtonEnabled { + let method = backgroundType == .silentTune ? "Silent Tune" : "Bluetooth" + LogManager.shared.log(category: .alarm, message: "App entered background with \(method) refresh and volume button snoozing enabled - maintaining volume monitoring for alarms") + // Keep volume monitoring active for Bluetooth/Silent Tune background refresh when volume button snoozing is enabled + // This allows volume button snoozing to work in the background + } else { + LogManager.shared.log(category: .alarm, message: "App entered background - volume monitoring may be limited or disabled") + } + } + + @objc private func appWillEnterForeground() { + LogManager.shared.log(category: .alarm, message: "App will enter foreground - ensuring volume monitoring is active") + // Ensure volume monitoring is active when coming to foreground + if isMonitoring, volumeMonitoringTimer == nil { + startVolumeMonitoringTimer() + } + } + // MARK: - Timer Management private func startVolumeMonitoringTimer() { @@ -348,10 +389,18 @@ class VolumeButtonHandler: NSObject { return } + // Use a more background-compatible timer approach volumeMonitoringTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in self.checkVolumeChange() } + // Ensure the timer works in background by adding it to the main run loop + // Use .common mode for better background compatibility + RunLoop.main.add(volumeMonitoringTimer!, forMode: .common) + + // Also add to default mode as backup + RunLoop.main.add(volumeMonitoringTimer!, forMode: .default) + LogManager.shared.log(category: .alarm, message: "Volume monitoring timer started") } From 688a367d97740a4ff6725e9ba81bc0073ddab74c Mon Sep 17 00:00:00 2001 From: codebymini Date: Tue, 12 Aug 2025 20:32:08 +0200 Subject: [PATCH 09/19] Fix scripted username change for some files --- LoopFollow/Helpers/DateExtensions.swift | 2 +- LoopFollow/Helpers/JWTManager.swift | 2 +- LoopFollow/Helpers/TOTPGenerator.swift | 2 +- LoopFollow/Helpers/Views/SimpleQRCodeScannerView.swift | 2 +- LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift | 2 +- LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift | 2 +- LoopFollow/Remote/LoopAPNS/LoopAPNSRemoteView.swift | 2 +- LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift | 2 +- LoopFollow/Remote/LoopAPNS/OverridePresetData.swift | 2 +- LoopFollow/Remote/LoopAPNS/OverridePresetsView.swift | 2 +- 10 files changed, 10 insertions(+), 10 deletions(-) diff --git a/LoopFollow/Helpers/DateExtensions.swift b/LoopFollow/Helpers/DateExtensions.swift index 6b421f18b..f68645b4e 100644 --- a/LoopFollow/Helpers/DateExtensions.swift +++ b/LoopFollow/Helpers/DateExtensions.swift @@ -1,6 +1,6 @@ // LoopFollow // DateExtensions.swift -// Created by codebymini. +// Created by Daniel Mini Johansson. import Foundation diff --git a/LoopFollow/Helpers/JWTManager.swift b/LoopFollow/Helpers/JWTManager.swift index b3a55b35f..043b2593a 100644 --- a/LoopFollow/Helpers/JWTManager.swift +++ b/LoopFollow/Helpers/JWTManager.swift @@ -1,6 +1,6 @@ // LoopFollow // JWTManager.swift -// Created by Jonas Björkert. +// Created by Daniel Mini Johansson. import Foundation import SwiftJWT diff --git a/LoopFollow/Helpers/TOTPGenerator.swift b/LoopFollow/Helpers/TOTPGenerator.swift index 2e4fa8c89..00737325f 100644 --- a/LoopFollow/Helpers/TOTPGenerator.swift +++ b/LoopFollow/Helpers/TOTPGenerator.swift @@ -1,6 +1,6 @@ // LoopFollow // TOTPGenerator.swift -// Created by codebymini. +// Created by Daniel Mini Johansson. import CommonCrypto import Foundation diff --git a/LoopFollow/Helpers/Views/SimpleQRCodeScannerView.swift b/LoopFollow/Helpers/Views/SimpleQRCodeScannerView.swift index 1ae542e04..2da725738 100644 --- a/LoopFollow/Helpers/Views/SimpleQRCodeScannerView.swift +++ b/LoopFollow/Helpers/Views/SimpleQRCodeScannerView.swift @@ -1,6 +1,6 @@ // LoopFollow // SimpleQRCodeScannerView.swift -// Created by codebymini. +// Created by Daniel Mini Johansson. import AVFoundation import SwiftUI diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift index 4377d9ec8..70ca9dc51 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift @@ -1,6 +1,6 @@ // LoopFollow // LoopAPNSBolusView.swift -// Created by codebymini. +// Created by Daniel Mini Johansson. import HealthKit import LocalAuthentication diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift index bb1259484..6d15252d4 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift @@ -1,6 +1,6 @@ // LoopFollow // LoopAPNSCarbsView.swift -// Created by codebymini. +// Created by Daniel Mini Johansson. import HealthKit import SwiftUI diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSRemoteView.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSRemoteView.swift index 133250122..15c660f07 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSRemoteView.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSRemoteView.swift @@ -1,6 +1,6 @@ // LoopFollow // LoopAPNSRemoteView.swift -// Created by codebymini. +// Created by Daniel Mini Johansson. import SwiftUI diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift index f3d353610..6379df72e 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift @@ -1,6 +1,6 @@ // LoopFollow // LoopAPNSService.swift -// Created by codebymini. +// Created by Daniel Mini Johansson. import CryptoKit import Foundation diff --git a/LoopFollow/Remote/LoopAPNS/OverridePresetData.swift b/LoopFollow/Remote/LoopAPNS/OverridePresetData.swift index 5deb62962..fe58ce259 100644 --- a/LoopFollow/Remote/LoopAPNS/OverridePresetData.swift +++ b/LoopFollow/Remote/LoopAPNS/OverridePresetData.swift @@ -1,6 +1,6 @@ // LoopFollow // OverridePresetData.swift -// Created by codebymini. +// Created by Daniel Mini Johansson. import Foundation diff --git a/LoopFollow/Remote/LoopAPNS/OverridePresetsView.swift b/LoopFollow/Remote/LoopAPNS/OverridePresetsView.swift index 2273eda2a..3fd675694 100644 --- a/LoopFollow/Remote/LoopAPNS/OverridePresetsView.swift +++ b/LoopFollow/Remote/LoopAPNS/OverridePresetsView.swift @@ -1,6 +1,6 @@ // LoopFollow // OverridePresetsView.swift -// Created by codebymini. +// Created by Daniel Mini Johansson. import SwiftUI From 074f7d757fa9fe1ceb3ac9af9007d106cc5904c3 Mon Sep 17 00:00:00 2001 From: codebymini Date: Tue, 12 Aug 2025 21:02:21 +0200 Subject: [PATCH 10/19] Remove unused code --- LoopFollow/Application/AppDelegate.swift | 23 ------------------- .../Controllers/VolumeButtonHandler.swift | 2 ++ 2 files changed, 2 insertions(+), 23 deletions(-) diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index 1dece5309..bc4fbadc7 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/LoopFollow/Application/AppDelegate.swift @@ -140,29 +140,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } } - // MARK: - App Lifecycle - - func applicationWillResignActive(_: UIApplication) { - // Check if we should keep volume button monitoring active for Bluetooth background refresh - let backgroundType = Storage.shared.backgroundRefreshType.value - let volumeButtonEnabled = Storage.shared.alarmConfiguration.value.enableVolumeButtonSilence - - if backgroundType.isBluetooth || backgroundType == .silentTune, volumeButtonEnabled { - // Keep volume button monitoring active when using Bluetooth/Silent Tune AND volume button snoozing is enabled - let method = backgroundType == .silentTune ? "Silent Tune" : "Bluetooth" - LogManager.shared.log(category: .alarm, message: "App going to background with \(method) refresh and volume button snoozing enabled - keeping volume button monitoring active") - } else { - // Stop volume button monitoring for other background refresh types or when volume button snoozing is disabled - LogManager.shared.log(category: .alarm, message: "App going to background - stopping volume button monitoring") - VolumeButtonHandler.shared.stopMonitoring() - } - } - - func applicationDidBecomeActive(_: UIApplication) { - // Always restart volume button monitoring when app comes to foreground - LogManager.shared.log(category: .alarm, message: "App becoming active - starting volume button monitoring") - VolumeButtonHandler.shared.startMonitoring() - } } extension AppDelegate: UNUserNotificationCenterDelegate { diff --git a/LoopFollow/Controllers/VolumeButtonHandler.swift b/LoopFollow/Controllers/VolumeButtonHandler.swift index 3af20dc1f..a172da246 100644 --- a/LoopFollow/Controllers/VolumeButtonHandler.swift +++ b/LoopFollow/Controllers/VolumeButtonHandler.swift @@ -88,6 +88,8 @@ class VolumeButtonHandler: NSObject { LogManager.shared.log(category: .alarm, message: "Volume button monitoring started (waiting for alarm)") } catch { LogManager.shared.log(category: .alarm, message: "Failed to start volume monitoring: \(error)") + // Reset state on failure + isMonitoring = false } } From 91c604a20f4d0b93e0c6221b7675955ed73992e1 Mon Sep 17 00:00:00 2001 From: codebymini Date: Wed, 13 Aug 2025 10:53:37 +0200 Subject: [PATCH 11/19] Clean up volume button snoozer handling --- LoopFollow/Application/AppDelegate.swift | 5 +- .../Controllers/VolumeButtonHandler.swift | 297 ++++-------------- 2 files changed, 65 insertions(+), 237 deletions(-) diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index bc4fbadc7..4be83b79b 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/LoopFollow/Application/AppDelegate.swift @@ -40,8 +40,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate { _ = BLEManager.shared - // Start volume button monitoring - VolumeButtonHandler.shared.startMonitoring() + // Ensure VolumeButtonHandler is initialized so it can receive alarm notifications + _ = VolumeButtonHandler.shared return true } @@ -139,7 +139,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate { return .all } } - } extension AppDelegate: UNUserNotificationCenterDelegate { diff --git a/LoopFollow/Controllers/VolumeButtonHandler.swift b/LoopFollow/Controllers/VolumeButtonHandler.swift index a172da246..c68ad1cf7 100644 --- a/LoopFollow/Controllers/VolumeButtonHandler.swift +++ b/LoopFollow/Controllers/VolumeButtonHandler.swift @@ -12,10 +12,10 @@ class VolumeButtonHandler: NSObject { // Volume button snoozer activation delay in seconds private let volumeButtonActivationDelay: TimeInterval = 0.9 - // Improved volume button detection parameters - private let volumeButtonPressThreshold: Float = 0.02 // Minimum volume change to consider a button press - private let volumeButtonPressTimeWindow: TimeInterval = 0.3 // Time window to detect rapid volume changes - private let volumeButtonCooldown: TimeInterval = 0.5 // Cooldown between button presses + // Volume button detection parameters + private let volumeButtonPressThreshold: Float = 0.02 + private let volumeButtonPressTimeWindow: TimeInterval = 0.3 + private let volumeButtonCooldown: TimeInterval = 0.5 private var lastVolume: Float = 0.0 private var isMonitoring = false @@ -27,68 +27,57 @@ class VolumeButtonHandler: NSObject { private var consecutiveVolumeChanges: Int = 0 private var isAlarmSystemChangingVolume: Bool = false - // Improved button press detection + // Button press detection private var recentVolumeChanges: [(volume: Float, timestamp: Date)] = [] private var lastSignificantVolumeChange: Date? - private var volumeChangePattern: [TimeInterval] = [] // Track timing between changes + private var volumeChangePattern: [TimeInterval] = [] override private init() { super.init() + + NotificationCenter.default.addObserver( + self, + selector: #selector(alarmStarted), + name: .alarmStarted, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(alarmStopped), + name: .alarmStopped, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(appDidEnterBackground), + name: UIApplication.didEnterBackgroundNotification, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(appWillEnterForeground), + name: UIApplication.willEnterForegroundNotification, + object: nil + ) + } + + deinit { + NotificationCenter.default.removeObserver(self) } func startMonitoring() { - guard !isMonitoring else { - LogManager.shared.log(category: .alarm, message: "Volume monitoring already active") - return - } + guard !isMonitoring else { return } do { try AVAudioSession.sharedInstance().setActive(true) lastVolume = AVAudioSession.sharedInstance().outputVolume isMonitoring = true - - LogManager.shared.log(category: .alarm, message: "Initial volume: \(lastVolume)") - - // Test volume monitoring after 2 seconds - DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { - let currentVol = AVAudioSession.sharedInstance().outputVolume - LogManager.shared.log(category: .alarm, message: "Volume monitoring test - current volume: \(currentVol)") - } - - // Listen for alarm start/stop notifications - NotificationCenter.default.addObserver( - self, - selector: #selector(alarmStarted), - name: .alarmStarted, - object: nil - ) - - NotificationCenter.default.addObserver( - self, - selector: #selector(alarmStopped), - name: .alarmStopped, - object: nil - ) - - // Listen for app state changes to handle background/foreground transitions - NotificationCenter.default.addObserver( - self, - selector: #selector(appDidEnterBackground), - name: UIApplication.didEnterBackgroundNotification, - object: nil - ) - - NotificationCenter.default.addObserver( - self, - selector: #selector(appWillEnterForeground), - name: UIApplication.willEnterForegroundNotification, - object: nil - ) - - LogManager.shared.log(category: .alarm, message: "Volume button monitoring started (waiting for alarm)") + startVolumeMonitoringTimer() } catch { LogManager.shared.log(category: .alarm, message: "Failed to start volume monitoring: \(error)") - // Reset state on failure isMonitoring = false } } @@ -100,14 +89,6 @@ class VolumeButtonHandler: NSObject { stopVolumeMonitoringTimer() volumeChangeTimer?.invalidate() volumeChangeTimer = nil - - // Remove notification observers - NotificationCenter.default.removeObserver(self, name: .alarmStarted, object: nil) - NotificationCenter.default.removeObserver(self, name: .alarmStopped, object: nil) - NotificationCenter.default.removeObserver(self, name: UIApplication.didEnterBackgroundNotification, object: nil) - NotificationCenter.default.removeObserver(self, name: UIApplication.willEnterForegroundNotification, object: nil) - - LogManager.shared.log(category: .alarm, message: "Volume button monitoring stopped") } private func checkVolumeChange() { @@ -115,89 +96,50 @@ class VolumeButtonHandler: NSObject { let volumeDifference = abs(currentVolume - lastVolume) let now = Date() - // Log volume changes for debugging - if volumeDifference > 0.01 { - LogManager.shared.log(category: .alarm, message: "Volume change: \(lastVolume) -> \(currentVolume) (diff: \(volumeDifference))") - } - - // Only respond to significant volume changes (likely from hardware buttons) if volumeDifference > volumeButtonPressThreshold { - LogManager.shared.log(category: .alarm, message: "Significant volume change detected: \(volumeDifference) (threshold: \(volumeButtonPressThreshold))") - - // Check if this volume change is likely from the alarm system if let startTime = alarmStartTime { let timeSinceAlarmStart = now.timeIntervalSince(startTime) - // If the alarm just started and this is a volume change in the expected direction (increase), - // it's likely from the alarm system setting the volume + // Ignore volume changes from alarm system if timeSinceAlarmStart < 2.0, currentVolume > lastVolume { - // Additional check: if this volume change matches the expected pattern from alarm system - // (typically a small increase to ensure alarm is audible) if volumeDifference <= 0.15, timeSinceAlarmStart < 1.5 { - LogManager.shared.log(category: .alarm, message: "Ignoring volume change likely from alarm system: \(lastVolume) -> \(currentVolume) (alarm started \(timeSinceAlarmStart)s ago)") lastVolume = currentVolume return } } } - // Record this volume change for pattern analysis recordVolumeChange(currentVolume: currentVolume, timestamp: now) - // Additional check: ensure we're not just getting the initial volume reading - if lastVolume > 0 { - // Check if there's an active alarm - if let startTime = alarmStartTime { - let timeSinceAlarmStart = now.timeIntervalSince(startTime) - LogManager.shared.log(category: .alarm, message: "Alarm active for \(timeSinceAlarmStart)s, activation delay: \(volumeButtonActivationDelay)s") - - if timeSinceAlarmStart > volumeButtonActivationDelay { - // Check if we've pressed volume buttons recently (cooldown) - if let lastPress = lastVolumeButtonPressTime { - let timeSinceLastPress = now.timeIntervalSince(lastPress) - if timeSinceLastPress < volumeButtonCooldown { - LogManager.shared.log(category: .alarm, message: "Volume button pressed too recently (\(timeSinceLastPress)s ago), ignoring") - return - } - } - - // Improved button press detection - if isLikelyVolumeButtonPress(volumeDifference: volumeDifference, timestamp: now) { - LogManager.shared.log(category: .alarm, message: "Volume button press detected: \(volumeDifference), handling volume button press") - handleVolumeButtonPress() - } else { - LogManager.shared.log(category: .alarm, message: "Volume change detected but not recognized as button press: \(volumeDifference)") - } - } else { - LogManager.shared.log(category: .alarm, message: "Volume change detected but alarm hasn't been playing long enough: \(timeSinceAlarmStart)s (need \(volumeButtonActivationDelay)s)") + if lastVolume > 0, let startTime = alarmStartTime { + let timeSinceAlarmStart = now.timeIntervalSince(startTime) + + if timeSinceAlarmStart > volumeButtonActivationDelay { + if let lastPress = lastVolumeButtonPressTime { + let timeSinceLastPress = now.timeIntervalSince(lastPress) + if timeSinceLastPress < volumeButtonCooldown { return } + } + + if isLikelyVolumeButtonPress(volumeDifference: volumeDifference, timestamp: now) { + handleVolumeButtonPress() } - } else { - LogManager.shared.log(category: .alarm, message: "Volume change detected but no alarm start time recorded") } - } else { - LogManager.shared.log(category: .alarm, message: "Volume change detected but lastVolume is 0") } } lastVolume = currentVolume } - // MARK: - Improved Button Press Detection - private func recordVolumeChange(currentVolume: Float, timestamp: Date) { - // Add this volume change to our recent history recentVolumeChanges.append((volume: currentVolume, timestamp: timestamp)) - // Keep only recent changes (within the time window) let cutoffTime = timestamp.timeIntervalSinceReferenceDate - volumeButtonPressTimeWindow recentVolumeChanges = recentVolumeChanges.filter { $0.timestamp.timeIntervalSinceReferenceDate > cutoffTime } - // Update pattern tracking if let lastChange = lastSignificantVolumeChange { let timeSinceLastChange = timestamp.timeIntervalSince(lastChange) volumeChangePattern.append(timeSinceLastChange) - // Keep only recent patterns if volumeChangePattern.count > 5 { volumeChangePattern.removeFirst() } @@ -207,134 +149,51 @@ class VolumeButtonHandler: NSObject { } private func isLikelyVolumeButtonPress(volumeDifference: Float, timestamp: Date) -> Bool { - // Criteria for identifying a volume button press: - - // 1. Volume change should be significant but not too large (typical button press range) - // Make this more strict to avoid system volume changes let isReasonableChange = volumeDifference >= 0.03 && volumeDifference <= 0.12 - - // 2. Should be a discrete change (not part of a continuous adjustment) let isDiscreteChange = recentVolumeChanges.count <= 2 - - // 3. Timing should be consistent with button press patterns - let hasConsistentTiming = volumeChangePattern.isEmpty || - volumeChangePattern.last! >= 0.15 // At least 150ms between changes (increased from 100ms) - - // 4. Should not be part of a rapid sequence (which might indicate slider usage) + 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 // At least 80ms between rapid changes (increased from 50ms) + return timestamp - previousTimestamp > 0.08 }) - let isButtonPress = isReasonableChange && isDiscreteChange && hasConsistentTiming && isNotRapidSequence - - LogManager.shared.log(category: .alarm, message: "Button press analysis: change=\(volumeDifference), discrete=\(isDiscreteChange), timing=\(hasConsistentTiming), rapid=\(!isNotRapidSequence), result=\(isButtonPress)") - - return isButtonPress + return isReasonableChange && isDiscreteChange && hasConsistentTiming && isNotRapidSequence } private func handleVolumeButtonPress() { - LogManager.shared.log(category: .alarm, message: "=== handleVolumeButtonPress called ===") - LogManager.shared.log(category: .alarm, message: "Current time: \(Date())") - LogManager.shared.log(category: .alarm, message: "Alarm start time: \(alarmStartTime?.description ?? "nil")") - LogManager.shared.log(category: .alarm, message: "AlarmSound.isPlaying: \(AlarmSound.isPlaying)") - - // Check if volume button silencing is enabled - guard Storage.shared.alarmConfiguration.value.enableVolumeButtonSilence else { - LogManager.shared.log(category: .alarm, message: "Volume button silencing is disabled") - return - } - - // Check if there's an active alarm - guard AlarmSound.isPlaying else { - LogManager.shared.log(category: .alarm, message: "No alarm is currently playing") - return - } + guard Storage.shared.alarmConfiguration.value.enableVolumeButtonSilence else { return } + guard AlarmSound.isPlaying else { return } + guard volumeChangeTimer == nil else { return } - // Prevent multiple rapid triggers - guard volumeChangeTimer == nil else { - LogManager.shared.log(category: .alarm, message: "Volume change timer already active, ignoring") - return - } - - LogManager.shared.log(category: .alarm, message: "Immediately silencing alarm") - - // Silence the alarm immediately without delay silenceActiveAlarm() } private func silenceActiveAlarm() { - LogManager.shared.log(category: .alarm, message: "Volume button pressed - silencing active alarm") - - // Record the time of this volume button press lastVolumeButtonPressTime = Date() - - // Check if alarm is still playing before stopping - let wasPlaying = AlarmSound.isPlaying - LogManager.shared.log(category: .alarm, message: "Alarm was playing: \(wasPlaying)") - - // Stop the alarm sound AlarmSound.stop() - - // Check if alarm stopped - let isStillPlaying = AlarmSound.isPlaying - LogManager.shared.log(category: .alarm, message: "Alarm is still playing after stop: \(isStillPlaying)") - - // Perform snooze on the current alarm AlarmManager.shared.performSnooze() - // Log snooze details - if let currentAlarmID = Observable.shared.currentAlarm.value { - let alarms = Storage.shared.alarms.value - if let alarm = alarms.first(where: { $0.id == currentAlarmID }) { - LogManager.shared.log(category: .alarm, message: "Snoozed alarm: \(alarm.name), snooze duration: \(alarm.snoozeDuration) units") - } - } - - LogManager.shared.log(category: .alarm, message: "Alarm silenced and snoozed via volume button") - - // Check if snooze was successful - DispatchQueue.main.asyncAfter(deadline: .now() + volumeButtonActivationDelay) { - if let currentAlarm = Observable.shared.currentAlarm.value { - LogManager.shared.log(category: .alarm, message: "Current alarm after snooze: \(currentAlarm)") - } else { - LogManager.shared.log(category: .alarm, message: "No current alarm after snooze - snooze successful") - } - } - - // Provide haptic feedback to confirm the action if #available(iOS 10.0, *) { let impactFeedback = UIImpactFeedbackGenerator(style: .medium) impactFeedback.impactOccurred() } } - // MARK: - Notification Handlers - @objc private func alarmStarted() { alarmStartTime = Date() hasReceivedFirstVolumeAfterAlarm = false consecutiveVolumeChanges = 0 - // Reset improved button press detection recentVolumeChanges.removeAll() lastSignificantVolumeChange = nil volumeChangePattern.removeAll() - LogManager.shared.log(category: .alarm, message: "Alarm started - volume button silencing enabled after \(volumeButtonActivationDelay) seconds") + startMonitoring() - // Start volume monitoring after the activation delay DispatchQueue.main.asyncAfter(deadline: .now() + volumeButtonActivationDelay) { if let startTime = self.alarmStartTime { - let timeSince = Date().timeIntervalSince(startTime) - LogManager.shared.log(category: .alarm, message: "Alarm has been playing for \(timeSince)s - volume button silencing now active") - - // Start the volume monitoring timer now that the alarm is active - self.startVolumeMonitoringTimer() - - // Mark that we're ready to receive volume button presses self.hasReceivedFirstVolumeAfterAlarm = true } } @@ -347,75 +206,45 @@ class VolumeButtonHandler: NSObject { volumeChangeTimer?.invalidate() volumeChangeTimer = nil - // Stop the volume monitoring timer since no alarm is active - stopVolumeMonitoringTimer() + stopMonitoring() - // Reset improved button press detection recentVolumeChanges.removeAll() lastSignificantVolumeChange = nil volumeChangePattern.removeAll() - - LogManager.shared.log(category: .alarm, message: "Alarm stopped - volume button silencing disabled") } - // MARK: - App State Change Handlers - @objc private func appDidEnterBackground() { let backgroundType = Storage.shared.backgroundRefreshType.value let volumeButtonEnabled = Storage.shared.alarmConfiguration.value.enableVolumeButtonSilence if backgroundType.isBluetooth || backgroundType == .silentTune, volumeButtonEnabled { - let method = backgroundType == .silentTune ? "Silent Tune" : "Bluetooth" - LogManager.shared.log(category: .alarm, message: "App entered background with \(method) refresh and volume button snoozing enabled - maintaining volume monitoring for alarms") - // Keep volume monitoring active for Bluetooth/Silent Tune background refresh when volume button snoozing is enabled - // This allows volume button snoozing to work in the background - } else { - LogManager.shared.log(category: .alarm, message: "App entered background - volume monitoring may be limited or disabled") + // Keep volume monitoring active for background refresh } } @objc private func appWillEnterForeground() { - LogManager.shared.log(category: .alarm, message: "App will enter foreground - ensuring volume monitoring is active") - // Ensure volume monitoring is active when coming to foreground - if isMonitoring, volumeMonitoringTimer == nil { + if let startTime = alarmStartTime, isMonitoring, volumeMonitoringTimer == nil { startVolumeMonitoringTimer() } } - // MARK: - Timer Management - private func startVolumeMonitoringTimer() { - // Only start if not already running - guard volumeMonitoringTimer == nil else { - LogManager.shared.log(category: .alarm, message: "Volume monitoring timer already running") - return - } + guard volumeMonitoringTimer == nil else { return } - // Use a more background-compatible timer approach volumeMonitoringTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in self.checkVolumeChange() } - // Ensure the timer works in background by adding it to the main run loop - // Use .common mode for better background compatibility RunLoop.main.add(volumeMonitoringTimer!, forMode: .common) - - // Also add to default mode as backup RunLoop.main.add(volumeMonitoringTimer!, forMode: .default) - - LogManager.shared.log(category: .alarm, message: "Volume monitoring timer started") } private func stopVolumeMonitoringTimer() { volumeMonitoringTimer?.invalidate() volumeMonitoringTimer = nil - LogManager.shared.log(category: .alarm, message: "Volume monitoring timer stopped") } - // MARK: - Testing - func testSnoozeFunctionality() { - LogManager.shared.log(category: .alarm, message: "Testing snooze functionality manually") silenceActiveAlarm() } } From b8dbd37b00a468e70ed8bf7a63ce81779e55d9f0 Mon Sep 17 00:00:00 2001 From: codebymini Date: Wed, 13 Aug 2025 11:29:36 +0200 Subject: [PATCH 12/19] Fix error message from AVAudioSession --- .../Controllers/VolumeButtonHandler.swift | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/LoopFollow/Controllers/VolumeButtonHandler.swift b/LoopFollow/Controllers/VolumeButtonHandler.swift index c68ad1cf7..96a30f39b 100644 --- a/LoopFollow/Controllers/VolumeButtonHandler.swift +++ b/LoopFollow/Controllers/VolumeButtonHandler.swift @@ -71,9 +71,24 @@ class VolumeButtonHandler: NSObject { func startMonitoring() { guard !isMonitoring else { return } + // Try to get volume without activating audio session first + let audioSession = AVAudioSession.sharedInstance() + let currentVolume = audioSession.outputVolume + + // If we can get volume without activation, use that approach + if currentVolume > 0 { + lastVolume = currentVolume + isMonitoring = true + startVolumeMonitoringTimer() + return + } + + // Only activate audio session if we can't get volume passively do { - try AVAudioSession.sharedInstance().setActive(true) - lastVolume = AVAudioSession.sharedInstance().outputVolume + try audioSession.setCategory(.playback, mode: .default, options: [.mixWithOthers]) + try audioSession.setActive(true, options: .notifyOthersOnDeactivation) + + lastVolume = audioSession.outputVolume isMonitoring = true startVolumeMonitoringTimer() } catch { @@ -89,6 +104,15 @@ class VolumeButtonHandler: NSObject { stopVolumeMonitoringTimer() volumeChangeTimer?.invalidate() volumeChangeTimer = nil + + // Only deactivate audio session if we activated it + if AVAudioSession.sharedInstance().isOtherAudioPlaying == false { + do { + try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) + } catch { + LogManager.shared.log(category: .alarm, message: "Failed to deactivate audio session: \(error)") + } + } } private func checkVolumeChange() { From 6a1830960b07d0883f5ba2fde2df0d6f8cf7cb4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Thu, 14 Aug 2025 11:27:52 +0200 Subject: [PATCH 13/19] Rename to ..Snooze --- LoopFollow/Alarm/AlarmConfiguration.swift | 4 ++-- LoopFollow/Alarm/AlarmSettingsView.swift | 11 ++++------- LoopFollow/Controllers/VolumeButtonHandler.swift | 4 ++-- 3 files changed, 8 insertions(+), 11 deletions(-) diff --git a/LoopFollow/Alarm/AlarmConfiguration.swift b/LoopFollow/Alarm/AlarmConfiguration.swift index 0058d97e1..a3fa72297 100644 --- a/LoopFollow/Alarm/AlarmConfiguration.swift +++ b/LoopFollow/Alarm/AlarmConfiguration.swift @@ -19,7 +19,7 @@ struct AlarmConfiguration: Codable, Equatable { var audioDuringCalls: Bool var ignoreZeroBG: Bool var autoSnoozeCGMStart: Bool - var enableVolumeButtonSilence: Bool + var enableVolumeButtonSnooze: Bool static let `default` = AlarmConfiguration( muteUntil: nil, @@ -30,6 +30,6 @@ struct AlarmConfiguration: Codable, Equatable { audioDuringCalls: true, ignoreZeroBG: true, autoSnoozeCGMStart: false, - enableVolumeButtonSilence: true + enableVolumeButtonSnooze: true ) } diff --git a/LoopFollow/Alarm/AlarmSettingsView.swift b/LoopFollow/Alarm/AlarmSettingsView.swift index 2963d4853..ba940f7ad 100644 --- a/LoopFollow/Alarm/AlarmSettingsView.swift +++ b/LoopFollow/Alarm/AlarmSettingsView.swift @@ -140,10 +140,7 @@ struct AlarmSettingsView: View { .datePickerStyle(.compact) } - Section( - header: Text("Alarm Settings"), - footer: Text("When enabled, pressing the volume buttons will silence active alarms and snooze them.") - ) { + Section(header: Text("Alarm Settings")) { Toggle( "Override System Volume", isOn: Binding( @@ -189,10 +186,10 @@ struct AlarmSettingsView: View { ) Toggle( - "Volume Buttons Silence Alarms", + "Volume Buttons Snooze Alarms", isOn: Binding( - get: { cfgStore.value.enableVolumeButtonSilence }, - set: { cfgStore.value.enableVolumeButtonSilence = $0 } + get: { cfgStore.value.enableVolumeButtonSnooze }, + set: { cfgStore.value.enableVolumeButtonSnooze = $0 } ) ) } diff --git a/LoopFollow/Controllers/VolumeButtonHandler.swift b/LoopFollow/Controllers/VolumeButtonHandler.swift index 96a30f39b..f3addbcda 100644 --- a/LoopFollow/Controllers/VolumeButtonHandler.swift +++ b/LoopFollow/Controllers/VolumeButtonHandler.swift @@ -187,7 +187,7 @@ class VolumeButtonHandler: NSObject { } private func handleVolumeButtonPress() { - guard Storage.shared.alarmConfiguration.value.enableVolumeButtonSilence else { return } + guard Storage.shared.alarmConfiguration.value.enableVolumeButtonSnooze else { return } guard AlarmSound.isPlaying else { return } guard volumeChangeTimer == nil else { return } @@ -239,7 +239,7 @@ class VolumeButtonHandler: NSObject { @objc private func appDidEnterBackground() { let backgroundType = Storage.shared.backgroundRefreshType.value - let volumeButtonEnabled = Storage.shared.alarmConfiguration.value.enableVolumeButtonSilence + let volumeButtonEnabled = Storage.shared.alarmConfiguration.value.enableVolumeButtonSnooze if backgroundType.isBluetooth || backgroundType == .silentTune, volumeButtonEnabled { // Keep volume monitoring active for background refresh From 3fdaf0a28b2fe6f450085a9389a98aeb53c493c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Thu, 14 Aug 2025 19:43:12 +0200 Subject: [PATCH 14/19] Refactor Volume Button Snooze --- LoopFollow/Controllers/AlarmSound.swift | 31 +-- .../Controllers/VolumeButtonHandler.swift | 190 ++++++------------ LoopFollow/Log/LogManager.swift | 1 + 3 files changed, 66 insertions(+), 156 deletions(-) diff --git a/LoopFollow/Controllers/AlarmSound.swift b/LoopFollow/Controllers/AlarmSound.swift index bd5a6fdac..e2575a5de 100644 --- a/LoopFollow/Controllers/AlarmSound.swift +++ b/LoopFollow/Controllers/AlarmSound.swift @@ -7,11 +7,6 @@ import Foundation import MediaPlayer import UIKit -extension Notification.Name { - static let alarmStarted = Notification.Name("alarmStarted") - static let alarmStopped = Notification.Name("alarmStopped") -} - /* * Class that handles the playing and the volume of the alarm sound. */ @@ -83,9 +78,6 @@ class AlarmSound { audioPlayer = nil restoreSystemOutputVolume() - - // Notify that alarm has stopped - NotificationCenter.default.post(name: .alarmStopped, object: nil) } static func playTest() { @@ -127,13 +119,13 @@ class AlarmSound { enableAudio() - // Notify that alarm is starting - NotificationCenter.default.post(name: .alarmStarted, object: nil) - do { audioPlayer = try AVAudioPlayer(contentsOf: soundURL) audioPlayer!.delegate = audioPlayerDelegate + try AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category(rawValue: convertFromAVAudioSessionCategory(AVAudioSession.Category.playback))) + try AVAudioSession.sharedInstance().setActive(true) + audioPlayer!.numberOfLoops = repeating ? -1 : 0 // Store existing volume @@ -155,14 +147,7 @@ class AlarmSound { } if Storage.shared.alarmConfiguration.value.overrideSystemOutputVolume { - let targetVolume = Storage.shared.alarmConfiguration.value.forcedOutputVolume - LogManager.shared.log(category: .alarm, message: "Setting system volume to \(targetVolume) (was \(AVAudioSession.sharedInstance().outputVolume))") - - // Add a small delay to ensure the audio session is fully established - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - MPVolumeView.setVolume(targetVolume) - LogManager.shared.log(category: .alarm, message: "System volume set to \(targetVolume)") - } + MPVolumeView.setVolume(Storage.shared.alarmConfiguration.value.forcedOutputVolume) } } catch { LogManager.shared.log(category: .alarm, message: "AlarmSound - unable to play sound; error: \(error)") @@ -174,12 +159,13 @@ class AlarmSound { return } - enableAudio() - do { audioPlayer = try AVAudioPlayer(contentsOf: soundURL) audioPlayer!.delegate = audioPlayerDelegate + try AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category(rawValue: convertFromAVAudioSessionCategory(AVAudioSession.Category.playback))) + try AVAudioSession.sharedInstance().setActive(true) + // Play endless loops audioPlayer!.numberOfLoops = 2 @@ -226,8 +212,7 @@ class AlarmSound { fileprivate static func enableAudio() { do { - // Use playback category with options that work well in background and with Bluetooth - try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: [.mixWithOthers, .allowBluetooth]) + try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: .mixWithOthers) try AVAudioSession.sharedInstance().setActive(true) } catch { LogManager.shared.log(category: .alarm, message: "Enable audio error: \(error)") diff --git a/LoopFollow/Controllers/VolumeButtonHandler.swift b/LoopFollow/Controllers/VolumeButtonHandler.swift index f3addbcda..3b581de92 100644 --- a/LoopFollow/Controllers/VolumeButtonHandler.swift +++ b/LoopFollow/Controllers/VolumeButtonHandler.swift @@ -3,6 +3,7 @@ // Created by codebymini. import AVFoundation +import Combine import Foundation import UIKit @@ -20,99 +21,32 @@ class VolumeButtonHandler: NSObject { private var lastVolume: Float = 0.0 private var isMonitoring = false private var volumeMonitoringTimer: Timer? - private var volumeChangeTimer: Timer? private var alarmStartTime: Date? - private var hasReceivedFirstVolumeAfterAlarm: Bool = false private var lastVolumeButtonPressTime: Date? - private var consecutiveVolumeChanges: Int = 0 - private var isAlarmSystemChangingVolume: Bool = false // Button press detection private var recentVolumeChanges: [(volume: Float, timestamp: Date)] = [] private var lastSignificantVolumeChange: Date? private var volumeChangePattern: [TimeInterval] = [] + private var cancellables = Set() + private var isAlarmActive = false + override private init() { super.init() - NotificationCenter.default.addObserver( - self, - selector: #selector(alarmStarted), - name: .alarmStarted, - object: nil - ) - - NotificationCenter.default.addObserver( - self, - selector: #selector(alarmStopped), - name: .alarmStopped, - object: nil - ) - - NotificationCenter.default.addObserver( - self, - selector: #selector(appDidEnterBackground), - name: UIApplication.didEnterBackgroundNotification, - object: nil - ) - - NotificationCenter.default.addObserver( - self, - selector: #selector(appWillEnterForeground), - name: UIApplication.willEnterForegroundNotification, - object: nil - ) - } - - deinit { - NotificationCenter.default.removeObserver(self) - } - - func startMonitoring() { - guard !isMonitoring else { return } - - // Try to get volume without activating audio session first - let audioSession = AVAudioSession.sharedInstance() - let currentVolume = audioSession.outputVolume - - // If we can get volume without activation, use that approach - if currentVolume > 0 { - lastVolume = currentVolume - isMonitoring = true - startVolumeMonitoringTimer() - return - } - - // Only activate audio session if we can't get volume passively - do { - try audioSession.setCategory(.playback, mode: .default, options: [.mixWithOthers]) - try audioSession.setActive(true, options: .notifyOthersOnDeactivation) - - lastVolume = audioSession.outputVolume - isMonitoring = true - startVolumeMonitoringTimer() - } catch { - LogManager.shared.log(category: .alarm, message: "Failed to start volume monitoring: \(error)") - isMonitoring = false - } - } - - func stopMonitoring() { - guard isMonitoring else { return } - - isMonitoring = false - stopVolumeMonitoringTimer() - volumeChangeTimer?.invalidate() - volumeChangeTimer = nil - - // Only deactivate audio session if we activated it - if AVAudioSession.sharedInstance().isOtherAudioPlaying == false { - do { - try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) - } catch { - LogManager.shared.log(category: .alarm, message: "Failed to deactivate audio session: \(error)") + Observable.shared.currentAlarm.$value + .sink { [weak self] alarmID in + guard let self = self else { return } + let nowActive = alarmID != nil + if !self.isAlarmActive && nowActive { + self.alarmStarted() + } else if self.isAlarmActive && !nowActive { + self.alarmStopped() + } + self.isAlarmActive = nowActive } - } + .store(in: &cancellables) } private func checkVolumeChange() { @@ -187,88 +121,78 @@ class VolumeButtonHandler: NSObject { } private func handleVolumeButtonPress() { - guard Storage.shared.alarmConfiguration.value.enableVolumeButtonSnooze else { return } - guard AlarmSound.isPlaying else { return } - guard volumeChangeTimer == nil else { return } - - silenceActiveAlarm() + snoozeActiveAlarm() } - private func silenceActiveAlarm() { + private func snoozeActiveAlarm() { + LogManager.shared.log(category: .volumeButtonSnooze, message: "Snoozing alarm") + lastVolumeButtonPressTime = Date() - AlarmSound.stop() AlarmManager.shared.performSnooze() - if #available(iOS 10.0, *) { - let impactFeedback = UIImpactFeedbackGenerator(style: .medium) - impactFeedback.impactOccurred() - } + let impactFeedback = UIImpactFeedbackGenerator(style: .medium) + impactFeedback.impactOccurred() } - @objc private func alarmStarted() { + private func alarmStarted() { + guard Storage.shared.alarmConfiguration.value.enableVolumeButtonSnooze else { return } + + LogManager.shared.log(category: .volumeButtonSnooze, message: "Alarm start detected") alarmStartTime = Date() - hasReceivedFirstVolumeAfterAlarm = false - consecutiveVolumeChanges = 0 recentVolumeChanges.removeAll() lastSignificantVolumeChange = nil volumeChangePattern.removeAll() startMonitoring() - - DispatchQueue.main.asyncAfter(deadline: .now() + volumeButtonActivationDelay) { - if let startTime = self.alarmStartTime { - self.hasReceivedFirstVolumeAfterAlarm = true - } - } } - @objc private func alarmStopped() { - alarmStartTime = nil - hasReceivedFirstVolumeAfterAlarm = false - consecutiveVolumeChanges = 0 - volumeChangeTimer?.invalidate() - volumeChangeTimer = nil - - stopMonitoring() - - recentVolumeChanges.removeAll() - lastSignificantVolumeChange = nil - volumeChangePattern.removeAll() - } - - @objc private func appDidEnterBackground() { - let backgroundType = Storage.shared.backgroundRefreshType.value - let volumeButtonEnabled = Storage.shared.alarmConfiguration.value.enableVolumeButtonSnooze + func startMonitoring() { + guard !isMonitoring else { return } - if backgroundType.isBluetooth || backgroundType == .silentTune, volumeButtonEnabled { - // Keep volume monitoring active for background refresh - } - } + let audioSession = AVAudioSession.sharedInstance() + let currentVolume = audioSession.outputVolume - @objc private func appWillEnterForeground() { - if let startTime = alarmStartTime, isMonitoring, volumeMonitoringTimer == nil { + if currentVolume > 0 { + lastVolume = currentVolume + isMonitoring = true startVolumeMonitoringTimer() + return } + + LogManager.shared.log(category: .volumeButtonSnooze, message: "Did not get a valid volume, not monitoring") } private func startVolumeMonitoringTimer() { guard volumeMonitoringTimer == nil else { return } - volumeMonitoringTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in - self.checkVolumeChange() + let timer = Timer(timeInterval: 0.1, repeats: true) { [weak self] _ in + self?.checkVolumeChange() } - RunLoop.main.add(volumeMonitoringTimer!, forMode: .common) - RunLoop.main.add(volumeMonitoringTimer!, forMode: .default) + volumeMonitoringTimer = timer + + RunLoop.main.add(timer, forMode: .common) } - private func stopVolumeMonitoringTimer() { - volumeMonitoringTimer?.invalidate() - volumeMonitoringTimer = nil + private func alarmStopped() { + LogManager.shared.log(category: .volumeButtonSnooze, message: "Alarm stop detected") + + alarmStartTime = nil + + stopMonitoring() + + recentVolumeChanges.removeAll() + lastSignificantVolumeChange = nil + volumeChangePattern.removeAll() } - func testSnoozeFunctionality() { - silenceActiveAlarm() + func stopMonitoring() { + guard isMonitoring else { return } + + isMonitoring = false + + volumeMonitoringTimer?.invalidate() + volumeMonitoringTimer = nil } } diff --git a/LoopFollow/Log/LogManager.swift b/LoopFollow/Log/LogManager.swift index 7608cd4c8..ecb956b73 100644 --- a/LoopFollow/Log/LogManager.swift +++ b/LoopFollow/Log/LogManager.swift @@ -26,6 +26,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" } From 536baec789b70be9ed818c98cfded704453eeb93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Thu, 14 Aug 2025 20:03:26 +0200 Subject: [PATCH 15/19] Simplification --- LoopFollow/Controllers/VolumeButtonHandler.swift | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/LoopFollow/Controllers/VolumeButtonHandler.swift b/LoopFollow/Controllers/VolumeButtonHandler.swift index 3b581de92..e54588000 100644 --- a/LoopFollow/Controllers/VolumeButtonHandler.swift +++ b/LoopFollow/Controllers/VolumeButtonHandler.swift @@ -79,7 +79,7 @@ class VolumeButtonHandler: NSObject { } if isLikelyVolumeButtonPress(volumeDifference: volumeDifference, timestamp: now) { - handleVolumeButtonPress() + snoozeActiveAlarm() } } } @@ -120,10 +120,6 @@ class VolumeButtonHandler: NSObject { return isReasonableChange && isDiscreteChange && hasConsistentTiming && isNotRapidSequence } - private func handleVolumeButtonPress() { - snoozeActiveAlarm() - } - private func snoozeActiveAlarm() { LogManager.shared.log(category: .volumeButtonSnooze, message: "Snoozing alarm") From 2ec5d1446500b7cea6ac8ac5d403a0b19e8dc840 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Fri, 15 Aug 2025 14:08:00 +0200 Subject: [PATCH 16/19] Refactoring of alarm detection --- LoopFollow/Alarm/AlarmConfiguration.swift | 2 +- LoopFollow/Alarm/AlarmManager.swift | 1 + LoopFollow/Controllers/AlarmSound.swift | 2 ++ LoopFollow/Controllers/VolumeButtonHandler.swift | 12 +++++------- LoopFollow/Storage/Observable.swift | 1 + 5 files changed, 10 insertions(+), 8 deletions(-) diff --git a/LoopFollow/Alarm/AlarmConfiguration.swift b/LoopFollow/Alarm/AlarmConfiguration.swift index a3fa72297..359a3af99 100644 --- a/LoopFollow/Alarm/AlarmConfiguration.swift +++ b/LoopFollow/Alarm/AlarmConfiguration.swift @@ -30,6 +30,6 @@ struct AlarmConfiguration: Codable, Equatable { audioDuringCalls: true, ignoreZeroBG: true, autoSnoozeCGMStart: false, - enableVolumeButtonSnooze: true + enableVolumeButtonSnooze: false ) } diff --git a/LoopFollow/Alarm/AlarmManager.swift b/LoopFollow/Alarm/AlarmManager.swift index 2edc10331..d3d9e0242 100644 --- a/LoopFollow/Alarm/AlarmManager.swift +++ b/LoopFollow/Alarm/AlarmManager.swift @@ -162,6 +162,7 @@ class AlarmManager { alarms[idx].snoozedUntil = Date().addingTimeInterval(snoozeSeconds) Storage.shared.alarms.value = alarms } + Observable.shared.alarmSoundPlaying.value = false stopAlarm() } } diff --git a/LoopFollow/Controllers/AlarmSound.swift b/LoopFollow/Controllers/AlarmSound.swift index e2575a5de..b81e87c10 100644 --- a/LoopFollow/Controllers/AlarmSound.swift +++ b/LoopFollow/Controllers/AlarmSound.swift @@ -141,6 +141,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") diff --git a/LoopFollow/Controllers/VolumeButtonHandler.swift b/LoopFollow/Controllers/VolumeButtonHandler.swift index e54588000..c4d9b3719 100644 --- a/LoopFollow/Controllers/VolumeButtonHandler.swift +++ b/LoopFollow/Controllers/VolumeButtonHandler.swift @@ -30,21 +30,19 @@ class VolumeButtonHandler: NSObject { private var volumeChangePattern: [TimeInterval] = [] private var cancellables = Set() - private var isAlarmActive = false override private init() { super.init() - Observable.shared.currentAlarm.$value - .sink { [weak self] alarmID in + Observable.shared.alarmSoundPlaying.$value + .removeDuplicates() + .sink { [weak self] alarmSoundPlaying in guard let self = self else { return } - let nowActive = alarmID != nil - if !self.isAlarmActive && nowActive { + if alarmSoundPlaying { self.alarmStarted() - } else if self.isAlarmActive && !nowActive { + } else { self.alarmStopped() } - self.isAlarmActive = nowActive } .store(in: &cancellables) } diff --git a/LoopFollow/Storage/Observable.swift b/LoopFollow/Storage/Observable.swift index fe6d39a03..1b0d7a9f7 100644 --- a/LoopFollow/Storage/Observable.swift +++ b/LoopFollow/Storage/Observable.swift @@ -25,6 +25,7 @@ class Observable { var deltaText = ObservableValue(default: "+0") var currentAlarm = ObservableValue(default: nil) + var alarmSoundPlaying = ObservableValue(default: false) var debug = ObservableValue(default: false) From 0427ca026b4d151cd2bfa9f220bd7de70ff9cf1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Fri, 15 Aug 2025 19:43:17 +0200 Subject: [PATCH 17/19] Stop monitoring early on --- LoopFollow/Controllers/AlarmSound.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/LoopFollow/Controllers/AlarmSound.swift b/LoopFollow/Controllers/AlarmSound.swift index b81e87c10..c51993333 100644 --- a/LoopFollow/Controllers/AlarmSound.swift +++ b/LoopFollow/Controllers/AlarmSound.swift @@ -25,8 +25,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() @@ -71,8 +69,7 @@ class AlarmSound { } static func stop() { - playingTimer?.invalidate() - playingTimer = nil + Observable.shared.alarmSoundPlaying.value = false audioPlayer?.stop() audioPlayer = nil @@ -226,6 +223,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. */ @@ -242,12 +240,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 } } From fa4d3b9fad13643f0fb5d39ebef55134e20e94a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Mon, 18 Aug 2025 09:23:08 +0200 Subject: [PATCH 18/19] Add retry for current volume --- LoopFollow/Controllers/VolumeButtonHandler.swift | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/LoopFollow/Controllers/VolumeButtonHandler.swift b/LoopFollow/Controllers/VolumeButtonHandler.swift index 3ed04b6ef..ce835ef3a 100644 --- a/LoopFollow/Controllers/VolumeButtonHandler.swift +++ b/LoopFollow/Controllers/VolumeButtonHandler.swift @@ -140,20 +140,32 @@ class VolumeButtonHandler: NSObject { startMonitoring() } - func startMonitoring() { + func startMonitoring(retryCount: Int = 0) { guard !isMonitoring else { return } let audioSession = AVAudioSession.sharedInstance() let currentVolume = audioSession.outputVolume if currentVolume > 0 { + LogManager.shared.log(category: .volumeButtonSnooze, message: "Successfully started volume monitoring.") lastVolume = currentVolume isMonitoring = true startVolumeMonitoringTimer() return } - LogManager.shared.log(category: .volumeButtonSnooze, message: "Did not get a valid volume, not monitoring") + // Failure case: Volume is still 0. Let's retry if we can. + let maxRetries = 5 + if retryCount < maxRetries { + LogManager.shared.log(category: .volumeButtonSnooze, message: "Did not get a valid volume, retrying... (Attempt \(retryCount + 1)/\(maxRetries))") + let delay = 0.2 // 200ms delay between retries + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in + self?.startMonitoring(retryCount: retryCount + 1) + } + } else { + // We've exhausted all retries, log the final failure. + LogManager.shared.log(category: .volumeButtonSnooze, message: "Did not get a valid volume after \(maxRetries) retries, not monitoring.") + } } private func startVolumeMonitoringTimer() { From 958907f1d6d96da866dcc01513b5dbb8e979e221 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Mon, 18 Aug 2025 11:48:14 +0200 Subject: [PATCH 19/19] Alternative version of volume change detection --- .../Controllers/VolumeButtonHandler.swift | 147 ++++++++---------- 1 file changed, 63 insertions(+), 84 deletions(-) diff --git a/LoopFollow/Controllers/VolumeButtonHandler.swift b/LoopFollow/Controllers/VolumeButtonHandler.swift index ce835ef3a..7f4d16de6 100644 --- a/LoopFollow/Controllers/VolumeButtonHandler.swift +++ b/LoopFollow/Controllers/VolumeButtonHandler.swift @@ -17,9 +17,11 @@ class VolumeButtonHandler: NSObject { 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 volumeMonitoringTimer: Timer? private var alarmStartTime: Date? private var lastVolumeButtonPressTime: Date? @@ -46,45 +48,6 @@ class VolumeButtonHandler: NSObject { .store(in: &cancellables) } - private func checkVolumeChange() { - let currentVolume = AVAudioSession.sharedInstance().outputVolume - let volumeDifference = abs(currentVolume - lastVolume) - let now = Date() - - if volumeDifference > volumeButtonPressThreshold { - if let startTime = alarmStartTime { - let timeSinceAlarmStart = now.timeIntervalSince(startTime) - - // Ignore volume changes from alarm system - if timeSinceAlarmStart < 2.0, currentVolume > lastVolume { - if volumeDifference <= 0.15, timeSinceAlarmStart < 1.5 { - lastVolume = currentVolume - return - } - } - } - - recordVolumeChange(currentVolume: currentVolume, timestamp: now) - - if lastVolume > 0, let startTime = alarmStartTime { - let timeSinceAlarmStart = now.timeIntervalSince(startTime) - - if timeSinceAlarmStart > volumeButtonActivationDelay { - if let lastPress = lastVolumeButtonPressTime { - let timeSinceLastPress = now.timeIntervalSince(lastPress) - if timeSinceLastPress < volumeButtonCooldown { return } - } - - if isLikelyVolumeButtonPress(volumeDifference: volumeDifference, timestamp: now) { - snoozeActiveAlarm() - } - } - } - } - - lastVolume = currentVolume - } - private func recordVolumeChange(currentVolume: Float, timestamp: Date) { recentVolumeChanges.append((volume: currentVolume, timestamp: timestamp)) @@ -99,7 +62,6 @@ class VolumeButtonHandler: NSObject { volumeChangePattern.removeFirst() } } - lastSignificantVolumeChange = timestamp } @@ -129,10 +91,9 @@ class VolumeButtonHandler: NSObject { private func alarmStarted() { guard Storage.shared.alarmConfiguration.value.enableVolumeButtonSnooze else { return } + LogManager.shared.log(category: .volumeButtonSnooze, message: "Alarm start detected, setting up volume observer.") - LogManager.shared.log(category: .volumeButtonSnooze, message: "Alarm start detected") alarmStartTime = Date() - recentVolumeChanges.removeAll() lastSignificantVolumeChange = nil volumeChangePattern.removeAll() @@ -140,64 +101,82 @@ class VolumeButtonHandler: NSObject { startMonitoring() } - func startMonitoring(retryCount: Int = 0) { + 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 } - let audioSession = AVAudioSession.sharedInstance() - let currentVolume = audioSession.outputVolume + isMonitoring = true - if currentVolume > 0 { - LogManager.shared.log(category: .volumeButtonSnooze, message: "Successfully started volume monitoring.") - lastVolume = currentVolume - isMonitoring = true - startVolumeMonitoringTimer() - return - } + volumeObserver = AVAudioSession.sharedInstance().observe(\.outputVolume, options: [.new]) { [weak self] session, _ in + guard let self = self, let alarmStartTime = self.alarmStartTime else { return } - // Failure case: Volume is still 0. Let's retry if we can. - let maxRetries = 5 - if retryCount < maxRetries { - LogManager.shared.log(category: .volumeButtonSnooze, message: "Did not get a valid volume, retrying... (Attempt \(retryCount + 1)/\(maxRetries))") - let delay = 0.2 // 200ms delay between retries - DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in - self?.startMonitoring(retryCount: retryCount + 1) + 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 } - } else { - // We've exhausted all retries, log the final failure. - LogManager.shared.log(category: .volumeButtonSnooze, message: "Did not get a valid volume after \(maxRetries) retries, not monitoring.") - } - } - private func startVolumeMonitoringTimer() { - guard volumeMonitoringTimer == nil else { return } + guard self.lastVolume > 0.0 else { return } - let timer = Timer(timeInterval: 0.1, repeats: true) { [weak self] _ in - self?.checkVolumeChange() - } + let volumeDifference = abs(currentVolume - self.lastVolume) - volumeMonitoringTimer = timer + if volumeDifference > self.volumeButtonPressThreshold { + let timeSinceAlarmStart = now.timeIntervalSince(alarmStartTime) - RunLoop.main.add(timer, forMode: .common) - } - - private func alarmStopped() { - LogManager.shared.log(category: .volumeButtonSnooze, message: "Alarm stop detected") + // 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 + } + } - alarmStartTime = nil + self.recordVolumeChange(currentVolume: currentVolume, timestamp: now) - stopMonitoring() + if timeSinceAlarmStart > self.volumeButtonActivationDelay { + if let lastPress = self.lastVolumeButtonPressTime { + let timeSinceLastPress = now.timeIntervalSince(lastPress) + if timeSinceLastPress < self.volumeButtonCooldown { + self.lastVolume = currentVolume + return + } + } - recentVolumeChanges.removeAll() - lastSignificantVolumeChange = nil - volumeChangePattern.removeAll() + if self.isLikelyVolumeButtonPress(volumeDifference: volumeDifference, timestamp: now) { + self.snoozeActiveAlarm() + } + } + } + self.lastVolume = currentVolume + } } func stopMonitoring() { guard isMonitoring else { return } - isMonitoring = false + LogManager.shared.log(category: .volumeButtonSnooze, message: "Invalidating volume observer.") + + // Invalidate the observer to stop receiving notifications and prevent memory leaks. + volumeObserver?.invalidate() + volumeObserver = nil - volumeMonitoringTimer?.invalidate() - volumeMonitoringTimer = nil + isMonitoring = false + lastVolume = 0.0 // Reset for the next alarm. } }