Search code examples
iosnotificationcentermpvolumeviewavsystemcontroller

AVSystemController_SystemVolumeDidChangeNotification triggers when device locked


Using iOS 12, I am observing AVSystemController_SystemVolumeDidChangeNotification to detect volume presses to capture images:

let volumeView = MPVolumeView(frame: CGRect(x: 0, y: -40, width: 0, height: 0)) // override volume view
view.addSubview(volumeView)

NotificationCenter.default.addObserver(self, selector: #selector(captureImage), name: Notification.Name(rawValue: "AVSystemController_SystemVolumeDidChangeNotification"), object: nil)

However, I've noticed that the notification also fires, at least on an iPhone XS and XS Max, when the lock button (on the right side of the device) is pressed.

Tried searching around and haven't seen anyone mentioning this issue or much discussion of this notification. Other similar attempts to listen to the volume buttons presses use AVAudionSessions / KVO, but I found that whenever I used that the observer did not get called when the volume was already at max/min. This AVSystemController_SystemVolumeDidChangeNotification seems to work just fine, except for this strange lock button issue. Don't see how, from the name of the notification, why it would respond to the lock button being pressed.

When the lock button is pressed I get the following messages in the console:

[avas] AVAudioSessionPortImpl.mm:56:ValidateRequiredFields: Unknown selected data source for Port Speaker (type: Speaker) // this appears four times

+[CATransaction synchronize] called within transaction // this appears twice

These logs do not appear when the volume button is pressed.

Note also I don't plan to submit the App Store, so I am not concerned about whether Apple rejects this app on the basis of using this possibly private notification.

If instead of AVSystemController_SystemVolumeDidChangeNotification I create an AVAudioSession and observe outputVolume like so:

let audioSession = AVAudioSession()
try? audioSession.setActive(true)
audioSession.addObserver(self, forKeyPath: "outputVolume", options: NSKeyValueObservingOptions.new, context: nil)

… then it isn't hit when the device locks, but I'm still seeing the "AVAudioSessionPortImpl.mm unknown selected data source for Port Speaker" console errors. But then when the volume is muted it doesn't receive presses anymore. I guess what I'd need to do is manually change the volume so it doesn't hit the min or max?

Thank you


Solution

  • Wound up manually keeping the volume constant and using the audio session method. Had to throw in a couple hacks. This is a bit convoluted, so I'm open to cleaner alternatives. No idea how Apple would react to this being submitted in an app, although it seems that they definitely accept apps that use the volume buttons for interacting with the cameras.

    Inside a UIViewController subclass:

    override func viewDidLoad() {
        super.viewDidLoad()
        // …
        setupVolumeButton()
    }
    
    private let volumeView = MPVolumeView(frame: CGRect(x: 0, y: -100, width: 0, height: 0)) // override volume view
    
    private func setupVolumeButton() {
        view.addSubview(volumeView)
    
        setVolume(0.5) { // in case app launches with volume at max/min already
            // need to wait until initial volume setting is done 
            // so it doesn't get triggered on launch
    
            let audioSession = AVAudioSession()
            try? audioSession.setActive(true)
            audioSession.addObserver(self, forKeyPath: "outputVolume", options: NSKeyValueObservingOptions.new, context: nil)
        }
    }
    
    private func setVolume(_ volume: Float, completion: (() -> Void)? = nil) {
        let slider = volumeView.subviews.first(where: { $0 is UISlider }) as? UISlider
        DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.1) {
            slider?.value = volume
    
            DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.01) {
                // needed to wait a bit before completing so the observer doesn't pick up the manualq volume change
                completion?()
            }
        }
    }
    
    override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
        if keyPath == "outputVolume" {
            setVolume(0.5) // keep from reaching max or min volume so button keeps working
    
            doTheThingThatShouldHappenWhenTheVolumeButtonIsPressed()
        }
    }
    

    Edit: Also I noticed that the audio session was deactivated when the app was closed, so I stored the audio session in a property, added an observer for when the app became active again, and in the associated method I set the audio session to be active again.