AVAudioSession lifecycle management

I have a question around AVAudioSession lifecycle management. I already got a lot of help in yesterday's lab appointment - thanks a lot for that - but two questions remain.

In particular, I'm wondering how to deal with exceptions in session.activate() and session.configure().

Here is my current understanding, assuming that the session configuration is intended to remain constant throughout an app life cycle:

  • a session needs to be configured when the app first launches, or when the media service is reset
  • a session needs to be activated for first use, and after every interruption (e.g. phone call, other app getting access, app was suspended).

Because we cannot guarantee that a session.configure or session.activate call will succeed at all times, currently, in our app, we check whether the session is configured and activated before starting playback, and if not, we configure/activate it:

extension Conductor {
    @discardableResult
    private func configureSessionIfNeeded() -> Bool {
        guard !isAudioSessionConfigured else { return true }
        
        let session = AVAudioSession.sharedInstance()
        do {
            try session.setCategory(.playAndRecord, options: [.defaultToSpeaker,
                                                              .allowBluetoothA2DP,
                                                              .allowAirPlay])
            isAudioSessionConfigured = true
        } catch {
            Logging.capture(error)
        }
        return isAudioSessionConfigured
    }    
    @discardableResult
    func activateSessionIfNeeded() -> Bool {
        guard !isAudioSessionActive else { return true }
        guard configureSessionIfNeeded() else { return false }
        
        let session = AVAudioSession.sharedInstance()
        do {
            try session.setActive(true)
            isAudioSessionActive = true
        } catch {
            Logging.capture(error)
        }
        return isAudioSessionActive
    }
}

This, however, requires keeping track of the state of the session:

class Conductor {
    // Singleton
    static let shared: Conductor = Conductor()
    
    private var isAudioSessionActive = false
    private var isAudioSessionConfigured = false
}

This feels error-prone.

Here is how we currently deal with interruptions:

extension Conductor {
    // AVAudioSession.interruptionNotification
    @objc private func handleInterruption(_ notification: Notification) {
        guard
            let info = notification.userInfo,
            let typeValue = info[AVAudioSessionInterruptionTypeKey] as? UInt,
            let type = AVAudioSession.InterruptionType(rawValue: typeValue)
        else { return }
        
        if type == .began {
            // WWDC session advice: ignore the "app was suspended" reason. By the time it is delivered
            // (when the app re-enters the foreground) it is outdated and useless anyway. They probably
            // should not have introduced it in the first place, but thought too much information is
            // better than too little and erred on the safe side.
            // While the app is in the background, the user could interact with it from the control center, and
            // for example start playback. This will resume the app, and we will receive both the command from
            // the control center (resome) and the interruption notification (pause), but in undefined order.
            // It's a race condition, solved by simply ignoring the app-was-suspended notification.
            if let wasSuspended = info[AVAudioSessionInterruptionWasSuspendedKey] as? NSNumber,
               wasSuspended == true { return }
            // FIXME: in the app-was-suspended case, isAudioSessionActive remains true but should be false.
            
            if playbackState == .playing { pausePlayback() }
            if isRecording { stopRecording() }
            isAudioSessionActive = false
        } else if type == .ended {
            // Resume playback
            guard let optionsValue = notification.userInfo?[AVAudioSessionInterruptionOptionKey] as? UInt else { return }
            let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
            if options.contains(.shouldResume) {
                startPlayback()
            }
            // NOTE: imagine the session was active, and the user stopped playback, and then we get interrupted
            // When the interruption ends, we will still get a .shouldResume, but we should check our own state
            // to see whether we were even playing before that.
        }
    }
    
    // AVAudioSession.mediaServicesWereResetNotification
    @objc private func handleMediaServicesWereReset(_ notification: Notification) {
        // We need to completely reinitialise the audio stack here, including redoing session configuration
        pausePlayback()
        isAudioSessionActive = false
        isAudioSessionConfigured = false
        configureSessionIfNeeded()
    }
}

And here, for full reference, is the rest of this example class: https://gist.github.com/tcwalther/8999e19ab7e3c952d6763f11c984ef70

With the above design, we check at every playback whether we need to configure or activate the session. If we do and configuration or activation fails, we just ignore the playback request and silently fail. We feel that this is a better UX experience ("play button not working") than crashing the app or landing in an inconsistent UI state.

I think we could simplify this dramatically if

  • we know that we'll get an interruption-ended notification alongside the interruption-began notification in case the app was suspended, and that, if the app was resumed because of a media center control, the interruption-ended notification will come before the playback request
  • we can trust session.activate() and session.configure() to never throw an exception.

How would you advise simplifying and/or improving this code to correctly deal with AVAudioSession interruption and error cases?

AVAudioSession lifecycle management
 
 
Q