From 68b1e97094a12b52381c4d76240c0e25a87bcf8d Mon Sep 17 00:00:00 2001 From: Kevin Lundberg Date: Sat, 24 Jun 2023 22:28:42 -0400 Subject: [PATCH 1/2] Refactor centralmanager to move more complex logic to interface to make it testable, keeping live impl closer to raw cbcentralmanager operations --- .../project.pbxproj | 10 +- .../Convenience+CentralManager.swift | 18 --- .../Interface+CentralManager.swift | 120 ++++++++++---- .../CentralManager/Live+CentralManager.swift | 152 +++++++----------- .../CentralManager/Mock+CentralManager.swift | 6 +- .../Peripheral/Interface+Peripheral.swift | 3 +- .../Peripheral/Live+Peripheral.swift | 28 ++-- 7 files changed, 166 insertions(+), 171 deletions(-) delete mode 100644 Sources/CombineCoreBluetooth/CentralManager/Convenience+CentralManager.swift diff --git a/CombineCoreBluetooth.xcodeproj/project.pbxproj b/CombineCoreBluetooth.xcodeproj/project.pbxproj index d5708ef..b070d83 100644 --- a/CombineCoreBluetooth.xcodeproj/project.pbxproj +++ b/CombineCoreBluetooth.xcodeproj/project.pbxproj @@ -37,7 +37,6 @@ EB443FCC27C6BDE10005CCEA /* Live+CentralManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB443FB427C6BDE00005CCEA /* Live+CentralManager.swift */; }; EB443FCD27C6BDE10005CCEA /* Interface+CentralManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB443FB527C6BDE00005CCEA /* Interface+CentralManager.swift */; }; EB443FCE27C6BDE10005CCEA /* Mock+CentralManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB443FB627C6BDE00005CCEA /* Mock+CentralManager.swift */; }; - EB443FCF27C6BDE10005CCEA /* Convenience+CentralManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB443FB727C6BDE00005CCEA /* Convenience+CentralManager.swift */; }; EB443FDD27C6C1940005CCEA /* CombineCoreBluetooth.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EB443F8E27C6BCA70005CCEA /* CombineCoreBluetooth.framework */; }; EB443FE627C6C1B40005CCEA /* CentralManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB443FE527C6C1B40005CCEA /* CentralManagerTests.swift */; }; /* End PBXBuildFile section */ @@ -83,10 +82,9 @@ EB443FB027C6BDE00005CCEA /* Mock+Central.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Mock+Central.swift"; sourceTree = ""; }; EB443FB127C6BDE00005CCEA /* Live+Central.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Live+Central.swift"; sourceTree = ""; }; EB443FB227C6BDE00005CCEA /* Interface+Central.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Interface+Central.swift"; sourceTree = ""; }; - EB443FB427C6BDE00005CCEA /* Live+CentralManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Live+CentralManager.swift"; sourceTree = ""; }; - EB443FB527C6BDE00005CCEA /* Interface+CentralManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Interface+CentralManager.swift"; sourceTree = ""; }; + EB443FB427C6BDE00005CCEA /* Live+CentralManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; indentWidth = 2; lastKnownFileType = sourcecode.swift; path = "Live+CentralManager.swift"; sourceTree = ""; tabWidth = 2; }; + EB443FB527C6BDE00005CCEA /* Interface+CentralManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; indentWidth = 2; lastKnownFileType = sourcecode.swift; path = "Interface+CentralManager.swift"; sourceTree = ""; tabWidth = 2; }; EB443FB627C6BDE00005CCEA /* Mock+CentralManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Mock+CentralManager.swift"; sourceTree = ""; }; - EB443FB727C6BDE00005CCEA /* Convenience+CentralManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Convenience+CentralManager.swift"; sourceTree = ""; }; EB443FD127C6BE440005CCEA /* CoreBluetooth.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreBluetooth.framework; path = Platforms/MacOSX.platform/Developer/SDKs/MacOSX12.1.sdk/System/Library/Frameworks/CoreBluetooth.framework; sourceTree = DEVELOPER_DIR; }; EB443FD327C6BE5A0005CCEA /* Combine.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Combine.framework; path = Platforms/MacOSX.platform/Developer/SDKs/MacOSX12.1.sdk/System/Library/Frameworks/Combine.framework; sourceTree = DEVELOPER_DIR; }; EB443FD927C6C1940005CCEA /* CombineCoreBluetooth Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "CombineCoreBluetooth Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -150,7 +148,9 @@ EB443F8F27C6BCA70005CCEA /* Products */, EB443FD027C6BE440005CCEA /* Frameworks */, ); + indentWidth = 2; sourceTree = ""; + tabWidth = 2; }; EB443F8F27C6BCA70005CCEA /* Products */ = { isa = PBXGroup; @@ -245,7 +245,6 @@ EB443FB427C6BDE00005CCEA /* Live+CentralManager.swift */, EB443FB527C6BDE00005CCEA /* Interface+CentralManager.swift */, EB443FB627C6BDE00005CCEA /* Mock+CentralManager.swift */, - EB443FB727C6BDE00005CCEA /* Convenience+CentralManager.swift */, ); path = CentralManager; sourceTree = ""; @@ -427,7 +426,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - EB443FCF27C6BDE10005CCEA /* Convenience+CentralManager.swift in Sources */, EB443FCD27C6BDE10005CCEA /* Interface+CentralManager.swift in Sources */, 0023587228ACBF3300E42C0B /* CentralManagerError.swift in Sources */, EB443FC127C6BDE10005CCEA /* Peer.swift in Sources */, diff --git a/Sources/CombineCoreBluetooth/CentralManager/Convenience+CentralManager.swift b/Sources/CombineCoreBluetooth/CentralManager/Convenience+CentralManager.swift deleted file mode 100644 index 4d4d0a5..0000000 --- a/Sources/CombineCoreBluetooth/CentralManager/Convenience+CentralManager.swift +++ /dev/null @@ -1,18 +0,0 @@ -import Foundation - -extension CentralManager { - /// Monitors connection events to the given peripheral and represents them as a publisher that sends `true` on connect and `false` on disconnect. - /// - Parameter peripheral: The peripheral to monitor for connection events. - /// - Returns: A publisher that sends `true` on connect and `false` on disconnect for the given peripheral. - public func monitorConnection(for peripheral: Peripheral) -> AnyPublisher { - Publishers.Merge( - didConnectPeripheral - .filter { p in p == peripheral } - .map { _ in true }, - didDisconnectPeripheral - .filter { (p, error) in p == peripheral } - .map { _ in false } - ) - .eraseToAnyPublisher() - } -} diff --git a/Sources/CombineCoreBluetooth/CentralManager/Interface+CentralManager.swift b/Sources/CombineCoreBluetooth/CentralManager/Interface+CentralManager.swift index 45b9c37..2f05dbe 100644 --- a/Sources/CombineCoreBluetooth/CentralManager/Interface+CentralManager.swift +++ b/Sources/CombineCoreBluetooth/CentralManager/Interface+CentralManager.swift @@ -3,68 +3,68 @@ import CoreBluetooth import Foundation public struct CentralManager { - #if os(macOS) && !targetEnvironment(macCatalyst) +#if os(macOS) && !targetEnvironment(macCatalyst) public typealias Feature = Never - #else +#else public typealias Feature = CBCentralManager.Feature - #endif - +#endif + let delegate: Delegate? + let _state: () -> CBManagerState let _authorization: () -> CBManagerAuthorization let _isScanning: () -> Bool - - @available(macOS, unavailable) + let _supportsFeatures: (_ feature: Feature) -> Bool - + let _retrievePeripheralsWithIdentifiers: ([UUID]) -> [Peripheral] let _retrieveConnectedPeripheralsWithServices: ([CBUUID]) -> [Peripheral] - let _scanForPeripheralsWithServices: (_ serviceUUIDs: [CBUUID]?, _ options: ScanOptions?) -> AnyPublisher - let _connectToPeripheral: (Peripheral, _ options: PeripheralConnectionOptions?) -> AnyPublisher + let _scanForPeripheralsWithServices: (_ serviceUUIDs: [CBUUID]?, _ options: ScanOptions?) -> Void + let _stopScan: () -> Void + + let _connectToPeripheral: (Peripheral, _ options: PeripheralConnectionOptions?) -> Void let _cancelPeripheralConnection: (_ peripheral: Peripheral) -> Void let _registerForConnectionEvents: (_ options: [CBConnectionEventMatchingOption : Any]?) -> Void - + public let didUpdateState: AnyPublisher public let willRestoreState: AnyPublisher<[String: Any], Never> public let didConnectPeripheral: AnyPublisher public let didFailToConnectPeripheral: AnyPublisher<(Peripheral, Error?), Never> public let didDisconnectPeripheral: AnyPublisher<(Peripheral, Error?), Never> - - @available(macOS, unavailable) + public let connectionEventDidOccur: AnyPublisher<(CBConnectionEvent, Peripheral), Never> public let didDiscoverPeripheral: AnyPublisher - - @available(macOS, unavailable) + public let didUpdateACNSAuthorizationForPeripheral: AnyPublisher - + public var state: CBManagerState { _state() } - + public var authorization: CBManagerAuthorization { _authorization() } - + public var isScanning: Bool { _isScanning() } - + @available(macOS, unavailable) public func supports(_ features: Feature) -> Bool { - #if os(macOS) && !targetEnvironment(macCatalyst) +#if os(macOS) && !targetEnvironment(macCatalyst) // do nothing - #else +#else return _supportsFeatures(features) - #endif +#endif } - + public func retrievePeripherals(withIdentifiers identifiers: [UUID]) -> [Peripheral] { _retrievePeripheralsWithIdentifiers(identifiers) } - + public func retrieveConnectedPeripherals(withServices serviceUUIDs: [CBUUID]) -> [Peripheral] { _retrieveConnectedPeripheralsWithServices(serviceUUIDs) } - + /// Starts scanning for peripherals that are advertising any of the services listed in `serviceUUIDs` /// /// To stop scanning for peripherals, cancel the subscription made to the returned publisher. @@ -73,47 +73,86 @@ public struct CentralManager { /// - options: An optional dictionary specifying options for the scan. /// - Returns: A publisher that sends values anytime peripherals are discovered that match the given service UUIDs. public func scanForPeripherals(withServices serviceUUIDs: [CBUUID]?, options: ScanOptions? = nil) -> AnyPublisher { - _scanForPeripheralsWithServices(serviceUUIDs, options) + didDiscoverPeripheral + .handleEvents(receiveSubscription: { _ in + _scanForPeripheralsWithServices(serviceUUIDs, options) + }, receiveCancel: { + _stopScan() + }) + .shareCurrentValue() + .eraseToAnyPublisher() } - + public func connect(_ peripheral: Peripheral, options: PeripheralConnectionOptions? = nil) -> AnyPublisher { - _connectToPeripheral(peripheral, options) + Publishers.Merge( + didConnectPeripheral + .filter { $0 == peripheral } + .setFailureType(to: Error.self), + didFailToConnectPeripheral + .filter { p, _ in p == peripheral } + .tryMap { p, error in + throw CentralManagerError.failedToConnect(error as NSError?) + } + ) + .prefix(1) + .handleEvents(receiveSubscription: { _ in + _connectToPeripheral(peripheral, options) + }, receiveCancel: { + _cancelPeripheralConnection(peripheral) + }) + .shareCurrentValue() + .eraseToAnyPublisher() } - + public func cancelPeripheralConnection(_ peripheral: Peripheral) { _cancelPeripheralConnection(peripheral) } - + public func registerForConnectionEvents(options: [CBConnectionEventMatchingOption: Any]? = nil) { _registerForConnectionEvents(options) } - + + /// Monitors connection events to the given peripheral and represents them as a publisher that sends `true` on connect and `false` on disconnect. + /// - Parameter peripheral: The peripheral to monitor for connection events. + /// - Returns: A publisher that sends `true` on connect and `false` on disconnect for the given peripheral. + public func monitorConnection(for peripheral: Peripheral) -> AnyPublisher { + Publishers.Merge( + didConnectPeripheral + .filter { p in p == peripheral } + .map { _ in true }, + didDisconnectPeripheral + .filter { (p, error) in p == peripheral } + .map { _ in false } + ) + .eraseToAnyPublisher() + } + /// Configuration options used when creating a `CentralManager`. public struct CreationOptions { /// If true, display a warning dialog to the user when the `CentralManager` is instantiated if Bluetooth is powered off public var showPowerAlert: Bool? /// A unique identifier for the Central Manager that's being instantiated. This identifier is used by the system to identify a specific CBCentralManager instance for restoration and, therefore, must remain the same for subsequent application executions in order for the manager to be restored. public var restoreIdentifierKey: String? - + public init(showPowerAlert: Bool? = nil, restoreIdentifierKey: String? = nil) { self.showPowerAlert = showPowerAlert self.restoreIdentifierKey = restoreIdentifierKey } } - + /// Options used when scanning for peripherals. public struct ScanOptions { /// Whether or not the scan should filter duplicate peripheral discoveries public var allowDuplicates: Bool? /// Causes the scan to also look for peripherals soliciting any of the services contained in the list. public var solicitedServiceUUIDs: [CBUUID]? - + public init(allowDuplicates: Bool? = nil, solicitedServiceUUIDs: [CBUUID]? = nil) { self.allowDuplicates = allowDuplicates self.solicitedServiceUUIDs = solicitedServiceUUIDs } } - + /// Options used when connecting to a given `Peripheral` public struct PeripheralConnectionOptions { /// If true, indicates that the system should display a connection alert for a given peripheral, if the application is suspended when a successful connection is made. @@ -124,7 +163,7 @@ public struct CentralManager { public var notifyOnNotification: Bool? /// The number of seconds for the system to wait before starting a connection. public var startDelay: TimeInterval? - + public init(notifyOnConnection: Bool? = nil, notifyOnDisconnection: Bool? = nil, notifyOnNotification: Bool? = nil, startDelay: TimeInterval? = nil) { self.notifyOnConnection = notifyOnConnection self.notifyOnDisconnection = notifyOnDisconnection @@ -132,4 +171,15 @@ public struct CentralManager { self.startDelay = startDelay } } + + class Delegate: NSObject { + let didUpdateState: PassthroughSubject = .init() + let willRestoreState: PassthroughSubject<[String: Any], Never> = .init() + let didConnectPeripheral: PassthroughSubject = .init() + let didFailToConnectPeripheral: PassthroughSubject<(Peripheral, Error?), Never> = .init() + let didDisconnectPeripheral: PassthroughSubject<(Peripheral, Error?), Never> = .init() + let connectionEventDidOccur: PassthroughSubject<(CBConnectionEvent, Peripheral), Never> = .init() + let didDiscoverPeripheral: PassthroughSubject = .init() + let didUpdateACNSAuthorizationForPeripheral: PassthroughSubject = .init() + } } diff --git a/Sources/CombineCoreBluetooth/CentralManager/Live+CentralManager.swift b/Sources/CombineCoreBluetooth/CentralManager/Live+CentralManager.swift index 6f17fb3..d1fe2eb 100644 --- a/Sources/CombineCoreBluetooth/CentralManager/Live+CentralManager.swift +++ b/Sources/CombineCoreBluetooth/CentralManager/Live+CentralManager.swift @@ -11,13 +11,14 @@ extension CentralManager { options: options?.dictionary ) - #if os(macOS) && !targetEnvironment(macCatalyst) +#if os(macOS) && !targetEnvironment(macCatalyst) func supportsFeatures(_ feature: Never) -> A {} - #else +#else let supportsFeatures = CBCentralManager.supports - #endif - +#endif + return Self.init( + delegate: delegate, _state: { centralManager.state }, _authorization: { if #available(iOS 13.1, *) { @@ -35,54 +36,31 @@ extension CentralManager { centralManager.retrieveConnectedPeripherals(withServices: serviceIDs).map(Peripheral.init(cbperipheral:)) }, _scanForPeripheralsWithServices: { services, options in - delegate.didDiscoverPeripheral - .handleEvents(receiveSubscription: { _ in - centralManager.scanForPeripherals(withServices: services, options: options?.dictionary) - }, receiveCancel: { - centralManager.stopScan() - }) - .shareCurrentValue() - .eraseToAnyPublisher() + centralManager.scanForPeripherals(withServices: services, options: options?.dictionary) }, + _stopScan: centralManager.stopScan, _connectToPeripheral: { (peripheral, options) in - Publishers.Merge( - delegate.didConnectPeripheral - .filter { $0 == peripheral } - .setFailureType(to: Error.self), - delegate.didFailToConnectPeripheral - .filter { p, _ in p == peripheral } - .tryMap { p, error in - throw CentralManagerError.failedToConnect(error as NSError?) - } - ) - .prefix(1) - .handleEvents(receiveSubscription: { _ in - centralManager.connect(peripheral.rawValue!, options: options?.dictionary) - }, receiveCancel: { - centralManager.cancelPeripheralConnection(peripheral.rawValue!) - }) - .shareCurrentValue() - .eraseToAnyPublisher() + centralManager.connect(peripheral.rawValue!, options: options?.dictionary) }, _cancelPeripheralConnection: { (peripheral) in centralManager.cancelPeripheralConnection(peripheral.rawValue!) }, _registerForConnectionEvents: { - #if os(macOS) && !targetEnvironment(macCatalyst) +#if os(macOS) && !targetEnvironment(macCatalyst) fatalError("This method is not callable on native macOS") - #else +#else centralManager.registerForConnectionEvents(options: $0) - #endif +#endif }, - - didUpdateState: delegate.didUpdateState, - willRestoreState: delegate.willRestoreState, - didConnectPeripheral: delegate.didConnectPeripheral, - didFailToConnectPeripheral: delegate.didFailToConnectPeripheral, - didDisconnectPeripheral: delegate.didDisconnectPeripheral, - connectionEventDidOccur: delegate.connectionEventDidOccur, - didDiscoverPeripheral: delegate.didDiscoverPeripheral, - didUpdateACNSAuthorizationForPeripheral: delegate.didUpdateACNSAuthorizationForPeripheral + + didUpdateState: delegate.didUpdateState.eraseToAnyPublisher(), + willRestoreState: delegate.willRestoreState.eraseToAnyPublisher(), + didConnectPeripheral: delegate.didConnectPeripheral.eraseToAnyPublisher(), + didFailToConnectPeripheral: delegate.didFailToConnectPeripheral.eraseToAnyPublisher(), + didDisconnectPeripheral: delegate.didDisconnectPeripheral.eraseToAnyPublisher(), + connectionEventDidOccur: delegate.connectionEventDidOccur.eraseToAnyPublisher(), + didDiscoverPeripheral: delegate.didDiscoverPeripheral.eraseToAnyPublisher(), + didUpdateACNSAuthorizationForPeripheral: delegate.didUpdateACNSAuthorizationForPeripheral.eraseToAnyPublisher() ) } } @@ -116,58 +94,44 @@ extension CentralManager.PeripheralConnectionOptions { } } -extension CentralManager { - class Delegate: NSObject, CBCentralManagerDelegate { - @PassthroughBacked var didUpdateState: AnyPublisher - func centralManagerDidUpdateState(_ central: CBCentralManager) { - _didUpdateState.send(central.state) - } - - @PassthroughBacked var willRestoreState: AnyPublisher<[String: Any], Never> - func centralManager(_ central: CBCentralManager, willRestoreState dict: [String : Any]) { - _willRestoreState.send(dict) - } - - @PassthroughBacked var didConnectPeripheral: AnyPublisher - func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { - _didConnectPeripheral.send(Peripheral(cbperipheral: peripheral)) - } - - @PassthroughBacked var didFailToConnectPeripheral: AnyPublisher<(Peripheral, Error?), Never> - func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { - _didFailToConnectPeripheral.send((Peripheral(cbperipheral: peripheral), error)) - } - - @PassthroughBacked var didDisconnectPeripheral: AnyPublisher<(Peripheral, Error?), Never> - func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { - _didDisconnectPeripheral.send((Peripheral(cbperipheral: peripheral), error)) - } - - #if os(iOS) || os(tvOS) || os(watchOS) - @PassthroughBacked var connectionEventDidOccur: AnyPublisher<(CBConnectionEvent, Peripheral), Never> - func centralManager(_ central: CBCentralManager, connectionEventDidOccur event: CBConnectionEvent, for peripheral: CBPeripheral) { - _connectionEventDidOccur.send((event, Peripheral(cbperipheral: peripheral))) - } - #else - var connectionEventDidOccur: AnyPublisher<(CBConnectionEvent, Peripheral), Never> = Empty().eraseToAnyPublisher() - #endif - - @PassthroughBacked var didDiscoverPeripheral: AnyPublisher - func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) { - _didDiscoverPeripheral.send( - PeripheralDiscovery( - peripheral: Peripheral(cbperipheral: peripheral), - advertisementData: AdvertisementData(advertisementData), - rssi: RSSI.doubleValue - ) +extension CentralManager.Delegate: CBCentralManagerDelegate { + func centralManagerDidUpdateState(_ central: CBCentralManager) { + didUpdateState.send(central.state) + } + + func centralManager(_ central: CBCentralManager, willRestoreState dict: [String : Any]) { + willRestoreState.send(dict) + } + + func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { + didConnectPeripheral.send(Peripheral(cbperipheral: peripheral)) + } + + func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { + didFailToConnectPeripheral.send((Peripheral(cbperipheral: peripheral), error)) + } + + func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { + didDisconnectPeripheral.send((Peripheral(cbperipheral: peripheral), error)) + } + + func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) { + didDiscoverPeripheral.send( + PeripheralDiscovery( + peripheral: Peripheral(cbperipheral: peripheral), + advertisementData: AdvertisementData(advertisementData), + rssi: RSSI.doubleValue ) - } - - @PassthroughBacked var didUpdateACNSAuthorizationForPeripheral: AnyPublisher - #if os(iOS) || os(tvOS) || os(watchOS) - func centralManager(_ central: CBCentralManager, didUpdateANCSAuthorizationFor peripheral: CBPeripheral) { - _didUpdateACNSAuthorizationForPeripheral.send(Peripheral(cbperipheral: peripheral)) - } - #endif + ) + } + +#if os(iOS) || os(tvOS) || os(watchOS) || targetEnvironment(macCatalyst) + func centralManager(_ central: CBCentralManager, connectionEventDidOccur event: CBConnectionEvent, for peripheral: CBPeripheral) { + connectionEventDidOccur.send((event, Peripheral(cbperipheral: peripheral))) + } + + func centralManager(_ central: CBCentralManager, didUpdateANCSAuthorizationFor peripheral: CBPeripheral) { + didUpdateACNSAuthorizationForPeripheral.send(Peripheral(cbperipheral: peripheral)) } +#endif } diff --git a/Sources/CombineCoreBluetooth/CentralManager/Mock+CentralManager.swift b/Sources/CombineCoreBluetooth/CentralManager/Mock+CentralManager.swift index 654be53..776befb 100644 --- a/Sources/CombineCoreBluetooth/CentralManager/Mock+CentralManager.swift +++ b/Sources/CombineCoreBluetooth/CentralManager/Mock+CentralManager.swift @@ -10,9 +10,9 @@ extension CentralManager { supportsFeatures: @escaping (Feature) -> Bool = Internal._unimplemented("supportsFeatures"), retrievePeripheralsWithIdentifiers: @escaping ([UUID]) -> [Peripheral] = Internal._unimplemented("retrievePeripheralsWithIdentifiers"), retrieveConnectedPeripheralsWithServices: @escaping ([CBUUID]) -> [Peripheral] = Internal._unimplemented("retrieveConnectedPeripheralsWithServices"), - scanForPeripheralsWithServices: @escaping ([CBUUID]?, ScanOptions?) -> AnyPublisher = Internal._unimplemented("scanForPeripheralsWithServices"), + scanForPeripheralsWithServices: @escaping ([CBUUID]?, ScanOptions?) -> Void = Internal._unimplemented("scanForPeripheralsWithServices"), stopScanForPeripherals: @escaping () -> Void = Internal._unimplemented("stopScanForPeripherals"), - connectToPeripheral: @escaping (Peripheral, PeripheralConnectionOptions?) -> AnyPublisher = Internal._unimplemented("connectToPeripheral"), + connectToPeripheral: @escaping (Peripheral, PeripheralConnectionOptions?) -> Void = Internal._unimplemented("connectToPeripheral"), cancelPeripheralConnection: @escaping (Peripheral) -> Void = Internal._unimplemented("cancelPeripheralConnection"), registerForConnectionEvents: @escaping ([CBConnectionEventMatchingOption : Any]?) -> Void = Internal._unimplemented("registerForConnectionEvents"), @@ -28,6 +28,7 @@ extension CentralManager { didUpdateACNSAuthorizationForPeripheral: AnyPublisher = Internal._unimplemented("didUpdateACNSAuthorizationForPeripheral") ) -> Self { return Self( + delegate: nil, _state: state, _authorization: authorization, _isScanning: isScanning, @@ -35,6 +36,7 @@ extension CentralManager { _retrievePeripheralsWithIdentifiers: retrievePeripheralsWithIdentifiers, _retrieveConnectedPeripheralsWithServices: retrieveConnectedPeripheralsWithServices, _scanForPeripheralsWithServices: scanForPeripheralsWithServices, + _stopScan: stopScanForPeripherals, _connectToPeripheral: connectToPeripheral, _cancelPeripheralConnection: cancelPeripheralConnection, _registerForConnectionEvents: registerForConnectionEvents, diff --git a/Sources/CombineCoreBluetooth/Peripheral/Interface+Peripheral.swift b/Sources/CombineCoreBluetooth/Peripheral/Interface+Peripheral.swift index a169554..91cd5b7 100644 --- a/Sources/CombineCoreBluetooth/Peripheral/Interface+Peripheral.swift +++ b/Sources/CombineCoreBluetooth/Peripheral/Interface+Peripheral.swift @@ -12,7 +12,6 @@ public struct Peripheral { var _services: () -> [CBService]? var _canSendWriteWithoutResponse: () -> Bool - @available(macOS, unavailable) var _ancsAuthorized: () -> Bool var _readRSSI: () -> Void @@ -359,7 +358,7 @@ public struct Peripheral { // MARK: - extension Peripheral { - public class Delegate: NSObject { + class Delegate: NSObject { let nameUpdates: PassthroughSubject = .init() let didInvalidateServices: PassthroughSubject<[CBService], Never> = .init() let didReadRSSI: PassthroughSubject, Never> = .init() diff --git a/Sources/CombineCoreBluetooth/Peripheral/Live+Peripheral.swift b/Sources/CombineCoreBluetooth/Peripheral/Live+Peripheral.swift index d845126..aa3db27 100644 --- a/Sources/CombineCoreBluetooth/Peripheral/Live+Peripheral.swift +++ b/Sources/CombineCoreBluetooth/Peripheral/Live+Peripheral.swift @@ -55,15 +55,15 @@ extension Peripheral { } extension Peripheral.Delegate: CBPeripheralDelegate { - public func peripheralDidUpdateName(_ peripheral: CBPeripheral) { + func peripheralDidUpdateName(_ peripheral: CBPeripheral) { nameUpdates.send(peripheral.name) } - public func peripheral(_ peripheral: CBPeripheral, didModifyServices invalidatedServices: [CBService]) { + func peripheral(_ peripheral: CBPeripheral, didModifyServices invalidatedServices: [CBService]) { didInvalidateServices.send(invalidatedServices) } - public func peripheral(_ peripheral: CBPeripheral, didReadRSSI RSSI: NSNumber, error: Error?) { + func peripheral(_ peripheral: CBPeripheral, didReadRSSI RSSI: NSNumber, error: Error?) { if let error = error { didReadRSSI.send(.failure(error)) } else { @@ -71,47 +71,47 @@ extension Peripheral.Delegate: CBPeripheralDelegate { } } - public func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { + func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { didDiscoverServices.send((peripheral.services ?? [], error)) } - public func peripheral(_ peripheral: CBPeripheral, didDiscoverIncludedServicesFor service: CBService, error: Error?) { + func peripheral(_ peripheral: CBPeripheral, didDiscoverIncludedServicesFor service: CBService, error: Error?) { didDiscoverIncludedServices.send((service, error)) } - public func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { + func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { didDiscoverCharacteristics.send((service, error)) } - public func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { + func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { didUpdateValueForCharacteristic.send((characteristic, error)) } - public func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { + func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { didWriteValueForCharacteristic.send((characteristic, error)) } - public func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) { + func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) { didUpdateNotificationState.send((characteristic, error)) } - public func peripheral(_ peripheral: CBPeripheral, didDiscoverDescriptorsFor characteristic: CBCharacteristic, error: Error?) { + func peripheral(_ peripheral: CBPeripheral, didDiscoverDescriptorsFor characteristic: CBCharacteristic, error: Error?) { didDiscoverDescriptorsForCharacteristic.send((characteristic, error)) } - public func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor descriptor: CBDescriptor, error: Error?) { + func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor descriptor: CBDescriptor, error: Error?) { didUpdateValueForDescriptor.send((descriptor, error)) } - public func peripheral(_ peripheral: CBPeripheral, didWriteValueFor descriptor: CBDescriptor, error: Error?) { + func peripheral(_ peripheral: CBPeripheral, didWriteValueFor descriptor: CBDescriptor, error: Error?) { didWriteValueForDescriptor.send((descriptor, error)) } - public func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) { + func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) { isReadyToSendWriteWithoutResponse.send() } - public func peripheral(_ peripheral: CBPeripheral, didOpen channel: CBL2CAPChannel?, error: Error?) { + func peripheral(_ peripheral: CBPeripheral, didOpen channel: CBL2CAPChannel?, error: Error?) { didOpenChannel.send((channel.map(L2CAPChannel.init(channel:)), error)) } } From 73787f5d2561178107709373a8479e44b5ea0bbc Mon Sep 17 00:00:00 2001 From: Kevin Lundberg Date: Tue, 18 Jul 2023 22:36:28 -0400 Subject: [PATCH 2/2] refactor CentralManager to make interface logic more testable --- .../xcschemes/CombineCoreBluetooth.xcscheme | 3 +- .../CentralManagerTests.swift | 39 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/CombineCoreBluetooth.xcodeproj/xcshareddata/xcschemes/CombineCoreBluetooth.xcscheme b/CombineCoreBluetooth.xcodeproj/xcshareddata/xcschemes/CombineCoreBluetooth.xcscheme index c405218..28ac96f 100644 --- a/CombineCoreBluetooth.xcodeproj/xcshareddata/xcschemes/CombineCoreBluetooth.xcscheme +++ b/CombineCoreBluetooth.xcodeproj/xcshareddata/xcschemes/CombineCoreBluetooth.xcscheme @@ -26,7 +26,8 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" - shouldUseLaunchSchemeArgsEnv = "YES"> + shouldUseLaunchSchemeArgsEnv = "YES" + enableAddressSanitizer = "YES"> diff --git a/Tests/CombineCoreBluetoothTests/CentralManagerTests.swift b/Tests/CombineCoreBluetoothTests/CentralManagerTests.swift index 4d07a10..ea130f6 100644 --- a/Tests/CombineCoreBluetoothTests/CentralManagerTests.swift +++ b/Tests/CombineCoreBluetoothTests/CentralManagerTests.swift @@ -62,4 +62,43 @@ final class CentralManagerTests: XCTestCase { XCTAssertEqual(values, [false], "Errors from disconnects should not affect disconnect values sent to subscribers") } + + func testScanForPeripheralsScansOnlyOnSubscription() { + var scanCount = 0 + var stopCount = 0 + let peripheralDiscovery = PassthroughSubject() + let centralManager = CentralManager.unimplemented(scanForPeripheralsWithServices: { _, _ in + scanCount += 1 + }, stopScanForPeripherals: { + stopCount += 1 + }, didDiscoverPeripheral: peripheralDiscovery.eraseToAnyPublisher() + ) + + let p = centralManager.scanForPeripherals(withServices: nil) + XCTAssertEqual(scanCount, 0) + let _ = p.sink(receiveValue: { _ in }) + XCTAssertEqual(scanCount, 1) + } + + func testScanForPeripheralsStopsOnCancellation() { + var scanCount = 0 + var stopCount = 0 + let peripheralDiscovery = PassthroughSubject() + let centralManager = CentralManager.unimplemented(scanForPeripheralsWithServices: { _, _ in + scanCount += 1 + }, stopScanForPeripherals: { + stopCount += 1 + }, didDiscoverPeripheral: peripheralDiscovery.eraseToAnyPublisher() + ) + + let p = centralManager.scanForPeripherals(withServices: nil) + XCTAssertEqual(scanCount, 0) + let cancellable = p.sink(receiveValue: { _ in }) + XCTAssertEqual(scanCount, 1) + XCTAssertEqual(stopCount, 0) + + cancellable.cancel() + XCTAssertEqual(scanCount, 1) + XCTAssertEqual(stopCount, 1) + } }