Skip to content

Commit

Permalink
Add support for L2CAP and refactor state management (#32)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
todd-spruceid authored Sep 6, 2024
1 parent 8b80370 commit e1e8d12
Show file tree
Hide file tree
Showing 10 changed files with 1,034 additions and 183 deletions.
2 changes: 2 additions & 0 deletions .swiftlint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,7 @@ disabled_rules:
- cyclomatic_complexity
- todo
- file_length
- function_body_length
- type_body_length
- force_try
- non_optional_string_data_conversion
184 changes: 184 additions & 0 deletions Sources/MobileSdk/BLEConnection.swift
Original file line number Diff line number Diff line change
@@ -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<UInt8>.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<UInt16>.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)
}
}
}
8 changes: 6 additions & 2 deletions Sources/MobileSdk/Credentials.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
8 changes: 6 additions & 2 deletions Sources/MobileSdk/MDoc.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand Down
63 changes: 63 additions & 0 deletions Sources/MobileSdk/MDocBLEUtils.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(" <no descriptors>")
}
}
Loading

0 comments on commit e1e8d12

Please sign in to comment.