// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation import AVFoundation import SignalCoreKit import SessionUtilitiesKit @objc(OWSAudioActivity) public class AudioActivity: NSObject { let audioDescription: String let behavior: OWSAudioBehavior @objc public init(audioDescription: String, behavior: OWSAudioBehavior) { self.audioDescription = audioDescription self.behavior = behavior } deinit { Environment.shared?.audioSession.ensureAudioSessionActivationStateAfterDelay() } // MARK: override public var description: String { return "<\(self.logTag) audioDescription: \"\(audioDescription)\">" } } @objc public class OWSAudioSession: NSObject { @objc public func setup() { NotificationCenter.default.addObserver(self, selector: #selector(proximitySensorStateDidChange(notification:)), name: UIDevice.proximityStateDidChangeNotification, object: nil) } // MARK: Dependencies private let avAudioSession = AVAudioSession.sharedInstance() private let device = UIDevice.current // MARK: private var currentActivities: [Weak] = [] var aggregateBehaviors: Set { return Set(self.currentActivities.compactMap { $0.value?.behavior }) } @objc public func startAudioActivity(_ audioActivity: AudioActivity) -> Bool { Logger.debug("with \(audioActivity)") objc_sync_enter(self) defer { objc_sync_exit(self) } self.currentActivities.append(Weak(value: audioActivity)) do { try ensureAudioCategory() return true } catch { owsFailDebug("failed with error: \(error)") return false } } @objc public func endAudioActivity(_ audioActivity: AudioActivity) { Logger.debug("with audioActivity: \(audioActivity)") objc_sync_enter(self) defer { objc_sync_exit(self) } currentActivities = currentActivities.filter { return $0.value != audioActivity } do { try ensureAudioCategory() } catch { owsFailDebug("error in ensureAudioCategory: \(error)") } } func ensureAudioCategory() throws { if aggregateBehaviors.contains(.audioMessagePlayback) { Environment.shared?.proximityMonitoringManager.add(lifetime: self) } else { Environment.shared?.proximityMonitoringManager.remove(lifetime: self) } if aggregateBehaviors.contains(.call) { // Do nothing while on a call. // WebRTC/CallAudioService manages call audio // Eventually it would be nice to consolidate more of the audio // session handling. } else if aggregateBehaviors.contains(.playAndRecord) { assert(avAudioSession.recordPermission == .granted) try avAudioSession.setCategory(.record) } else if aggregateBehaviors.contains(.audioMessagePlayback) { if self.device.proximityState { Logger.debug("proximityState: true") try avAudioSession.setCategory(.playAndRecord) try avAudioSession.overrideOutputAudioPort(.none) } else { Logger.debug("proximityState: false") try avAudioSession.setCategory(.playback) } } else if aggregateBehaviors.contains(.playback) { try avAudioSession.setCategory(.playback) } else { ensureAudioSessionActivationStateAfterDelay() } } @objc func proximitySensorStateDidChange(notification: Notification) { do { try ensureAudioCategory() } catch { owsFailDebug("error in response to proximity change: \(error)") } } fileprivate func ensureAudioSessionActivationStateAfterDelay() { // Without this delay, we sometimes error when deactivating the audio session with: // Error Domain=NSOSStatusErrorDomain Code=560030580 “The operation couldn’t be completed. (OSStatus error 560030580.)” // aka "AVAudioSessionErrorCodeIsBusy" // FIXME: The code below was causing a bug, and disabling it * seems * fine. Don't feel super confident about it though... /* DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { self.ensureAudioSessionActivationState() } */ } private func ensureAudioSessionActivationState() { objc_sync_enter(self) defer { objc_sync_exit(self) } // Cull any stale activities currentActivities = currentActivities.compactMap { oldActivity in guard oldActivity.value != nil else { // Normally we should be explicitly stopping an audio activity, but this allows // for recovery if the owner of the AudioAcivity was GC'd without ending it's // audio activity Logger.warn("an old activity has been gc'd") return nil } // return any still-active activities return oldActivity } guard currentActivities.isEmpty else { Logger.debug("not deactivating due to currentActivities: \(currentActivities)") return } do { // When playing audio in Signal, other apps audio (e.g. Music) is paused. // By notifying when we deactivate, the other app can resume playback. try avAudioSession.setActive(false, options: [.notifyOthersOnDeactivation]) } catch { owsFailDebug("failed with error: \(error)") } } }