1

I'm working on a Flutter App, and I'm writing some platform code in Swift to be able to connect to both Bluetooth Classic and BLE devices.

Flutter packages are unfortunately targeted at BLE.

As of 2019 Apple's BluetoothCore works with Classic/BDR/EDR devices as presented in the lib's docs: https://developer.apple.com/videos/play/wwdc2019/901

Support starts from IOS 13, and I'm testing with a IOS 17.4.1.

Yet, even when trying to replicate the steps in the documentation I simply cannot:

  1. Find devices like cars, watches, etc during a scan
  2. Display a list of already connected devices

I'm aware of things such as "devices must be in advertisement mode".

Keep in mind I have no way of being in touch with device providers, manufacturers, etc to obtain any sort of ID.

Prints were removed from code to save space.

Let's start with my Swift implementation, starting with the main AppDelegate class:

@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
    
    override func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
            let controller = window?.rootViewController as! FlutterViewController
            
            // Channels that send requests from Flutter to IOS, and receive responses or execute actions
            let bluetoothChannel: FlutterMethodChannel = FlutterMethodChannel(name: "bluetooth_channel", binaryMessenger: controller.binaryMessenger)
            
            // Channels that communicate automatically from IOS to Flutter (EventChannels)
            let cbManagerStateChannel = FlutterEventChannel(name: "cbmanager_state_channel", binaryMessenger: controller.binaryMessenger)
            let didDiscoverChannel = FlutterEventChannel(name: "did_discover_channel", binaryMessenger: controller.binaryMessenger)
            // other channels...
            
            // Handler classes for EventChannels
            let cbManagerStateController: CBManagerStateController = CBManagerStateController()
            let didDiscoverController: DidDiscoverController = DidDiscoverController()
            // other handler classes...
            
            // Manager classes for Channels
            let bluetoothManager: BluetoothManager = BluetoothManager(cbManagerStateController: cbManagerStateController, didDiscoverController: didDiscoverController, didConnectController: didConnectController, didFailToConnectController: didFailToConnectController, didDisconnectController: didDisconnectController, connectionEventDidOccurController: connectionEventDidOccurController)
            
            // Connecting channels and controllers
            cbManagerStateChannel.setStreamHandler(cbManagerStateController)
            didDiscoverChannel.setStreamHandler(didDiscoverController)
            // other connections...
            
            // Registering all available methods for bluetoothChannel
            bluetoothChannel.setMethodCallHandler({
                (call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in
                switch call.method {
                case "getCBManagerState":
                    result(bluetoothManager.getCBManagerState())
                case "getCBManagerAuthorization":
                    result(bluetoothManager.getCBManagerAuthorization())
                // other cases... 
                default:
                    result(FlutterMethodNotImplemented)
                }
            })
            
            GeneratedPluginRegistrant.register(with: self)
            return super.application(application, didFinishLaunchingWithOptions: launchOptions)
        }
}

Next, here's my BluetoothManager class which implements all the delegate methods:

class BluetoothManager: NSObject, CBCentralManagerDelegate {
    
    private var centralManager: CBCentralManager
    private var cbManagerAuthorization: CBManagerAuthorization = CBManagerAuthorization.notDetermined
    private var scannedPeripheralList: [CBPeripheral]
    private let cbManagerStateController: CBManagerStateController
    private var didDiscoverController: DidDiscoverController
    private var didConnectController: DidConnectController
    private var didFailToConnectController: DidFailToConnectController
    private var didDisconnectController: DidDisconnectController
    private var connectionEventDidOccurController: ConnectionEventDidOccurController
    
    init(cbManagerStateController: CBManagerStateController, didDiscoverController: DidDiscoverController, didConnectController: DidConnectController, didFailToConnectController: DidFailToConnectController, didDisconnectController: DidDisconnectController, connectionEventDidOccurController: ConnectionEventDidOccurController) {
        self.centralManager = CBCentralManager(delegate: nil, queue: nil)
        self.scannedPeripheralList = []
        self.cbManagerStateController = cbManagerStateController
        self.didDiscoverController = didDiscoverController
        self.didConnectController = didConnectController
        self.didFailToConnectController = didFailToConnectController
        self.didDisconnectController = didDisconnectController
        self.connectionEventDidOccurController = connectionEventDidOccurController
        super.init()
        centralManager.delegate = self
        centralManager.registerForConnectionEvents(options: [:])
    }
    
    // Methods directly called by the Flutter side //
    
    func getCBManagerState() -> Int {
        return centralManager.state.rawValue
    }
    
    func getCBManagerAuthorization() -> Int {
        return cbManagerAuthorization.rawValue
    }
    
    func getIsScanning() -> Bool {
        return centralManager.isScanning
    }
    
    func getConnectedPeripherals() -> [Dictionary<String, String>] {
        var devices:[[String : String]] = [Dictionary<String, String>]()
        let connectedPeripherals: [CBPeripheral] = centralManager.retrieveConnectedPeripherals(withServices: [])
        connectedPeripherals.forEach { peripheral in
            let name: String = peripheral.name ?? "Unknown Name"
            let peripheralId: String = peripheral.identifier.uuidString
            devices.append([
                "name": name,
                "id": peripheralId
            ])
        }
        return devices
    }
    
    // Returns nothing, instead it calls centralManager(didDiscover)
    func scanPeripherals() {
        let serviceUUIDs: [CBUUID]? = nil
        centralManager.scanForPeripherals(withServices: serviceUUIDs, options: nil)
    }
    
    // Returns nothing, To confirm if scan state actually stopped, call getIsScanning()
    func stopScan() {
        centralManager.stopScan()
        scannedPeripheralList.removeAll()
    }
    
    // Returns nothing, instead it calls centralManager(didConnect) or (didFailToConnect)
    func connectToPeripheral(_ peripheralIdString: String) {
        guard let peripheralIdString: UUID = UUID(uuidString: peripheralIdString) else {
            return
        }
        let peripherals:[CBPeripheral] = scannedPeripheralList
        if let peripheral: CBPeripheral = peripherals.first(where: { $0.identifier == peripheralIdString }) {
            centralManager.connect(peripheral)
        }
    }
    
    func disconnectFromPeripheral(_ deviceIdString: String) {
        guard let deviceId: UUID = UUID(uuidString: deviceIdString) else {
            return
        }
        let peripherals: [CBPeripheral] = centralManager.retrieveConnectedPeripherals(withServices: [])
        if let peripheral: CBPeripheral = peripherals.first(where: {$0.identifier == deviceId}) {
            centralManager.cancelPeripheralConnection(peripheral)
        }
    }
    
    // Methods called by the Swift side, we only keep open streams and channels on Flutter
    
    // Called automatically when scanPeripherals() discovers a peripheral device during a scan
    func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) {
        var name: String?
        if let peripheralName: String = peripheral.name {
          name = peripheralName
        } else if let advertisementName = advertisementData[CBAdvertisementDataLocalNameKey] as? String {
          name = advertisementName
        }
        let peripheralId: String = peripheral.identifier.uuidString
        let peripheralState: Int = peripheral.state.rawValue
        if (name != nil && !scannedPeripheralList.contains(where: { $0.identifier == peripheral.identifier })){
            let argumentMap : [String: Any?] = [
                "name": name,
                "id": peripheralId,
                "state": peripheralState
            ]
            scannedPeripheralList.append(peripheral)
            didDiscoverController.eventSink?(argumentMap)
        }
    }
    
    // Called automatically when a peripheral is paired to your phone
    func centralManager(_ central: CBCentralManager, connectionEventDidOccur event: CBConnectionEvent, for peripheral: CBPeripheral) {
        if (event == .peerConnected) {
            print("IOS: Case is peer connected")
            connectToPeripheral(peripheral.identifier.uuidString)
        } else if (event == .peerDisconnected ){
            print("IOS: Peer %@ disconnected!", peripheral)
        } 
    }
    
    // Called automatically when connectToPeripheral() connects to a device
    func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
        let name: String = peripheral.name ?? "No name"
        let peripheralId: String = peripheral.identifier.uuidString
        let argumentMap : [String: String] = [
            "name": name,
            "id": peripheralId
        ]
        didConnectController.eventSink?(argumentMap)
    }
    
    // Called automatically when connectToPeripheral tries to connect to a device but fails
    func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
        let errorMessage: String = error?.localizedDescription ?? ""
        let name: String = peripheral.name ?? "No name"
        let peripheralId: String = peripheral.identifier.uuidString
        let argumentMap: [String: String] = [
            "name" : name,
            "id": peripheralId,
            "error": errorMessage
        ]
        didFailToConnectController.eventSink?(argumentMap)
    }

    // Called automatically when disconnectFromPeripheral() disconnects from a device
    func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
    let name: String = peripheral.name ?? "No name"
    let peripheralId: String = peripheral.identifier.uuidString
    var argumentMap: [String: Any] = [
        "name" : name,
        "id": peripheralId
    ]
    
    if let error = error {
        argumentMap["error"] = error.localizedDescription
    } 
    
    didDisconnectController.eventSink?(argumentMap)
}
    
    // Called automatically when there is a change in the Bluetooth adapter's state.
    func centralManagerDidUpdateState(_ central: CBCentralManager) {
        let arguments : Int = central.state.rawValue
        cbManagerStateController.eventSink?(arguments)
    }
}

Finally, A list of controller classes, they're mostly all the same:

class CBManagerStateController: NSObject, FlutterStreamHandler {
    
    var eventSink: FlutterEventSink?
    
    func onListen(withArguments arguments: Any?, eventSink: @escaping FlutterEventSink) -> FlutterError? {
        self.eventSink = eventSink
        return nil
    }
    
    func onCancel(withArguments arguments: Any?) -> FlutterError? {
        eventSink = nil
        return nil
    }
}

class ConnectionEventDidOccurController: NSObject, FlutterStreamHandler {
    
    var eventSink: FlutterEventSink?
    
    func onListen(withArguments arguments: Any?, eventSink: @escaping FlutterEventSink) -> FlutterError? {
        self.eventSink = eventSink
        return nil
    }
    
    func onCancel(withArguments arguments: Any?) -> FlutterError? {
        eventSink = nil
        return nil
    }
}

// other controllers...

On the Flutter side, first I've declared the channels as constants in a separate file:

// Flutter sends requests, IOS returns a response
const MethodChannel bluetoothChannel = MethodChannel("bluetooth_channel");

// IOS sends values as streams through these channels
const EventChannel cbManagerStateChannel = EventChannel("cbmanager_state_channel");
// other channels...

Next I created a repository class exclusively for the methods I can call directly, and wrapped it inside a provider just for easier injection. They're mostly similar, using try/catch blocks to get PlatformExceptions and printing the values obtained from the IOS side:


@riverpod
class BluetoothRepository extends _$BluetoothRepository {
  @override
  void build() {}

  Future<int> getCBManagerState() async {
    int initialState = 0;
      initialState = await bluetoothChannel.invokeMethod<int>('getCBManagerState') as int;
      debugPrint('Repository: initialCBManagerState arrived with value: $initialState');
      return initialState;
  }

  Future<void> scanPeripherals() async {
      await bluetoothChannel.invokeMethod('scanPeripherals');
  }

// other methods, stop scan, connect/disconnect to/from peripheral, etc...

For the CBManager's state I used two separate providers as a work around to get the initial value, and receive the stream only as the value changes:

@riverpod
class InitialCBManagerState extends _$InitialCBManagerState {
  @override
  FutureOr<int> build() async {
    FutureOr<int> initialCBManagerState = 0;
    initialCBManagerState = await getCBManagerState();
    return initialCBManagerState;
  }

  FutureOr<int> getCBManagerState() async {
    int initialState = 0;
      initialState = await ref.read(bluetoothRepositoryProvider.notifier).getCBManagerState();
      debugPrint('Provider: initialCBManagerState arrived with value: $initialState');
      return initialState;
  }
}

@riverpod
Stream<int> cBManagerState(CBManagerStateRef ref, EventChannel channel) async* {
  Stream<dynamic>? cbManagerStateStream;
  int cbManagerState;
    cbManagerStateStream = channel.receiveBroadcastStream();
    await for (final state in cbManagerStateStream) {
      debugPrint('Provider: new cbManagerState arrived with value: $state');
      cbManagerState = state;
      yield cbManagerState;
    }
}

In another file I created a provider focused on the scanning process and its state:

@riverpod
class BluetoothScan extends _$BluetoothScan {
  @override
  bool build(MethodChannel channel) => false;

  Future<void> _updateIsScanning() async {
    bool? isScanning = await channel.invokeMethod('getIsScanning') as bool;
    debugPrint('Provider: isScanning $isScanning');
    state = isScanning;
  }

  Future<void> scanPeripherals() async {
    try {
      ref.read(bluetoothRepositoryProvider.notifier).scanPeripherals();
      _updateIsScanning();
    } catch (exception) {
      debugPrint('Controller: Exception while scanning: $exception');
    }
  }

// Also stop scan...

}

Finally on my last provider file I created providers dedicated to: calling the repository methods, handling connection attempts and events, retreieving already connected devices and handling discovery:

@riverpod
class BluetoothController extends _$BluetoothController {
  @override
  void build() {}

  Future<void> connectToPeripheral(String peripheralId) async {
      ref.read(bluetoothRepositoryProvider.notifier).connectToPeripheral(peripheralId);
  }

  // other methods like disconnect...
}

@riverpod
Stream<List<BluetoothPeripheral>> connectionEventDidOccur(ConnectionEventDidOccurRef ref, EventChannel channel) async* {
  debugPrint('Provider: connectionEventDidOccur was called');
  Stream<dynamic> connectionEventDidOccurStream;
  BluetoothPeripheral peripheral;
  List<BluetoothPeripheral> peripheralList = [];
  CBPeripheralState peripheralState;

    connectionEventDidOccurStream = channel.receiveBroadcastStream();
    await for (final device in connectionEventDidOccurStream) {
      debugPrint('Provider: connectionEventDidOccur device in for loop is $device');
      peripheralState = ref.read(parsedPeripheralStateProvider(device['state']));
      BluetoothPeripheral bluetoothPeripheral = BluetoothPeripheral.fromJson({
        'name': device['name'],
        'id': device['id'],
        'state': peripheralState.name,
      });
      if (!peripheralList.contains(bluetoothPeripheral)) {
        debugPrint('Provider: didDiscover if statement condition fulfilled');
        peripheral = bluetoothPeripheral;
        debugPrint('Provider: didDiscover peripheral before adding to list is ${peripheral.name}, ${peripheral.id}');
        peripheralList.add(bluetoothPeripheral);
        debugPrint(
            'Provider: didDiscover peripheralList before yield length is ${peripheralList.length}, and content is ${peripheralList.toString()}');
        yield peripheralList;
      }
    }
}

@riverpod
Future<List<BluetoothPeripheral>> connectedPeripherals(ConnectedPeripheralsRef ref, MethodChannel channel) async {
  ref.watch(connectionEventDidOccurProvider(connectionEventDidOccurChannel));
  List<Map<String, String>>? deviceMapList = [];
  List<BluetoothPeripheral> bluetoothPeripheralList = [];

    deviceMapList = await channel.invokeListMethod<Map<String, String>>('getConnectedPeripherals');
    debugPrint('Provider: deviceMapList $deviceMapList');
    for (final device in deviceMapList!) {
      final bluetoothPeripheral = BluetoothPeripheral.fromJson(device);
      debugPrint('Provider: deviceMapList $bluetoothPeripheral');
      bluetoothPeripheralList.add(bluetoothPeripheral);
    }
  return bluetoothPeripheralList;
}

// other providers...

Moving to the UI, I'm essentially using the provider.when syntax from Riverpod to show different data depending on the state of each provider.

Also here's where the first big error happens, even when I'm already connected to devices, the list is always emppty, this is confirmed by all the prints from the IOS side all the way to the view:

class BluetoothConfiguration extends ConsumerWidget {
  const BluetoothConfiguration({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final AsyncValue<List<BluetoothPeripheral>> peripheralList = ref.watch(connectedPeripheralsProvider(bluetoothChannel));
    final AsyncValue<int> cbManagerProvider = ref.watch(cBManagerStateProvider(cbManagerStateChannel));

// other providers...

    return Scaffold(
      body: Padding(
          child: Center(
            child: Column(
              children: [
                peripheralList.when(
                  data: (peripheral) {
                    if (peripheral.isNotEmpty) {
                      return Column(
                        children: [
                          const Text('Choose your main device'),
                          ListView.builder(itemBuilder: (context, index) {
                            return ListTile(
                                title: Text(peripheral[index].name),
                                subtitle: Text(peripheral[index].id),
                                trailing: IconButton(
                                    onPressed: () {
                                      peripheral[index] = peripheral[index].copyWith(mainPeripheral: true);
                                      localStorage.value?.setString('mainPeripheral', peripheral[index].id);
                                    },
                                    icon: peripheral[index].id == localStorage.value?.getString('mainPeripheral')
                                        ? const Icon(Icons.play_circle_fill)
                                        : const Icon(Icons.play_circle)));
                          }),
                        ],
                      );
                    } else {
                      return const Column(
                        children: [
                          Text('There are no connected devices'),
                          SizedBox(height: 10),
                          Text('Connect your first one below'),
                        ],
                      );
                    }
                  },
                  error: (_, stackTrace) => const Icon(Icons.error),
                  loading: () => const CircularProgressIndicator(),
                ),
                const SizedBox(height: 40),
                cbManagerProvider.when(
                  data: (cbManagerState) => Row(
                    mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                    children: [
                      ElevatedButton(
                        onPressed: () async {
                          (cbManagerState == 5 && !scanProvider)
                              ? await ref.read(bluetoothScanProvider(bluetoothChannel).notifier).scanPeripherals()
                              : null;
                          context.mounted
                              ? showDialog(
                                  context: context,
                                  builder: (context) => const DeviceListDialog(),
                                )
                              : null;
                        },
                        child: Text(cbManagerState == 5 && !scanProvider ? 'Scan Devices' : 'Cannot Scan'),
                      ),
                    ],
                  ),
                  error: (_, stackTrace) => Column(
                    children: [
                      const Icon(Icons.bluetooth_disabled),
                      Text('Error of type: ${stackTrace.toString()}'),
                    ],
                  ),
                  loading: () => initialCBManagerProvider.when(
                    data: (initialCBManagerState) => Column(
                      crossAxisAlignment: CrossAxisAlignment.center,
                      children: [
                        ElevatedButton(
                          onPressed: () async {
                            (initialCBManagerState == 5 && !scanProvider)
                                ? await ref.read(bluetoothScanProvider(bluetoothChannel).notifier).scanPeripherals()
                                : null;
                            context.mounted
                                ? showDialog(
                                    context: context,
                                    builder: (context) => const DeviceListDialog(),
                                  )
                                : null;
                          },
                          child: Text(initialCBManagerState == 5 && !scanProvider ? 'Scan Devices' : 'Cannot Scan'),
                        ),
                      ],
                    ),
                    error: (_, stackTrace) => Column(
                      children: [
                        const Icon(Icons.bluetooth_disabled),
                        Text('Error of type: ${stackTrace.toString()}'),
                      ],
                    ),
                    loading: () => const Center(child: CircularProgressIndicator()),
                  ),
                ),
              ],
            ),
          )),
    );
  }
}

Well finally there's the dialog that shows the devices that are currently being scanned, here's where the second big error occurs, as I simply don't find many devices around me (I still many others though), even when I'm scanning, with my bluetooth on, and they're on advertisement mode.

The devices which are found though, are perfectly rendered on the screen with name and ID.

class DeviceListDialog extends ConsumerStatefulWidget {
  const DeviceListDialog({super.key});

  @override
  ConsumerState<DeviceListDialog> createState() => _DeviceListDialogState();
}

class _DeviceListDialogState extends ConsumerState<DeviceListDialog> {
  void _closeModal() {
    ref.read(bluetoothScanProvider(bluetoothChannel).notifier).stopScan();
    Navigator.pop(context);
  }

  @override
  Widget build(BuildContext context) {
    final AsyncValue<List<BluetoothPeripheral>> discoveredPeripheralStream = ref.watch(didDiscoverProvider(didDiscoverChannel));

    return AlertDialog(
      title: Row(
        children: [
          const Text(
            'Available Devices',
            style: TextStyle(color: SparxColor.secondaryTextColor),
          ),
              IconButton(
              onPressed: _closeModal,
              icon: const Icon(Icons.clear),
            ),
        ],
      ),
      content: SingleChildScrollView(
        child: SizedBox(
          height: 300,
          width: 250,
          child: switch (discoveredPeripheralStream) {
            AsyncData(:var value) => ListView.builder(
                itemCount: value.length,
                itemBuilder: ((context, index) {
                  debugPrint('View: Peripheral Map in Dialog is ${value[index]}');
                  return ListTile(
                    title: Text(value[index].name),
                    subtitle: Text(value[index].id),
                    trailing: ElevatedButton(
                        onPressed: () {
                          value[index].state != CBPeripheralState.connected
                              ? ref.read(bluetoothControllerProvider.notifier).connectToPeripheral(value[index].id)
                              : null;
                        },
                        child: value[index].state == CBPeripheralState.connecting
                            ? const CircularProgressIndicator()
                            : value[index].state == CBPeripheralState.connected
                                ? const Text('Connected', selectionColor: SparxColor.tertiaryColor)
                                : const Text('Connect')),
                  );
                })),
            AsyncError(:final error) => ErrorWidget(error),
            _ => const Row(mainAxisAlignment: MainAxisAlignment.center, children: [CircularProgressIndicator()]),
          },
        ),
      ),
      actions: [
        TextButton(
          onPressed: _closeModal,
          child: const Text('Stop Scan'),
        ),
      ],
    );
  }
}

This is it, thanks for reading this far.

4
  • Once you connected to BLE Peripheral device, your discovery of Bluetooth central will be off. So will not see the list of BLE peripheral devices after. You need to disconnect from connected device, so you can see list of peroheral device again. Commented Apr 23 at 11:32
  • @Shivbaba'sson thanks for contributing. But do you have any idea why some devices like cars or watches simply wouldn't show up during a scan? Commented Apr 23 at 11:39
  • It should show, Either they are not BLE peripheral, or check they are turned on for discovery. Commented Apr 23 at 11:46
  • @Shivbaba'sson according to apple documentation, from 2019 being classic instead of BLE should not be a problem anymore: developer.apple.com/videos/play/wwdc2019/901 Commented Apr 23 at 11:51

1 Answer 1

1

The iOS 13 changes to Core Bluetooth do not make it possible for your app to scan for all BR/EDR devices.

Core Bluetooth can interact with BR/EDR peripherals that support the GATT profile - this is the same profile that has always been supported by Core Bluetooth, but was previously only supported with BLE.

Core Bluetooth does not allow your app to discover or interact with BR/EDR peripherals that do not support the GATT profile.

For example, a typical car audio system will support the A2DP, AVRCP and HFP profiles, but will not support the GATT profile on BLE or BR/EDR.

The details of how this works is described beginning at this point in the video

Note, also, this part of the transcript:

So, what does the incoming connection look like from your app's point of view? Your app will have instantiated a CBCentralManager, passed us a known service UID, and in the case of a BR/EDR or classic device, your user will go to the Bluetooth settings and search for the device, in this case let's say it's a headset running heart rate. They'll discover the device, find it, and attempt to connect. Pairing will be triggered, and then afterwards when we're connected, we'll run a service discovery of the GATT services. If we find a service that you want, then you'll get the delegate callback.

See how the user must still initiate connection to the classic device from Bluetooth settings. Only after that classic pairing takes place will your app receive notification if the device exposes a GATT service that the app has registered for; the heart rate service in this example.

2
  • Hi Paul! Thanks for your answer. It seems that given that cars in general do not use the GATT protocol, bluetooth or at least BluetoothCore doesn't fulfill my use case. I'm simply interested in knowing whether the user has/is connected to a device, no need to hard check for a car although it'd be good, whether he has disconnected from it, an something like a name or id I can display on the app. There's no need to discover or interact with any other specific services or characteristics. Do you have any suggestions of what I could try? I'll research the details. Again, thank you! Commented Apr 23 at 17:23
  • You can observe audio route changes but this notification won't fire while your app is in the background unless you are actively playing background audio.
    – Paulw11
    Commented Apr 23 at 21:00

Not the answer you're looking for? Browse other questions tagged or ask your own question.