diff --git a/Sources/iOS-BLE-Library-Mock/Alias.swift b/Sources/iOS-BLE-Library-Mock/Alias.swift new file mode 100644 index 0000000..3f8811d --- /dev/null +++ b/Sources/iOS-BLE-Library-Mock/Alias.swift @@ -0,0 +1,96 @@ +/* +* Copyright (c) 2020, Nordic Semiconductor +* All rights reserved. +* +* Redistribution and use in source and binary forms, with or without modification, +* are permitted provided that the following conditions are met: +* +* 1. Redistributions of source code must retain the above copyright notice, this +* list of conditions and the following disclaimer. +* +* 2. Redistributions in binary form must reproduce the above copyright notice, this +* list of conditions and the following disclaimer in the documentation and/or +* other materials provided with the distribution. +* +* 3. Neither the name of the copyright holder nor the names of its contributors may +* be used to endorse or promote products derived from this software without +* specific prior written permission. +* +* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. +* IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, +* INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT +* NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +* PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +* WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +* POSSIBILITY OF SUCH DAMAGE. +*/ + +import CoreBluetoothMock + +// Copy this file to your project to start using CoreBluetoothMock classes +// without having to refactor any of your code. You will just have to remove +// the imports to CoreBluetooth to fix conflicts and initiate the manager +// using CBCentralManagerFactory, instad of just creating a CBCentralManager. + +// disabled for Xcode 12.5 beta +//typealias CBPeer = CBMPeer +//typealias CBAttribute = CBMAttribute +public typealias CBCentralManagerFactory = CBMCentralManagerFactory +public typealias CBUUID = CBMUUID +public typealias CBError = CBMError +public typealias CBATTError = CBMATTError +public typealias CBManagerState = CBMManagerState +public typealias CBPeripheralState = CBMPeripheralState +public typealias CBCentralManager = CBMCentralManager +public typealias CBCentralManagerDelegate = CBMCentralManagerDelegate +public typealias CBPeripheral = CBMPeripheral +public typealias CBPeripheralDelegate = CBMPeripheralDelegate +public typealias CBService = CBMService +public typealias CBCharacteristic = CBMCharacteristic +public typealias CBCharacteristicWriteType = CBMCharacteristicWriteType +public typealias CBCharacteristicProperties = CBMCharacteristicProperties +public typealias CBDescriptor = CBMDescriptor +public typealias CBConnectionEvent = CBMConnectionEvent +public typealias CBConnectionEventMatchingOption = CBMConnectionEventMatchingOption +@available(iOS 11.0, tvOS 11.0, watchOS 4.0, *) +public typealias CBL2CAPPSM = CBML2CAPPSM +@available(iOS 11.0, tvOS 11.0, watchOS 4.0, *) +public typealias CBL2CAPChannel = CBML2CAPChannel + +public let CBCentralManagerScanOptionAllowDuplicatesKey = + CBMCentralManagerScanOptionAllowDuplicatesKey +public let CBCentralManagerOptionShowPowerAlertKey = CBMCentralManagerOptionShowPowerAlertKey +public let CBCentralManagerOptionRestoreIdentifierKey = CBMCentralManagerOptionRestoreIdentifierKey +public let CBCentralManagerScanOptionSolicitedServiceUUIDsKey = + CBMCentralManagerScanOptionSolicitedServiceUUIDsKey +public let CBConnectPeripheralOptionStartDelayKey = CBMConnectPeripheralOptionStartDelayKey +#if !os(macOS) + @available(iOS 13.0, tvOS 13.0, watchOS 6.0, *) + public let CBConnectPeripheralOptionRequiresANCS = CBMConnectPeripheralOptionRequiresANCS +#endif +public let CBCentralManagerRestoredStatePeripheralsKey = + CBMCentralManagerRestoredStatePeripheralsKey +public let CBCentralManagerRestoredStateScanServicesKey = + CBMCentralManagerRestoredStateScanServicesKey +public let CBCentralManagerRestoredStateScanOptionsKey = + CBMCentralManagerRestoredStateScanOptionsKey + +public let CBAdvertisementDataLocalNameKey = CBMAdvertisementDataLocalNameKey +public let CBAdvertisementDataServiceUUIDsKey = CBMAdvertisementDataServiceUUIDsKey +public let CBAdvertisementDataIsConnectable = CBMAdvertisementDataIsConnectable +public let CBAdvertisementDataTxPowerLevelKey = CBMAdvertisementDataTxPowerLevelKey +public let CBAdvertisementDataServiceDataKey = CBMAdvertisementDataServiceDataKey +public let CBAdvertisementDataManufacturerDataKey = CBMAdvertisementDataManufacturerDataKey +public let CBAdvertisementDataOverflowServiceUUIDsKey = CBMAdvertisementDataOverflowServiceUUIDsKey +public let CBAdvertisementDataSolicitedServiceUUIDsKey = + CBMAdvertisementDataSolicitedServiceUUIDsKey + +public let CBConnectPeripheralOptionNotifyOnConnectionKey = + CBMConnectPeripheralOptionNotifyOnConnectionKey +public let CBConnectPeripheralOptionNotifyOnDisconnectionKey = + CBMConnectPeripheralOptionNotifyOnDisconnectionKey +public let CBConnectPeripheralOptionNotifyOnNotificationKey = + CBMConnectPeripheralOptionNotifyOnNotificationKey diff --git a/Sources/iOS-BLE-Library-Mock/CentralManager/CentralManager.swift b/Sources/iOS-BLE-Library-Mock/CentralManager/CentralManager.swift new file mode 100644 index 0000000..c1d5059 --- /dev/null +++ b/Sources/iOS-BLE-Library-Mock/CentralManager/CentralManager.swift @@ -0,0 +1,264 @@ +// +// File.swift +// +// +// Created by Nick Kibysh on 18/04/2023. +// + +import Combine +import CoreBluetoothMock +import Foundation + +extension CentralManager { + public enum Err: Error { + case wrongManager + case badState(CBManagerState) + case unknownError + + public var localizedDescription: String { + switch self { + case .wrongManager: + return "Incorrect manager instance provided." + case .badState(let state): + return "Bad state: \(state)" + case .unknownError: + return "An unknown error occurred." + } + } + } +} + +private class Observer: NSObject { + @objc dynamic private weak var cm: CBCentralManager? + private weak var publisher: CurrentValueSubject? + private var observation: NSKeyValueObservation? + + init(cm: CBCentralManager, publisher: CurrentValueSubject) { + self.cm = cm + self.publisher = publisher + super.init() + } + + func setup() { + observation = observe( + \.cm?.isScanning, + options: [.old, .new], + changeHandler: { _, change in + + change.newValue?.flatMap { [weak self] new in + self?.publisher?.send(new) + } + } + ) + } +} + +/// A custom Central Manager class that extends the functionality of the standard CBCentralManager. +/// This class brings a reactive approach and is based on the Swift Combine framework. +public class CentralManager { + private let isScanningSubject = CurrentValueSubject(false) + private let killSwitchSubject = PassthroughSubject() + private lazy var observer = Observer(cm: centralManager, publisher: isScanningSubject) + + public let centralManager: CBCentralManager + public let centralManagerDelegate: ReactiveCentralManagerDelegate + + /// Initializes a new instance of `CentralManager`. + /// - Parameters: + /// - centralManagerDelegate: The delegate for the reactive central manager. Default is `ReactiveCentralManagerDelegate()`. + /// - queue: The queue to perform operations on. Default is the main queue. + public init( + centralManagerDelegate: ReactiveCentralManagerDelegate = + ReactiveCentralManagerDelegate(), queue: DispatchQueue = .main + ) { + self.centralManagerDelegate = centralManagerDelegate + self.centralManager = CBMCentralManagerFactory.instance( + delegate: centralManagerDelegate, queue: queue) + observer.setup() + } + + /// Initializes a new instance of `CentralManager` with an existing CBCentralManager instance. + /// - Parameter centralManager: An existing CBCentralManager instance. + /// - Throws: An error if the provided manager's delegate is not of type `ReactiveCentralManagerDelegate`. + public init(centralManager: CBCentralManager) throws { + guard + let reactiveDelegate = centralManager.delegate + as? ReactiveCentralManagerDelegate + else { + throw Err.wrongManager + } + + self.centralManager = centralManager + self.centralManagerDelegate = reactiveDelegate + + observer.setup() + } +} + +// MARK: Establishing or Canceling Connections with Peripherals +extension CentralManager { + /// Establishes a connection with the specified peripheral. + /// - Parameters: + /// - peripheral: The peripheral to connect to. + /// - options: Optional connection options. + /// - Returns: A publisher that emits the connected peripheral on successful connection. + /// The publisher does not finish until the peripheral is successfully connected. + /// If the peripheral was disconnected successfully, the publisher finishes without error. + /// If the connection was unsuccessful or disconnection returns an error (e.g., peripheral disconnected unexpectedly), + /// the publisher finishes with an error. + public func connect(_ peripheral: CBPeripheral, options: [String: Any]? = nil) + -> Publishers.BluetoothPublisher + { + let killSwitch = self.disconnectedPeripheralsChannel.tryFirst(where: { p in + if let e = p.1 { + throw e + } + return p.0.identifier == peripheral.identifier + }) + + return self.connectedPeripheralChannel + .filter { $0.0.identifier == peripheral.identifier } + .tryMap { p in + if let e = p.1 { + throw e + } + + return p.0 + } + .prefix(untilUntilOutputOrCompletion: killSwitch) + .bluetooth { + self.centralManager.connect(peripheral, options: options) + } + } + + /// Cancels the connection with the specified peripheral. + /// - Parameter peripheral: The peripheral to disconnect from. + /// - Returns: A publisher that emits the disconnected peripheral. + public func cancelPeripheralConnection(_ peripheral: CBPeripheral) -> Publishers.Peripheral + { + return self.disconnectedPeripheralsChannel + .tryFilter { r in + guard r.0.identifier == peripheral.identifier else { + return false + } + + if let e = r.1 { + throw e + } else { + return true + } + } + .map { $0.0 } + .first() + .peripheral { + self.centralManager.cancelPeripheralConnection(peripheral) + } + } +} + +// MARK: Retrieving Lists of Peripherals +extension CentralManager { + /// Returns a list of the peripherals connected to the system whose + /// services match a given set of criteria. + /// + /// The list of connected peripherals can include those that other apps + /// have connected. You need to connect these peripherals locally using + /// the `connect(_:options:)` method before using them. + /// - Parameter serviceUUIDs: A list of service UUIDs, represented by + /// `CBUUID` objects. + /// - Returns: A list of the peripherals that are currently connected + /// to the system and that contain any of the services + /// specified in the `serviceUUID` parameter. + public func retrieveConnectedPeripherals(withServices identifiers: [CBUUID]) + -> [CBPeripheral] + { + centralManager.retrieveConnectedPeripherals(withServices: identifiers) + } + + /// Returns a list of known peripherals by their identifiers. + /// - Parameter identifiers: A list of peripheral identifiers + /// (represented by `NSUUID` objects) from which + /// ``CBPeripheral`` objects can be retrieved. + /// - Returns: A list of peripherals that the central manager is able + /// to match to the provided identifiers. + public func retrievePeripherals(withIdentifiers identifiers: [UUID]) -> [CBPeripheral] { + centralManager.retrievePeripherals(withIdentifiers: identifiers) + } +} + +// MARK: Scanning or Stopping Scans of Peripherals +extension CentralManager { + /// Initiates a scan for peripherals with the specified services. + /// - Parameter services: The services to scan for. + /// - Returns: A publisher that emits scan results or errors. + public func scanForPeripherals(withServices services: [CBUUID]?) + -> Publishers.BluetoothPublisher + { + stopScan() + // TODO: Change to BluetoothPublisher + return centralManagerDelegate.stateSubject + .tryFirst { state in + guard let determined = state.ready else { return false } + + guard determined else { throw Err.badState(state) } + return true + } + .flatMap { _ in + // TODO: Check for mmemory leaks + return self.centralManagerDelegate.scanResultSubject + .setFailureType(to: Error.self) + } + .map { a in + return a + } + .prefix(untilOutputFrom: killSwitchSubject) + .mapError { [weak self] e in + self?.stopScan() + return e + } + .bluetooth { + self.centralManager.scanForPeripherals(withServices: services) + } + } + + /// Stops an ongoing scan for peripherals. + /// Calling this method finishes the publisher returned by ``scanForPeripherals(withServices:)``. + public func stopScan() { + centralManager.stopScan() + killSwitchSubject.send(()) + } +} + +// MARK: Channels +extension CentralManager { + /// A publisher that emits the state of the central manager. + public var stateChannel: AnyPublisher { + centralManagerDelegate + .stateSubject + .eraseToAnyPublisher() + } + + /// A publisher that emits the scanning state. + public var isScanningChannel: AnyPublisher { + isScanningSubject + .eraseToAnyPublisher() + } + + /// A publisher that emits scan results. + public var scanResultsChannel: AnyPublisher { + centralManagerDelegate.scanResultSubject + .eraseToAnyPublisher() + } + + /// A publisher that emits connected peripherals along with errors. + public var connectedPeripheralChannel: AnyPublisher<(CBPeripheral, Error?), Never> { + centralManagerDelegate.connectedPeripheralSubject + .eraseToAnyPublisher() + } + + /// A publisher that emits disconnected peripherals along with errors. + public var disconnectedPeripheralsChannel: AnyPublisher<(CBPeripheral, Error?), Never> { + centralManagerDelegate.disconnectedPeripheralsSubject + .eraseToAnyPublisher() + } +} diff --git a/Sources/iOS-BLE-Library-Mock/CentralManager/Model/ScanResult.swift b/Sources/iOS-BLE-Library-Mock/CentralManager/Model/ScanResult.swift new file mode 100644 index 0000000..16a5c97 --- /dev/null +++ b/Sources/iOS-BLE-Library-Mock/CentralManager/Model/ScanResult.swift @@ -0,0 +1,25 @@ +// +// File.swift +// +// +// Created by Nick Kibysh on 19/04/2023. +// + +import CoreBluetoothMock +import Foundation + +public struct ScanResult { + public let peripheral: CBPeripheral + public let rssi: RSSI + public let advertisementData: AdvertisementData + + init(peripheral: CBPeripheral, rssi: NSNumber, advertisementData: [String: Any]) { + self.peripheral = peripheral + self.rssi = RSSI(integerLiteral: rssi.intValue) + self.advertisementData = AdvertisementData(advertisementData) + } + + public var name: String? { + peripheral.name ?? advertisementData.localName + } +} diff --git a/Sources/iOS-BLE-Library-Mock/CentralManager/ReactiveCentralManagerDelegate.swift b/Sources/iOS-BLE-Library-Mock/CentralManager/ReactiveCentralManagerDelegate.swift new file mode 100644 index 0000000..3f8f57b --- /dev/null +++ b/Sources/iOS-BLE-Library-Mock/CentralManager/ReactiveCentralManagerDelegate.swift @@ -0,0 +1,91 @@ +// +// File.swift +// +// +// Created by Nick Kibysh on 18/04/2023. +// + +import Combine +import CoreBluetoothMock +import Foundation + +open class ReactiveCentralManagerDelegate: NSObject, CBCentralManagerDelegate { + enum BluetoothError: Error { + case failedToConnect + } + + let stateSubject = CurrentValueSubject(.unknown) + let scanResultSubject = PassthroughSubject() + let connectedPeripheralSubject = PassthroughSubject<(CBPeripheral, Error?), Never>() + let disconnectedPeripheralsSubject = PassthroughSubject<(CBPeripheral, Error?), Never>() + let connectionEventSubject = PassthroughSubject<(CBPeripheral, CBConnectionEvent), Never>() + + // MARK: Monitoring Connections with Peripherals + open func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { + connectedPeripheralSubject.send((peripheral, nil)) + } + + open func centralManager( + _ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, + error: Error? + ) { + disconnectedPeripheralsSubject.send((peripheral, error)) + } + + open func centralManager( + _ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, + error: Error? + ) { + connectedPeripheralSubject.send((peripheral, error)) + } + + #if !os(macOS) + open func centralManager( + _ central: CBCentralManager, + connectionEventDidOccur event: CBConnectionEvent, + for peripheral: CBPeripheral + ) { + connectionEventSubject.send((peripheral, event)) + } + #endif + + // MARK: Discovering and Retrieving Peripherals + + open func centralManager( + _ central: CBCentralManager, didDiscover peripheral: CBPeripheral, + advertisementData: [String: Any], rssi RSSI: NSNumber + ) { + let scanResult = ScanResult( + peripheral: peripheral, + rssi: RSSI, + advertisementData: advertisementData + ) + scanResultSubject.send(scanResult) + } + + // MARK: Monitoring the Central Manager’s State + + open func centralManagerDidUpdateState(_ central: CBCentralManager) { + stateSubject.send(central.state) + } + + public func centralManager( + _ central: CBCentralManager, willRestoreState dict: [String: Any] + ) { + unimplementedError() + } + + // MARK: Monitoring the Central Manager’s Authorization + #if !os(macOS) + public func centralManager( + _ central: CBCentralManager, + didUpdateANCSAuthorizationFor peripheral: CBPeripheral + ) { + unimplementedError() + } + #endif + + // MARK: Instance Methods + // BETA + // func centralManager(CBCentralManager, didDisconnectPeripheral: CBPeripheral, timestamp: CFAbsoluteTime, isReconnecting: Bool, error: Error?) +} diff --git a/Sources/iOS-BLE-Library-Mock/Peripheral/Peripheral+Writer.swift b/Sources/iOS-BLE-Library-Mock/Peripheral/Peripheral+Writer.swift new file mode 100644 index 0000000..0f0b052 --- /dev/null +++ b/Sources/iOS-BLE-Library-Mock/Peripheral/Peripheral+Writer.swift @@ -0,0 +1,226 @@ +// +// File.swift +// +// +// Created by Nick Kibysh on 07/05/2023. +// + +import Combine +import CoreBluetoothMock +import Foundation + +extension Peripheral { + class OperationQueue { + let queue = Foundation.OperationQueue() + let peripheral: CBPeripheral + + init(peripheral: CBPeripheral) { + self.peripheral = peripheral + queue.maxConcurrentOperationCount = 1 + } + } + + class CharacteristicWriter: OperationQueue { + let writtenEventsPublisher: AnyPublisher<(CBCharacteristic, Error?), Never> + + init( + writtenEventsPublisher: AnyPublisher<(CBCharacteristic, Error?), Never>, + peripheral: CBPeripheral + ) { + self.writtenEventsPublisher = writtenEventsPublisher + super.init(peripheral: peripheral) + } + } + + class CharacteristicReader: OperationQueue { + let updateEventPublisher: AnyPublisher<(CBCharacteristic, Error?), Never> + + init( + updateEventPublisher: AnyPublisher<(CBCharacteristic, Error?), Never>, + peripheral: CBPeripheral + ) { + self.updateEventPublisher = updateEventPublisher + super.init(peripheral: peripheral) + } + } +} + +extension Peripheral.CharacteristicWriter { + func write(_ value: Data, to characteristic: CBCharacteristic) -> Future { + let operation = WriteCharacteristicOperation( + data: value, + writtenEventsPublisher: writtenEventsPublisher, + characteristic: characteristic, + peripheral: peripheral) + + queue.addOperation(operation) + + return operation.future + } +} + +extension Peripheral.CharacteristicReader { + func readValue(from characteristc: CBCharacteristic) -> Future { + let operation = ReadCharacteristicOperation( + updateEventPublisher: updateEventPublisher, + characteristic: characteristc, + peripheral: peripheral + ) + + queue.addOperation(operation) + + return operation.future + } +} + +private class BasicOperation: Operation { + let peripheral: CBPeripheral + var cancelable: AnyCancellable? + + private(set) var promise: ((Result) -> Void)? + + enum State: String { + case ready, executing, finished + + var keyPath: String { + "is\(rawValue.capitalized)" + } + } + + init(peripheral: CBPeripheral) { + self.peripheral = peripheral + } + + lazy private(set) var future: Future = Future { [unowned self] promise in + self.promise = promise + } + + var state: State = .ready { + willSet { + willChangeValue(forKey: state.keyPath) + willChangeValue(forKey: newValue.keyPath) + } + didSet { + didChangeValue(forKey: state.keyPath) + didChangeValue(forKey: state.keyPath) + } + } + + override var isExecuting: Bool { + state == .executing + } + + override var isFinished: Bool { + state == .finished + } + + override func cancel() { + cancelable?.cancel() + } + + override var isAsynchronous: Bool { + true + } +} + +private class WriteCharacteristicOperation: BasicOperation { + + let writtenEventsPublisher: AnyPublisher<(CBCharacteristic, Error?), Never> + let characteristic: CBCharacteristic + + let data: Data + + init( + data: Data, writtenEventsPublisher: AnyPublisher<(CBCharacteristic, Error?), Never>, + characteristic: CBCharacteristic, peripheral: CBPeripheral + ) { + self.data = data + self.writtenEventsPublisher = writtenEventsPublisher + self.characteristic = characteristic + super.init(peripheral: peripheral) + } + + override func main() { + peripheral.writeValue(data, for: characteristic, type: .withResponse) + } + + override func start() { + if isCancelled { + state = .finished + return + } + + self.cancelable = writtenEventsPublisher.share() + .filter { $0.0.uuid == self.characteristic.uuid } + .first() + .tryMap { v in + if let e = v.1 { + throw e + } else { + return v.0 + } + } + .sink { [unowned self] completion in + switch completion { + case .finished: + self.promise?(.success(())) + case .failure(let e): + self.promise?(.failure(e)) + } + self.state = .finished + } receiveValue: { _ in + + } + + state = .executing + main() + } +} + +private class ReadCharacteristicOperation: BasicOperation { + let updateEventPublisher: AnyPublisher<(CBCharacteristic, Error?), Never> + let characteristic: CBCharacteristic + + init( + updateEventPublisher: AnyPublisher<(CBCharacteristic, Error?), Never>, + characteristic: CBCharacteristic, peripheral: CBPeripheral + ) { + self.updateEventPublisher = updateEventPublisher + self.characteristic = characteristic + super.init(peripheral: peripheral) + } + + override func main() { + peripheral.readValue(for: characteristic) + } + + override func start() { + if isCancelled { + state = .finished + return + } + + self.cancelable = updateEventPublisher.share() + .filter { $0.0.uuid == self.characteristic.uuid } + .first() + .tryMap { v in + if let e = v.1 { + throw e + } else { + return v.0.value + } + } + .sink { [unowned self] completion in + if case .failure(let e) = completion { + self.promise?(.failure(e)) + } + self.state = .finished + } receiveValue: { v in + self.promise?(.success(v)) + } + + state = .executing + main() + } + +} diff --git a/Sources/iOS-BLE-Library-Mock/Peripheral/Peripheral.swift b/Sources/iOS-BLE-Library-Mock/Peripheral/Peripheral.swift new file mode 100644 index 0000000..22cda2f --- /dev/null +++ b/Sources/iOS-BLE-Library-Mock/Peripheral/Peripheral.swift @@ -0,0 +1,283 @@ +// +// File.swift +// +// +// Created by Nick Kibysh on 28/04/2023. +// + +import Combine +import CoreBluetooth +import CoreBluetoothMock +import Foundation + +private class Observer: NSObject { + func setup() {} +} + +private class NativeObserver: Observer { + @objc private var peripheral: CoreBluetooth.CBPeripheral + + private weak var publisher: CurrentValueSubject! + private var observation: NSKeyValueObservation? + + init( + peripheral: CoreBluetooth.CBPeripheral, + publisher: CurrentValueSubject + ) { + self.peripheral = peripheral + self.publisher = publisher + super.init() + } + + override func setup() { + observation = peripheral.observe(\.state, options: [.new]) { + [weak self] _, change in + #warning("queue can be not only main") + DispatchQueue.main.async { + guard let self else { return } + self.publisher.send(self.peripheral.state) + } + } + } +} + +private class MockObserver: Observer { + @objc private var peripheral: CBMPeripheralMock + + private weak var publisher: CurrentValueSubject! + private var observation: NSKeyValueObservation? + + init( + peripheral: CBMPeripheralMock, + publisher: CurrentValueSubject + ) { + self.peripheral = peripheral + self.publisher = publisher + super.init() + } + + override func setup() { + observation = peripheral.observe(\.state, options: [.new]) { + [weak self] _, change in + #warning("queue can be not only main") + DispatchQueue.main.async { + guard let self else { return } + self.publisher.send(self.peripheral.state) + } + } + } +} + +public class Peripheral { + /// I'm Errr from Omicron Persei 8 + public enum Err: Error { + case badDelegate + } + + public let peripheral: CBPeripheral + public let peripheralDelegate: ReactivePeripheralDelegate + + private let stateSubject = CurrentValueSubject(.disconnected) + private var observer: Observer! + private lazy var writer = CharacteristicWriter( + writtenEventsPublisher: self.peripheralDelegate.writtenCharacteristicValuesSubject + .eraseToAnyPublisher(), + peripheral: self.peripheral + ) + + private lazy var reader = CharacteristicReader( + updateEventPublisher: self.peripheralDelegate.updatedCharacteristicValuesSubject + .eraseToAnyPublisher(), + peripheral: peripheral + ) + + // TODO: Why don't we use default delegate? + public init(peripheral: CBPeripheral, delegate: ReactivePeripheralDelegate) { + self.peripheral = peripheral + self.peripheralDelegate = delegate + peripheral.delegate = delegate + + if let p = peripheral as? CBMPeripheralNative { + observer = NativeObserver(peripheral: p.peripheral, publisher: stateSubject) + observer.setup() + } else if let p = peripheral as? CBMPeripheralMock { + observer = MockObserver(peripheral: p, publisher: stateSubject) + observer.setup() + } + } +} + +// MARK: - Channels +extension Peripheral { + public var peripheralStateChannel: AnyPublisher { + stateSubject.eraseToAnyPublisher() + } +} + +extension Peripheral { + // TODO: Extract repeated code + public func discoverServices(serviceUUIDs: [CBUUID]?) + -> Publishers.BluetoothPublisher + { + let allServices = peripheralDelegate.discoveredServicesSubject + .tryCompactMap { result throws -> [CBService]? in + if let e = result.1 { + throw e + } else { + return result.0 + } + } + .flatMap { services -> Publishers.Sequence<[CBService], Error> in + Publishers.Sequence(sequence: services) + } + + let filtered: AnyPublisher + + if let serviceList = serviceUUIDs { + filtered = allServices.guestList(serviceList, keypath: \.uuid) + .eraseToAnyPublisher() + } else { + filtered = allServices.eraseToAnyPublisher() + } + + return filtered.bluetooth { + self.peripheral.discoverServices(serviceUUIDs) + } + } + + public func discoverCharacteristics( + _ characteristicUUIDs: [CBUUID]?, for service: CBService + ) -> Publishers.BluetoothPublisher { + let allCharacteristics = peripheralDelegate.discoveredCharacteristicsSubject + .filter { + $0.0.uuid == service.uuid + } + .tryCompactMap { result throws -> [CBCharacteristic]? in + if let e = result.2 { + throw e + } else { + return result.1 + } + } + .flatMap { + characteristics -> Publishers.Sequence<[CBCharacteristic], Error> in + Publishers.Sequence(sequence: characteristics) + } + + let filtered: AnyPublisher + + if let list = characteristicUUIDs { + filtered = + allCharacteristics + .guestList(list, keypath: \.uuid) + .eraseToAnyPublisher() + } else { + filtered = allCharacteristics.eraseToAnyPublisher() + } + + return filtered.bluetooth { + self.peripheral.discoverCharacteristics(characteristicUUIDs, for: service) + } + } + + public func discoverDescriptors(for characteristic: CBCharacteristic) + -> Publishers.BluetoothPublisher + { + return peripheralDelegate.discoveredDescriptorsSubject + .filter { + $0.0.uuid == characteristic.uuid + } + .tryCompactMap { result throws -> [CBDescriptor]? in + if let e = result.2 { + throw e + } else { + return result.1 + } + } + .flatMap { descriptors -> Publishers.Sequence<[CBDescriptor], Error> in + Publishers.Sequence(sequence: descriptors) + } + .bluetooth { + self.peripheral.discoverDescriptors(for: characteristic) + } + } +} + +// MARK: - Writing Characteristic and Descriptor Values +extension Peripheral { + public func writeValueWithResponse(_ data: Data, for characteristic: CBCharacteristic) + -> Publishers.BluetoothPublisher + { + return peripheralDelegate.writtenCharacteristicValuesSubject + .first(where: { $0.0.uuid == characteristic.uuid }) + .tryMap { result in + if let e = result.1 { + throw e + } else { + return () + } + } + .bluetooth { + self.peripheral.writeValue( + data, for: characteristic, type: .withResponse) + } + } + + public func writeValueWithoutResponse(_ data: Data, for characteristic: CBCharacteristic) { + peripheral.writeValue(data, for: characteristic, type: .withoutResponse) + } + + public func writeValue(_ data: Data, for descriptor: CBDescriptor) { + fatalError() + } +} + +// MARK: - Reading Characteristic and Descriptor Values +extension Peripheral { + public func readValue(for characteristic: CBCharacteristic) -> Future { + return reader.readValue(from: characteristic) + } + + public func listenValues(for characteristic: CBCharacteristic) -> AnyPublisher + { + return peripheralDelegate.updatedCharacteristicValuesSubject + .filter { $0.0.uuid == characteristic.uuid } + .tryCompactMap { (ch, err) in + if let err { + throw err + } + + return ch.value + } + .eraseToAnyPublisher() + } + + public func readValue(for descriptor: CBDescriptor) -> Future { + fatalError() + } +} + +// MARK: - Setting Notifications for a Characteristic’s Value +extension Peripheral { + public func setNotifyValue(_ isEnabled: Bool, for characteristic: CBCharacteristic) + -> Publishers.BluetoothPublisher + { + if characteristic.isNotifying == isEnabled { + return Just(isEnabled) + .setFailureType(to: Error.self) + .bluetooth {} + } + + return peripheralDelegate.notificationStateSubject + .first { $0.0.uuid == characteristic.uuid } + .tryMap { result in + if let e = result.1 { + throw e + } + return result.0.isNotifying + } + .bluetooth { + self.peripheral.setNotifyValue(isEnabled, for: characteristic) + } + } +} diff --git a/Sources/iOS-BLE-Library-Mock/Peripheral/ReactivePeripheralDelegate.swift b/Sources/iOS-BLE-Library-Mock/Peripheral/ReactivePeripheralDelegate.swift new file mode 100644 index 0000000..c342031 --- /dev/null +++ b/Sources/iOS-BLE-Library-Mock/Peripheral/ReactivePeripheralDelegate.swift @@ -0,0 +1,173 @@ +// +// File.swift +// +// +// Created by Nick Kibysh on 28/04/2023. +// + +import Combine +import CoreBluetoothMock +import Foundation + +public class ReactivePeripheralDelegate: NSObject { + let l = L(category: #file) + + // MARK: Subjects + public let discoveredServicesSubject = PassthroughSubject<([CBService]?, Error?), Never>() + public let discoveredIncludedServicesSubject = PassthroughSubject< + (CBService, [CBService]?, Error?), Never + >() + public let discoveredCharacteristicsSubject = PassthroughSubject< + (CBService, [CBCharacteristic]?, Error?), Never + >() + public let discoveredDescriptorsSubject = PassthroughSubject< + (CBCharacteristic, [CBDescriptor]?, Error?), Never + >() + + // MARK: Retrieving Characteristic and Descriptor Values + public let updatedCharacteristicValuesSubject = PassthroughSubject< + (CBCharacteristic, Error?), Never + >() + public let updatedDescriptorValuesSubject = PassthroughSubject< + (CBDescriptor, Error?), Never + >() + + public let writtenCharacteristicValuesSubject = PassthroughSubject< + (CBCharacteristic, Error?), Never + >() + public let writtenDescriptorValuesSubject = PassthroughSubject< + (CBDescriptor, Error?), Never + >() + + // MARK: Managing Notifications for a Characteristic’s Value + public let notificationStateSubject = PassthroughSubject< + (CBCharacteristic, Error?), Never + >() + + // MARK: Monitoring Changes to a Peripheral’s Name or Services + public let updateNameSubject = PassthroughSubject() +} + +extension ReactivePeripheralDelegate: CBPeripheralDelegate { + // MARK: Discovering Services + + public func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { + l.i(#function) + discoveredServicesSubject.send((peripheral.services, error)) + } + + public func peripheral( + _ peripheral: CBPeripheral, didDiscoverIncludedServicesFor service: CBService, + error: Error? + ) { + l.i(#function) + discoveredIncludedServicesSubject.send((service, service.includedServices, error)) + } + + // MARK: Discovering Characteristics and their Descriptors + + public func peripheral( + _ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, + error: Error? + ) { + l.i(#function) + discoveredCharacteristicsSubject.send((service, service.characteristics, error)) + } + + public func peripheral( + _ peripheral: CBPeripheral, + didDiscoverDescriptorsFor characteristic: CBCharacteristic, error: Error? + ) { + l.i(#function) + discoveredDescriptorsSubject.send( + (characteristic, characteristic.descriptors, error)) + } + + // MARK: Retrieving Characteristic and Descriptor Values + + public func peripheral( + _ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, + error: Error? + ) { + l.i(#function) + updatedCharacteristicValuesSubject.send((characteristic, error)) + } + + public func peripheral( + _ peripheral: CBPeripheral, didUpdateValueFor descriptor: CBDescriptor, + error: Error? + ) { + l.i(#function) + updatedDescriptorValuesSubject.send((descriptor, error)) + } + + // MARK: Writing Characteristic and Descriptor Values + + public func peripheral( + _ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, + error: Error? + ) { + l.i(#function) + writtenCharacteristicValuesSubject.send((characteristic, error)) + } + + public func peripheral( + _ peripheral: CBPeripheral, didWriteValueFor descriptor: CBDescriptor, error: Error? + ) { + l.i(#function) + writtenDescriptorValuesSubject.send((descriptor, error)) + } + + public func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) { + l.i(#function) + fatalError() + } + + // MARK: Managing Notifications for a Characteristic’s Value + + public func peripheral( + _ peripheral: CBPeripheral, + didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error? + ) { + l.i(#function) + notificationStateSubject.send((characteristic, error)) + } + + // MARK: Retrieving a Peripheral’s RSSI Data + + public func peripheral( + _ peripheral: CBPeripheral, didReadRSSI RSSI: NSNumber, error: Error? + ) { + l.i(#function) + fatalError() + } + + public func peripheralDidUpdateRSSI(_ peripheral: CBPeripheral, error: Error?) { + l.i(#function) + fatalError() + } + + // MARK: Monitoring Changes to a Peripheral’s Name or Services + + public func peripheralDidUpdateName(_ peripheral: CBPeripheral) { + l.i(#function) + updateNameSubject.send(peripheral.name) + } + + public func peripheral( + _ peripheral: CBPeripheral, didModifyServices invalidatedServices: [CBService] + ) { + l.i(#function) + fatalError() + } + + // MARK: Monitoring L2CAP Channels + + public func peripheral( + _ peripheral: CBPeripheral, didOpen channel: CBL2CAPChannel?, error: Error? + ) { + l.i(#function) + fatalError() + } + +} diff --git a/Sources/iOS-BLE-Library-Mock/Utilities/AdvertisementData.swift b/Sources/iOS-BLE-Library-Mock/Utilities/AdvertisementData.swift new file mode 100644 index 0000000..b2df1cd --- /dev/null +++ b/Sources/iOS-BLE-Library-Mock/Utilities/AdvertisementData.swift @@ -0,0 +1,113 @@ +// +// AdvertisementData.swift +// iOS-BLE-Library +// +// Created by Dinesh Harjani on 23/8/22. +// + +import CoreBluetoothMock +import Foundation + +public struct AdvertisementData: Hashable { + public static func == (lhs: AdvertisementData, rhs: AdvertisementData) -> Bool { + return lhs.localName == rhs.localName + && lhs.manufacturerData == rhs.manufacturerData + && lhs.serviceData == rhs.serviceData + && lhs.serviceUUIDs == rhs.serviceUUIDs + && lhs.overflowServiceUUIDs == rhs.overflowServiceUUIDs + && lhs.txPowerLevel == rhs.txPowerLevel + && lhs.isConnectable == rhs.isConnectable + && lhs.solicitedServiceUUIDs == rhs.solicitedServiceUUIDs + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(localName) + hasher.combine(manufacturerData) + hasher.combine(serviceData) + hasher.combine(serviceUUIDs) + hasher.combine(overflowServiceUUIDs) + hasher.combine(txPowerLevel) + hasher.combine(isConnectable) + hasher.combine(solicitedServiceUUIDs) + } + + public let rawData: [String: Any] + + // MARK: - Properties + + public var localName: String? { // CBAdvertisementDataLocalNameKey + rawData[CBAdvertisementDataLocalNameKey] as? String + } + + public var manufacturerData: Data? { // CBAdvertisementDataManufacturerDataKey + rawData[CBAdvertisementDataManufacturerDataKey] as? Data + } + + public var serviceData: [CBUUID: Data]? { // CBAdvertisementDataServiceDataKey + rawData[CBAdvertisementDataServiceDataKey] as? [CBUUID: Data] + } + + public var serviceUUIDs: [CBUUID]? { // CBAdvertisementDataServiceUUIDsKey + rawData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] + } + + public var overflowServiceUUIDs: [CBUUID]? { // CBAdvertisementDataOverflowServiceUUIDsKey + rawData[CBAdvertisementDataOverflowServiceUUIDsKey] as? [CBUUID] + } + + public var txPowerLevel: Int? { // CBAdvertisementDataTxPowerLevelKey + (rawData[CBAdvertisementDataTxPowerLevelKey] as? NSNumber)?.intValue + } + + public var isConnectable: Bool? { // CBAdvertisementDataIsConnectable + (rawData[CBAdvertisementDataIsConnectable] as? NSNumber)?.boolValue + } + + public var solicitedServiceUUIDs: [CBUUID]? { // CBAdvertisementDataSolicitedServiceUUIDsKey + rawData[CBAdvertisementDataSolicitedServiceUUIDsKey] as? [CBUUID] + } + + // MARK: - Init + + public init() { + self.init([:]) + } + + public init(_ advertisementData: [String: Any]) { + self.rawData = advertisementData + } + + // MARK: - Advertised ID (MAC Address) + + internal static let ExpectedManufacturerDataPrefix: UInt8 = 225 + + public func advertisedID() -> String? { + guard let data = manufacturerData, data.count > 4 else { return nil } + var advData = data.suffix(from: 2) // Skip 'Nordic' Manufacturer Code + guard advData.removeFirst() == Self.ExpectedManufacturerDataPrefix else { + return nil + } + return advData.hexEncodedString(separator: ":").uppercased() + } +} + +// MARK: - Debug + +#if DEBUG + extension AdvertisementData { + + public static var connectableMock: AdvertisementData { + AdvertisementData([ + CBAdvertisementDataLocalNameKey: "iPhone 13", + CBAdvertisementDataIsConnectable: true as NSNumber, + ]) + } + + public static var unconnectableMock: AdvertisementData { + AdvertisementData([ + CBAdvertisementDataLocalNameKey: "iPhone 14", + CBAdvertisementDataIsConnectable: false as NSNumber, + ]) + } + } +#endif diff --git a/Sources/iOS-BLE-Library-Mock/Utilities/AsyncCharacteristicData.swift b/Sources/iOS-BLE-Library-Mock/Utilities/AsyncCharacteristicData.swift new file mode 100644 index 0000000..6a95d00 --- /dev/null +++ b/Sources/iOS-BLE-Library-Mock/Utilities/AsyncCharacteristicData.swift @@ -0,0 +1,34 @@ +// +// AsyncCharacteristicData.swift +// iOS-BLE-Library +// +// Created by Dinesh Harjani on 23/8/22. +// + +import CoreBluetoothMock +import Foundation + +public typealias AsyncStreamValue = (characteristic: CBCharacteristic, data: Data?) + +public struct AsyncCharacteristicData: AsyncSequence, AsyncIteratorProtocol { + public typealias Element = Data? + + let serviceUUID: String + let characteristicUUID: String + let stream: AsyncThrowingStream + + public func makeAsyncIterator() -> AsyncCharacteristicData { + self + } + + mutating public func next() async throws -> Element? { + for try await newValue in stream { + guard newValue.characteristic.uuid.uuidString == characteristicUUID, + let service = newValue.characteristic.service, + service.uuid.uuidString == serviceUUID + else { continue } + return newValue.data + } + return nil + } +} diff --git a/Sources/iOS-BLE-Library-Mock/Utilities/CBManagerState.swift b/Sources/iOS-BLE-Library-Mock/Utilities/CBManagerState.swift new file mode 100644 index 0000000..0f15b75 --- /dev/null +++ b/Sources/iOS-BLE-Library-Mock/Utilities/CBManagerState.swift @@ -0,0 +1,37 @@ +// +// CBManagerState.swift +// +// +// Created by Dinesh Harjani on 23/8/22. +// + +import CoreBluetoothMock +import Foundation + +// MARK: - CBManagerState + +@available(iOS 10.0, *) +@available(macOS 10.13, *) +extension CBManagerState: CustomDebugStringConvertible, CustomStringConvertible { + + public var debugDescription: String { + return description + } + + public var description: String { + switch self { + case .poweredOff: + return "poweredOff" + case .poweredOn: + return "poweredOn" + case .resetting: + return "resetting" + case .unauthorized: + return "unauthorized" + case .unknown: + return "unknown" + case .unsupported: + return "unsupported" + } + } +} diff --git a/Sources/iOS-BLE-Library-Mock/Utilities/Extensions/CBManagerState+Ext.swift b/Sources/iOS-BLE-Library-Mock/Utilities/Extensions/CBManagerState+Ext.swift new file mode 100644 index 0000000..adfd47f --- /dev/null +++ b/Sources/iOS-BLE-Library-Mock/Utilities/Extensions/CBManagerState+Ext.swift @@ -0,0 +1,22 @@ +// +// File.swift +// +// +// Created by Nick Kibysh on 19/04/2023. +// + +import CoreBluetoothMock +import Foundation + +extension CBManagerState { + var ready: Bool? { + switch self { + case .poweredOn: + return true + case .unknown, .resetting: + return nil + case .poweredOff, .unauthorized, .unsupported: + return false + } + } +} diff --git a/Sources/iOS-BLE-Library-Mock/Utilities/Extensions/Data+Ext.swift b/Sources/iOS-BLE-Library-Mock/Utilities/Extensions/Data+Ext.swift new file mode 100644 index 0000000..565f23b --- /dev/null +++ b/Sources/iOS-BLE-Library-Mock/Utilities/Extensions/Data+Ext.swift @@ -0,0 +1,44 @@ +// +// Data.swift +// +// +// Created by Dinesh Harjani on 18/8/22. +// + +import Foundation + +// MARK: - Data Extension + +extension Data { + + // MARK: HexEncodingOptions + + public struct HexEncodingOptions: OptionSet { + + public static let upperCase = HexEncodingOptions(rawValue: 1) + public static let reverseEndianness = HexEncodingOptions(rawValue: 2) + + public let rawValue: Int + + public init(rawValue: Int) { + self.rawValue = rawValue << 0 + } + } + + // MARK: hexEncodedString + + public func hexEncodedString(options: HexEncodingOptions = [], separator: String = "") + -> String + { + let format = options.contains(.upperCase) ? "%02hhX" : "%02hhx" + + var bytes = self + if options.contains(.reverseEndianness) { + bytes.reverse() + } + return + bytes + .map { String(format: format, $0) } + .joined(separator: separator) + } +} diff --git a/Sources/iOS-BLE-Library-Mock/Utilities/Extensions/Publishers+Async.swift b/Sources/iOS-BLE-Library-Mock/Utilities/Extensions/Publishers+Async.swift new file mode 100644 index 0000000..530e851 --- /dev/null +++ b/Sources/iOS-BLE-Library-Mock/Utilities/Extensions/Publishers+Async.swift @@ -0,0 +1,325 @@ +// +// File.swift +// +// +// Created by Nick Kibysh on 24/03/2023. +// + +import Combine +import Foundation + +extension Publisher where Failure == Never { + + var values: AsyncPublisher { + return .init(self) + } +} + +extension Publisher { + public var value: Output { + get async throws { + try await ContinuationSubscriber.withCheckedContinuation(self) + } + } +} + +//extension Publishers.Autoconnect where Upstream == Publishers.Peripheral { +// public var value: Output { +// get async throws { +// try await ContinuationSubscriber.withCheckedContinuation(self) +// } +// } +//} + +public struct AsyncPublisher: AsyncSequence where Upstream.Failure == Never { + public typealias Element = Upstream.Output + + public struct Iterator: AsyncIteratorProtocol { + public typealias Element = Upstream.Output + fileprivate let inner: Inner + + public mutating func next() async -> Element? { + return await withTaskCancellationHandler { + [inner] in + await inner.next() + } onCancel: { + [inner] in + inner.cancel() + } + } + } + + public typealias AsyncIterator = Iterator + + private let publisher: Upstream + + public init(_ publisher: Upstream) { + self.publisher = publisher + } + + public func makeAsyncIterator() -> Iterator { + let inner = Iterator.Inner() + publisher.subscribe(inner) + return Iterator(inner: inner) + } +} + +extension AsyncPublisher.Iterator { + fileprivate final class Inner: Subscriber, Cancellable { + typealias Input = Upstream.Output + typealias Failure = Upstream.Failure + + private enum State { + case awaitingSubscription + case subscribed(Subscription) + case terminal + } + + private let lock = NSLock() + private var pending: [UnsafeContinuation] = [] + private var state = State.awaitingSubscription + private var pendingDemand = Subscribers.Demand.none + + func receive(subscription: Subscription) { + lock.lock() + guard case .awaitingSubscription = state else { + lock.unlock() + subscription.cancel() + return + } + state = .subscribed(subscription) + let pendingDemand = self.pendingDemand + self.pendingDemand = .none + lock.unlock() + if pendingDemand != .none { + subscription.request(pendingDemand) + } + } + + func receive(_ input: Input) -> Subscribers.Demand { + lock.lock() + guard case .subscribed = state else { + let pending = self.pending + lock.unlock() + pending.resumeAllWithNil() + return .none + } + precondition( + !pending.isEmpty, "Received an output without requesting demand") + let continuation = pending.removeFirst() + lock.unlock() + continuation.resume(returning: input) + return .none + } + + func receive(completion: Subscribers.Completion) { + lock.lock() + state = .terminal + let pending = self.pending + lock.unlock() + pending.resumeAllWithNil() + } + + func cancel() { + lock.lock() + let pending = self.pending + guard case .subscribed(let subscription) = state else { + state = .terminal + lock.unlock() + pending.resumeAllWithNil() + return + } + state = .terminal + lock.unlock() + subscription.cancel() + pending.resumeAllWithNil() + } + + fileprivate func next() async -> Input? { + return await withUnsafeContinuation { continuation in + lock.lock() + switch state { + case .awaitingSubscription: + pending.append(continuation) + pendingDemand += 1 + lock.unlock() + case .subscribed(let subscription): + pending.append(continuation) + lock.unlock() + subscription.request(.max(1)) + case .terminal: + lock.unlock() + continuation.resume(returning: nil) + } + } + } + } +} + +extension Publisher { + var values: AsyncThrowingPublisher { + return .init(self) + } +} + +public struct AsyncThrowingPublisher: AsyncSequence { + public typealias Element = Upstream.Output + + public struct Iterator: AsyncIteratorProtocol { + + public typealias Element = Upstream.Output + + fileprivate let inner: Inner + + public mutating func next() async throws -> Element? { + try await withTaskCancellationHandler { + [inner] in + try await inner.next() + } onCancel: { + [inner] in + inner.cancel() + } + } + } + + public typealias AsyncIterator = Iterator + + private let publisher: Upstream + + public init(_ publisher: Upstream) { + self.publisher = publisher + } + + public func makeAsyncIterator() -> Iterator { + let inner = Iterator.Inner() + publisher.subscribe(inner) + return Iterator(inner: inner) + } +} + +extension AsyncThrowingPublisher.Iterator { + + fileprivate final class Inner: Subscriber, Cancellable { + typealias Input = Upstream.Output + typealias Failure = Upstream.Failure + + private enum State { + case awaitingSubscription + case subscribed(Subscription) + case terminal(Error?) + } + + private let lock = NSLock() + private var pending: [UnsafeContinuation] = [] + private var state = State.awaitingSubscription + private var pendingDemand = Subscribers.Demand.none + + func receive(subscription: Subscription) { + lock.lock() + guard case .awaitingSubscription = state else { + lock.unlock() + subscription.cancel() + return + } + state = .subscribed(subscription) + let pendingDemand = self.pendingDemand + self.pendingDemand = .none + lock.unlock() + if pendingDemand != .none { + subscription.request(pendingDemand) + } + } + + func receive(_ input: Input) -> Subscribers.Demand { + lock.lock() + guard case .subscribed = state else { + let pending = self.pending + lock.unlock() + pending.resumeAllWithNil() + return .none + } + precondition( + !pending.isEmpty, "Received an output without requesting demand") + let continuation = pending.removeFirst() + lock.unlock() + continuation.resume(returning: input) + return .none + } + + func receive(completion: Subscribers.Completion) { + lock.lock() + switch state { + case .awaitingSubscription, .subscribed: + if let continuation = pending.first { + state = .terminal(nil) + let remaining = pending.dropFirst() + lock.unlock() + switch completion { + case .finished: + continuation.resume(returning: nil) + case .failure(let error): + continuation.resume(throwing: error) + } + remaining.resumeAllWithNil() + } else if case .failure(let e) = completion { + state = .terminal(e) + lock.unlock() + } else { + state = .terminal(nil) + lock.unlock() + } + case .terminal: + let pending = self.pending + lock.unlock() + pending.resumeAllWithNil() + } + } + + func cancel() { + lock.lock() + let pending = self.pending + guard case .subscribed(let subscription) = state else { + state = .terminal(nil) + lock.unlock() + pending.resumeAllWithNil() + return + } + state = .terminal(nil) + lock.unlock() + subscription.cancel() + pending.resumeAllWithNil() + } + + func next() async throws -> Input? { + return try await withUnsafeThrowingContinuation { continuation in + lock.lock() + switch state { + case .awaitingSubscription: + pending.append(continuation) + pendingDemand += 1 + lock.unlock() + case .subscribed(let subscription): + pending.append(continuation) + lock.unlock() + subscription.request(.max(1)) + case .terminal(nil): + lock.unlock() + continuation.resume(returning: nil) + case .terminal(let error?): + state = .terminal(nil) + lock.unlock() + continuation.resume(throwing: error) + } + } + } + } +} + +extension Sequence { + fileprivate func resumeAllWithNil() + where Element == UnsafeContinuation { + for continuation in self { + continuation.resume(returning: nil) + } + } +} diff --git a/Sources/iOS-BLE-Library-Mock/Utilities/Logger.swift b/Sources/iOS-BLE-Library-Mock/Utilities/Logger.swift new file mode 100644 index 0000000..889c3d2 --- /dev/null +++ b/Sources/iOS-BLE-Library-Mock/Utilities/Logger.swift @@ -0,0 +1,58 @@ +// +// File.swift +// +// +// Created by Nick Kibysh on 20/01/2023. +// + +import Foundation +import os + +struct L { + @inline(__always) + static var enabled: Bool { + return false + } + + let subsystem: String + let category: String + + private let shouldLog: Bool + + init( + subsystem: String = "com.nordicsemi.ios_ble_library", category: String, + enabled: Bool = false + ) { + self.subsystem = subsystem + self.category = category + self.shouldLog = enabled + } + + func i(_ msg: String) { + #if DEBUG + if !shouldLog { return } + os_log("%@", type: .info, msg) + #endif + } + + func d(_ msg: String) { + #if DEBUG + if !shouldLog { return } + os_log("%@", type: .debug, msg) + #endif + } + + func e(_ msg: String) { + #if DEBUG + if !shouldLog { return } + os_log("%@", type: .error, msg) + #endif + } + + func f(_ msg: String) { + #if DEBUG + if !shouldLog { return } + os_log("%@", type: .fault, msg) + #endif + } +} diff --git a/Sources/iOS-BLE-Library-Mock/Utilities/Publishers/ContinuationSubscriber.swift b/Sources/iOS-BLE-Library-Mock/Utilities/Publishers/ContinuationSubscriber.swift new file mode 100644 index 0000000..18d98a8 --- /dev/null +++ b/Sources/iOS-BLE-Library-Mock/Utilities/Publishers/ContinuationSubscriber.swift @@ -0,0 +1,88 @@ +// +// File.swift +// +// +// Created by Nick Kibysh on 05/05/2023. +// + +import Combine +import Foundation + +class ContinuationSubscriber: Subscriber { + + typealias Input = Upstream.Output + typealias Failure = Upstream.Failure + + private let continuation: CheckedContinuation + private var state: State = .waitingForSubscription + private var lock = NSLock() + private var subscription: Subscription? + + enum State { + case waitingForSubscription + case receivedSubscription + case terminated + } + + init(continuation: CheckedContinuation) { + self.continuation = continuation + } + + func receive(subscription: Subscription) { + lock.lock() + guard case .waitingForSubscription = state else { + lock.unlock() + return + } + + self.state = .receivedSubscription + self.subscription = subscription + lock.unlock() + + subscription.request(.max(1)) + } + + func receive(_ input: Upstream.Output) -> Subscribers.Demand { + lock.lock() + guard case .receivedSubscription = state else { + lock.unlock() + return .none + } + self.state = .terminated + continuation.resume(returning: input) + + self.subscription?.cancel() + lock.unlock() + + return .none + } + + func receive(completion: Subscribers.Completion) { + lock.lock() + guard case .receivedSubscription = state else { + lock.unlock() + return + } + + self.state = .terminated + + switch completion { + case .finished: + break + case .failure(let failure): + continuation.resume(throwing: failure) + } + lock.unlock() + } +} + +extension ContinuationSubscriber { + static func withCheckedContinuation(_ upstream: Upstream) async throws + -> Input where Upstream.Output == Input, Upstream.Failure == Failure + { + + try await withCheckedThrowingContinuation { c in + upstream.subscribe(ContinuationSubscriber(continuation: c)) + } + } +} diff --git a/Sources/iOS-BLE-Library-Mock/Utilities/Publishers/Publishers+Bluetooth.swift b/Sources/iOS-BLE-Library-Mock/Utilities/Publishers/Publishers+Bluetooth.swift new file mode 100644 index 0000000..8b72a6f --- /dev/null +++ b/Sources/iOS-BLE-Library-Mock/Utilities/Publishers/Publishers+Bluetooth.swift @@ -0,0 +1,39 @@ +// +// File.swift +// +// +// Created by Nick Kibysh on 05/05/2023. +// + +import Combine +import Foundation + +extension Publisher { + func bluetooth(_ fire: @escaping () -> Void) + -> Publishers.BluetoothPublisher + { + Publishers.BluetoothPublisher(self, fire: fire) + } +} + +extension Publishers { + public class BluetoothPublisher: ConnectablePublisher { + + private let inner: BaseConnectable + + init( + _ publisher: PublisherType, fire: @escaping () -> Void + ) where Output == PublisherType.Output, Failure == PublisherType.Failure { + self.inner = ClosureConnectablePublisher(upstream: publisher, fire: fire) + } + + public func receive(subscriber: S) + where S: Subscriber, Failure == S.Failure, Output == S.Input { + inner.receive(subscriber: subscriber) + } + + public func connect() -> Cancellable { + return inner.connect() + } + } +} diff --git a/Sources/iOS-BLE-Library-Mock/Utilities/Publishers/Publishers+Connectable.swift b/Sources/iOS-BLE-Library-Mock/Utilities/Publishers/Publishers+Connectable.swift new file mode 100644 index 0000000..4df3440 --- /dev/null +++ b/Sources/iOS-BLE-Library-Mock/Utilities/Publishers/Publishers+Connectable.swift @@ -0,0 +1,58 @@ +// +// File.swift +// +// +// Created by Nick Kibysh on 05/05/2023. +// + +import Combine +import Foundation + +class BaseConnectable: ConnectablePublisher { + func connect() -> Cancellable { + fatalError() + } + + func receive(subscriber: Downstream) + where Failure == Downstream.Failure, Output == Downstream.Input { + fatalError() + } +} + +class ClosureConnectablePublisher: BaseConnectable< + Upstream.Output, Upstream.Failure +> +{ + typealias Output = Upstream.Output + typealias Failure = Upstream.Failure + + let upstream: Upstream + let fire: () -> Void + let onCancel: (() -> Void)? + + init(upstream: Upstream, fire: @escaping () -> Void, onCancel: (() -> Void)? = nil) { + self.upstream = upstream + self.fire = fire + self.onCancel = onCancel + } + + override func receive(subscriber: S) + where S: Subscriber, Failure == S.Failure, Output == S.Input { + upstream.subscribe(subscriber) + } + + override func connect() -> Cancellable { + fire() + return Cancelator(onCancel: onCancel) + } +} + +extension ClosureConnectablePublisher { + struct Cancelator: Cancellable { + let onCancel: (() -> Void)? + + func cancel() { + onCancel?() + } + } +} diff --git a/Sources/iOS-BLE-Library-Mock/Utilities/Publishers/Publishers+FailablePrefix.swift.swift b/Sources/iOS-BLE-Library-Mock/Utilities/Publishers/Publishers+FailablePrefix.swift.swift new file mode 100644 index 0000000..1334e48 --- /dev/null +++ b/Sources/iOS-BLE-Library-Mock/Utilities/Publishers/Publishers+FailablePrefix.swift.swift @@ -0,0 +1,128 @@ +// +// Publishers+FailablePrefix.swift +// +// +// Created by Nick Kibysh on 22/08/2023. +// + +import Combine +import Foundation + +extension Publisher { + func prefix( + untilUntilOutputOrCompletion publisher: Other + ) -> Publishers.PrefixUntilOutputOrCompletion { + return .init(upstream: self, other: publisher) + } +} + +extension Publishers { + struct PrefixUntilOutputOrCompletion: Publisher + where Other.Failure == Upstream.Failure { + + public typealias Output = Upstream.Output + public typealias Failure = Upstream.Failure + + public let upstream: Upstream + public let other: Other + + public init(upstream: Upstream, other: Other) { + self.upstream = upstream + self.other = other + } + + public func receive(subscriber: Downstream) + where Downstream.Failure == Failure, Downstream.Input == Output { + upstream.subscribe(Inner(downstream: subscriber, trigger: other)) + } + } +} + +#warning("Thread safety should be considered") +extension Publishers.PrefixUntilOutputOrCompletion { + private final class Inner: Subscriber, Subscription + where Downstream.Input == Upstream.Output, Downstream.Failure == Upstream.Failure { + typealias Input = Upstream.Output + typealias Failure = Upstream.Failure + + private struct Termination: Subscriber { + + let inner: Inner + + var combineIdentifier: CombineIdentifier { + return inner.combineIdentifier + } + + func receive(subscription: Subscription) { + inner.terminationReceive(subscription: subscription) + } + + func receive(_ input: Other.Output) -> Subscribers.Demand { + return inner.terminationReceive(input) + } + + func receive(completion: Subscribers.Completion) { + inner.terminationReceive(completion: completion) + } + } + + private var subscription: Subscription? + private var termination: Termination? + private var terminationSubscription: Subscription? + private let downstream: Downstream + + init(downstream: Downstream, trigger: Other) { + self.downstream = downstream + let termination = Termination(inner: self) + self.termination = termination + trigger.subscribe(termination) + } + + func receive(subscription: Subscription) { + self.subscription = subscription + downstream.receive(subscription: self) + } + + func receive(_ input: Input) -> Subscribers.Demand { + return downstream.receive(input) + } + + func receive(completion: Subscribers.Completion) { + terminationSubscription?.cancel() + termination = nil + subscription = nil + downstream.receive(completion: completion) + } + + func request(_ demand: Subscribers.Demand) { + subscription?.request(demand) + } + + func cancel() { + subscription?.cancel() + terminationSubscription?.cancel() + } + + // MARK: - Private + + private func terminationReceive(subscription: Subscription) { + terminationSubscription = subscription + subscription.request(.max(1)) + } + + private func terminationReceive(_ input: Other.Output) -> Subscribers.Demand { + terminate(.finished) + return .none + } + + private func terminationReceive(completion: Subscribers.Completion) { + terminate(completion) + } + + private func terminate(_ completion: Subscribers.Completion) { + terminationSubscription?.cancel() + self.subscription?.cancel() + downstream.receive(completion: completion) + } + } +} diff --git a/Sources/iOS-BLE-Library-Mock/Utilities/Publishers/Publishers+GuestList.swift b/Sources/iOS-BLE-Library-Mock/Utilities/Publishers/Publishers+GuestList.swift new file mode 100644 index 0000000..400f839 --- /dev/null +++ b/Sources/iOS-BLE-Library-Mock/Utilities/Publishers/Publishers+GuestList.swift @@ -0,0 +1,121 @@ +// +// File.swift +// +// +// Created by Nick Kibysh on 01/05/2023. +// + +import Combine + +extension Publishers { + struct GuestList: Publisher where Upstream: Publisher { + typealias Output = Upstream.Output + typealias Failure = Upstream.Failure + + private let list: [Guest] + private let check: (Guest, Output) -> Bool + private let upstream: Upstream + + init(upstream: Upstream, list: [Guest], check: @escaping (Guest, Output) -> Bool) { + self.list = list + self.check = check + self.upstream = upstream + } + + init(upstream: Upstream, list: [Guest]) where Guest == Output { + self.list = list + self.check = { g, o in g == o } + self.upstream = upstream + } + + init(upstream: Upstream, list: [Guest], keypath: KeyPath) { + self.list = list + self.check = { g, o in o[keyPath: keypath] == g } + self.upstream = upstream + } + + func receive(subscriber: S) + where S: Subscriber, Upstream.Failure == S.Failure, Upstream.Output == S.Input { + upstream.subscribe(Inner(downstream: subscriber, list: list, check: check)) + } + } +} + +extension Publishers.GuestList { + class Inner: Subscriber, Subscription + where + Downstream: Subscriber, Upstream.Output == Downstream.Input, + Upstream.Failure == Downstream.Failure + { + typealias Input = Upstream.Output + typealias Failure = Upstream.Failure + + private var list: [Guest] + private var subscription: Subscription? + private let check: (Guest, Upstream.Output) -> Bool + private let downstream: Downstream + private var demand: Subscribers.Demand = .unlimited + + init(downstream: Downstream, list: [Guest], check: @escaping (Guest, Input) -> Bool) + { + self.downstream = downstream + self.list = list + self.check = check + } + + func receive(subscription: Subscription) { + self.subscription = subscription + downstream.receive(subscription: self) + } + + func receive(_ input: Upstream.Output) -> Subscribers.Demand { + for guest in list.enumerated() { + if check(guest.element, input) { + list.remove(at: guest.offset) + demand = downstream.receive(input) + break + } + } + + if list.isEmpty { + downstream.receive(completion: .finished) + cancel() + } + + return demand + } + + func receive(completion: Subscribers.Completion) { + downstream.receive(completion: completion) + } + + func request(_ demand: Subscribers.Demand) { + self.demand = demand + subscription?.request(demand) + } + + func cancel() { + subscription?.cancel() + } + + } +} + +extension Publisher { + func guestList(_ list: [Guest], check: @escaping (Guest, Output) -> Bool) + -> Publishers.GuestList + { + Publishers.GuestList(upstream: self, list: list, check: check) + } + + func guestList(_ list: [Guest]) -> Publishers.GuestList + where Guest == Output { + Publishers.GuestList(upstream: self, list: list) + } + + func guestList(_ list: [Guest], keypath: KeyPath) + -> Publishers.GuestList + { + Publishers.GuestList(upstream: self, list: list, keypath: keypath) + } +} diff --git a/Sources/iOS-BLE-Library-Mock/Utilities/Publishers/Publishers+Peripheral.swift b/Sources/iOS-BLE-Library-Mock/Utilities/Publishers/Publishers+Peripheral.swift new file mode 100644 index 0000000..99f8be2 --- /dev/null +++ b/Sources/iOS-BLE-Library-Mock/Utilities/Publishers/Publishers+Peripheral.swift @@ -0,0 +1,71 @@ +// +// File.swift +// +// +// Created by Nick Kibysh on 25/04/2023. +// + +import Combine +import CoreBluetoothMock +import Foundation + +extension Publisher where Output == CBPeripheral, Failure == Error { + func peripheral(_ fire: @escaping () -> Void) -> Publishers.Peripheral { + Publishers.Peripheral(self, fire: fire) + } +} + +extension Publishers.Peripheral { + var value: Output { + get async throws { + try await ContinuationSubscriber.withCheckedContinuation(self) + } + } +} + +extension Publishers { + public class Peripheral: ConnectablePublisher { + public typealias Output = CBPeripheral + public typealias Failure = Error + + private let inner: BaseConnectable + + init( + _ publisher: PublisherType, fire: @escaping () -> Void + ) where Output == PublisherType.Output, Failure == PublisherType.Failure { + self.inner = ClosureConnectablePublisher(upstream: publisher, fire: fire) + } + + public func receive(subscriber: S) + where S: Subscriber, Failure == S.Failure, CBPeripheral == S.Input { + inner.receive(subscriber: subscriber) + } + + public func connect() -> Cancellable { + return inner.connect() + } + } + + public class Service: ConnectablePublisher { + public typealias Output = CBService + public typealias Failure = Error + + private let inner: BaseConnectable + + init( + _ publisher: PublisherType, fire: @escaping () -> Void + ) where Output == PublisherType.Output, Failure == PublisherType.Failure { + self.inner = ClosureConnectablePublisher(upstream: publisher, fire: fire) + } + + public func receive(subscriber: S) + where S: Subscriber, Failure == S.Failure, CBService == S.Input { + inner.receive(subscriber: subscriber) + } + + public func connect() -> Cancellable { + return inner.connect() + } + } + +} diff --git a/Sources/iOS-BLE-Library-Mock/Utilities/RSSI.swift b/Sources/iOS-BLE-Library-Mock/Utilities/RSSI.swift new file mode 100644 index 0000000..3d3a2cd --- /dev/null +++ b/Sources/iOS-BLE-Library-Mock/Utilities/RSSI.swift @@ -0,0 +1,64 @@ +// +// RSSI.swift +// iOS-BLE-Library +// +// Created by Dinesh Harjani on 23/8/22. +// + +import Foundation + +// MARK: - RSSI + +private struct Const { + public static let outOfRange = 127 + public static let practicalWorst = -100 + public static let bad = -90 + public static let ok = -80 + public static let good = 50 +} + +public struct RSSI: ExpressibleByIntegerLiteral, Equatable, Hashable { + + public enum Signal { + case outOfRange + case practicalWorst + case bad + case ok + case good + + init(rssi: Int) { + switch rssi { + case let x where x == Const.outOfRange: self = .outOfRange + case let x where x < Const.bad: self = .practicalWorst + case let x where x < Const.ok: self = .bad + case let x where x < Const.good: self = .ok + default: self = .good + } + } + } + + public typealias IntegerLiteralType = Int + + // MARK: Properties + + public let value: Int + public let signal: Signal + + // MARK: Init + + public init(integerLiteral value: Int) { + self.value = value + self.signal = Signal(rssi: value) + } +} + +// MARK: - Constants + +extension RSSI { + + public static let outOfRange = RSSI(integerLiteral: Const.outOfRange) + public static let practicalWorst = RSSI(integerLiteral: Const.practicalWorst) + public static let bad = RSSI(integerLiteral: Const.bad) + public static let ok = RSSI(integerLiteral: Const.ok) + public static let good = RSSI(integerLiteral: Const.good) +} diff --git a/Sources/iOS-BLE-Library-Mock/Utilities/UnimplementedError.swift b/Sources/iOS-BLE-Library-Mock/Utilities/UnimplementedError.swift new file mode 100644 index 0000000..764bfbf --- /dev/null +++ b/Sources/iOS-BLE-Library-Mock/Utilities/UnimplementedError.swift @@ -0,0 +1,12 @@ +// +// File.swift +// +// +// Created by Nick Kibysh on 16/08/2023. +// + +import Foundation + +func unimplementedError() -> Never { + fatalError("Unimplemented Method") +}