Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/Signal/util/VolumeButtons.swift
1 views
//
// Copyright 2019 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import AVKit
import MediaPlayer
import SignalServiceKit

protocol PassiveVolumeButtonObserver: AnyObject {

    /// Does not say which volume button was tapped (because we may not know),
    /// just that the system volume was changed by tapping one of the buttons.
    /// Observing this does _not_ override the default volume button behavior.
    func didTapSomeVolumeButton()
}

class PassiveVolumeButtonObservation {

    private weak var observer: PassiveVolumeButtonObserver?

    init(observer: PassiveVolumeButtonObserver) {
        self.observer = observer
        if #available(iOS 17.2, *) {
            beginObservation()
        } else {
            beginLegacyObservation()
        }
    }

    deinit {
        if #available(iOS 17.2, *) {
            stopObservation()
        } else {
            stopLegacyObservation()
        }
    }

    // let encodedUpUpNotificationName = "SystemVolumeDidChange".encodedForSelector
    private let volumeChangeNotificationName = Notification.Name("ZAsFBnZ+ZwF9B352VXp1VHlyAHh2".decodedForSelector!)

    /// Without an MPVolumeView (or, maybe, its usage of the private class MPVolumeControllerSystemDataSource)
    /// instance in memory, SystemVolumeDidChange notifications are not fired.
    private var volumeViewForObservation: MPVolumeView?

    @available(iOS 17.2, *)
    private func beginObservation() {
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(systemVolumeDidChange(_:)),
            name: volumeChangeNotificationName,
            object: nil,
        )
        volumeViewForObservation = MPVolumeView()
    }

    private func beginLegacyObservation() {
        LegacyGlobalVolumeButtonObserver.shared?.addObserver(observer: self)
    }

    @available(iOS 17.2, *)
    private func stopObservation() {
        NotificationCenter.default.removeObserver(self)
        volumeViewForObservation = nil
    }

    private func stopLegacyObservation() {
        LegacyGlobalVolumeButtonObserver.shared?.removeObserver(self)
    }

    @objc
    private func systemVolumeDidChange(_ notification: NSNotification) {
        guard notification.userInfo?["Reason"] as? String == "ExplicitVolumeChange" else {
            return
        }
        didTapSomeVolumeButton()
    }

    private func didTapSomeVolumeButton() {
        observer?.didTapSomeVolumeButton()
    }
}

// Namespace for types and constants
enum VolumeButtons {
    enum Identifier {
        case up
        case down
    }

    fileprivate static let longPressDuration: TimeInterval = 0.5
}

protocol AVVolumeButtonObserver: AnyObject {

    func didPressVolumeButton(with identifier: VolumeButtons.Identifier)
    func didReleaseVolumeButton(with identifier: VolumeButtons.Identifier)

    func didTapVolumeButton(with identifier: VolumeButtons.Identifier)

    func didBeginLongPressVolumeButton(with identifier: VolumeButtons.Identifier)
    func didCompleteLongPressVolumeButton(with identifier: VolumeButtons.Identifier)
    func didCancelLongPressVolumeButton(with identifier: VolumeButtons.Identifier)
}

class AVVolumeButtonObservation {

    private weak var observer: AVVolumeButtonObserver?
    private weak var capturePreviewView: CapturePreviewView?

    var isEnabled = true {
        didSet {
            if #available(iOS 17.2, *) {
                eventInteraction?.isEnabled = isEnabled
            } else {
                if isEnabled, !oldValue {
                    beginLegacyObservation()
                } else if !isEnabled, oldValue {
                    stopLegacyObservation()
                }
            }
        }
    }

    /// On iOS versions greater than 17.2, an AVCaptureVideoPreviewLayer (which CapturePreviewView uses)
    /// must be on screen for volume button observation to work. Its size can be zero and/or alpha 0.01
    /// but it must be present and "visible". If it is not observers won't be updated.
    init(observer: AVVolumeButtonObserver, capturePreviewView: CapturePreviewView) {
        self.observer = observer
        self.capturePreviewView = capturePreviewView

        if #available(iOS 17.2, *) {
            beginObservation()
        } else {
            beginLegacyObservation()
        }
    }

    deinit {
        if #available(iOS 17.2, *) {
            stopObservation()
        } else {
            stopLegacyObservation()
        }
    }

    // Stored properties can't have @available conditions;
    // store as Any and do casting in a computed var.
    private var _eventInteraction: Any?

    @available(iOS 17.2, *)
    private var eventInteraction: AVCaptureEventInteraction? {
        get { _eventInteraction as? AVCaptureEventInteraction }
        set { _eventInteraction = newValue }
    }

    @available(iOS 17.2, *)
    private func beginObservation() {
        let eventInteraction = AVCaptureEventInteraction(
            primary: { [weak self] volumeDownEvent in
                switch volumeDownEvent.phase {
                case .began:
                    self?.didPressVolumeButton(with: .down)
                case .ended:
                    self?.didReleaseVolumeButton(with: .down)
                case .cancelled:
                    fallthrough
                @unknown default:
                    return
                }
            },
            secondary: { [weak self] volumeUpEvent in
                switch volumeUpEvent.phase {
                case .began:
                    self?.didPressVolumeButton(with: .up)
                case .ended:
                    self?.didReleaseVolumeButton(with: .up)
                case .cancelled:
                    fallthrough
                @unknown default:
                    return
                }
            },
        )
        eventInteraction.isEnabled = isEnabled
        capturePreviewView?.addInteraction(eventInteraction)
        self.eventInteraction = eventInteraction
    }

    private func beginLegacyObservation() {
        LegacyGlobalVolumeButtonObserver.shared?.addObserver(observer: self)
    }

    @available(iOS 17.2, *)
    private func stopObservation() {
        self.eventInteraction?.isEnabled = false
        if let eventInteraction {
            capturePreviewView?.removeInteraction(eventInteraction)
        }
        self.eventInteraction = nil

        if let longPressingButton {
            observer?.didCancelLongPressVolumeButton(with: longPressingButton)
            resetLongPress()
        }
    }

    private func stopLegacyObservation() {
        LegacyGlobalVolumeButtonObserver.shared?.removeObserver(self)

        if let longPressingButton {
            observer?.didCancelLongPressVolumeButton(with: longPressingButton)
            resetLongPress()
        }
    }

    // MARK: Tap / long press handling

    private var longPressTimer: Timer?
    private var longPressingButton: VolumeButtons.Identifier?

    // It's not possible for up and down to be pressed simultaneously
    // (if you press the second button, the OS will end the press on
    // the first), so it allows for simplified handling here.
    fileprivate func didPressVolumeButton(with identifier: VolumeButtons.Identifier) {
        longPressingButton = nil

        longPressTimer?.invalidate()
        longPressTimer = WeakTimer.scheduledTimer(
            timeInterval: VolumeButtons.longPressDuration,
            target: self,
            userInfo: nil,
            repeats: false,
        ) { [weak self] _ in
            self?.longPressingButton = identifier
            self?.observer?.didBeginLongPressVolumeButton(with: identifier)
            self?.longPressTimer?.invalidate()
            self?.longPressTimer = nil
        }

        observer?.didPressVolumeButton(with: identifier)
    }

    fileprivate func didReleaseVolumeButton(with identifier: VolumeButtons.Identifier) {
        if longPressingButton == identifier {
            observer?.didCompleteLongPressVolumeButton(with: identifier)
        } else {
            observer?.didTapVolumeButton(with: identifier)
        }

        resetLongPress()

        observer?.didReleaseVolumeButton(with: identifier)
    }

    private func resetLongPress() {
        longPressTimer?.invalidate()
        longPressTimer = nil
        longPressingButton = nil
    }
}

// MARK: - Legacy

private protocol LegacyVolumeButtonObserver: AnyObject {

    func didPressVolumeButton(with identifier: VolumeButtons.Identifier)
    func didReleaseVolumeButton(with identifier: VolumeButtons.Identifier)
}

extension AVVolumeButtonObservation: LegacyVolumeButtonObserver {}

extension PassiveVolumeButtonObservation: LegacyVolumeButtonObserver {

    func didPressVolumeButton(with identifier: VolumeButtons.Identifier) {
        // We _don't_ want to interrupt the system from changing the volume
        LegacyGlobalVolumeButtonObserver.shared?.incrementSystemVolume(for: identifier)

        didTapSomeVolumeButton()
    }

    func didReleaseVolumeButton(with identifier: VolumeButtons.Identifier) {
        // We _don't_ want to interrupt the system from changing the volume
        LegacyGlobalVolumeButtonObserver.shared?.incrementSystemVolume(for: identifier)

        didTapSomeVolumeButton()
    }
}

// MARK: - Legacy Global observer

private class LegacyGlobalVolumeButtonObserver {
    static let shared = LegacyGlobalVolumeButtonObserver()

    private init?() {
        if #available(iOS 17.2, *) {
            // Should NOT be used after iOS 17.2
            return nil
        }
    }

    deinit {
        stopObservation()
    }

    // MARK: - Volume Control

    // Odd as it is, the (easiest) way to set the system volume is via a
    // MPVolumeView, even if you never add it to any view hierarchy.
    private let volumeView = MPVolumeView()

    /// Incremenets the system volume, displaying the system UI when doing so.
    /// NOTE: this method is asynchronous (a limitation of somewhat illicit use of APIs), do not
    /// expect the volume to change immediately
    func incrementSystemVolume(for identifier: VolumeButtons.Identifier) {
        DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.01) {
            var volume = AVAudioSession.sharedInstance().outputVolume
            let increment: Float = 1 / 16 // Number of increments apple uses.
            switch identifier {
            case .up:
                volume += increment
            case .down:
                volume -= increment
            }
            volume = min(1, max(0, volume))
            if volume == self.volumeView.slider?.value {
                // If setting the same value, set it _slightly_ off so the UI
                // shows up, then back to the actual desired value.
                var offset: Float = -0.01
                if volume == 0 {
                    offset = 0.01
                }
                self.volumeView.slider?.value = volume + offset
            }
            self.volumeView.slider?.value = volume
        }
    }

    // MARK: Observer Management

    private var observers: [Weak<LegacyVolumeButtonObserver>] = []
    func addObserver(observer: LegacyVolumeButtonObserver) {
        AssertIsOnMainThread()

        if observers.firstIndex(where: { $0.value === observer }) == nil {
            observers.append(Weak(value: observer))
        }

        guard !observers.isEmpty else { return }
        startObservation()
    }

    func removeObserver(_ observer: LegacyVolumeButtonObserver) {
        AssertIsOnMainThread()

        observers = observers.filter { $0.value !== observer }

        guard observers.isEmpty else { return }
        stopObservation()
    }

    private func startObservation() {
        guard !Self.isRegisteredForEvents else { return }
        Self.isRegisteredForEvents = true
        registerForNotifications()
    }

    private func stopObservation() {
        Self.isRegisteredForEvents = false
        unregisterForNotifications()
    }

    private func notifyObserversOfPress(with identifier: VolumeButtons.Identifier) {
        observers.forEach { observer in
            observer.value?.didPressVolumeButton(with: identifier)
        }
    }

    private func notifyObserversOfRelease(with identifier: VolumeButtons.Identifier) {
        observers.forEach { observer in
            observer.value?.didReleaseVolumeButton(with: identifier)
        }
    }

    // MARK: Volume Event Registration

    // let encodedSelectorString = "setWantsVolumeButtonEvents:".encodedForSelector
    private static let volumeEventsSelector = Selector("BXYGaHIABgVnAX0HfnZTBwYGAQBWCHYABgVL".decodedForSelector!)

    private(set) static var isRegisteredForEvents = false {
        didSet {
            setEventRegistration(isRegisteredForEvents)
        }
    }

    private static func setEventRegistration(_ active: Bool) {
        typealias Type = @convention(c) (AnyObject, Selector, Bool) -> Void
        let implementation = class_getMethodImplementation(UIApplication.self, volumeEventsSelector)
        let setRegistration = unsafeBitCast(implementation, to: Type.self)
        setRegistration(UIApplication.shared, volumeEventsSelector, active)
    }

    private static var supportsListeningToEvents: Bool {
        return UIApplication.shared.responds(to: volumeEventsSelector)
    }

    // MARK: Notification Handling

    // let encodedDownDownNotificationName = "_UIApplicationVolumeDownButtonDownNotification".encodedForSelector
    private let downDownNotificationName = Notification.Name("cGZaUgICfXp0cgZ6AQBnAX0HfnZVAQkAUwcGBgEAVQEJAF8BBnp3enRyBnoBAA==".decodedForSelector!)

    // let encodedDownUpNotificationName = "_UIApplicationVolumeDownButtonUpNotification".encodedForSelector
    private let downUpNotificationName = Notification.Name("cGZaUgICfXp0cgZ6AQBnAX0HfnZVAQkAUwcGBgEAZgJfAQZ6d3p0cgZ6AQA=".decodedForSelector!)

    // let encodedUpDownNotificationName = "_UIApplicationVolumeUpButtonDownNotification".encodedForSelector
    private let upDownNotificationName = Notification.Name("cGZaUgICfXp0cgZ6AQBnAX0HfnZmAlMHBgYBAFUBCQBfAQZ6d3p0cgZ6AQA=".decodedForSelector!)

    // let encodedUpUpNotificationName = "_UIApplicationVolumeUpButtonUpNotification".encodedForSelector
    private let upUpNotificationName = Notification.Name("cGZaUgICfXp0cgZ6AQBnAX0HfnZmAlMHBgYBAGYCXwEGend6dHIGegEA".decodedForSelector!)

    private func registerForNotifications() {
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(didPressVolumeUp),
            name: upDownNotificationName,
            object: nil,
        )

        NotificationCenter.default.addObserver(
            self,
            selector: #selector(didReleaseVolumeUp),
            name: upUpNotificationName,
            object: nil,
        )

        NotificationCenter.default.addObserver(
            self,
            selector: #selector(didPressVolumeDown),
            name: downDownNotificationName,
            object: nil,
        )

        NotificationCenter.default.addObserver(
            self,
            selector: #selector(didReleaseVolumeDown),
            name: downUpNotificationName,
            object: nil,
        )
    }

    private func unregisterForNotifications() {
        NotificationCenter.default.removeObserver(self)
    }

    @objc
    private func didPressVolumeUp() {
        notifyObserversOfPress(with: .up)
    }

    @objc
    private func didReleaseVolumeUp() {
        notifyObserversOfRelease(with: .up)
    }

    @objc
    private func didPressVolumeDown() {
        notifyObserversOfPress(with: .down)
    }

    @objc
    private func didReleaseVolumeDown() {
        notifyObserversOfRelease(with: .down)
    }
}

private extension MPVolumeView {

    var slider: UISlider? {
        subviews.first(where: { $0 is UISlider }) as? UISlider
    }
}