//
// 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
}
}