diff --git a/.swiftlint.yml b/.swiftlint.yml index 57a0484..4fba1ac 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -4,5 +4,7 @@ disabled_rules: - cyclomatic_complexity - todo - file_length + - function_body_length + - type_body_length - force_try - non_optional_string_data_conversion diff --git a/Sources/MobileSdk/BLEConnection.swift b/Sources/MobileSdk/BLEConnection.swift new file mode 100644 index 0000000..2dd21ef --- /dev/null +++ b/Sources/MobileSdk/BLEConnection.swift @@ -0,0 +1,184 @@ +// Derived from MIT-licensed work by Paul Wilkinson: https://github.com/paulw11/L2Cap + +import CoreBluetooth +import Foundation + +/// The base BLE connection, only intended for subclassing. +class BLEInternalL2CAPConnection: NSObject, StreamDelegate { + var channel: CBL2CAPChannel? + + private var outputData = Data() + private var outputDelivered = false + private var incomingData = Data() + private var incomingTime = Date(timeIntervalSinceNow: 0) + private var incomingDelivered = false + private var openCount = 0 + private var totalBytesWritten = 0 + + /// Handle stream events. Many of these we hand to local methods which the child classes are expected to + /// override. + func stream(_ aStream: Stream, handle eventCode: Stream.Event) { + switch eventCode { + case Stream.Event.openCompleted: + // TODO: This is a bit of a hack, but it'll do for now. There are two streams, one input, one + // output, and we get notified about both. We really only want to start doing things when + // both are available. + openCount += 1 + + if openCount == 2 { + streamIsOpen() + } + + case Stream.Event.endEncountered: + openCount -= 1 + streamEnded() + + case Stream.Event.hasBytesAvailable: + streamBytesAvailable() + if let stream = aStream as? InputStream { + readBytes(from: stream) + } + + case Stream.Event.hasSpaceAvailable: + streamSpaceAvailable() + send() + + case Stream.Event.errorOccurred: + streamError() + + default: + streamUnknownEvent() + } + } + + /// Public send() interface. + public func send(data: Data) { + if !outputDelivered { + outputDelivered = true + outputData = data + totalBytesWritten = 0 + send() + } + } + + /// Internal send() interface. + private func send() { + guard let ostream = channel?.outputStream, !outputData.isEmpty, ostream.hasSpaceAvailable else { + return + } + let bytesWritten = ostream.write(outputData) + + totalBytesWritten += bytesWritten + + // The isEmpty guard above should prevent div0 errors here. + let fracDone = Double(totalBytesWritten) / Double(outputData.count) + + streamSentData(bytes: bytesWritten, total: totalBytesWritten, fraction: fracDone) + + if bytesWritten < outputData.count { + outputData = outputData.advanced(by: bytesWritten) + } else { + outputData.removeAll() + } + } + + /// Close the stream. + public func close() { + if let chn = channel { + chn.outputStream.close() + chn.inputStream.close() + chn.inputStream.remove(from: .main, forMode: .default) + chn.outputStream.remove(from: .main, forMode: .default) + chn.inputStream.delegate = nil + chn.outputStream.delegate = nil + openCount = 0 + } + + channel = nil + } + + /// Read from the stream. + private func readBytes(from stream: InputStream) { + let bufLength = 1024 + let buffer = UnsafeMutablePointer.allocate(capacity: bufLength) + defer { + buffer.deallocate() + } + let bytesRead = stream.read(buffer, maxLength: bufLength) + incomingData.append(buffer, count: bytesRead) + + // This is an awful hack to work around a hairy problem. L2CAP is a stream protocol; there's + // no framing on data, so there's no way to signal that the data exchange is complete. In principle + // we could build a framing protocol on top, or we could use the State characteristics to signal out + // of band, but neither of those are specified by the spec, so we'd be out of compliance. The State + // signalling is what the non-L2CAP flow uses, but the spec explicitly says it's not used with L2CAP. + // + // Another thing we could do would be close the connection, but there are two problems with that; + // the first is we'd be out of spec compliance again, and the second is that we actually have two + // messages going, one in each direction, serially. If we closed to indicate the length of the first, + // we'd have no connection for the second. + // + // So, we have data coming in, and we don't know how much. The stream lets us know when more data + // has arrived, the data comes in chunks. What we do, then, is timestamp when we receive some data, + // and then half a second later see if we got any more. Hopefully the half second delay is small + // enough not to annoy the user and large enough to account for noisy radio environments, but the + // numbers here are a heuristic, and may need to be tuned. If we have no recent data, we assume + // everything is ok, and declare the transmission complete. + + incomingTime = Date(timeIntervalSinceNow: 0) + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { // Half second delay. + if self.incomingDelivered { + return + } + + let timeSinceLastData = -self.incomingTime.timeIntervalSinceNow // Make it positive. + let complete = timeSinceLastData > 0.25 + + if complete { + self.streamReceivedData(self.incomingData) + self.incomingDelivered = true + } + } + + if stream.hasBytesAvailable { + readBytes(from: stream) + } + } + + /// Methods to be overridden by child classes. + func streamIsOpen() { print("The stream is open.") } + func streamEnded() { print("The stream has ended.") } + func streamBytesAvailable() { print("The stream has bytes available.") } + func streamSpaceAvailable() { print("The stream has space available.") } + func streamError() { print("Stream error.") } + func streamUnknownEvent() { print("Stream unknown event.") } + func streamSentData(bytes _: Int, total _: Int, fraction _: Double) { print("Stream sent data.") } + func streamReceivedData(_: Data) { print("Stream received data.") } +} + +/// A UInt16 from Data extension. +extension UInt16 { + var data: Data { + var int = self + return Data(bytes: &int, count: MemoryLayout.size) + } +} + +/// A Data from UInt16 extension. +extension Data { + var uint16: UInt16 { + let i16array = withUnsafeBytes { $0.load(as: UInt16.self) } + return i16array + } +} + +/// A write() on OutputStream extension. +extension OutputStream { + func write(_ data: Data) -> Int { + return data.withUnsafeBytes { (rawBufferPointer: UnsafeRawBufferPointer) -> Int in + let bufferPointer = rawBufferPointer.bindMemory(to: UInt8.self) + return self.write(bufferPointer.baseAddress!, maxLength: data.count) + } + } +} diff --git a/Sources/MobileSdk/Credentials.swift b/Sources/MobileSdk/Credentials.swift index 3fd16dd..62fece5 100644 --- a/Sources/MobileSdk/Credentials.swift +++ b/Sources/MobileSdk/Credentials.swift @@ -9,11 +9,15 @@ public class CredentialStore { // swiftlint:disable force_cast public func presentMdocBLE(deviceEngagement: DeviceEngagement, - callback: BLESessionStateDelegate + callback: BLESessionStateDelegate, + useL2CAP: Bool = true // , trustedReaders: TrustedReaders ) -> BLESessionManager? { if let firstMdoc = self.credentials.first(where: {$0 is MDoc}) { - return BLESessionManager(mdoc: firstMdoc as! MDoc, engagement: DeviceEngagement.QRCode, callback: callback) + return BLESessionManager(mdoc: firstMdoc as! MDoc, + engagement: DeviceEngagement.QRCode, + callback: callback, + useL2CAP: useL2CAP) } else { return nil } diff --git a/Sources/MobileSdk/MDoc.swift b/Sources/MobileSdk/MDoc.swift index 4c8c736..7c29775 100644 --- a/Sources/MobileSdk/MDoc.swift +++ b/Sources/MobileSdk/MDoc.swift @@ -43,16 +43,20 @@ public class BLESessionManager { var sessionManager: SessionManager? var mdoc: MDoc var bleManager: MDocHolderBLECentral! + var useL2CAP: Bool - init?(mdoc: MDoc, engagement: DeviceEngagement, callback: BLESessionStateDelegate) { + init?(mdoc: MDoc, engagement: DeviceEngagement, callback: BLESessionStateDelegate, useL2CAP: Bool) { self.callback = callback self.uuid = UUID() self.mdoc = mdoc + self.useL2CAP = useL2CAP do { let sessionData = try SpruceIDMobileSdkRs.initialiseSession(document: mdoc.inner, uuid: self.uuid.uuidString) self.state = sessionData.state - bleManager = MDocHolderBLECentral(callback: self, serviceUuid: CBUUID(nsuuid: self.uuid)) + bleManager = MDocHolderBLECentral(callback: self, + serviceUuid: CBUUID(nsuuid: self.uuid), + useL2CAP: useL2CAP) self.callback.update(state: .engagingQRCode(sessionData.qrCodeUri.data(using: .ascii)!)) } catch { print("\(error)") diff --git a/Sources/MobileSdk/MDocBLEUtils.swift b/Sources/MobileSdk/MDocBLEUtils.swift index cafc8ce..c9d411a 100644 --- a/Sources/MobileSdk/MDocBLEUtils.swift +++ b/Sources/MobileSdk/MDocBLEUtils.swift @@ -51,3 +51,66 @@ enum MDocReaderBLECallback { protocol MDocReaderBLEDelegate: AnyObject { func callback(message: MDocReaderBLECallback) } + +/// Return a string describing a BLE characteristic property. +func MDocCharacteristicPropertyName(_ prop: CBCharacteristicProperties) -> String { + return switch prop { + case .broadcast: "broadcast" + case .read: "read" + case .writeWithoutResponse: "write without response" + case .write: "write" + case .notify: "notify" + case .indicate: "indicate" + case .authenticatedSignedWrites: "authenticated signed writes" + case .extendedProperties: "extended properties" + case .notifyEncryptionRequired: "notify encryption required" + case .indicateEncryptionRequired: "indicate encryption required" + default: "unknown property" + } +} + +/// Return a string describing a BLE characteristic. +func MDocCharacteristicName(_ chr: CBCharacteristic) -> String { + return MDocCharacteristicNameFromUUID(chr.uuid) +} + +/// Return a string describing a BLE characteristic given its UUID. +func MDocCharacteristicNameFromUUID(_ chr: CBUUID) -> String { + return switch chr { + case holderStateCharacteristicId: "Holder:State" + case holderClient2ServerCharacteristicId: "Holder:Client2Server" + case holderServer2ClientCharacteristicId: "Holder:Server2Client" + case holderL2CAPCharacteristicId: "Holder:L2CAP" + case readerStateCharacteristicId: "Reader:State" + case readerClient2ServerCharacteristicId: "Reader:Client2Server" + case readerServer2ClientCharacteristicId: "Reader:Server2Client" + case readerIdentCharacteristicId: "Reader:Ident" + case readerL2CAPCharacteristicId: "Reader:L2CAP" + default: "Unknown:\(chr)" + } +} + +/// Print a description of a BLE characteristic. +func MDocDesribeCharacteristic(_ chr: CBCharacteristic) { + print(" \(MDocCharacteristicName(chr)) ( ", terminator: "") + + if chr.properties.contains(.broadcast) { print("broadcast", terminator: " ") } + if chr.properties.contains(.read) { print("read", terminator: " ") } + if chr.properties.contains(.writeWithoutResponse) { print("writeWithoutResponse", terminator: " ") } + if chr.properties.contains(.write) { print("write", terminator: " ") } + if chr.properties.contains(.notify) { print("notify", terminator: " ") } + if chr.properties.contains(.indicate) { print("indicate", terminator: " ") } + if chr.properties.contains(.authenticatedSignedWrites) { print("authenticatedSignedWrites", terminator: " ") } + if chr.properties.contains(.extendedProperties) { print("extendedProperties", terminator: " ") } + if chr.properties.contains(.notifyEncryptionRequired) { print("notifyEncryptionRequired", terminator: " ") } + if chr.properties.contains(.indicateEncryptionRequired) { print("indicateEncryptionRequired", terminator: " ") } + print(")") + + if let descriptors = chr.descriptors { + for desc in descriptors { + print(" : \(desc.uuid)") + } + } else { + print(" ") + } +} diff --git a/Sources/MobileSdk/MDocHolderBLECentral.swift b/Sources/MobileSdk/MDocHolderBLECentral.swift index 1c3b05c..66ca533 100644 --- a/Sources/MobileSdk/MDocHolderBLECentral.swift +++ b/Sources/MobileSdk/MDocHolderBLECentral.swift @@ -4,11 +4,13 @@ import Foundation import os import SpruceIDMobileSdkRs +/// Characteristic errors. enum CharacteristicsError: Error { case missingMandatoryCharacteristic(name: String) case missingMandatoryProperty(name: String, characteristicName: String) } +/// Data errors. enum DataError: Error { case noData(characteristic: CBUUID) case invalidStateLength @@ -17,33 +19,175 @@ enum DataError: Error { case unknownDataTransferPrefix(byte: UInt8) } +/// The MDoc holder as a BLE central. class MDocHolderBLECentral: NSObject { + enum MachineState { + case initial, hardwareOn, fatalError, complete, halted + case awaitPeripheralDiscovery, peripheralDiscovered, checkPeripheral + case awaitRequest, requestReceived, sendingResponse + case l2capAwaitRequest, l2capRequestReceived, l2capSendingResponse + } + var centralManager: CBCentralManager! var serviceUuid: CBUUID var callback: MDocBLEDelegate var peripheral: CBPeripheral? + var writeCharacteristic: CBCharacteristic? var readCharacteristic: CBCharacteristic? var stateCharacteristic: CBCharacteristic? + var l2capCharacteristic: CBCharacteristic? + var maximumCharacteristicSize: Int? var writingQueueTotalChunks = 0 var writingQueueChunkIndex = 0 var writingQueue: IndexingIterator>? var incomingMessageBuffer = Data() + var outgoingMessageBuffer = Data() + + private var channelPSM: UInt16? + private var activeStream: MDocHolderBLECentralConnection? + + /// If this is `false`, we decline to connect to L2CAP even if it is offered. + var useL2CAP: Bool - init(callback: MDocBLEDelegate, serviceUuid: CBUUID) { + var machineState = MachineState.initial + var machinePendingState = MachineState.initial { + didSet { + updateState() + } + } + + init(callback: MDocBLEDelegate, serviceUuid: CBUUID, useL2CAP: Bool) { self.serviceUuid = serviceUuid self.callback = callback + self.useL2CAP = useL2CAP super.init() - self.centralManager = CBCentralManager(delegate: self, queue: nil) + centralManager = CBCentralManager(delegate: self, queue: nil) } - func startScanning() { - centralManager.scanForPeripherals(withServices: [serviceUuid]) + /// Update the state machine. + private func updateState() { + var update = true + + while update { + if machineState != machinePendingState { + print("「\(machineState) → \(machinePendingState)」") + } else { + print("「\(machineState)」") + } + + update = false + + switch machineState { + /// Core. + case .initial: // Object just initialized, hardware not ready. + if machinePendingState == .hardwareOn { + machineState = machinePendingState + update = true + } + + case .hardwareOn: // Hardware is ready. + centralManager.scanForPeripherals(withServices: [serviceUuid]) + machineState = machinePendingState + machinePendingState = .awaitPeripheralDiscovery + + case .awaitPeripheralDiscovery: + if machinePendingState == .peripheralDiscovered { + machineState = machinePendingState + } + + case .peripheralDiscovered: + if machinePendingState == .checkPeripheral { + machineState = machinePendingState + + centralManager?.stopScan() + callback.callback(message: .connected) + } + + case .checkPeripheral: + if machinePendingState == .awaitRequest { + if let peri = peripheral { + if useL2CAP, let l2capC = l2capCharacteristic { + peri.setNotifyValue(true, for: l2capC) + peri.readValue(for: l2capC) + machineState = .l2capAwaitRequest + } else if let readC = readCharacteristic, + let stateC = stateCharacteristic { + peri.setNotifyValue(true, for: readC) + peri.setNotifyValue(true, for: stateC) + peri.writeValue(_: Data([0x01]), for: stateC, type: .withoutResponse) + machineState = machinePendingState + } + } + } + + /// Original flow. + case .awaitRequest: + if machinePendingState == .requestReceived { + machineState = machinePendingState + callback.callback(message: MDocBLECallback.message(incomingMessageBuffer)) + incomingMessageBuffer = Data() + } + + /// The request has been received, we're waiting for the user to respond to the selective diclosure + /// dialog. + case .requestReceived: + if machinePendingState == .sendingResponse { + machineState = machinePendingState + let chunks = outgoingMessageBuffer.chunks(ofCount: maximumCharacteristicSize! - 1) + writingQueueTotalChunks = chunks.count + writingQueue = chunks.makeIterator() + writingQueueChunkIndex = 0 + drainWritingQueue() + update = true + } + + case .sendingResponse: + if machinePendingState == .complete { + machineState = machinePendingState + } + + /// L2CAP flow. + case .l2capAwaitRequest: + if machinePendingState == .l2capRequestReceived { + machineState = machinePendingState + callback.callback(message: MDocBLECallback.message(incomingMessageBuffer)) + incomingMessageBuffer = Data() + } + + /// The request has been received, we're waiting for the user to respond to the selective diclosure + /// dialog. + case .l2capRequestReceived: + if machinePendingState == .l2capSendingResponse { + machineState = machinePendingState + activeStream?.send(data: outgoingMessageBuffer) + machinePendingState = .l2capSendingResponse + update = true + } + + case .l2capSendingResponse: + if machinePendingState == .complete { + machineState = machinePendingState + } + + // + + case .fatalError: // Something went wrong. + machineState = .halted + machinePendingState = .halted + + case .complete: // Transfer complete. + break + + case .halted: // Transfer incomplete, but we gave up. + break + } + } } - func disconnectFromDevice () { + func disconnectFromDevice() { let message: Data do { message = try terminateSession() @@ -58,17 +202,23 @@ class MDocHolderBLECentral: NSObject { } private func disconnect() { - if let peripheral = self.peripheral { + if let peripheral = peripheral { centralManager.cancelPeripheralConnection(peripheral) } } func writeOutgoingValue(data: Data) { - let chunks = data.chunks(ofCount: maximumCharacteristicSize! - 1) - writingQueueTotalChunks = chunks.count - writingQueue = chunks.makeIterator() - writingQueueChunkIndex = 0 - drainWritingQueue() + outgoingMessageBuffer = data + switch machineState { + case .requestReceived: + machinePendingState = .sendingResponse + + case .l2capRequestReceived: + machinePendingState = .l2capSendingResponse + + default: + print("Unexpected write in state \(machineState)") + } } private func drainWritingQueue() { @@ -84,121 +234,143 @@ class MDocHolderBLECentral: NSObject { chunk.reverse() chunk.append(firstByte) chunk.reverse() - self.callback.callback(message: .uploadProgress(writingQueueChunkIndex, writingQueueTotalChunks)) + callback.callback(message: .uploadProgress(writingQueueChunkIndex, writingQueueTotalChunks)) peripheral?.writeValue(_: chunk, for: writeCharacteristic!, type: CBCharacteristicWriteType.withoutResponse) + if firstByte == 0x00 { + machinePendingState = .complete + } } else { - self.callback.callback(message: .uploadProgress(writingQueueTotalChunks, writingQueueTotalChunks)) + callback.callback(message: .uploadProgress(writingQueueTotalChunks, writingQueueTotalChunks)) writingQueue = nil + machinePendingState = .complete } } } - func processCharacteristics(peripheral: CBPeripheral, characteristics: [CBCharacteristic]) throws { - if let characteristic = characteristics.first(where: {$0.uuid == readerStateCharacteristicId}) { - if !characteristic.properties.contains(CBCharacteristicProperties.notify) { - throw CharacteristicsError.missingMandatoryProperty(name: "notify", characteristicName: "State") - } - if !characteristic.properties.contains(CBCharacteristicProperties.writeWithoutResponse) { - throw CharacteristicsError.missingMandatoryProperty( - name: "write without response", - characteristicName: "State" - ) - } - self.stateCharacteristic = characteristic - } else { - throw CharacteristicsError.missingMandatoryCharacteristic(name: "State") - } + /// Verify that a characteristic matches what is required of it. + private func getCharacteristic(list: [CBCharacteristic], + uuid: CBUUID, properties: [CBCharacteristicProperties], + required: Bool) throws -> CBCharacteristic? { + let chName = MDocCharacteristicNameFromUUID(uuid) - if let characteristic = characteristics.first(where: {$0.uuid == readerClient2ServerCharacteristicId}) { - if !characteristic.properties.contains(CBCharacteristicProperties.writeWithoutResponse) { - throw CharacteristicsError.missingMandatoryProperty( - name: "write without response", - characteristicName: "Client2Server" - ) + if let candidate = list.first(where: { $0.uuid == uuid }) { + for prop in properties where !candidate.properties.contains(prop) { + let propName = MDocCharacteristicPropertyName(prop) + if required { + throw CharacteristicsError.missingMandatoryProperty(name: propName, characteristicName: chName) + } else { + return nil + } } - self.writeCharacteristic = characteristic + return candidate } else { - throw CharacteristicsError.missingMandatoryCharacteristic(name: "Client2Server") - } - - if let characteristic = characteristics.first(where: {$0.uuid == readerServer2ClientCharacteristicId}) { - if !characteristic.properties.contains(CBCharacteristicProperties.notify) { - throw CharacteristicsError.missingMandatoryProperty(name: "notify", characteristicName: "Server2Client") + if required { + throw CharacteristicsError.missingMandatoryCharacteristic(name: chName) + } else { + return nil } - self.readCharacteristic = characteristic - } else { - throw CharacteristicsError.missingMandatoryCharacteristic(name: "Server2Client") } + } - if let characteristic = characteristics.first(where: {$0.uuid == readerIdentCharacteristicId}) { - if !characteristic.properties.contains(CBCharacteristicProperties.read) { - throw CharacteristicsError.missingMandatoryProperty(name: "read", characteristicName: "Ident") - } - peripheral.readValue(for: characteristic) - } else { - throw CharacteristicsError.missingMandatoryCharacteristic(name: "Ident") - } + /// Check that the reqiured characteristics are available with the required properties. + func processCharacteristics(peripheral: CBPeripheral, characteristics: [CBCharacteristic]) throws { + stateCharacteristic = try getCharacteristic(list: characteristics, + uuid: readerStateCharacteristicId, + properties: [.notify, .writeWithoutResponse], + required: true) - if let characteristic = characteristics.first(where: {$0.uuid == readerL2CAPCharacteristicId}) { - if !characteristic.properties.contains(CBCharacteristicProperties.read) { - throw CharacteristicsError.missingMandatoryProperty(name: "read", characteristicName: "L2CAP") - } + writeCharacteristic = try getCharacteristic(list: characteristics, + uuid: readerClient2ServerCharacteristicId, + properties: [.writeWithoutResponse], + required: true) + + readCharacteristic = try getCharacteristic(list: characteristics, + uuid: readerServer2ClientCharacteristicId, + properties: [.notify], + required: true) + + if let readerIdent = try getCharacteristic(list: characteristics, + uuid: readerIdentCharacteristicId, + properties: [.read], + required: true) { + peripheral.readValue(for: readerIdent) } + l2capCharacteristic = try getCharacteristic(list: characteristics, + uuid: readerL2CAPCharacteristicId, + properties: [.read], + required: false) + // iOS controls MTU negotiation. Since MTU is just a maximum, we can use a lower value than the negotiated value. // 18013-5 expects an upper limit of 515 MTU, so we cap at this even if iOS negotiates a higher value. -// +// // maximumWriteValueLength() returns the maximum characteristic size, which is 3 less than the MTU. - let negotiatedMaximumCharacteristicSize = peripheral.maximumWriteValueLength(for: .withoutResponse) - maximumCharacteristicSize = min(negotiatedMaximumCharacteristicSize - 3, 512) - + let negotiatedMaximumCharacteristicSize = peripheral.maximumWriteValueLength(for: .withoutResponse) + maximumCharacteristicSize = min(negotiatedMaximumCharacteristicSize - 3, 512) } + /// Process incoming data from a peripheral. This handles incoming data from any and all characteristics (though not + /// the L2CAP stream...), so we hit this call multiple times from several angles, at least in the original flow. func processData(peripheral: CBPeripheral, characteristic: CBCharacteristic) throws { if var data = characteristic.value { - print("Processing data for \(characteristic.uuid)") + print("Processing \(data.count) bytes for \(MDocCharacteristicNameFromUUID(characteristic.uuid)) → ", + terminator: "") switch characteristic.uuid { + /// Transfer indicator. case readerStateCharacteristicId: if data.count != 1 { throw DataError.invalidStateLength } switch data[0] { case 0x02: - self.callback.callback(message: .done) - self.disconnect() + callback.callback(message: .done) + disconnect() case let byte: throw DataError.unknownState(byte: byte) } + + /// Incoming request. case readerServer2ClientCharacteristicId: let firstByte = data.popFirst() incomingMessageBuffer.append(data) switch firstByte { case .none: throw DataError.noData(characteristic: characteristic.uuid) + case 0x00: // end - print("End of message") - self.callback.callback(message: MDocBLECallback.message(incomingMessageBuffer)) - self.incomingMessageBuffer = Data() - return + print("End") + machinePendingState = .requestReceived + case 0x01: // partial - print("Partial message") - // TODO check length against MTU - return + print("Chunk") + // TODO: check length against MTU + case let .some(byte): throw DataError.unknownDataTransferPrefix(byte: byte) } - // Looks like this should just happen after discovering characteristics + + /// Ident check. case readerIdentCharacteristicId: - self.peripheral?.setNotifyValue(true, for: self.readCharacteristic!) - self.peripheral?.setNotifyValue(true, for: self.stateCharacteristic!) - self.peripheral?.writeValue(_: Data([0x01]), - for: self.stateCharacteristic!, - type: CBCharacteristicWriteType.withoutResponse) - return + // Looks like this should just happen after discovering characteristics + print("Ident") + // TODO: Presumably we should be doing something with the ident value; probably handing it + // to the callback to see if the caller likes it. + machinePendingState = .awaitRequest + + /// L2CAP channel ID. case readerL2CAPCharacteristicId: + print("PSM: ", terminator: "") + if data.count == 2 { + let psm = data.uint16 + print("\(psm)") + channelPSM = psm + peripheral.openL2CAPChannel(psm) + machinePendingState = .l2capAwaitRequest + } return + case let uuid: throw DataError.unknownCharacteristic(uuid: uuid) } @@ -209,34 +381,39 @@ class MDocHolderBLECentral: NSObject { } extension MDocHolderBLECentral: CBCentralManagerDelegate { + /// Handle a state change in the central manager. func centralManagerDidUpdateState(_ central: CBCentralManager) { if central.state == .poweredOn { - startScanning() + machinePendingState = .hardwareOn } else { - self.callback.callback(message: .error(.bluetooth(central))) + callback.callback(message: .error(.bluetooth(central))) } } - func centralManager(_ central: CBCentralManager, + + /// Handle discovering a peripheral. + func centralManager(_: CBCentralManager, didDiscover peripheral: CBPeripheral, - advertisementData: [String: Any], - rssi RSSI: NSNumber) { + advertisementData _: [String: Any], + rssi _: NSNumber) { print("Discovered peripheral") peripheral.delegate = self self.peripheral = peripheral centralManager?.connect(peripheral, options: nil) + machinePendingState = .peripheralDiscovered } - func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { - print("Connected to peripheral") - centralManager?.stopScan() - peripheral.discoverServices([self.serviceUuid]) - self.callback.callback(message: .connected) + + /// Handle connecting to a peripheral. + func centralManager(_: CBCentralManager, didConnect peripheral: CBPeripheral) { + peripheral.discoverServices([serviceUuid]) + machinePendingState = .checkPeripheral } } extension MDocHolderBLECentral: CBPeripheralDelegate { + /// Handle discovery of peripheral services. func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { - if (error) != nil { - self.callback.callback( + if error != nil { + callback.callback( message: .error(.peripheral("Error discovering services: \(error!.localizedDescription)")) ) return @@ -249,9 +426,10 @@ extension MDocHolderBLECentral: CBPeripheralDelegate { } } + /// Handle discovery of characteristics for a peripheral service. func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { - if (error) != nil { - self.callback.callback( + if error != nil { + callback.callback( message: .error(.peripheral("Error discovering characteristics: \(error!.localizedDescription)")) ) return @@ -259,20 +437,20 @@ extension MDocHolderBLECentral: CBPeripheralDelegate { if let characteristics = service.characteristics { print("Discovered characteristics") do { - try self.processCharacteristics(peripheral: peripheral, characteristics: characteristics) + try processCharacteristics(peripheral: peripheral, characteristics: characteristics) } catch { - self.callback.callback(message: .error(.peripheral("\(error)"))) + callback.callback(message: .error(.peripheral("\(error)"))) centralManager?.cancelPeripheralConnection(peripheral) } } } + /// Handle a characteristic value being updated. func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { do { - print("Processing data") - try self.processData(peripheral: peripheral, characteristic: characteristic) + try processData(peripheral: peripheral, characteristic: characteristic) } catch { - self.callback.callback(message: .error(.peripheral("\(error)"))) + callback.callback(message: .error(.peripheral("\(error)"))) centralManager?.cancelPeripheralConnection(peripheral) } } @@ -281,12 +459,24 @@ extension MDocHolderBLECentral: CBPeripheralDelegate { /// This is called after the buffer gets filled to capacity, and then has space again. /// /// Only available on iOS 11 and up. - func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) { + func peripheralIsReady(toSendWriteWithoutResponse _: CBPeripheral) { drainWritingQueue() } + + func peripheral(_: CBPeripheral, didOpen channel: CBL2CAPChannel?, error: Error?) { + if let error = error { + print("Error opening l2cap channel - \(error.localizedDescription)") + return + } + + if let channel = channel { + activeStream = MDocHolderBLECentralConnection(delegate: self, channel: channel) + } + } } extension MDocHolderBLECentral: CBPeripheralManagerDelegate { + /// Handle peripheral manager state change. func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) { switch peripheral.state { case .poweredOn: @@ -306,3 +496,20 @@ extension MDocHolderBLECentral: CBPeripheralManagerDelegate { } } } + +extension MDocHolderBLECentral: MDocHolderBLECentralConnectionDelegate { + func request(_ data: Data) { + incomingMessageBuffer = data + machinePendingState = .l2capRequestReceived + } + + func sendUpdate(bytes: Int, total: Int, fraction _: Double) { + callback.callback(message: .uploadProgress(bytes, total)) + } + + func sendComplete() { + machinePendingState = .complete + } + + func connectionEnd() {} +} diff --git a/Sources/MobileSdk/MDocHolderBLECentralConnection.swift b/Sources/MobileSdk/MDocHolderBLECentralConnection.swift new file mode 100644 index 0000000..34d6f87 --- /dev/null +++ b/Sources/MobileSdk/MDocHolderBLECentralConnection.swift @@ -0,0 +1,68 @@ +// Derived from MIT-licensed work by Paul Wilkinson: https://github.com/paulw11/L2Cap + +import CoreBluetooth +import Foundation + +public protocol MDocHolderBLECentralConnectionDelegate: AnyObject { + func request(_ data: Data) + func sendUpdate(bytes: Int, total: Int, fraction: Double) + func sendComplete() + func connectionEnd() +} + +class MDocHolderBLECentralConnection: BLEInternalL2CAPConnection { + private let controlDelegate: MDocHolderBLECentralConnectionDelegate + + /// Initialize a reader peripheral connection. + init(delegate: MDocHolderBLECentralConnectionDelegate, channel: CBL2CAPChannel) { + controlDelegate = delegate + super.init() + self.channel = channel + channel.inputStream.delegate = self + channel.outputStream.delegate = self + channel.inputStream.schedule(in: RunLoop.main, forMode: .default) + channel.outputStream.schedule(in: RunLoop.main, forMode: .default) + channel.inputStream.open() + channel.outputStream.open() + } + + /// Called by super when the stream is open. + override func streamIsOpen() {} + + /// Called by super when the stream ends. + override func streamEnded() { + close() + controlDelegate.connectionEnd() + } + + /// Called by super when the stream has readable data. + override func streamBytesAvailable() {} + + /// Called by super when the stream has space in the outbound buffer. + override func streamSpaceAvailable() {} + + /// Called by super if the stream encounters an error. + override func streamError() { + close() + controlDelegate.connectionEnd() + } + + /// Called by super if an unknown stream event occurs. + override func streamUnknownEvent() {} + + /// Called by super when data is sent. + override func streamSentData(bytes: Int, total: Int, fraction: Double) { + print("Stream sent \(bytes) of \(total) bytes, \(fraction * 100)% complete.") + + controlDelegate.sendUpdate(bytes: bytes, total: total, fraction: fraction) + + if bytes == total { + controlDelegate.sendComplete() + } + } + + /// Called by super when data is received. + override func streamReceivedData(_ data: Data) { + controlDelegate.request(data) + } +} diff --git a/Sources/MobileSdk/MDocReader.swift b/Sources/MobileSdk/MDocReader.swift index 4c2d603..2ec6c2a 100644 --- a/Sources/MobileSdk/MDocReader.swift +++ b/Sources/MobileSdk/MDocReader.swift @@ -88,6 +88,7 @@ public enum BleReaderSessionError { init(readerBleError: MdocReaderBleError) { switch readerBleError { + case .server(let string): self = .server(string) case .bluetooth(let string): diff --git a/Sources/MobileSdk/MDocReaderBLEPeripheral.swift b/Sources/MobileSdk/MDocReaderBLEPeripheral.swift index fc4628b..d05833f 100644 --- a/Sources/MobileSdk/MDocReaderBLEPeripheral.swift +++ b/Sources/MobileSdk/MDocReaderBLEPeripheral.swift @@ -3,7 +3,19 @@ import CoreBluetooth import Foundation import SpruceIDMobileSdkRs +// NOTE: https://blog.valerauko.net/2024/03/24/some-notes-on-ios-ble/ +// error 431 is "peer requested disconnect" +// error 436 is "local requested disconnect" + class MDocReaderBLEPeripheral: NSObject { + enum MachineState { + case initial, hardwareOn, servicePublished + case fatalError, complete, halted + case l2capRead, l2capAwaitChannelPublished, l2capChannelPublished + case l2capStreamOpen, l2capSendingRequest, l2capAwaitingResponse + case stateSubscribed, awaitRequestStart, sendingRequest, awaitResponse + } + var peripheralManager: CBPeripheralManager! var serviceUuid: CBUUID var bleIdent: Data @@ -16,59 +28,226 @@ class MDocReaderBLEPeripheral: NSObject { var identCharacteristic: CBMutableCharacteristic? var l2capCharacteristic: CBMutableCharacteristic? var requestData: Data + var requestSent = false var maximumCharacteristicSize: Int? var writingQueueTotalChunks: Int? var writingQueueChunkIndex: Int? var writingQueue: IndexingIterator>? + var activeStream: MDocReaderBLEPeripheralConnection? + + /// If this is `true`, we offer an L2CAP characteristic and set up an L2CAP stream. If it is `false` we do neither + /// of these things, and use the old flow. + var useL2CAP = true + + private var channelPSM: UInt16? { + didSet { + updatePSM() + } + } + + var machineState = MachineState.initial + var machinePendingState = MachineState.initial { + didSet { + updateState() + } + } + init(callback: MDocReaderBLEDelegate, serviceUuid: CBUUID, request: Data, bleIdent: Data) { self.serviceUuid = serviceUuid self.callback = callback self.bleIdent = bleIdent - self.requestData = request - self.incomingMessageBuffer = Data() + requestData = request + incomingMessageBuffer = Data() super.init() - self.peripheralManager = CBPeripheralManager(delegate: self, - queue: nil, - options: [CBPeripheralManagerOptionShowPowerAlertKey: true]) + peripheralManager = CBPeripheralManager(delegate: self, + queue: nil, + options: [CBPeripheralManagerOptionShowPowerAlertKey: true]) + } + + /// Update the state machine. + private func updateState() { + var update = true + + while update { + print(machineState == machinePendingState + ? "「\(machineState)」" + : "「\(machineState) → \(machinePendingState)」") + update = false + + switch machineState { + /// Core. + case .initial: // Object just initialized, hardware not ready. + if machinePendingState == .hardwareOn { + machineState = .hardwareOn + update = true + } + + case .hardwareOn: // Hardware is ready. + print("Advertising...") + setupService() + peripheralManager.startAdvertising([CBAdvertisementDataServiceUUIDsKey: [serviceUuid]]) + machineState = .servicePublished + machinePendingState = .servicePublished + update = true + + case .servicePublished: // Characteristics set up, we're publishing our service. + if machinePendingState == .l2capRead { + machineState = machinePendingState + update = true + } else if machinePendingState == .stateSubscribed { + machineState = machinePendingState + update = true + } + + case .fatalError: // Something went wrong. + machineState = .halted + machinePendingState = .halted + + case .complete: // Transfer complete. + break + + case .halted: // Transfer incomplete, but we gave up. + break + + /// L2CAP flow. + case .l2capRead: // We have a read on our L2CAP characteristic, start L2CAP flow. + machineState = .l2capAwaitChannelPublished + peripheralManager.publishL2CAPChannel(withEncryption: true) + update = true + + case .l2capAwaitChannelPublished: + if machinePendingState == .l2capChannelPublished { + machineState = machinePendingState + } + + case .l2capChannelPublished: + if machinePendingState == .l2capStreamOpen { + machineState = machinePendingState + update = true + } + + case .l2capStreamOpen: // An L2CAP stream is opened. + if !requestSent { + // We occasionally seem to get a transient condition where the stream gets opened + // more than once; locally, I've seen the stream open 10 times in a row, and with + // the non-framed transport for data, that means we send the request 10 times, the + // far side reads that as a single request, and errors out. The requestSent bool + // is to keep this from happening. + activeStream?.send(data: requestData) + requestSent = true + } + machineState = .l2capSendingRequest + machinePendingState = .l2capSendingRequest + update = true + + case .l2capSendingRequest: // The request is being sent over the L2CAP stream. + if machinePendingState == .l2capAwaitingResponse { + machineState = machinePendingState + update = true + } + + case .l2capAwaitingResponse: // The request is sent, the response is (hopefully) coming in. + if machinePendingState == .complete { + machineState = machinePendingState + callback.callback(message: MDocReaderBLECallback.message(incomingMessageBuffer)) + update = true + } + + /// Original flow. + case .stateSubscribed: // We have a subscription to our State characteristic, start original flow. + // This will trigger wallet-sdk-swift to send 0x01 to start the exchange + peripheralManager.updateValue(bleIdent, for: identCharacteristic!, onSubscribedCentrals: nil) + + // I think the updateValue() below is out of spec; 8.3.3.1.1.5 says we wait for a write without + // response of 0x01 to State, but that's supposed to come from the holder to indicate it's ready + // for us to initiate. + + // This will trigger wallet-sdk-kt to send 0x01 to start the exchange + // peripheralManager.updateValue(Data([0x01]), for: self.stateCharacteristic!, + // onSubscribedCentrals: nil) + + machineState = .awaitRequestStart + machinePendingState = .awaitRequestStart + + case .awaitRequestStart: // We've let the holder know we're ready, waiting for their ack. + if machinePendingState == .sendingRequest { + writeOutgoingValue(data: requestData) + machineState = .sendingRequest + } + + case .sendingRequest: + if machinePendingState == .awaitResponse { + machineState = .awaitResponse + } + + case .awaitResponse: + if machinePendingState == .complete { + machineState = .complete + update = true + } + } + } } func setupService() { - let service = CBMutableService(type: self.serviceUuid, primary: true) - self.stateCharacteristic = CBMutableCharacteristic(type: readerStateCharacteristicId, - properties: [.notify, .writeWithoutResponse, .write], - value: nil, - permissions: [.writeable]) - self.readCharacteristic = CBMutableCharacteristic(type: readerClient2ServerCharacteristicId, - properties: [.writeWithoutResponse, .write], + let service = CBMutableService(type: serviceUuid, primary: true) + // CBUUIDClientCharacteristicConfigurationString only returns "2902" + // let clientDescriptor = CBMutableDescriptor(type: CBUUID(string: "00002902-0000-1000-8000-00805f9b34fb"), + // value: Data([0x00, 0x00])) as CBDescriptor + // wallet-sdk-kt isn't using write without response... + stateCharacteristic = CBMutableCharacteristic(type: readerStateCharacteristicId, + properties: [.notify, .writeWithoutResponse, .write], + value: nil, + permissions: [.writeable]) + // for some reason this seems to drop all other descriptors + // self.stateCharacteristic!.descriptors = [clientDescriptor] + (self.stateCharacteristic!.descriptors ?? [] ) + // self.stateCharacteristic!.descriptors?.insert(clientDescriptor, at: 0) + // wallet-sdk-kt isn't using write without response... + readCharacteristic = CBMutableCharacteristic(type: readerClient2ServerCharacteristicId, + properties: [.writeWithoutResponse, .write], + value: nil, + permissions: [.writeable]) + writeCharacteristic = CBMutableCharacteristic(type: readerServer2ClientCharacteristicId, + properties: [.notify], + value: nil, + permissions: [.readable, .writeable]) + // self.writeCharacteristic!.descriptors = [clientDescriptor] + (self.writeCharacteristic!.descriptors ?? [] ) + // self.writeCharacteristic!.descriptors?.insert(clientDescriptor, at: 0) + identCharacteristic = CBMutableCharacteristic(type: readerIdentCharacteristicId, + properties: [.read], + value: bleIdent, + permissions: [.readable]) + // wallet-sdk-kt is failing if this is present + if useL2CAP { + // 18013-5 doesn't require .indicate, but without it we don't seem to be able to propagate the PSM + // through to central. + l2capCharacteristic = CBMutableCharacteristic(type: readerL2CAPCharacteristicId, + properties: [.read, .indicate], value: nil, - permissions: [.writeable]) - self.writeCharacteristic = CBMutableCharacteristic(type: readerServer2ClientCharacteristicId, - properties: [.notify], - value: nil, - permissions: [.readable, .writeable]) - self.identCharacteristic = CBMutableCharacteristic(type: readerIdentCharacteristicId, - properties: [.read], - value: bleIdent, - permissions: [.readable]) - // self.l2capCharacteristic = CBMutableCharacteristic(type: readerL2CAPCharacteristicId, - // properties: [.read], - // value: nil, - // permissions: [.readable]) - service.characteristics = (service.characteristics ?? []) + [ - stateCharacteristic! as CBCharacteristic, - readCharacteristic! as CBCharacteristic, - writeCharacteristic! as CBCharacteristic, - identCharacteristic! as CBCharacteristic - // l2capCharacteristic! as CBCharacteristic - ] + permissions: [.readable]) + + if let stateC = stateCharacteristic, + let readC = readCharacteristic, + let writeC = writeCharacteristic, + let identC = identCharacteristic, + let l2capC = l2capCharacteristic { + service.characteristics = (service.characteristics ?? []) + [stateC, readC, writeC, identC, l2capC] + } + } else { + if let stateC = stateCharacteristic, + let readC = readCharacteristic, + let writeC = writeCharacteristic, + let identC = identCharacteristic { + service.characteristics = (service.characteristics ?? []) + [stateC, readC, writeC, identC] + } + } peripheralManager.add(service) } - func disconnect() { - return - } + func disconnect() {} + /// Write the request using the old flow. func writeOutgoingValue(data: Data) { let chunks = data.chunks(ofCount: maximumCharacteristicSize! - 1) writingQueueTotalChunks = chunks.count @@ -90,68 +269,96 @@ class MDocReaderBLEPeripheral: NSObject { chunk.reverse() chunk.append(firstByte) chunk.reverse() - self.peripheralManager?.updateValue(chunk, for: self.writeCharacteristic!, onSubscribedCentrals: nil) + peripheralManager?.updateValue(chunk, for: writeCharacteristic!, onSubscribedCentrals: nil) + + if firstByte == 0x00 { + machinePendingState = .awaitResponse + } } else { writingQueue = nil + machinePendingState = .awaitResponse } } } - func processData(central: CBCentral, characteristic: CBCharacteristic, value: Data?) throws { + /// Process incoming data. + func processData(central _: CBCentral, characteristic: CBCharacteristic, value: Data?) throws { if var data = value { - print("Processing data for \(characteristic.uuid)") + let name = MDocCharacteristicNameFromUUID(characteristic.uuid) + print("Processing \(data.count) bytes of data for \(name) → ", terminator: "") switch characteristic.uuid { case readerClient2ServerCharacteristicId: let firstByte = data.popFirst() incomingMessageBuffer.append(data) switch firstByte { case .none: + print("Nothing?") throw DataError.noData(characteristic: characteristic.uuid) case 0x00: // end - print("End of message") - self.callback.callback(message: MDocReaderBLECallback.message(incomingMessageBuffer)) - self.incomingMessageBuffer = Data() - self.incomingMessageIndex = 0 + print("End") + callback.callback(message: MDocReaderBLECallback.message(incomingMessageBuffer)) + incomingMessageBuffer = Data() + incomingMessageIndex = 0 + machinePendingState = .complete return case 0x01: // partial - print("Partial message") - self.incomingMessageIndex += 1 - self.callback.callback(message: .downloadProgress(self.incomingMessageIndex)) - // TODO check length against MTU + print("Chunk") + incomingMessageIndex += 1 + callback.callback(message: .downloadProgress(incomingMessageIndex)) + // TODO: check length against MTU return case let .some(byte): + print("Unexpected byte \(String(format: "$%02X", byte))") throw DataError.unknownDataTransferPrefix(byte: byte) } + case readerStateCharacteristicId: + print("State") if data.count != 1 { throw DataError.invalidStateLength } switch data[0] { case 0x01: - print("Starting to send request") - writeOutgoingValue(data: self.requestData) + machinePendingState = .sendingRequest case let byte: throw DataError.unknownState(byte: byte) } + + case readerL2CAPCharacteristicId: + print("L2CAP") + machinePendingState = .l2capRead return -// case readerL2CAPCharacteristicId: -// return + case let uuid: + print("Unexpected UUID") throw DataError.unknownCharacteristic(uuid: uuid) } } else { throw DataError.noData(characteristic: characteristic.uuid) } } + + /// Update the channel PSM. + private func updatePSM() { + l2capCharacteristic?.value = channelPSM?.data + + if let l2capC = l2capCharacteristic { + let value = channelPSM?.data ?? Data() + + l2capC.value = value + print("Sending l2cap channel update \(value.uint16).") + peripheralManager.updateValue(value, for: l2capC, onSubscribedCentrals: nil) + } + } } +/// Peripheral manager delegate functions. extension MDocReaderBLEPeripheral: CBPeripheralManagerDelegate { + /// Handle the peripheral updating state. func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) { switch peripheral.state { case .poweredOn: - print("Advertising...") - setupService() - peripheralManager.startAdvertising([CBAdvertisementDataServiceUUIDsKey: [serviceUuid]]) + machinePendingState = .hardwareOn case .unsupported: print("Peripheral Is Unsupported.") case .unauthorized: @@ -167,32 +374,43 @@ extension MDocReaderBLEPeripheral: CBPeripheralManagerDelegate { } } - // This is called when there is space in the queue again (so it is part of the loop for drainWritingQueue) - func peripheralManagerIsReady(toUpdateSubscribers peripheral: CBPeripheralManager) { - self.drainWritingQueue() + /// Handle space available for sending. This is part of the send loop for the old (non-L2CAP) flow. + func peripheralManagerIsReady(toUpdateSubscribers _: CBPeripheralManager) { + drainWritingQueue() } - func peripheralManager( - _ peripheral: CBPeripheralManager, - central: CBCentral, - didSubscribeTo characteristic: CBCharacteristic - ) { - print("Subscribed to \(characteristic.uuid)") - self.callback.callback(message: .connected) - self.peripheralManager?.stopAdvertising() + /// Handle incoming subscriptions. + func peripheralManager(_: CBPeripheralManager, + central _: CBCentral, + didSubscribeTo characteristic: CBCharacteristic) { + print("Subscribed to \(MDocCharacteristicNameFromUUID(characteristic.uuid))") + callback.callback(message: .connected) + peripheralManager?.stopAdvertising() switch characteristic.uuid { - case readerStateCharacteristicId: - // This will trigger wallet-sdk-swift to send 0x01 to start the exchange - peripheralManager.updateValue(bleIdent, for: self.identCharacteristic!, onSubscribedCentrals: nil) - // This will trigger wallet-sdk-kt to send 0x01 to start the exchange - peripheralManager.updateValue(Data([0x01]), for: self.stateCharacteristic!, onSubscribedCentrals: nil) + case l2capCharacteristic: // If we get this, we're in the L2CAP flow. + // TODO: If this gets hit after a subscription to the State characteristic, something has gone wrong; + // the holder should choose one flow or the other. We have options here: + // + // - ignore the corner case -- what the code is doing now, not ideal + // - error out -- the holder is doing something screwy, we want no part of it + // - try to adapt -- send the data a second time, listen on both L2CAP and normal - probably a bad idea; + // it will make us mildly more tolerant of out-of-spec holders, but may increase our attack surface + machinePendingState = .l2capRead + + case readerStateCharacteristicId: // If we get this, we're in the original flow. + // TODO: See the comment block in the L2CAP characteristic, above; only one of these can be valid for + // a given exchange. + + machinePendingState = .stateSubscribed + case _: return } } - func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveRead request: CBATTRequest) { - print("Received read request for \(request.characteristic.uuid)") + /// Handle read requests. + func peripheralManager(_: CBPeripheralManager, didReceiveRead request: CBATTRequest) { + print("Received read request for \(MDocCharacteristicNameFromUUID(request.characteristic.uuid))") // Since there is no callback for MTU on iOS we will grab it here. maximumCharacteristicSize = min(request.central.maximumUpdateValueLength, 512) @@ -200,33 +418,71 @@ extension MDocReaderBLEPeripheral: CBPeripheralManagerDelegate { if request.characteristic.uuid == readerIdentCharacteristicId { peripheralManager.respond(to: request, withResult: .success) } else if request.characteristic.uuid == readerL2CAPCharacteristicId { -// peripheralManager.publishL2CAPChannel(withEncryption: true) -// peripheralManager.respond(to: request, withResult: .success) + peripheralManager.respond(to: request, withResult: .success) + machinePendingState = .l2capRead } else { - self.callback.callback(message: - .error(.server("Read on unexpected characteristic with UUID \(request.characteristic.uuid)")) - ) + let name = MDocCharacteristicNameFromUUID(request.characteristic.uuid) + callback.callback(message: .error(.server("Read on unexpected characteristic with UUID \(name)"))) } } - func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveWrite requests: [CBATTRequest]) { + /// Handle write requests. + func peripheralManager(_: CBPeripheralManager, didReceiveWrite requests: [CBATTRequest]) { for request in requests { // Since there is no callback for MTU on iOS we will grab it here. maximumCharacteristicSize = min(request.central.maximumUpdateValueLength, 512) do { - print("Processing request") try processData(central: request.central, characteristic: request.characteristic, value: request.value) // This can be removed, or return an error, once wallet-sdk-kt is fixed and uses withoutResponse writes if request.characteristic.properties.contains(.write) { peripheralManager.respond(to: request, withResult: .success) } } catch { - self.callback.callback(message: .error(.server("\(error)"))) - self.peripheralManager?.updateValue(Data([0x02]), - for: self.stateCharacteristic!, - onSubscribedCentrals: nil) + callback.callback(message: .error(.server("\(error)"))) + peripheralManager?.updateValue(Data([0x02]), for: stateCharacteristic!, onSubscribedCentrals: nil) } } } + + /// Handle an L2CAP channel being published. + public func peripheralManager(_: CBPeripheralManager, didPublishL2CAPChannel PSM: CBL2CAPPSM, error: Error?) { + if let error = error { + print("Error publishing channel: \(error.localizedDescription)") + return + } + print("Published channel \(PSM)") + channelPSM = PSM + machinePendingState = .l2capChannelPublished + } + + /// Handle an L2CAP channel opening. + public func peripheralManager(_: CBPeripheralManager, didOpen channel: CBL2CAPChannel?, error: Error?) { + if let error = error { + print("Error opening channel: \(error.localizedDescription)") + return + } + + if let channel = channel { + activeStream = MDocReaderBLEPeripheralConnection(delegate: self, channel: channel) + } + } +} + +/// L2CAP Stream delegate functions. +extension MDocReaderBLEPeripheral: MDocReaderBLEPeriConnDelegate { + func streamOpen() { + machinePendingState = .l2capStreamOpen + } + + func sentData(_ bytes: Int) { + if bytes >= requestData.count { + machinePendingState = .l2capAwaitingResponse + } + } + + func receivedData(_ data: Data) { + incomingMessageBuffer = data + machinePendingState = .complete + } } diff --git a/Sources/MobileSdk/MDocReaderBLEPeripheralConnection.swift b/Sources/MobileSdk/MDocReaderBLEPeripheralConnection.swift new file mode 100644 index 0000000..7542556 --- /dev/null +++ b/Sources/MobileSdk/MDocReaderBLEPeripheralConnection.swift @@ -0,0 +1,62 @@ +// Derived from MIT-licensed work by Paul Wilkinson: https://github.com/paulw11/L2Cap + +import CoreBluetooth +import Foundation + +protocol MDocReaderBLEPeriConnDelegate: AnyObject { + func streamOpen() + func sentData(_ bytes: Int) + func receivedData(_ data: Data) +} + +class MDocReaderBLEPeripheralConnection: BLEInternalL2CAPConnection { + private let controlDelegate: MDocReaderBLEPeriConnDelegate + + /// Initialize a reader peripheral connection. + init(delegate: MDocReaderBLEPeriConnDelegate, channel: CBL2CAPChannel) { + controlDelegate = delegate + super.init() + self.channel = channel + channel.inputStream.delegate = self + channel.outputStream.delegate = self + channel.inputStream.schedule(in: RunLoop.main, forMode: .default) + channel.outputStream.schedule(in: RunLoop.main, forMode: .default) + channel.inputStream.open() + channel.outputStream.open() + } + + /// Called by super when the stream opens. + override func streamIsOpen() { + controlDelegate.streamOpen() + } + + /// Called by super when the stream ends. + override func streamEnded() { + close() + } + + /// Called by super when the stream has bytes available for reading. + override func streamBytesAvailable() {} + + /// Called by super when the stream has buffer space available for sending. + override func streamSpaceAvailable() {} + + /// Called by super when the stream has an error. + override func streamError() { + close() + } + + /// Called by super when the stream has an unknown event; these can probably be ignored. + override func streamUnknownEvent() {} + + /// Called by super when data is sent. + override func streamSentData(bytes: Int, total: Int, fraction: Double) { + print("Stream sent \(bytes) of \(total) bytes, \(fraction * 100)% complete.") + controlDelegate.sentData(bytes) + } + + /// Called by super when data is received. + override func streamReceivedData(_ data: Data) { + controlDelegate.receivedData(data) + } +}