From e1e8d1291402199923f425a561b5a4ff6d954aee Mon Sep 17 00:00:00 2001 From: todd-spruceid <125476187+todd-spruceid@users.noreply.github.com> Date: Fri, 6 Sep 2024 09:00:09 -0700 Subject: [PATCH] Add support for L2CAP and refactor state management (#32) This is a fairly large change; both the holder and the reader have been extended to support L2CAP. In service of this, the original flow has been refactored into more explicit state machines, and the L2CAP flow has been added to those state machines. Both the reader and holder have `useL2CAP` booleans; if either is `false`, the reader and holder will use the old flow to communicate. This should also mean that they will use the old flow when working with other readers or holders that do not support L2CAP. Some of this work (notably the `*Connection.swift` files) is derived from Paul Wilkinson's MIT-licensed L2Cap library: https://github.com/paulw11/L2Cap This repo is MIT-licensed as well, so the licenses are compatible. We aren't using the L2Cap library as-is for a variety of reasons, but the main reason is that the behaviour we need differs significantly from the behaviour L2Cap (the library) offers. There are a couple of places in this change that are hacking around impedance mismatches: - we seem to need to have `notify` on the L2CAP characteristic in order to propagate the PSM to central, though 18013-5 Annex A claims the only required property is `read` -- we're not out of spec, since we're offering an optional property, but it remains to be seen how that will interact with 3rd party centrals - 18013-5 specifies no framing for the request or response sent over the L2CAP stream, so we have to infer when the data has arrived from timing; this seems like a fragile method particularly if confronted by noisy radio environments --- .swiftlint.yml | 2 + Sources/MobileSdk/BLEConnection.swift | 184 ++++++++ Sources/MobileSdk/Credentials.swift | 8 +- Sources/MobileSdk/MDoc.swift | 8 +- Sources/MobileSdk/MDocBLEUtils.swift | 63 +++ Sources/MobileSdk/MDocHolderBLECentral.swift | 401 +++++++++++++---- .../MDocHolderBLECentralConnection.swift | 68 +++ Sources/MobileSdk/MDocReader.swift | 1 + .../MobileSdk/MDocReaderBLEPeripheral.swift | 420 ++++++++++++++---- .../MDocReaderBLEPeripheralConnection.swift | 62 +++ 10 files changed, 1034 insertions(+), 183 deletions(-) create mode 100644 Sources/MobileSdk/BLEConnection.swift create mode 100644 Sources/MobileSdk/MDocHolderBLECentralConnection.swift create mode 100644 Sources/MobileSdk/MDocReaderBLEPeripheralConnection.swift 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) + } +}