Skip to content

Commit 204e3cc

Browse files
authored
Fix alarm sound session activation failures in background (#596)
* 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. * 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. * Ladder audio session options instead of a binary switch 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.
1 parent b68598b commit 204e3cc

1 file changed

Lines changed: 33 additions & 19 deletions

File tree

LoopFollow/Controllers/AlarmSound.swift

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

91-
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: [])
92-
try AVAudioSession.sharedInstance().setActive(true)
91+
activateAudioSessionWithFallback()
9392

9493
audioPlayer?.numberOfLoops = 0
9594

@@ -116,8 +115,6 @@ class AlarmSound {
116115
return
117116
}
118117

119-
enableAudio()
120-
121118
// If repeating with delay, we'll handle it manually via the delegate
122119
// Only set repeatDelay if both repeating and delay > 0
123120
repeatDelay = (repeating && delay > 0) ? TimeInterval(delay) : 0
@@ -126,8 +123,7 @@ class AlarmSound {
126123
audioPlayer = try AVAudioPlayer(contentsOf: soundURL)
127124
audioPlayer!.delegate = audioPlayerDelegate
128125

129-
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: [])
130-
try AVAudioSession.sharedInstance().setActive(true)
126+
activateAudioSessionWithFallback()
131127

132128
// Only use numberOfLoops if we're not using delay-based repeating
133129
// When repeatDelay > 0, we play once and then use the delegate to schedule the next play with delay
@@ -145,8 +141,7 @@ class AlarmSound {
145141
// First sound plays immediately - delay only applies between repeated sounds
146142
if audioPlayer!.play() {
147143
if !isPlaying {
148-
LogManager.shared.log(category: .alarm, message: "AlarmSound - not playing after calling play")
149-
LogManager.shared.log(category: .alarm, message: "AlarmSound - rate value: \(audioPlayer!.rate)")
144+
LogManager.shared.log(category: .alarm, message: "AlarmSound - not playing after calling play (rate \(audioPlayer!.rate))")
150145
} else {
151146
Observable.shared.alarmSoundPlaying.value = true
152147
if repeatDelay > 0 {
@@ -184,8 +179,7 @@ class AlarmSound {
184179
audioPlayer = try AVAudioPlayer(contentsOf: soundURL)
185180
audioPlayer!.delegate = audioPlayerDelegate
186181

187-
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: [])
188-
try AVAudioSession.sharedInstance().setActive(true)
182+
activateAudioSessionWithFallback()
189183

190184
audioPlayer!.numberOfLoops = 0
191185

@@ -213,8 +207,7 @@ class AlarmSound {
213207
audioPlayer = try AVAudioPlayer(contentsOf: soundURL)
214208
audioPlayer!.delegate = audioPlayerDelegate
215209

216-
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: [])
217-
try AVAudioSession.sharedInstance().setActive(true)
210+
activateAudioSessionWithFallback()
218211

219212
// Play endless loops
220213
audioPlayer!.numberOfLoops = 2
@@ -260,14 +253,35 @@ class AlarmSound {
260253
systemOutputVolumeBeforeOverride = nil
261254
}
262255

263-
fileprivate static func enableAudio() {
264-
do {
265-
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: [])
266-
try AVAudioSession.sharedInstance().setActive(true)
267-
LogManager.shared.log(category: .alarm, message: "Audio session configured for alarm playback")
268-
} catch {
269-
LogManager.shared.log(category: .alarm, message: "Enable audio error: \(error)")
256+
// Background activation of a non-mixable .playback session is denied by iOS
257+
// (cannotInterruptOthers, 560557684) unless the app is already actively playing
258+
// audio. In foreground, or with Silent Tune holding a mixable session alive,
259+
// options: [] succeeds and lets the alarm dominate other audio. For
260+
// Bluetooth-heartbeat users with no Silent Tune we skip [] (it would always
261+
// be denied) and ladder through mixable options so activation is still
262+
// permitted from background. Each attempt is logged so we can see in the
263+
// field which fallback (if any) the user landed on.
264+
fileprivate static func activateAudioSessionWithFallback() {
265+
let isBackgroundWithoutSilentTune = UIApplication.shared.applicationState == .background
266+
&& Storage.shared.backgroundRefreshType.value != .silentTune
267+
268+
let dominate: (label: String, options: AVAudioSession.CategoryOptions) = ("[]", [])
269+
let duck: (label: String, options: AVAudioSession.CategoryOptions) = (".duckOthers", .duckOthers)
270+
let mix: (label: String, options: AVAudioSession.CategoryOptions) = (".mixWithOthers", .mixWithOthers)
271+
272+
let candidates = isBackgroundWithoutSilentTune ? [duck, mix] : [dominate, duck, mix]
273+
for candidate in candidates {
274+
do {
275+
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: candidate.options)
276+
try AVAudioSession.sharedInstance().setActive(true)
277+
LogManager.shared.log(category: .alarm, message: "AlarmSound - audio session active (options: \(candidate.label))")
278+
return
279+
} catch {
280+
let nsError = error as NSError
281+
LogManager.shared.log(category: .alarm, message: "AlarmSound - audio session activation failed (options: \(candidate.label)) [code \(nsError.code)]: \(error.localizedDescription)")
282+
}
270283
}
284+
LogManager.shared.log(category: .alarm, message: "AlarmSound - all audio session option fallbacks exhausted")
271285
}
272286
}
273287

0 commit comments

Comments
 (0)