Skip to content

Commit 54c2a5d

Browse files
committed
Gate alarm session option on app state and add notification fallback
Limit the .duckOthers option to the only state where legacy options: [] fails: background without Silent Tune holding a mixable session alive. In foreground or with Silent Tune, restore options: [] so the alarm continues to dominate other audio with no behavioral change for those users. In that same fail-prone state, plumb the alarm's soundFile through AlarmManager.sendNotification so the system-delivered notification carries the user's configured alarm sound as an audible fallback. In other states the notification keeps .default to avoid an echo with the in-app AVAudioPlayer loop.
1 parent 7197d14 commit 54c2a5d

3 files changed

Lines changed: 37 additions & 8 deletions

File tree

LoopFollow/Alarm/Alarm.swift

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import Foundation
55
import HealthKit
6+
import UIKit
67
import UserNotifications
78

89
protocol DayNightDisplayable {
@@ -259,7 +260,19 @@ struct Alarm: Identifiable, Codable, Equatable {
259260
}
260261
}()
261262

262-
AlarmManager.shared.sendNotification(title: type.rawValue, actionTitle: snoozeDuration == 0 ? "Acknowledge" : "Snooze")
263+
// When backgrounded without Silent Tune holding a session alive, the in-app
264+
// AVAudioSession activation can fail (cannotInterruptOthers, 560557684). Pass
265+
// the alarm's soundFile to the notification so the system delivers the user's
266+
// configured sound as a fallback. In foreground or with Silent Tune, the in-app
267+
// player is reliable and we keep .default to avoid an echo with the looping player.
268+
let inBackgroundWithoutSilentTune = UIApplication.shared.applicationState != .active
269+
&& Storage.shared.backgroundRefreshType.value != .silentTune
270+
let notificationSound: SoundFile? = (playSound && inBackgroundWithoutSilentTune) ? soundFile : nil
271+
AlarmManager.shared.sendNotification(
272+
title: type.rawValue,
273+
actionTitle: snoozeDuration == 0 ? "Acknowledge" : "Snooze",
274+
soundFile: notificationSound
275+
)
263276

264277
if playSound {
265278
AlarmSound.setSoundFile(str: soundFile.rawValue)

LoopFollow/Alarm/AlarmManager.swift

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ class AlarmManager {
176176
UNUserNotificationCenter.current().removeAllPendingNotificationRequests()
177177
}
178178

179-
func sendNotification(title: String, actionTitle: String? = nil) {
179+
func sendNotification(title: String, actionTitle: String? = nil, soundFile: SoundFile? = nil) {
180180
UNUserNotificationCenter.current().removeAllPendingNotificationRequests()
181181

182182
let content = UNMutableNotificationContent()
@@ -185,7 +185,11 @@ class AlarmManager {
185185
content.subtitle += Observable.shared.directionText.value + " "
186186
content.subtitle += Observable.shared.deltaText.value
187187
content.categoryIdentifier = "category"
188-
content.sound = .default
188+
if let soundFile {
189+
content.sound = UNNotificationSound(named: UNNotificationSoundName("\(soundFile.rawValue).caf"))
190+
} else {
191+
content.sound = .default
192+
}
189193

190194
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false)
191195
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: trigger)

LoopFollow/Controllers/AlarmSound.swift

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ class AlarmSound {
8888
audioPlayer = try AVAudioPlayer(contentsOf: soundURL)
8989
audioPlayer!.delegate = audioPlayerDelegate
9090

91-
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: .duckOthers)
91+
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: sessionCategoryOptions())
9292
try AVAudioSession.sharedInstance().setActive(true)
9393

9494
audioPlayer?.numberOfLoops = 0
@@ -126,7 +126,7 @@ class AlarmSound {
126126
audioPlayer = try AVAudioPlayer(contentsOf: soundURL)
127127
audioPlayer!.delegate = audioPlayerDelegate
128128

129-
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: .duckOthers)
129+
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: sessionCategoryOptions())
130130
try AVAudioSession.sharedInstance().setActive(true)
131131

132132
// Only use numberOfLoops if we're not using delay-based repeating
@@ -184,7 +184,7 @@ class AlarmSound {
184184
audioPlayer = try AVAudioPlayer(contentsOf: soundURL)
185185
audioPlayer!.delegate = audioPlayerDelegate
186186

187-
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: .duckOthers)
187+
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: sessionCategoryOptions())
188188
try AVAudioSession.sharedInstance().setActive(true)
189189

190190
audioPlayer!.numberOfLoops = 0
@@ -213,7 +213,7 @@ class AlarmSound {
213213
audioPlayer = try AVAudioPlayer(contentsOf: soundURL)
214214
audioPlayer!.delegate = audioPlayerDelegate
215215

216-
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: .duckOthers)
216+
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: sessionCategoryOptions())
217217
try AVAudioSession.sharedInstance().setActive(true)
218218

219219
// Play endless loops
@@ -262,13 +262,25 @@ class AlarmSound {
262262

263263
fileprivate static func enableAudio() {
264264
do {
265-
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: .duckOthers)
265+
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: sessionCategoryOptions())
266266
try AVAudioSession.sharedInstance().setActive(true)
267267
LogManager.shared.log(category: .alarm, message: "Audio session configured for alarm playback")
268268
} catch {
269269
LogManager.shared.log(category: .alarm, message: "Enable audio error: \(error)")
270270
}
271271
}
272+
273+
// Background activation of a non-mixable .playback session is denied by iOS
274+
// (cannotInterruptOthers, 560557684) unless the app is already actively playing
275+
// audio. In foreground, or with Silent Tune holding a mixable session alive,
276+
// the legacy options: [] succeeds and lets the alarm dominate other audio.
277+
// For Bluetooth-heartbeat users with no Silent Tune, fall back to .duckOthers
278+
// (mixable + ducks others) so activation is permitted from background.
279+
fileprivate static func sessionCategoryOptions() -> AVAudioSession.CategoryOptions {
280+
let inBackgroundWithoutSilentTune = UIApplication.shared.applicationState != .active
281+
&& Storage.shared.backgroundRefreshType.value != .silentTune
282+
return inBackgroundWithoutSilentTune ? .duckOthers : []
283+
}
272284
}
273285

274286
class AudioPlayerDelegate: NSObject, AVAudioPlayerDelegate {

0 commit comments

Comments
 (0)