Skip to content

Commit 191c86f

Browse files
committed
Merge PR loopandlearn#447: Add option to silence alarm with volume button
2 parents ea69e65 + 958907f commit 191c86f

File tree

9 files changed

+210
-6
lines changed

9 files changed

+210
-6
lines changed

LoopFollow.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
6541341A2E1DC27900BDBE08 /* OverridePresetData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654134192E1DC27900BDBE08 /* OverridePresetData.swift */; };
1515
6541341C2E1DC28000BDBE08 /* DateExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6541341B2E1DC28000BDBE08 /* DateExtensions.swift */; };
1616
6584B1012E4A263900135D4D /* TOTPService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6584B1002E4A263900135D4D /* TOTPService.swift */; };
17+
65E8A2862E44B0300065037B /* VolumeButtonHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E8A2852E44B0300065037B /* VolumeButtonHandler.swift */; };
1718
DD0247592DB2E89600FCADF6 /* AlarmCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0247582DB2E89600FCADF6 /* AlarmCondition.swift */; };
1819
DD0247712DB4337700FCADF6 /* BuildExpireCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD02475B2DB2E8FB00FCADF6 /* BuildExpireCondition.swift */; };
1920
DD0650A92DCA8A10004D3B41 /* AlarmBGSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0650A82DCA8A10004D3B41 /* AlarmBGSection.swift */; };
@@ -401,6 +402,7 @@
401402
654134192E1DC27900BDBE08 /* OverridePresetData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverridePresetData.swift; sourceTree = "<group>"; };
402403
6541341B2E1DC28000BDBE08 /* DateExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateExtensions.swift; sourceTree = "<group>"; };
403404
6584B1002E4A263900135D4D /* TOTPService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TOTPService.swift; sourceTree = "<group>"; };
405+
65E8A2852E44B0300065037B /* VolumeButtonHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VolumeButtonHandler.swift; sourceTree = "<group>"; };
404406
A7D55B42A22051DAD69E89D0 /* Pods_LoopFollow.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_LoopFollow.framework; sourceTree = BUILT_PRODUCTS_DIR; };
405407
DD0247582DB2E89600FCADF6 /* AlarmCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmCondition.swift; sourceTree = "<group>"; };
406408
DD02475B2DB2E8FB00FCADF6 /* BuildExpireCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuildExpireCondition.swift; sourceTree = "<group>"; };
@@ -1285,6 +1287,7 @@
12851287
FC1BDD2C24A23204001B652C /* MainViewController+updateStats.swift */,
12861288
FCA2DDE52501095000254A8C /* Timers.swift */,
12871289
DD608A0B2C27415C00F91132 /* BackgroundAlertManager.swift */,
1290+
65E8A2852E44B0300065037B /* VolumeButtonHandler.swift */,
12881291
);
12891292
path = Controllers;
12901293
sourceTree = "<group>";
@@ -1957,6 +1960,7 @@
19571960
DDC7E5162DBCFA7F00EB1127 /* SnoozerView.swift in Sources */,
19581961
FCFEECA2248857A600402A7F /* SettingsViewController.swift in Sources */,
19591962
DD7F4C232DD7A62200D449E9 /* AlarmType+SortDirection.swift in Sources */,
1963+
65E8A2862E44B0300065037B /* VolumeButtonHandler.swift in Sources */,
19601964
DD0650F72DCFDA26004D3B41 /* InfoBanner.swift in Sources */,
19611965
DDE75D292DE5E56C007C1FC1 /* LinkRow.swift in Sources */,
19621966
DD4AFB612DB68BBC00BB593F /* AlarmListView.swift in Sources */,

LoopFollow/Alarm/AlarmConfiguration.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ struct AlarmConfiguration: Codable, Equatable {
1818
var audioDuringCalls: Bool
1919
var ignoreZeroBG: Bool
2020
var autoSnoozeCGMStart: Bool
21+
var enableVolumeButtonSnooze: Bool
2122

2223
static let `default` = AlarmConfiguration(
2324
muteUntil: nil,
@@ -27,6 +28,7 @@ struct AlarmConfiguration: Codable, Equatable {
2728
forcedOutputVolume: 0.5,
2829
audioDuringCalls: true,
2930
ignoreZeroBG: true,
30-
autoSnoozeCGMStart: false
31+
autoSnoozeCGMStart: false,
32+
enableVolumeButtonSnooze: false
3133
)
3234
}

LoopFollow/Alarm/AlarmManager.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ class AlarmManager {
163163
alarms[idx].snoozedUntil = Date().addingTimeInterval(snoozeSeconds)
164164
Storage.shared.alarms.value = alarms
165165
}
166+
Observable.shared.alarmSoundPlaying.value = false
166167
stopAlarm()
167168
}
168169
}

LoopFollow/Alarm/AlarmSettingsView.swift

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,12 +177,20 @@ struct AlarmSettingsView: View {
177177
)
178178

179179
Toggle(
180-
"Auto‑Snooze CGM Start",
180+
"Auto‑Snooze CGM Start",
181181
isOn: Binding(
182182
get: { cfgStore.value.autoSnoozeCGMStart },
183183
set: { cfgStore.value.autoSnoozeCGMStart = $0 }
184184
)
185185
)
186+
187+
Toggle(
188+
"Volume Buttons Snooze Alarms",
189+
isOn: Binding(
190+
get: { cfgStore.value.enableVolumeButtonSnooze },
191+
set: { cfgStore.value.enableVolumeButtonSnooze = $0 }
192+
)
193+
)
186194
}
187195
}
188196
}

LoopFollow/Application/AppDelegate.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
4444
UIApplication.shared.registerForRemoteNotifications()
4545
}
4646

47+
// Ensure VolumeButtonHandler is initialized so it can receive alarm notifications
48+
_ = VolumeButtonHandler.shared
49+
4750
return true
4851
}
4952

LoopFollow/Controllers/AlarmSound.swift

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,6 @@ class AlarmSound {
2424

2525
fileprivate static var systemOutputVolumeBeforeOverride: Float?
2626

27-
fileprivate static var playingTimer: Timer?
28-
2927
fileprivate static var soundURL = Bundle.main.url(forResource: "Indeed", withExtension: "caf")!
3028
fileprivate static var audioPlayer: AVAudioPlayer?
3129
fileprivate static let audioPlayerDelegate = AudioPlayerDelegate()
@@ -70,8 +68,7 @@ class AlarmSound {
7068
}
7169

7270
static func stop() {
73-
playingTimer?.invalidate()
74-
playingTimer = nil
71+
Observable.shared.alarmSoundPlaying.value = false
7572

7673
audioPlayer?.stop()
7774
audioPlayer = nil
@@ -140,6 +137,8 @@ class AlarmSound {
140137
if !isPlaying {
141138
LogManager.shared.log(category: .alarm, message: "AlarmSound - not playing after calling play")
142139
LogManager.shared.log(category: .alarm, message: "AlarmSound - rate value: \(audioPlayer!.rate)")
140+
} else {
141+
Observable.shared.alarmSoundPlaying.value = true
143142
}
144143
} else {
145144
LogManager.shared.log(category: .alarm, message: "AlarmSound - audio player failed to play")
@@ -223,6 +222,7 @@ class AudioPlayerDelegate: NSObject, AVAudioPlayerDelegate {
223222
/* audioPlayerDidFinishPlaying:successfully: is called when a sound has finished playing. This method is NOT called if the player is stopped due to an interruption. */
224223
func audioPlayerDidFinishPlaying(_: AVAudioPlayer, successfully flag: Bool) {
225224
LogManager.shared.log(category: .alarm, message: "AlarmRule - audioPlayerDidFinishPlaying (\(flag))", isDebug: true)
225+
Observable.shared.alarmSoundPlaying.value = false
226226
}
227227

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

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

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
// LoopFollow
2+
// VolumeButtonHandler.swift
3+
4+
import AVFoundation
5+
import Combine
6+
import Foundation
7+
import UIKit
8+
9+
class VolumeButtonHandler: NSObject {
10+
static let shared = VolumeButtonHandler()
11+
12+
// Volume button snoozer activation delay in seconds
13+
private let volumeButtonActivationDelay: TimeInterval = 0.9
14+
15+
// Volume button detection parameters
16+
private let volumeButtonPressThreshold: Float = 0.02
17+
private let volumeButtonPressTimeWindow: TimeInterval = 0.3
18+
private let volumeButtonCooldown: TimeInterval = 0.5
19+
20+
// KVO observer for system volume
21+
private var volumeObserver: NSKeyValueObservation?
22+
23+
private var lastVolume: Float = 0.0
24+
private var isMonitoring = false
25+
private var alarmStartTime: Date?
26+
private var lastVolumeButtonPressTime: Date?
27+
28+
// Button press detection
29+
private var recentVolumeChanges: [(volume: Float, timestamp: Date)] = []
30+
private var lastSignificantVolumeChange: Date?
31+
private var volumeChangePattern: [TimeInterval] = []
32+
33+
private var cancellables = Set<AnyCancellable>()
34+
35+
override private init() {
36+
super.init()
37+
38+
Observable.shared.alarmSoundPlaying.$value
39+
.removeDuplicates()
40+
.sink { [weak self] alarmSoundPlaying in
41+
guard let self = self else { return }
42+
if alarmSoundPlaying {
43+
self.alarmStarted()
44+
} else {
45+
self.alarmStopped()
46+
}
47+
}
48+
.store(in: &cancellables)
49+
}
50+
51+
private func recordVolumeChange(currentVolume: Float, timestamp: Date) {
52+
recentVolumeChanges.append((volume: currentVolume, timestamp: timestamp))
53+
54+
let cutoffTime = timestamp.timeIntervalSinceReferenceDate - volumeButtonPressTimeWindow
55+
recentVolumeChanges = recentVolumeChanges.filter { $0.timestamp.timeIntervalSinceReferenceDate > cutoffTime }
56+
57+
if let lastChange = lastSignificantVolumeChange {
58+
let timeSinceLastChange = timestamp.timeIntervalSince(lastChange)
59+
volumeChangePattern.append(timeSinceLastChange)
60+
61+
if volumeChangePattern.count > 5 {
62+
volumeChangePattern.removeFirst()
63+
}
64+
}
65+
lastSignificantVolumeChange = timestamp
66+
}
67+
68+
private func isLikelyVolumeButtonPress(volumeDifference: Float, timestamp: Date) -> Bool {
69+
let isReasonableChange = volumeDifference >= 0.03 && volumeDifference <= 0.12
70+
let isDiscreteChange = recentVolumeChanges.count <= 2
71+
let hasConsistentTiming = volumeChangePattern.isEmpty || volumeChangePattern.last! >= 0.15
72+
let isNotRapidSequence = recentVolumeChanges.count < 3 ||
73+
(recentVolumeChanges.count >= 3 &&
74+
recentVolumeChanges.suffix(3).map { $0.timestamp.timeIntervalSinceReferenceDate }.enumerated().dropFirst().allSatisfy { index, timestamp in
75+
let previousTimestamp = recentVolumeChanges.suffix(3).map { $0.timestamp.timeIntervalSinceReferenceDate }[index - 1]
76+
return timestamp - previousTimestamp > 0.08
77+
})
78+
79+
return isReasonableChange && isDiscreteChange && hasConsistentTiming && isNotRapidSequence
80+
}
81+
82+
private func snoozeActiveAlarm() {
83+
LogManager.shared.log(category: .volumeButtonSnooze, message: "Snoozing alarm")
84+
85+
lastVolumeButtonPressTime = Date()
86+
AlarmManager.shared.performSnooze()
87+
88+
let impactFeedback = UIImpactFeedbackGenerator(style: .medium)
89+
impactFeedback.impactOccurred()
90+
}
91+
92+
private func alarmStarted() {
93+
guard Storage.shared.alarmConfiguration.value.enableVolumeButtonSnooze else { return }
94+
LogManager.shared.log(category: .volumeButtonSnooze, message: "Alarm start detected, setting up volume observer.")
95+
96+
alarmStartTime = Date()
97+
recentVolumeChanges.removeAll()
98+
lastSignificantVolumeChange = nil
99+
volumeChangePattern.removeAll()
100+
101+
startMonitoring()
102+
}
103+
104+
private func alarmStopped() {
105+
LogManager.shared.log(category: .volumeButtonSnooze, message: "Alarm stop detected")
106+
107+
alarmStartTime = nil
108+
stopMonitoring()
109+
110+
recentVolumeChanges.removeAll()
111+
lastSignificantVolumeChange = nil
112+
volumeChangePattern.removeAll()
113+
}
114+
115+
func startMonitoring() {
116+
guard !isMonitoring else { return }
117+
118+
isMonitoring = true
119+
120+
volumeObserver = AVAudioSession.sharedInstance().observe(\.outputVolume, options: [.new]) { [weak self] session, _ in
121+
guard let self = self, let alarmStartTime = self.alarmStartTime else { return }
122+
123+
let currentVolume = session.outputVolume
124+
let now = Date()
125+
126+
// On the first observation, capture the initial volume when the audio session
127+
// becomes active. This solves the race condition. We then return to avoid
128+
// treating this initial setup as a user-initiated button press.
129+
if self.lastVolume == 0.0, currentVolume > 0.0 {
130+
LogManager.shared.log(category: .volumeButtonSnooze, message: "Observer received initial valid volume: \(currentVolume)")
131+
self.lastVolume = currentVolume
132+
return
133+
}
134+
135+
guard self.lastVolume > 0.0 else { return }
136+
137+
let volumeDifference = abs(currentVolume - self.lastVolume)
138+
139+
if volumeDifference > self.volumeButtonPressThreshold {
140+
let timeSinceAlarmStart = now.timeIntervalSince(alarmStartTime)
141+
142+
// Ignore volume changes from the alarm system's own ramp-up.
143+
if timeSinceAlarmStart < 2.0, currentVolume > self.lastVolume {
144+
if volumeDifference <= 0.15, timeSinceAlarmStart < 1.5 {
145+
self.lastVolume = currentVolume
146+
return
147+
}
148+
}
149+
150+
self.recordVolumeChange(currentVolume: currentVolume, timestamp: now)
151+
152+
if timeSinceAlarmStart > self.volumeButtonActivationDelay {
153+
if let lastPress = self.lastVolumeButtonPressTime {
154+
let timeSinceLastPress = now.timeIntervalSince(lastPress)
155+
if timeSinceLastPress < self.volumeButtonCooldown {
156+
self.lastVolume = currentVolume
157+
return
158+
}
159+
}
160+
161+
if self.isLikelyVolumeButtonPress(volumeDifference: volumeDifference, timestamp: now) {
162+
self.snoozeActiveAlarm()
163+
}
164+
}
165+
}
166+
self.lastVolume = currentVolume
167+
}
168+
}
169+
170+
func stopMonitoring() {
171+
guard isMonitoring else { return }
172+
173+
LogManager.shared.log(category: .volumeButtonSnooze, message: "Invalidating volume observer.")
174+
175+
// Invalidate the observer to stop receiving notifications and prevent memory leaks.
176+
volumeObserver?.invalidate()
177+
volumeObserver = nil
178+
179+
isMonitoring = false
180+
lastVolume = 0.0 // Reset for the next alarm.
181+
}
182+
}

LoopFollow/Log/LogManager.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ class LogManager {
2525
case taskScheduler = "Task Scheduler"
2626
case dexcom = "Dexcom"
2727
case alarm = "Alarm"
28+
case volumeButtonSnooze = "Volume Button Snooze"
2829
case calendar = "Calendar"
2930
case deviceStatus = "Device Status"
3031
}

LoopFollow/Storage/Observable.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ class Observable {
2424
var deltaText = ObservableValue<String>(default: "+0")
2525

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

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

0 commit comments

Comments
 (0)