VolumeButtonHandler.swift 7.0 KB

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