import AVFoundation import Flutter /// Platform channel plugin that detects audio output devices on iOS via /// AVAudioSession route change notifications. /// /// Channel: "pluriwave/audio_devices" /// - MethodChannel `getActiveDevice` → Dictionary with `id`, `type`, `name` /// - EventChannel stream → same Dictionary on route change /// /// Device ID format (matches spec): /// "builtin_speaker" — AVAudioSession.Port.builtInSpeaker /// "wired_headset" — AVAudioSession.Port.headphones / .headsetMic /// "bt_a2dp:" — AVAudioSession.Port.bluetoothA2DP /// "usb_headset:" — AVAudioSession.Port.usbAudio /// /// iOS uid fallback (spec scenario "iOS uid fallback on uid instability"): /// Use portType+uid as the primary key; fall back to portType+portName /// if uid is empty or nil to guarantee a non-empty, non-null key. class AudioDevicesPlugin: NSObject, FlutterPlugin, FlutterStreamHandler { private static let channelName = "pluriwave/audio_devices" // Type int constants matching the Dart-side protocol (same as Android). private static let typeBuiltinSpeaker = 2 private static let typeWiredHeadset = 3 private static let typeBluetoothA2dp = 8 private static let typeUsbHeadset = 14 private static let typeUnknown = 0 private var eventSink: FlutterEventSink? // MARK: - Plugin registration static func register(with registrar: FlutterPluginRegistrar) { let messenger = registrar.messenger() let instance = AudioDevicesPlugin() let methodChannel = FlutterMethodChannel(name: channelName, binaryMessenger: messenger) registrar.addMethodCallDelegate(instance, channel: methodChannel) let eventChannel = FlutterEventChannel(name: channelName, binaryMessenger: messenger) eventChannel.setStreamHandler(instance) registrar.addApplicationDelegate(instance) } // MARK: - MethodChannel func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { switch call.method { case "getActiveDevice": result(currentDeviceMap()) default: result(FlutterMethodNotImplemented) } } // MARK: - EventChannel (FlutterStreamHandler) func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { self.eventSink = events NotificationCenter.default.addObserver( self, selector: #selector(routeChanged(_:)), name: AVAudioSession.routeChangeNotification, object: nil ) return nil } func onCancel(withArguments arguments: Any?) -> FlutterError? { NotificationCenter.default.removeObserver( self, name: AVAudioSession.routeChangeNotification, object: nil ) self.eventSink = nil return nil } // MARK: - Route change notification @objc private func routeChanged(_ notification: Notification) { guard let sink = eventSink else { return } sink(currentDeviceMap()) } // MARK: - Helpers /// Returns a dictionary representing the current active audio output route. private func currentDeviceMap() -> [String: Any] { let session = AVAudioSession.sharedInstance() let outputs = session.currentRoute.outputs // Priority order: BT A2DP > USB > wired > built-in speaker > unknown let priority: [AVAudioSession.Port] = [ .bluetoothA2DP, .usbAudio, .headphones, .headsetMic, .builtInSpeaker, ] let best = outputs.min { a, b in let ia = priority.firstIndex(of: a.portType) ?? Int.max let ib = priority.firstIndex(of: b.portType) ?? Int.max return ia < ib } return portToMap(best) } /// Converts an AVAudioSessionPortDescription to the channel map format. private func portToMap(_ port: AVAudioSessionPortDescription?) -> [String: Any] { guard let port = port else { return ["id": "builtin_speaker", "type": AudioDevicesPlugin.typeBuiltinSpeaker, "name": "Speaker"] } switch port.portType { case .builtInSpeaker: return ["id": "builtin_speaker", "type": AudioDevicesPlugin.typeBuiltinSpeaker, "name": port.portName] case .headphones, .headsetMic: return ["id": "wired_headset", "type": AudioDevicesPlugin.typeWiredHeadset, "name": port.portName] case .bluetoothA2DP: let key = stableKey(prefix: "bt_a2dp", port: port) return ["id": key, "type": AudioDevicesPlugin.typeBluetoothA2dp, "name": port.portName] case .usbAudio: let key = stableKey(prefix: "usb_headset", port: port) return ["id": key, "type": AudioDevicesPlugin.typeUsbHeadset, "name": port.portName] default: // Unknown type — use portType string as part of id to avoid empty key. let id = "unknown:\(port.portType.rawValue)" return ["id": id, "type": AudioDevicesPlugin.typeUnknown, "name": port.portName] } } /// Derives a stable device key using uid (preferred) or portName as fallback. /// /// Per spec scenario "iOS uid fallback on uid instability": uid may differ /// across sessions on some BT devices. In that case, use portType+portName. private func stableKey(prefix: String, port: AVAudioSessionPortDescription) -> String { let uid = port.uid if !uid.isEmpty { return "\(prefix):\(uid)" } // uid is empty — fall back to portType+portName (must not be empty per spec). let name = port.portName.isEmpty ? "unknown" : port.portName return "\(prefix):\(name)" } }