Browse Source

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.
Jonas Björkert 2 months ago
parent
commit
204e3cc2bb
1 changed files with 33 additions and 19 deletions
  1. 33 19
      LoopFollow/Controllers/AlarmSound.swift

+ 33 - 19
LoopFollow/Controllers/AlarmSound.swift

@@ -88,8 +88,7 @@ class AlarmSound {
             audioPlayer = try AVAudioPlayer(contentsOf: soundURL)
             audioPlayer = try AVAudioPlayer(contentsOf: soundURL)
             audioPlayer!.delegate = audioPlayerDelegate
             audioPlayer!.delegate = audioPlayerDelegate
 
 
-            try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: [])
-            try AVAudioSession.sharedInstance().setActive(true)
+            activateAudioSessionWithFallback()
 
 
             audioPlayer?.numberOfLoops = 0
             audioPlayer?.numberOfLoops = 0
 
 
@@ -116,8 +115,6 @@ class AlarmSound {
             return
             return
         }
         }
 
 
-        enableAudio()
-
         // If repeating with delay, we'll handle it manually via the delegate
         // If repeating with delay, we'll handle it manually via the delegate
         // Only set repeatDelay if both repeating and delay > 0
         // Only set repeatDelay if both repeating and delay > 0
         repeatDelay = (repeating && delay > 0) ? TimeInterval(delay) : 0
         repeatDelay = (repeating && delay > 0) ? TimeInterval(delay) : 0
@@ -126,8 +123,7 @@ class AlarmSound {
             audioPlayer = try AVAudioPlayer(contentsOf: soundURL)
             audioPlayer = try AVAudioPlayer(contentsOf: soundURL)
             audioPlayer!.delegate = audioPlayerDelegate
             audioPlayer!.delegate = audioPlayerDelegate
 
 
-            try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: [])
-            try AVAudioSession.sharedInstance().setActive(true)
+            activateAudioSessionWithFallback()
 
 
             // Only use numberOfLoops if we're not using delay-based repeating
             // 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
             // 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
             // First sound plays immediately - delay only applies between repeated sounds
             if audioPlayer!.play() {
             if audioPlayer!.play() {
                 if !isPlaying {
                 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 {
                 } else {
                     Observable.shared.alarmSoundPlaying.value = true
                     Observable.shared.alarmSoundPlaying.value = true
                     if repeatDelay > 0 {
                     if repeatDelay > 0 {
@@ -184,8 +179,7 @@ class AlarmSound {
                 audioPlayer = try AVAudioPlayer(contentsOf: soundURL)
                 audioPlayer = try AVAudioPlayer(contentsOf: soundURL)
                 audioPlayer!.delegate = audioPlayerDelegate
                 audioPlayer!.delegate = audioPlayerDelegate
 
 
-                try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: [])
-                try AVAudioSession.sharedInstance().setActive(true)
+                activateAudioSessionWithFallback()
 
 
                 audioPlayer!.numberOfLoops = 0
                 audioPlayer!.numberOfLoops = 0
 
 
@@ -213,8 +207,7 @@ class AlarmSound {
             audioPlayer = try AVAudioPlayer(contentsOf: soundURL)
             audioPlayer = try AVAudioPlayer(contentsOf: soundURL)
             audioPlayer!.delegate = audioPlayerDelegate
             audioPlayer!.delegate = audioPlayerDelegate
 
 
-            try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: [])
-            try AVAudioSession.sharedInstance().setActive(true)
+            activateAudioSessionWithFallback()
 
 
             // Play endless loops
             // Play endless loops
             audioPlayer!.numberOfLoops = 2
             audioPlayer!.numberOfLoops = 2
@@ -260,14 +253,35 @@ class AlarmSound {
         systemOutputVolumeBeforeOverride = nil
         systemOutputVolumeBeforeOverride = nil
     }
     }
 
 
-    fileprivate static func enableAudio() {
-        do {
-            try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: [])
-            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,
+    // 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
+
+        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")
     }
     }
 }
 }