From 32c103ad4282c9268b7c0b15383f91891e7b2b13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Mon, 30 Mar 2026 22:27:16 +0200 Subject: [PATCH 1/3] Fix alarm sound session activation failures in background Use .duckOthers instead of empty options when configuring the audio session for alarm playback. The empty options created a non-mixable session that conflicted with the background silent audio player (which uses .mixWithOthers), causing setActive(true) to fail with "Session activation failed" when the app was in the background. --- LoopFollow/Controllers/AlarmSound.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/LoopFollow/Controllers/AlarmSound.swift b/LoopFollow/Controllers/AlarmSound.swift index ba131bc4f..c4ded4d44 100644 --- a/LoopFollow/Controllers/AlarmSound.swift +++ b/LoopFollow/Controllers/AlarmSound.swift @@ -88,7 +88,7 @@ class AlarmSound { audioPlayer = try AVAudioPlayer(contentsOf: soundURL) audioPlayer!.delegate = audioPlayerDelegate - try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: []) + try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: .duckOthers) try AVAudioSession.sharedInstance().setActive(true) audioPlayer?.numberOfLoops = 0 @@ -126,7 +126,7 @@ class AlarmSound { audioPlayer = try AVAudioPlayer(contentsOf: soundURL) audioPlayer!.delegate = audioPlayerDelegate - try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: []) + try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: .duckOthers) try AVAudioSession.sharedInstance().setActive(true) // Only use numberOfLoops if we're not using delay-based repeating @@ -184,7 +184,7 @@ class AlarmSound { audioPlayer = try AVAudioPlayer(contentsOf: soundURL) audioPlayer!.delegate = audioPlayerDelegate - try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: []) + try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: .duckOthers) try AVAudioSession.sharedInstance().setActive(true) audioPlayer!.numberOfLoops = 0 @@ -213,7 +213,7 @@ class AlarmSound { audioPlayer = try AVAudioPlayer(contentsOf: soundURL) audioPlayer!.delegate = audioPlayerDelegate - try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: []) + try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: .duckOthers) try AVAudioSession.sharedInstance().setActive(true) // Play endless loops @@ -262,7 +262,7 @@ class AlarmSound { fileprivate static func enableAudio() { do { - try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: []) + try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: .duckOthers) try AVAudioSession.sharedInstance().setActive(true) LogManager.shared.log(category: .alarm, message: "Audio session configured for alarm playback") } catch { From 54c2a5d1d3607e454f265b52a90d0ee6f9fdc26e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Wed, 29 Apr 2026 13:56:38 +0200 Subject: [PATCH 2/3] 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. --- LoopFollow/Alarm/Alarm.swift | 15 ++++++++++++++- LoopFollow/Alarm/AlarmManager.swift | 8 ++++++-- LoopFollow/Controllers/AlarmSound.swift | 22 +++++++++++++++++----- 3 files changed, 37 insertions(+), 8 deletions(-) diff --git a/LoopFollow/Alarm/Alarm.swift b/LoopFollow/Alarm/Alarm.swift index 2de36fb0f..f5288462d 100644 --- a/LoopFollow/Alarm/Alarm.swift +++ b/LoopFollow/Alarm/Alarm.swift @@ -3,6 +3,7 @@ import Foundation import HealthKit +import UIKit import UserNotifications protocol DayNightDisplayable { @@ -259,7 +260,19 @@ struct Alarm: Identifiable, Codable, Equatable { } }() - AlarmManager.shared.sendNotification(title: type.rawValue, actionTitle: snoozeDuration == 0 ? "Acknowledge" : "Snooze") + // When backgrounded without Silent Tune holding a session alive, the in-app + // AVAudioSession activation can fail (cannotInterruptOthers, 560557684). Pass + // the alarm's soundFile to the notification so the system delivers the user's + // configured sound as a fallback. In foreground or with Silent Tune, the in-app + // player is reliable and we keep .default to avoid an echo with the looping player. + let inBackgroundWithoutSilentTune = UIApplication.shared.applicationState != .active + && Storage.shared.backgroundRefreshType.value != .silentTune + let notificationSound: SoundFile? = (playSound && inBackgroundWithoutSilentTune) ? soundFile : nil + AlarmManager.shared.sendNotification( + title: type.rawValue, + actionTitle: snoozeDuration == 0 ? "Acknowledge" : "Snooze", + soundFile: notificationSound + ) if playSound { AlarmSound.setSoundFile(str: soundFile.rawValue) diff --git a/LoopFollow/Alarm/AlarmManager.swift b/LoopFollow/Alarm/AlarmManager.swift index 3f5aa84ec..f80b12422 100644 --- a/LoopFollow/Alarm/AlarmManager.swift +++ b/LoopFollow/Alarm/AlarmManager.swift @@ -176,7 +176,7 @@ class AlarmManager { UNUserNotificationCenter.current().removeAllPendingNotificationRequests() } - func sendNotification(title: String, actionTitle: String? = nil) { + func sendNotification(title: String, actionTitle: String? = nil, soundFile: SoundFile? = nil) { UNUserNotificationCenter.current().removeAllPendingNotificationRequests() let content = UNMutableNotificationContent() @@ -185,7 +185,11 @@ class AlarmManager { content.subtitle += Observable.shared.directionText.value + " " content.subtitle += Observable.shared.deltaText.value content.categoryIdentifier = "category" - content.sound = .default + if let soundFile { + content.sound = UNNotificationSound(named: UNNotificationSoundName("\(soundFile.rawValue).caf")) + } else { + content.sound = .default + } let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false) let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: trigger) diff --git a/LoopFollow/Controllers/AlarmSound.swift b/LoopFollow/Controllers/AlarmSound.swift index c4ded4d44..8e2a2bc1a 100644 --- a/LoopFollow/Controllers/AlarmSound.swift +++ b/LoopFollow/Controllers/AlarmSound.swift @@ -88,7 +88,7 @@ class AlarmSound { audioPlayer = try AVAudioPlayer(contentsOf: soundURL) audioPlayer!.delegate = audioPlayerDelegate - try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: .duckOthers) + try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: sessionCategoryOptions()) try AVAudioSession.sharedInstance().setActive(true) audioPlayer?.numberOfLoops = 0 @@ -126,7 +126,7 @@ class AlarmSound { audioPlayer = try AVAudioPlayer(contentsOf: soundURL) audioPlayer!.delegate = audioPlayerDelegate - try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: .duckOthers) + try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: sessionCategoryOptions()) try AVAudioSession.sharedInstance().setActive(true) // Only use numberOfLoops if we're not using delay-based repeating @@ -184,7 +184,7 @@ class AlarmSound { audioPlayer = try AVAudioPlayer(contentsOf: soundURL) audioPlayer!.delegate = audioPlayerDelegate - try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: .duckOthers) + try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: sessionCategoryOptions()) try AVAudioSession.sharedInstance().setActive(true) audioPlayer!.numberOfLoops = 0 @@ -213,7 +213,7 @@ class AlarmSound { audioPlayer = try AVAudioPlayer(contentsOf: soundURL) audioPlayer!.delegate = audioPlayerDelegate - try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: .duckOthers) + try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: sessionCategoryOptions()) try AVAudioSession.sharedInstance().setActive(true) // Play endless loops @@ -262,13 +262,25 @@ class AlarmSound { fileprivate static func enableAudio() { do { - try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: .duckOthers) + try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: sessionCategoryOptions()) try AVAudioSession.sharedInstance().setActive(true) LogManager.shared.log(category: .alarm, message: "Audio session configured for alarm playback") } catch { LogManager.shared.log(category: .alarm, message: "Enable audio error: \(error)") } } + + // Background activation of a non-mixable .playback session is denied by iOS + // (cannotInterruptOthers, 560557684) unless the app is already actively playing + // audio. In foreground, or with Silent Tune holding a mixable session alive, + // the legacy options: [] succeeds and lets the alarm dominate other audio. + // For Bluetooth-heartbeat users with no Silent Tune, fall back to .duckOthers + // (mixable + ducks others) so activation is permitted from background. + fileprivate static func sessionCategoryOptions() -> AVAudioSession.CategoryOptions { + let inBackgroundWithoutSilentTune = UIApplication.shared.applicationState != .active + && Storage.shared.backgroundRefreshType.value != .silentTune + return inBackgroundWithoutSilentTune ? .duckOthers : [] + } } class AudioPlayerDelegate: NSObject, AVAudioPlayerDelegate { From fad81fc9b588d6fc55b87f1fd52713b3a892ee58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Thu, 30 Apr 2026 12:38:05 +0200 Subject: [PATCH 3/3] Ladder audio session options instead of a binary switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the static .duckOthers/[] choice with a fallback ladder that tries options in order [] → .duckOthers → .mixWithOthers and stops at the first that activates. In background without Silent Tune the [] candidate is skipped, since iOS denies it there (cannotInterruptOthers, 560557684). Each attempt is logged with the iOS error code so failures are visible in the field. Revert the notification soundFile path; notifications stay on .default and the in-app AVAudioPlayer remains the only source of the alarm tone. Also drops the redundant enableAudio() call from play() — the do-block already activates the session. --- LoopFollow/Alarm/Alarm.swift | 15 +------ LoopFollow/Alarm/AlarmManager.swift | 8 +--- LoopFollow/Controllers/AlarmSound.swift | 58 +++++++++++++------------ 3 files changed, 33 insertions(+), 48 deletions(-) diff --git a/LoopFollow/Alarm/Alarm.swift b/LoopFollow/Alarm/Alarm.swift index f5288462d..2de36fb0f 100644 --- a/LoopFollow/Alarm/Alarm.swift +++ b/LoopFollow/Alarm/Alarm.swift @@ -3,7 +3,6 @@ import Foundation import HealthKit -import UIKit import UserNotifications protocol DayNightDisplayable { @@ -260,19 +259,7 @@ struct Alarm: Identifiable, Codable, Equatable { } }() - // When backgrounded without Silent Tune holding a session alive, the in-app - // AVAudioSession activation can fail (cannotInterruptOthers, 560557684). Pass - // the alarm's soundFile to the notification so the system delivers the user's - // configured sound as a fallback. In foreground or with Silent Tune, the in-app - // player is reliable and we keep .default to avoid an echo with the looping player. - let inBackgroundWithoutSilentTune = UIApplication.shared.applicationState != .active - && Storage.shared.backgroundRefreshType.value != .silentTune - let notificationSound: SoundFile? = (playSound && inBackgroundWithoutSilentTune) ? soundFile : nil - AlarmManager.shared.sendNotification( - title: type.rawValue, - actionTitle: snoozeDuration == 0 ? "Acknowledge" : "Snooze", - soundFile: notificationSound - ) + AlarmManager.shared.sendNotification(title: type.rawValue, actionTitle: snoozeDuration == 0 ? "Acknowledge" : "Snooze") if playSound { AlarmSound.setSoundFile(str: soundFile.rawValue) diff --git a/LoopFollow/Alarm/AlarmManager.swift b/LoopFollow/Alarm/AlarmManager.swift index f80b12422..3f5aa84ec 100644 --- a/LoopFollow/Alarm/AlarmManager.swift +++ b/LoopFollow/Alarm/AlarmManager.swift @@ -176,7 +176,7 @@ class AlarmManager { UNUserNotificationCenter.current().removeAllPendingNotificationRequests() } - func sendNotification(title: String, actionTitle: String? = nil, soundFile: SoundFile? = nil) { + func sendNotification(title: String, actionTitle: String? = nil) { UNUserNotificationCenter.current().removeAllPendingNotificationRequests() let content = UNMutableNotificationContent() @@ -185,11 +185,7 @@ class AlarmManager { content.subtitle += Observable.shared.directionText.value + " " content.subtitle += Observable.shared.deltaText.value content.categoryIdentifier = "category" - if let soundFile { - content.sound = UNNotificationSound(named: UNNotificationSoundName("\(soundFile.rawValue).caf")) - } else { - content.sound = .default - } + content.sound = .default let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false) let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: trigger) diff --git a/LoopFollow/Controllers/AlarmSound.swift b/LoopFollow/Controllers/AlarmSound.swift index 8e2a2bc1a..b83383185 100644 --- a/LoopFollow/Controllers/AlarmSound.swift +++ b/LoopFollow/Controllers/AlarmSound.swift @@ -88,8 +88,7 @@ class AlarmSound { audioPlayer = try AVAudioPlayer(contentsOf: soundURL) audioPlayer!.delegate = audioPlayerDelegate - try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: sessionCategoryOptions()) - try AVAudioSession.sharedInstance().setActive(true) + activateAudioSessionWithFallback() audioPlayer?.numberOfLoops = 0 @@ -116,8 +115,6 @@ class AlarmSound { return } - enableAudio() - // If repeating with delay, we'll handle it manually via the delegate // Only set repeatDelay if both repeating and delay > 0 repeatDelay = (repeating && delay > 0) ? TimeInterval(delay) : 0 @@ -126,8 +123,7 @@ class AlarmSound { audioPlayer = try AVAudioPlayer(contentsOf: soundURL) audioPlayer!.delegate = audioPlayerDelegate - try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: sessionCategoryOptions()) - try AVAudioSession.sharedInstance().setActive(true) + activateAudioSessionWithFallback() // Only use numberOfLoops if we're not using delay-based repeating // When repeatDelay > 0, we play once and then use the delegate to schedule the next play with delay @@ -145,8 +141,7 @@ class AlarmSound { // First sound plays immediately - delay only applies between repeated sounds if audioPlayer!.play() { 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)") + LogManager.shared.log(category: .alarm, message: "AlarmSound - not playing after calling play (rate \(audioPlayer!.rate))") } else { Observable.shared.alarmSoundPlaying.value = true if repeatDelay > 0 { @@ -184,8 +179,7 @@ class AlarmSound { audioPlayer = try AVAudioPlayer(contentsOf: soundURL) audioPlayer!.delegate = audioPlayerDelegate - try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: sessionCategoryOptions()) - try AVAudioSession.sharedInstance().setActive(true) + activateAudioSessionWithFallback() audioPlayer!.numberOfLoops = 0 @@ -213,8 +207,7 @@ class AlarmSound { audioPlayer = try AVAudioPlayer(contentsOf: soundURL) audioPlayer!.delegate = audioPlayerDelegate - try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: sessionCategoryOptions()) - try AVAudioSession.sharedInstance().setActive(true) + activateAudioSessionWithFallback() // Play endless loops audioPlayer!.numberOfLoops = 2 @@ -260,26 +253,35 @@ class AlarmSound { systemOutputVolumeBeforeOverride = nil } - fileprivate static func enableAudio() { - do { - try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: sessionCategoryOptions()) - try AVAudioSession.sharedInstance().setActive(true) - LogManager.shared.log(category: .alarm, message: "Audio session configured for alarm playback") - } catch { - LogManager.shared.log(category: .alarm, message: "Enable audio error: \(error)") - } - } - // Background activation of a non-mixable .playback session is denied by iOS // (cannotInterruptOthers, 560557684) unless the app is already actively playing // audio. In foreground, or with Silent Tune holding a mixable session alive, - // the legacy options: [] succeeds and lets the alarm dominate other audio. - // For Bluetooth-heartbeat users with no Silent Tune, fall back to .duckOthers - // (mixable + ducks others) so activation is permitted from background. - fileprivate static func sessionCategoryOptions() -> AVAudioSession.CategoryOptions { - let inBackgroundWithoutSilentTune = UIApplication.shared.applicationState != .active + // options: [] succeeds and lets the alarm dominate other audio. For + // Bluetooth-heartbeat users with no Silent Tune we skip [] (it would always + // be denied) and ladder through mixable options so activation is still + // permitted from background. Each attempt is logged so we can see in the + // field which fallback (if any) the user landed on. + fileprivate static func activateAudioSessionWithFallback() { + let isBackgroundWithoutSilentTune = UIApplication.shared.applicationState == .background && Storage.shared.backgroundRefreshType.value != .silentTune - return inBackgroundWithoutSilentTune ? .duckOthers : [] + + let dominate: (label: String, options: AVAudioSession.CategoryOptions) = ("[]", []) + let duck: (label: String, options: AVAudioSession.CategoryOptions) = (".duckOthers", .duckOthers) + let mix: (label: String, options: AVAudioSession.CategoryOptions) = (".mixWithOthers", .mixWithOthers) + + let candidates = isBackgroundWithoutSilentTune ? [duck, mix] : [dominate, duck, mix] + for candidate in candidates { + do { + try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: candidate.options) + try AVAudioSession.sharedInstance().setActive(true) + LogManager.shared.log(category: .alarm, message: "AlarmSound - audio session active (options: \(candidate.label))") + return + } catch { + let nsError = error as NSError + LogManager.shared.log(category: .alarm, message: "AlarmSound - audio session activation failed (options: \(candidate.label)) [code \(nsError.code)]: \(error.localizedDescription)") + } + } + LogManager.shared.log(category: .alarm, message: "AlarmSound - all audio session option fallbacks exhausted") } }