From 8b80370677fee201b872b8552ac9c927cf1dd793 Mon Sep 17 00:00:00 2001 From: Simon Bihel Date: Wed, 4 Sep 2024 13:53:48 +0100 Subject: [PATCH] Add MDoc Reader support (#22) --- Package.resolved | 4 +- Package.swift | 3 +- Sources/MobileSdk/MDocBLEUtils.swift | 21 ++ Sources/MobileSdk/MDocHolderBLECentral.swift | 1 + Sources/MobileSdk/MDocReader.swift | 97 ++++++++ .../MobileSdk/MDocReaderBLEPeripheral.swift | 232 ++++++++++++++++++ SpruceIDMobileSdk.podspec | 2 +- project.yml | 5 +- 8 files changed, 358 insertions(+), 7 deletions(-) create mode 100644 Sources/MobileSdk/MDocReader.swift create mode 100644 Sources/MobileSdk/MDocReaderBLEPeripheral.swift diff --git a/Package.resolved b/Package.resolved index 0670210..82f273c 100644 --- a/Package.resolved +++ b/Package.resolved @@ -5,8 +5,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/spruceid/mobile-sdk-rs.git", "state" : { - "revision" : "9deb085da5d26630045505cad6f46f11460d739d", - "version" : "0.0.27" + "revision" : "e8a1b7056989e4cb471281b464c1aeac298be97f", + "version" : "0.0.29" } }, { diff --git a/Package.swift b/Package.swift index b46b3e1..798581f 100644 --- a/Package.swift +++ b/Package.swift @@ -14,8 +14,7 @@ let package = Package( targets: ["SpruceIDMobileSdk"]) ], dependencies: [ - // .package(url: "https://github.com/spruceid/mobile-sdk-rs.git", .branch("main")), - .package(url: "https://github.com/spruceid/mobile-sdk-rs.git", from: "0.0.28"), + .package(url: "https://github.com/spruceid/mobile-sdk-rs.git", from: "0.0.29"), // .package(path: "../mobile-sdk-rs"), .package(url: "https://github.com/apple/swift-algorithms", from: "1.2.0") ], diff --git a/Sources/MobileSdk/MDocBLEUtils.swift b/Sources/MobileSdk/MDocBLEUtils.swift index a1ea982..cafc8ce 100644 --- a/Sources/MobileSdk/MDocBLEUtils.swift +++ b/Sources/MobileSdk/MDocBLEUtils.swift @@ -1,4 +1,5 @@ import CoreBluetooth +import SpruceIDMobileSdkRs let holderStateCharacteristicId = CBUUID(string: "00000001-A123-48CE-896B-4C76973373E6") let holderClient2ServerCharacteristicId = CBUUID(string: "00000002-A123-48CE-896B-4C76973373E6") @@ -18,6 +19,13 @@ enum MdocHolderBleError { case bluetooth(CBCentralManager) } +enum MdocReaderBleError { + /// When communication with the server fails + case server(String) + /// When Bluetooth is unusable (e.g. unauthorized). + case bluetooth(CBCentralManager) +} + enum MDocBLECallback { case done case connected @@ -30,3 +38,16 @@ enum MDocBLECallback { protocol MDocBLEDelegate: AnyObject { func callback(message: MDocBLECallback) } + +enum MDocReaderBLECallback { + case done([String: [String: MDocItem]]) + case connected + case error(MdocReaderBleError) + case message(Data) + /// Chunks received so far + case downloadProgress(Int) +} + +protocol MDocReaderBLEDelegate: AnyObject { + func callback(message: MDocReaderBLECallback) +} diff --git a/Sources/MobileSdk/MDocHolderBLECentral.swift b/Sources/MobileSdk/MDocHolderBLECentral.swift index be1086c..1c3b05c 100644 --- a/Sources/MobileSdk/MDocHolderBLECentral.swift +++ b/Sources/MobileSdk/MDocHolderBLECentral.swift @@ -189,6 +189,7 @@ class MDocHolderBLECentral: NSObject { case let .some(byte): throw DataError.unknownDataTransferPrefix(byte: byte) } + // Looks like this should just happen after discovering characteristics case readerIdentCharacteristicId: self.peripheral?.setNotifyValue(true, for: self.readCharacteristic!) self.peripheral?.setNotifyValue(true, for: self.stateCharacteristic!) diff --git a/Sources/MobileSdk/MDocReader.swift b/Sources/MobileSdk/MDocReader.swift new file mode 100644 index 0000000..4c2d603 --- /dev/null +++ b/Sources/MobileSdk/MDocReader.swift @@ -0,0 +1,97 @@ +import CoreBluetooth +import SpruceIDMobileSdkRs + +public class MDocReader { + var sessionManager: MdlSessionManager + var bleManager: MDocReaderBLEPeripheral! + var callback: BLEReaderSessionStateDelegate + + public init?( + callback: BLEReaderSessionStateDelegate, + uri: String, + requestedItems: [String: [String: Bool]], + trustAnchorRegistry: [String]? + ) { + self.callback = callback + do { + let sessionData = try SpruceIDMobileSdkRs.establishSession(uri: uri, + requestedItems: requestedItems, + trustAnchorRegistry: trustAnchorRegistry) + self.sessionManager = sessionData.state + self.bleManager = MDocReaderBLEPeripheral(callback: self, + serviceUuid: CBUUID(string: sessionData.uuid), + request: sessionData.request, + bleIdent: sessionData.bleIdent) + } catch { + print("\(error)") + return nil + } + } + + public func cancel() { + bleManager.disconnect() + } +} + +extension MDocReader: MDocReaderBLEDelegate { + func callback(message: MDocReaderBLECallback) { + switch message { + case .done(let data): + self.callback.update(state: .success(data)) + case .connected: + self.callback.update(state: .connected) + case .error(let error): + self.callback.update(state: .error(BleReaderSessionError(readerBleError: error))) + self.cancel() + case .message(let data): + do { + let responseData = try SpruceIDMobileSdkRs.handleResponse(state: self.sessionManager, response: data) + self.sessionManager = responseData.state + self.callback.update(state: .success(responseData.verifiedResponse)) + } catch { + self.callback.update(state: .error(.generic("\(error)"))) + self.cancel() + } + case .downloadProgress(let index): + self.callback.update(state: .downloadProgress(index)) + } + } +} + +/// To be implemented by the consumer to update the UI +public protocol BLEReaderSessionStateDelegate: AnyObject { + func update(state: BLEReaderSessionState) +} + +public enum BLEReaderSessionState { + /// App should display the error message + case error(BleReaderSessionError) + /// App should indicate to the reader is waiting to connect to the holder + case advertizing + /// App should indicate to the user that BLE connection has been established + case connected + /// App should display the fact that a certain amount of data has been received + /// - Parameters: + /// - 0: The number of chunks received to far + case downloadProgress(Int) + /// App should display a success message and offer to close the page + case success([String: [String: MDocItem]]) +} + +public enum BleReaderSessionError { + /// When communication with the server fails + case server(String) + /// When Bluetooth is unusable (e.g. unauthorized). + case bluetooth(CBCentralManager) + /// Generic unrecoverable error + case generic(String) + + init(readerBleError: MdocReaderBleError) { + switch readerBleError { + case .server(let string): + self = .server(string) + case .bluetooth(let string): + self = .bluetooth(string) + } + } +} diff --git a/Sources/MobileSdk/MDocReaderBLEPeripheral.swift b/Sources/MobileSdk/MDocReaderBLEPeripheral.swift new file mode 100644 index 0000000..fc4628b --- /dev/null +++ b/Sources/MobileSdk/MDocReaderBLEPeripheral.swift @@ -0,0 +1,232 @@ +import Algorithms +import CoreBluetooth +import Foundation +import SpruceIDMobileSdkRs + +class MDocReaderBLEPeripheral: NSObject { + var peripheralManager: CBPeripheralManager! + var serviceUuid: CBUUID + var bleIdent: Data + var incomingMessageBuffer = Data() + var incomingMessageIndex = 0 + var callback: MDocReaderBLEDelegate + var writeCharacteristic: CBMutableCharacteristic? + var readCharacteristic: CBMutableCharacteristic? + var stateCharacteristic: CBMutableCharacteristic? + var identCharacteristic: CBMutableCharacteristic? + var l2capCharacteristic: CBMutableCharacteristic? + var requestData: Data + var maximumCharacteristicSize: Int? + var writingQueueTotalChunks: Int? + var writingQueueChunkIndex: Int? + var writingQueue: IndexingIterator>? + + init(callback: MDocReaderBLEDelegate, serviceUuid: CBUUID, request: Data, bleIdent: Data) { + self.serviceUuid = serviceUuid + self.callback = callback + self.bleIdent = bleIdent + self.requestData = request + self.incomingMessageBuffer = Data() + super.init() + self.peripheralManager = CBPeripheralManager(delegate: self, + queue: nil, + options: [CBPeripheralManagerOptionShowPowerAlertKey: 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], + 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 + ] + peripheralManager.add(service) + } + + func disconnect() { + return + } + + func writeOutgoingValue(data: Data) { + let chunks = data.chunks(ofCount: maximumCharacteristicSize! - 1) + writingQueueTotalChunks = chunks.count + writingQueue = chunks.makeIterator() + writingQueueChunkIndex = 0 + drainWritingQueue() + } + + private func drainWritingQueue() { + if writingQueue != nil { + if var chunk = writingQueue?.next() { + var firstByte: Data.Element + writingQueueChunkIndex! += 1 + if writingQueueChunkIndex == writingQueueTotalChunks { + firstByte = 0x00 + } else { + firstByte = 0x01 + } + chunk.reverse() + chunk.append(firstByte) + chunk.reverse() + self.peripheralManager?.updateValue(chunk, for: self.writeCharacteristic!, onSubscribedCentrals: nil) + } else { + writingQueue = nil + } + } + } + + func processData(central: CBCentral, characteristic: CBCharacteristic, value: Data?) throws { + if var data = value { + print("Processing data for \(characteristic.uuid)") + switch characteristic.uuid { + case readerClient2ServerCharacteristicId: + 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: MDocReaderBLECallback.message(incomingMessageBuffer)) + self.incomingMessageBuffer = Data() + self.incomingMessageIndex = 0 + return + case 0x01: // partial + print("Partial message") + self.incomingMessageIndex += 1 + self.callback.callback(message: .downloadProgress(self.incomingMessageIndex)) + // TODO check length against MTU + return + case let .some(byte): + throw DataError.unknownDataTransferPrefix(byte: byte) + } + case readerStateCharacteristicId: + if data.count != 1 { + throw DataError.invalidStateLength + } + switch data[0] { + case 0x01: + print("Starting to send request") + writeOutgoingValue(data: self.requestData) + case let byte: + throw DataError.unknownState(byte: byte) + } + return +// case readerL2CAPCharacteristicId: +// return + case let uuid: + throw DataError.unknownCharacteristic(uuid: uuid) + } + } else { + throw DataError.noData(characteristic: characteristic.uuid) + } + } +} + +extension MDocReaderBLEPeripheral: CBPeripheralManagerDelegate { + func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) { + switch peripheral.state { + case .poweredOn: + print("Advertising...") + setupService() + peripheralManager.startAdvertising([CBAdvertisementDataServiceUUIDsKey: [serviceUuid]]) + case .unsupported: + print("Peripheral Is Unsupported.") + case .unauthorized: + print("Peripheral Is Unauthorized.") + case .unknown: + print("Peripheral Unknown") + case .resetting: + print("Peripheral Resetting") + case .poweredOff: + print("Peripheral Is Powered Off.") + @unknown default: + print("Error") + } + } + + // 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() + } + + func peripheralManager( + _ peripheral: CBPeripheralManager, + central: CBCentral, + didSubscribeTo characteristic: CBCharacteristic + ) { + print("Subscribed to \(characteristic.uuid)") + self.callback.callback(message: .connected) + self.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 _: + return + } + } + + func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveRead request: CBATTRequest) { + print("Received read request for \(request.characteristic.uuid)") + + // Since there is no callback for MTU on iOS we will grab it here. + maximumCharacteristicSize = min(request.central.maximumUpdateValueLength, 512) + + 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) + } else { + self.callback.callback(message: + .error(.server("Read on unexpected characteristic with UUID \(request.characteristic.uuid)")) + ) + } + } + + func peripheralManager(_ peripheral: 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) + } + } + } +} diff --git a/SpruceIDMobileSdk.podspec b/SpruceIDMobileSdk.podspec index 5d177cc..a20e63c 100644 --- a/SpruceIDMobileSdk.podspec +++ b/SpruceIDMobileSdk.podspec @@ -14,7 +14,7 @@ Pod::Spec.new do |spec| spec.platform = :ios spec.swift_version = '5.9' - spec.ios.deployment_target = '13.0' + spec.ios.deployment_target = '14.0' spec.source = { :git => "https://github.com/spruceid/mobile-sdk-swift.git", :tag => "#{spec.version}" } spec.source_files = "Sources/MobileSdk/*.swift" diff --git a/project.yml b/project.yml index c856b5f..f9d3c5c 100644 --- a/project.yml +++ b/project.yml @@ -4,7 +4,8 @@ options: packages: SpruceIDMobileSdkRs: url: https://github.com/spruceid/mobile-sdk-rs - from: 0.0.27 + revision: "820008ddd0e20c1c286d49e83967ef9362745902" + # from: 0.0.27 # path: "../mobile-sdk-rs" SwiftAlgorithms: url: https://github.com/apple/swift-algorithms @@ -20,7 +21,7 @@ targets: MobileSdk: type: library.dynamic platform: iOS - deploymentTarget: "13.0" + deploymentTarget: "14.0" sources: - Sources dependencies: