Explorar o código

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 hai 2 meses
pai
achega
204e3cc2bb
Modificáronse 1 ficheiros con 33 adicións e 19 borrados
  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!.delegate = audioPlayerDelegate
 
-            try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: [])
-            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: [])
-            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: [])
-                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: [])
-            try AVAudioSession.sharedInstance().setActive(true)
+            activateAudioSessionWithFallback()
 
             // Play endless loops
             audioPlayer!.numberOfLoops = 2
@@ -260,14 +253,35 @@ class AlarmSound {
         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")
     }
 }