Przeglądaj źródła

Add option to silence alarm with volume button (#447)

Daniel Mini Johansson 10 miesięcy temu
rodzic
commit
18a6ed1061

+ 4 - 0
LoopFollow.xcodeproj/project.pbxproj

@@ -14,6 +14,7 @@
 		6541341A2E1DC27900BDBE08 /* OverridePresetData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654134192E1DC27900BDBE08 /* OverridePresetData.swift */; };
 		6541341C2E1DC28000BDBE08 /* DateExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6541341B2E1DC28000BDBE08 /* DateExtensions.swift */; };
 		6584B1012E4A263900135D4D /* TOTPService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6584B1002E4A263900135D4D /* TOTPService.swift */; };
+		65E8A2862E44B0300065037B /* VolumeButtonHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E8A2852E44B0300065037B /* VolumeButtonHandler.swift */; };
 		DD0247592DB2E89600FCADF6 /* AlarmCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0247582DB2E89600FCADF6 /* AlarmCondition.swift */; };
 		DD0247712DB4337700FCADF6 /* BuildExpireCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD02475B2DB2E8FB00FCADF6 /* BuildExpireCondition.swift */; };
 		DD0650A92DCA8A10004D3B41 /* AlarmBGSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0650A82DCA8A10004D3B41 /* AlarmBGSection.swift */; };
@@ -399,6 +400,7 @@
 		654134192E1DC27900BDBE08 /* OverridePresetData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverridePresetData.swift; sourceTree = "<group>"; };
 		6541341B2E1DC28000BDBE08 /* DateExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateExtensions.swift; sourceTree = "<group>"; };
 		6584B1002E4A263900135D4D /* TOTPService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TOTPService.swift; sourceTree = "<group>"; };
+		65E8A2852E44B0300065037B /* VolumeButtonHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VolumeButtonHandler.swift; sourceTree = "<group>"; };
 		A7D55B42A22051DAD69E89D0 /* Pods_LoopFollow.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_LoopFollow.framework; sourceTree = BUILT_PRODUCTS_DIR; };
 		DD0247582DB2E89600FCADF6 /* AlarmCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmCondition.swift; sourceTree = "<group>"; };
 		DD02475B2DB2E8FB00FCADF6 /* BuildExpireCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuildExpireCondition.swift; sourceTree = "<group>"; };
@@ -1280,6 +1282,7 @@
 				FC1BDD2C24A23204001B652C /* MainViewController+updateStats.swift */,
 				FCA2DDE52501095000254A8C /* Timers.swift */,
 				DD608A0B2C27415C00F91132 /* BackgroundAlertManager.swift */,
+				65E8A2852E44B0300065037B /* VolumeButtonHandler.swift */,
 			);
 			path = Controllers;
 			sourceTree = "<group>";
@@ -1950,6 +1953,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 */,

+ 3 - 1
LoopFollow/Alarm/AlarmConfiguration.swift

@@ -18,6 +18,7 @@ struct AlarmConfiguration: Codable, Equatable {
     var audioDuringCalls: Bool
     var ignoreZeroBG: Bool
     var autoSnoozeCGMStart: Bool
+    var enableVolumeButtonSnooze: Bool
 
     static let `default` = AlarmConfiguration(
         muteUntil: nil,
@@ -27,6 +28,7 @@ struct AlarmConfiguration: Codable, Equatable {
         forcedOutputVolume: 0.5,
         audioDuringCalls: true,
         ignoreZeroBG: true,
-        autoSnoozeCGMStart: false
+        autoSnoozeCGMStart: false,
+        enableVolumeButtonSnooze: false
     )
 }

+ 1 - 0
LoopFollow/Alarm/AlarmManager.swift

@@ -163,6 +163,7 @@ class AlarmManager {
                 alarms[idx].snoozedUntil = Date().addingTimeInterval(snoozeSeconds)
                 Storage.shared.alarms.value = alarms
             }
+            Observable.shared.alarmSoundPlaying.value = false
             stopAlarm()
         }
     }

+ 9 - 1
LoopFollow/Alarm/AlarmSettingsView.swift

@@ -177,12 +177,20 @@ struct AlarmSettingsView: View {
                     )
 
                     Toggle(
-                        "Auto‑Snooze CGM Start",
+                        "Auto‑Snooze CGM Start",
                         isOn: Binding(
                             get: { cfgStore.value.autoSnoozeCGMStart },
                             set: { cfgStore.value.autoSnoozeCGMStart = $0 }
                         )
                     )
+
+                    Toggle(
+                        "Volume Buttons Snooze Alarms",
+                        isOn: Binding(
+                            get: { cfgStore.value.enableVolumeButtonSnooze },
+                            set: { cfgStore.value.enableVolumeButtonSnooze = $0 }
+                        )
+                    )
                 }
             }
         }

+ 3 - 0
LoopFollow/Application/AppDelegate.swift

@@ -39,6 +39,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
 
         _ = BLEManager.shared
 
+        // Ensure VolumeButtonHandler is initialized so it can receive alarm notifications
+        _ = VolumeButtonHandler.shared
+
         return true
     }
 

+ 6 - 4
LoopFollow/Controllers/AlarmSound.swift

@@ -24,8 +24,6 @@ class AlarmSound {
 
     fileprivate static var systemOutputVolumeBeforeOverride: Float?
 
-    fileprivate static var playingTimer: Timer?
-
     fileprivate static var soundURL = Bundle.main.url(forResource: "Indeed", withExtension: "caf")!
     fileprivate static var audioPlayer: AVAudioPlayer?
     fileprivate static let audioPlayerDelegate = AudioPlayerDelegate()
@@ -70,8 +68,7 @@ class AlarmSound {
     }
 
     static func stop() {
-        playingTimer?.invalidate()
-        playingTimer = nil
+        Observable.shared.alarmSoundPlaying.value = false
 
         audioPlayer?.stop()
         audioPlayer = nil
@@ -140,6 +137,8 @@ class AlarmSound {
                 if !isPlaying {
                     LogManager.shared.log(category: .alarm, message: "AlarmSound - not playing after calling play")
                     LogManager.shared.log(category: .alarm, message: "AlarmSound - rate value: \(audioPlayer!.rate)")
+                } else {
+                    Observable.shared.alarmSoundPlaying.value = true
                 }
             } else {
                 LogManager.shared.log(category: .alarm, message: "AlarmSound - audio player failed to play")
@@ -223,6 +222,7 @@ class AudioPlayerDelegate: NSObject, AVAudioPlayerDelegate {
     /* audioPlayerDidFinishPlaying:successfully: is called when a sound has finished playing. This method is NOT called if the player is stopped due to an interruption. */
     func audioPlayerDidFinishPlaying(_: AVAudioPlayer, successfully flag: Bool) {
         LogManager.shared.log(category: .alarm, message: "AlarmRule - audioPlayerDidFinishPlaying (\(flag))", isDebug: true)
+        Observable.shared.alarmSoundPlaying.value = false
     }
 
     /* if an error occurs while decoding it will be reported to the delegate. */
@@ -239,12 +239,14 @@ class AudioPlayerDelegate: NSObject, AVAudioPlayerDelegate {
     /* audioPlayerBeginInterruption: is called when the audio session has been interrupted while the player was playing. The player will have been paused. */
     func audioPlayerBeginInterruption(_: AVAudioPlayer) {
         LogManager.shared.log(category: .alarm, message: "AlarmRule - audioPlayerBeginInterruption")
+        Observable.shared.alarmSoundPlaying.value = false
     }
 
     /* audioPlayerEndInterruption:withOptions: is called when the audio session interruption has ended and this player had been interrupted while playing. */
     /* Currently the only flag is AVAudioSessionInterruptionFlags_ShouldResume. */
     func audioPlayerEndInterruption(_: AVAudioPlayer, withOptions flags: Int) {
         LogManager.shared.log(category: .alarm, message: "AlarmRule - audioPlayerEndInterruption withOptions: \(flags)")
+        Observable.shared.alarmSoundPlaying.value = false
     }
 }
 

+ 182 - 0
LoopFollow/Controllers/VolumeButtonHandler.swift

@@ -0,0 +1,182 @@
+// LoopFollow
+// VolumeButtonHandler.swift
+
+import AVFoundation
+import Combine
+import Foundation
+import UIKit
+
+class VolumeButtonHandler: NSObject {
+    static let shared = VolumeButtonHandler()
+
+    // Volume button snoozer activation delay in seconds
+    private let volumeButtonActivationDelay: TimeInterval = 0.9
+
+    // Volume button detection parameters
+    private let volumeButtonPressThreshold: Float = 0.02
+    private let volumeButtonPressTimeWindow: TimeInterval = 0.3
+    private let volumeButtonCooldown: TimeInterval = 0.5
+
+    // KVO observer for system volume
+    private var volumeObserver: NSKeyValueObservation?
+
+    private var lastVolume: Float = 0.0
+    private var isMonitoring = false
+    private var alarmStartTime: Date?
+    private var lastVolumeButtonPressTime: Date?
+
+    // Button press detection
+    private var recentVolumeChanges: [(volume: Float, timestamp: Date)] = []
+    private var lastSignificantVolumeChange: Date?
+    private var volumeChangePattern: [TimeInterval] = []
+
+    private var cancellables = Set<AnyCancellable>()
+
+    override private init() {
+        super.init()
+
+        Observable.shared.alarmSoundPlaying.$value
+            .removeDuplicates()
+            .sink { [weak self] alarmSoundPlaying in
+                guard let self = self else { return }
+                if alarmSoundPlaying {
+                    self.alarmStarted()
+                } else {
+                    self.alarmStopped()
+                }
+            }
+            .store(in: &cancellables)
+    }
+
+    private func recordVolumeChange(currentVolume: Float, timestamp: Date) {
+        recentVolumeChanges.append((volume: currentVolume, timestamp: timestamp))
+
+        let cutoffTime = timestamp.timeIntervalSinceReferenceDate - volumeButtonPressTimeWindow
+        recentVolumeChanges = recentVolumeChanges.filter { $0.timestamp.timeIntervalSinceReferenceDate > cutoffTime }
+
+        if let lastChange = lastSignificantVolumeChange {
+            let timeSinceLastChange = timestamp.timeIntervalSince(lastChange)
+            volumeChangePattern.append(timeSinceLastChange)
+
+            if volumeChangePattern.count > 5 {
+                volumeChangePattern.removeFirst()
+            }
+        }
+        lastSignificantVolumeChange = timestamp
+    }
+
+    private func isLikelyVolumeButtonPress(volumeDifference: Float, timestamp: Date) -> Bool {
+        let isReasonableChange = volumeDifference >= 0.03 && volumeDifference <= 0.12
+        let isDiscreteChange = recentVolumeChanges.count <= 2
+        let hasConsistentTiming = volumeChangePattern.isEmpty || volumeChangePattern.last! >= 0.15
+        let isNotRapidSequence = recentVolumeChanges.count < 3 ||
+            (recentVolumeChanges.count >= 3 &&
+                recentVolumeChanges.suffix(3).map { $0.timestamp.timeIntervalSinceReferenceDate }.enumerated().dropFirst().allSatisfy { index, timestamp in
+                    let previousTimestamp = recentVolumeChanges.suffix(3).map { $0.timestamp.timeIntervalSinceReferenceDate }[index - 1]
+                    return timestamp - previousTimestamp > 0.08
+                })
+
+        return isReasonableChange && isDiscreteChange && hasConsistentTiming && isNotRapidSequence
+    }
+
+    private func snoozeActiveAlarm() {
+        LogManager.shared.log(category: .volumeButtonSnooze, message: "Snoozing alarm")
+
+        lastVolumeButtonPressTime = Date()
+        AlarmManager.shared.performSnooze()
+
+        let impactFeedback = UIImpactFeedbackGenerator(style: .medium)
+        impactFeedback.impactOccurred()
+    }
+
+    private func alarmStarted() {
+        guard Storage.shared.alarmConfiguration.value.enableVolumeButtonSnooze else { return }
+        LogManager.shared.log(category: .volumeButtonSnooze, message: "Alarm start detected, setting up volume observer.")
+
+        alarmStartTime = Date()
+        recentVolumeChanges.removeAll()
+        lastSignificantVolumeChange = nil
+        volumeChangePattern.removeAll()
+
+        startMonitoring()
+    }
+
+    private func alarmStopped() {
+        LogManager.shared.log(category: .volumeButtonSnooze, message: "Alarm stop detected")
+
+        alarmStartTime = nil
+        stopMonitoring()
+
+        recentVolumeChanges.removeAll()
+        lastSignificantVolumeChange = nil
+        volumeChangePattern.removeAll()
+    }
+
+    func startMonitoring() {
+        guard !isMonitoring else { return }
+
+        isMonitoring = true
+
+        volumeObserver = AVAudioSession.sharedInstance().observe(\.outputVolume, options: [.new]) { [weak self] session, _ in
+            guard let self = self, let alarmStartTime = self.alarmStartTime else { return }
+
+            let currentVolume = session.outputVolume
+            let now = Date()
+
+            // On the first observation, capture the initial volume when the audio session
+            // becomes active. This solves the race condition. We then return to avoid
+            // treating this initial setup as a user-initiated button press.
+            if self.lastVolume == 0.0, currentVolume > 0.0 {
+                LogManager.shared.log(category: .volumeButtonSnooze, message: "Observer received initial valid volume: \(currentVolume)")
+                self.lastVolume = currentVolume
+                return
+            }
+
+            guard self.lastVolume > 0.0 else { return }
+
+            let volumeDifference = abs(currentVolume - self.lastVolume)
+
+            if volumeDifference > self.volumeButtonPressThreshold {
+                let timeSinceAlarmStart = now.timeIntervalSince(alarmStartTime)
+
+                // Ignore volume changes from the alarm system's own ramp-up.
+                if timeSinceAlarmStart < 2.0, currentVolume > self.lastVolume {
+                    if volumeDifference <= 0.15, timeSinceAlarmStart < 1.5 {
+                        self.lastVolume = currentVolume
+                        return
+                    }
+                }
+
+                self.recordVolumeChange(currentVolume: currentVolume, timestamp: now)
+
+                if timeSinceAlarmStart > self.volumeButtonActivationDelay {
+                    if let lastPress = self.lastVolumeButtonPressTime {
+                        let timeSinceLastPress = now.timeIntervalSince(lastPress)
+                        if timeSinceLastPress < self.volumeButtonCooldown {
+                            self.lastVolume = currentVolume
+                            return
+                        }
+                    }
+
+                    if self.isLikelyVolumeButtonPress(volumeDifference: volumeDifference, timestamp: now) {
+                        self.snoozeActiveAlarm()
+                    }
+                }
+            }
+            self.lastVolume = currentVolume
+        }
+    }
+
+    func stopMonitoring() {
+        guard isMonitoring else { return }
+
+        LogManager.shared.log(category: .volumeButtonSnooze, message: "Invalidating volume observer.")
+
+        // Invalidate the observer to stop receiving notifications and prevent memory leaks.
+        volumeObserver?.invalidate()
+        volumeObserver = nil
+
+        isMonitoring = false
+        lastVolume = 0.0 // Reset for the next alarm.
+    }
+}

+ 1 - 0
LoopFollow/Log/LogManager.swift

@@ -25,6 +25,7 @@ class LogManager {
         case taskScheduler = "Task Scheduler"
         case dexcom = "Dexcom"
         case alarm = "Alarm"
+        case volumeButtonSnooze = "Volume Button Snooze"
         case calendar = "Calendar"
         case deviceStatus = "Device Status"
     }

+ 1 - 0
LoopFollow/Storage/Observable.swift

@@ -24,6 +24,7 @@ class Observable {
     var deltaText = ObservableValue<String>(default: "+0")
 
     var currentAlarm = ObservableValue<UUID?>(default: nil)
+    var alarmSoundPlaying = ObservableValue<Bool>(default: false)
 
     var debug = ObservableValue<Bool>(default: false)