From 46fed1d702a38f8b2df8fa2953c7b4d5ea0436df Mon Sep 17 00:00:00 2001 From: Todd Showalter Date: Fri, 13 Sep 2024 16:04:15 -0400 Subject: [PATCH] L2CAP Support for ISO 18013-5 Holders NOTE: Currently not working! See below. This provides L2CAP support for the holder for 18013-5. This is disabled at present; the code is in place, but calling connect() on the L2CAP socket throws a "resources not available" exception for reasons I have not yet tracked down. The code should otherwise be ready, however; the request read and the response write are both in place. The presence of an L2CAP characteristic on the peripheral no longer breaks the old flow for the code, so we now properly do non-L2CAP connections to L2CAP peripherals. For convenience, this also includes a makefile to orchestrate building and running; `make` or `make install` will build the example app and attempt to install it on all connected android devices. `make run` will launch the example app on one device and attempt to attach `logcat`. `make help` lists the available commands. --- .../com/spruceid/mobile/sdk/BleCentral.kt | 4 +- .../com/spruceid/mobile/sdk/GattClient.kt | 533 ++++++++++++------ .../sdk/TransportBleCentralClientHolder.kt | 3 +- .../mobilesdkexample/wallet/NamespaceField.kt | 2 +- .../wallet/SelectiveDisclosureView.kt | 2 +- makefile | 44 ++ 6 files changed, 414 insertions(+), 174 deletions(-) create mode 100644 makefile diff --git a/MobileSdk/src/main/java/com/spruceid/mobile/sdk/BleCentral.kt b/MobileSdk/src/main/java/com/spruceid/mobile/sdk/BleCentral.kt index a08569b..b8dc6aa 100644 --- a/MobileSdk/src/main/java/com/spruceid/mobile/sdk/BleCentral.kt +++ b/MobileSdk/src/main/java/com/spruceid/mobile/sdk/BleCentral.kt @@ -56,7 +56,7 @@ class BleCentral( /** * Starts to scan for devices/peripherals to connect to - looks for a specific service UUID. * - * Scanning is limited with a timeout to preserve batter life of a device. + * Scanning is limited with a timeout to preserve battery life of a device. */ fun scan() { val filter: ScanFilter = ScanFilter.Builder() @@ -153,4 +153,4 @@ fun isBluetoothEnabled(context: Context): Boolean { fun getBluetoothManager(context: Context): BluetoothManager? { return context.getSystemService(BLUETOOTH_SERVICE) as? BluetoothManager -} \ No newline at end of file +} diff --git a/MobileSdk/src/main/java/com/spruceid/mobile/sdk/GattClient.kt b/MobileSdk/src/main/java/com/spruceid/mobile/sdk/GattClient.kt index 32eba19..22f5aa7 100644 --- a/MobileSdk/src/main/java/com/spruceid/mobile/sdk/GattClient.kt +++ b/MobileSdk/src/main/java/com/spruceid/mobile/sdk/GattClient.kt @@ -1,5 +1,6 @@ package com.spruceid.mobile.sdk +import android.bluetooth.BluetoothAdapter import android.bluetooth.BluetoothDevice import android.bluetooth.BluetoothGatt import android.bluetooth.BluetoothGattCallback @@ -7,18 +8,26 @@ import android.bluetooth.BluetoothGattCharacteristic import android.bluetooth.BluetoothGattDescriptor import android.bluetooth.BluetoothGattService import android.bluetooth.BluetoothProfile +import android.bluetooth.BluetoothSocket import android.bluetooth.BluetoothStatusCodes import android.content.Context import android.os.Build import android.util.Log import java.io.ByteArrayOutputStream +import java.io.IOException import java.lang.reflect.InvocationTargetException import java.util.ArrayDeque import java.util.Arrays import java.util.Queue import java.util.UUID +import java.util.concurrent.BlockingQueue +import java.util.concurrent.Executors +import java.util.concurrent.LinkedTransferQueue +import java.util.concurrent.TimeUnit import kotlin.math.min - +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.TimeSource /** * GATT client responsible for consuming data sent from a GATT server. @@ -26,6 +35,7 @@ import kotlin.math.min */ class GattClient(private var callback: GattClientCallback, private var context: Context, + private var btAdapter: BluetoothAdapter?, private var serviceUuid: UUID, private var characteristicStateUuid: UUID, private var characteristicClient2ServerUuid: UUID, @@ -33,8 +43,9 @@ class GattClient(private var callback: GattClientCallback, private var characteristicIdentUuid: UUID?, private var characteristicL2CAPUuid: UUID?) { - private var clientCharacteristicConfigUuid = + private val clientCharacteristicConfigUuid = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") + private val L2CAP_BUFFER_SIZE = (1 shl 16) // 64K var gattClient: BluetoothGatt? = null @@ -46,11 +57,17 @@ class GattClient(private var callback: GattClientCallback, private var mtu = 0 private var identValue: ByteArray? = byteArrayOf() - private var usingL2CAP = false private var writeIsOutstanding = false private var writingQueue: Queue = ArrayDeque() private var writingQueueTotalChunks = 0 + private var usingL2CAP = false + private var setL2CAPNotify = false + private var channelPSM = 0 + private var l2capSocket: BluetoothSocket? = null + private var l2capWriteThread: Thread? = null private var incomingMessage: ByteArrayOutputStream = ByteArrayOutputStream() + private val responseData: BlockingQueue = LinkedTransferQueue() + private var requestTimestamp = TimeSource.Monotonic.markNow() /** * Bluetooth GATT callback containing all of the events. @@ -60,13 +77,13 @@ class GattClient(private var callback: GattClientCallback, * Discover services to connect to. */ override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { - callback.onLog("onConnectionStateChange") + reportLog("onConnectionStateChange") if (newState == BluetoothProfile.STATE_CONNECTED) { clearCache() callback.onState(BleStates.GattClientConnected.string) - callback.onLog("Gatt Client is connected.") + reportLog("Gatt Client is connected.") try { gatt.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH) @@ -76,7 +93,7 @@ class GattClient(private var callback: GattClientCallback, } } else if (newState == BluetoothProfile.STATE_DISCONNECTED) { callback.onPeerDisconnected() - callback.onLog("GATT Server disconnected.") + reportLog("GATT Server disconnected.") } } @@ -86,7 +103,7 @@ class GattClient(private var callback: GattClientCallback, * 18013-5 section 8.3.3.1.1.6. */ override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { - callback.onLog("onServicesDiscovered") + reportLog("onServicesDiscovered") if (status == BluetoothGatt.GATT_SUCCESS) { try { @@ -99,46 +116,47 @@ class GattClient(private var callback: GattClientCallback, if (characteristicL2CAPUuid != null) { characteristicL2CAP = service.getCharacteristic(characteristicL2CAPUuid) - if (characteristicL2CAP != null) { - callback.onError(Error("L2CAP characteristic found $characteristicL2CAPUuid.")) - } + + // We don't check if the characteristic is null here because using it is optional; + // we'll decide later if we want to use it based on OS version and whether the + // characteristic actually resolved to something. } characteristicState = service.getCharacteristic(characteristicStateUuid) if (characteristicState == null) { - callback.onError(Error("State characteristic not found.")) + reportError("State characteristic not found.") return } characteristicClient2Server = service.getCharacteristic(characteristicClient2ServerUuid) if (characteristicClient2Server == null) { - callback.onError(Error("Client2Server characteristic not found.")) + reportError("Client2Server characteristic not found.") return } characteristicServer2Client = service.getCharacteristic(characteristicServer2ClientUuid) if (characteristicServer2Client == null) { - callback.onError(Error("Server2Client characteristic not found.")) + reportError("Server2Client characteristic not found.") return } if (characteristicIdentUuid != null) { characteristicIdent = service.getCharacteristic(characteristicIdentUuid) if (characteristicIdent == null) { - callback.onError(Error("Ident characteristic not found.")) + reportError("Ident characteristic not found.") return } } callback.onState(BleStates.ServicesDiscovered.string) - callback.onLog("Discovered expected services") + reportLog("Discovered expected services") } catch (error: Exception) { callback.onError(error) } try { if (!gatt.requestMtu(515)) { - callback.onError(Error("Error requesting MTU.")) + reportError("Error requesting MTU.") return } } catch (error: SecurityException) { @@ -154,16 +172,16 @@ class GattClient(private var callback: GattClientCallback, * Detecting the MTU limit adjustment. */ override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) { - callback.onLog("onMtuChanged") + reportLog("onMtuChanged") this@GattClient.set_mtu(mtu) if (status != BluetoothGatt.GATT_SUCCESS) { - callback.onError(Error("Error changing MTU, status: $status.")) + reportError("Error changing MTU, status: $status.") return } - callback.onLog("Negotiated MTU changed to $mtu.") + reportLog("Negotiated MTU changed to $mtu.") /** * Optional ident characteristic is used for additional reader validation. 18013-5 section @@ -172,7 +190,7 @@ class GattClient(private var callback: GattClientCallback, if (characteristicIdent != null) { try { if (!gatt.readCharacteristic(characteristicIdent)) { - callback.onLog("Warning: Reading from ident characteristic.") + reportLog("Warning: Reading from ident characteristic.") } } catch (error: SecurityException) { callback.onError(error) @@ -187,39 +205,52 @@ class GattClient(private var callback: GattClientCallback, */ @Deprecated("Deprecated in Java") override fun onCharacteristicRead(gatt: BluetoothGatt, - characteristic: BluetoothGattCharacteristic, status: Int) { - - callback.onLog("onCharacteristicRead, uuid=${characteristic.uuid} status=$status") + characteristic: BluetoothGattCharacteristic, + status: Int) { @Suppress("deprecation") - val value = characteristic.value + onCharacteristicRead(gatt, characteristic, characteristic.value, status) + } + + override fun onCharacteristicRead(gatt: BluetoothGatt, + characteristic: BluetoothGattCharacteristic, + value: ByteArray, + status: Int) { + + reportLog("onCharacteristicRead, uuid=${characteristic.uuid} status=$status") + //@Suppress("deprecation") /** * 18013-5 section 8.3.3.1.1.3. */ if (characteristic.uuid.equals(characteristicIdentUuid)) { - callback.onLog("Received identValue: ${byteArrayToHex(value)}.") + reportLog("Received identValue: ${byteArrayToHex(value)}.") if (!Arrays.equals(value, identValue)) { - callback.onLog("Warning: Received ident does not match expected ident.") + reportLog("Warning: Received ident does not match expected ident.") } afterIdentObtained(gatt) } else if (characteristic.uuid.equals(characteristicL2CAPUuid)) { - /** - * L2CAP placeholder. - */ + Log.d("[GattClient]","L2CAP read! '${value.size}' ${status == BluetoothGatt.GATT_SUCCESS}") + if (value.size == 2) { + // This doesn't appear to happen in practice; we get the data back in + // onCharacteristicChanged() instead. + dprint("L2CAP channel PSM read via onCharacteristicRead()") + //gatt.readCharacteristic(characteristicL2CAP) + } } else { - callback.onError(Error("Unexpected onCharacteristicRead for characteristic " + - "${characteristic.uuid} expected $characteristicIdentUuid.")) + reportError("Unexpected onCharacteristicRead for characteristic " + + "${characteristic.uuid} expected $characteristicIdentUuid.") } } + /** * Detecting descriptor write. */ override fun onDescriptorWrite(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) { - callback.onLog("onDescriptorWrite, descriptor-uuid=${descriptor.uuid} " + + reportLog("onDescriptorWrite, descriptor-uuid=${descriptor.uuid} " + "characteristic-uuid=${descriptor.characteristic.uuid} status=$status.") try { @@ -228,34 +259,7 @@ class GattClient(private var callback: GattClientCallback, if (charUuid.equals(characteristicServer2ClientUuid) && descriptor.uuid.equals(clientCharacteristicConfigUuid) ) { - if (!gatt.setCharacteristicNotification(characteristicState, true)) { - callback.onError(Error("Error setting notification on State.")) - return - } - - val stateDescriptor: BluetoothGattDescriptor = - characteristicState!!.getDescriptor(clientCharacteristicConfigUuid) - - Log.d("GattClient.onDescriptorWrite","-- descriptor value --\n${(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE)}") - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - val res = gatt.writeDescriptor(stateDescriptor, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE) - if(res != BluetoothStatusCodes.SUCCESS) { - callback.onError(Error("Error writing to Server2Client. Code: $res")) - return - } - } else { - // Above code addresses the deprecation but requires API 33+ - @Suppress("deprecation") - stateDescriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE - - @Suppress("deprecation") - if (!gatt.writeDescriptor(stateDescriptor)) { - callback.onError(Error("Error writing to Server2Client clientCharacteristicConfig: desc.")) - return - } - } - + enableNotification(gatt, characteristicState, "State") } else if (charUuid.equals(characteristicStateUuid) && descriptor.uuid.equals(clientCharacteristicConfigUuid) ) { @@ -265,7 +269,7 @@ class GattClient(private var callback: GattClientCallback, if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { val res = gatt.writeCharacteristic(characteristicState!!, byteArrayOf(0x01.toByte()), BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE) if(res != BluetoothStatusCodes.SUCCESS) { - callback.onError(Error("Error writing to Server2Client. Code: $res")) + reportError("Error writing to Server2Client. Code: $res") return } } else { @@ -274,17 +278,28 @@ class GattClient(private var callback: GattClientCallback, characteristicState!!.value = byteArrayOf(0x01.toByte()) @Suppress("deprecation") if (!gatt.writeCharacteristic(characteristicState)) { - callback.onError(Error("Error writing to state characteristic.")) + reportError("Error writing to state characteristic.") } } - } else { - callback.onError(Error("Unexpected onDescriptorWrite for characteristic UUID $charUuid " + - "and descriptor UUID ${descriptor.uuid}.")) + } else if (charUuid.equals(characteristicL2CAPUuid)) { + if (descriptor.uuid.equals(clientCharacteristicConfigUuid)) { + + if (setL2CAPNotify) { + dprint("Notify already set for l2cap characteristic, doing nothing.") + } else { + setL2CAPNotify = true + dprint("Wrote NOTIFY, got accepted.") + if (!gatt.readCharacteristic(characteristicL2CAP)) { + reportError("Error reading L2CAP characteristic.") + } + } + } else { + reportError("Unexpected onDescriptorWrite: char $charUuid desc ${descriptor.uuid}.") + } } } catch (error: SecurityException) { callback.onError(error) } - } /** @@ -295,11 +310,11 @@ class GattClient(private var callback: GattClientCallback, val charUuid = characteristic.uuid - callback.onLog("onCharacteristicWrite, status=$status uuid=$charUuid") + reportLog("onCharacteristicWrite, status=$status uuid=$charUuid") if (charUuid.equals(characteristicStateUuid)) { if (status != BluetoothGatt.GATT_SUCCESS) { - callback.onError(Error("Unexpected status for writing to State, status=$status.")) + reportError("Unexpected status for writing to State, status=$status.") return } @@ -307,7 +322,7 @@ class GattClient(private var callback: GattClientCallback, } else if (charUuid.equals(characteristicClient2ServerUuid)) { if (status != BluetoothGatt.GATT_SUCCESS) { - callback.onError(Error("Unexpected status for writing to Client2Server, status=$status.")) + reportError("Unexpected status for writing to Client2Server, status=$status.") return } @@ -333,102 +348,281 @@ class GattClient(private var callback: GattClientCallback, override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) { - callback.onLog("onCharacteristicChanged, uuid=${characteristic.uuid}") + reportLog("onCharacteristicChanged, uuid=${characteristic.uuid}") @Suppress("deprecation") val value = characteristic.value - if (characteristic.uuid.equals(characteristicServer2ClientUuid)) { - if (value.isEmpty()) { - callback.onError(Error("Invalid data length ${value.size} for Server2Client " + - "characteristic.")) - return - } + + when(characteristic.uuid) { - Log.d("GattClient.onCharacteristicChanged", byteArrayToHex(value)) + characteristicServer2ClientUuid -> { + if (value.isEmpty()) { + reportError("Invalid data length ${value.size} for Server2Client characteristic.") + return + } + + Log.d("GattClient.onCharacteristicChanged", byteArrayToHex(value)) - incomingMessage.write(value, 1, value.size - 1) + incomingMessage.write(value, 1, value.size - 1) - callback.onLog("Received chunk with ${value.size} bytes (last=${value[0].toInt() == 0x00}), " + - "incomingMessage.length=${incomingMessage.toByteArray().size}") + reportLog("Received chunk with ${value.size} bytes (last=${value[0].toInt() == 0x00}), " + + "incomingMessage.length=${incomingMessage.toByteArray().size}") - if (value[0].toInt() == 0x00) { - /** - * Last message. - */ - val entireMessage: ByteArray = incomingMessage.toByteArray() + if (value[0].toInt() == 0x00) { + /** + * Last message. + */ + val entireMessage: ByteArray = incomingMessage.toByteArray() - incomingMessage.reset() - callback.onMessageReceived(entireMessage) - } else if (value[0].toInt() == 0x01) { - // Message size is three less than MTU, as opcode and attribute handle take up 3 bytes. - if (value.size > mtu - 3) { - callback.onError(Error("Invalid size ${value.size} of data written Server2Client " + - "characteristic, expected maximum size ${mtu - 3}.")) + incomingMessage.reset() + callback.onMessageReceived(entireMessage) + } else if (value[0].toInt() == 0x01) { + // Message size is three less than MTU, as opcode and attribute handle take up 3 bytes. + if (value.size > mtu - 3) { + reportError("Invalid size ${value.size} of data written Server2Client " + + "characteristic, expected maximum size ${mtu - 3}.") + return + } + } else { + reportError("Invalid first byte ${value[0]} in Server2Client data chunk, expected 0 or 1.") return } - } else { - callback.onError(Error("Invalid first byte ${value[0]} in Server2Client data chunk, " + - "expected 0 or 1.")) - return } - } else if (characteristic.uuid.equals(characteristicStateUuid)) { - if (value.size != 1) { - callback.onError(Error("Invalid data length ${value.size} for state characteristic.")) - return + + characteristicStateUuid -> { + if (value.size != 1) { + reportError("Invalid data length ${value.size} for state characteristic.") + return + } + + if (value[0].toInt() == 0x02) { + callback.onTransportSpecificSessionTermination() + } else { + reportError("Invalid byte ${value[0]} for state characteristic.") + } } - if (value[0].toInt() == 0x02) { - callback.onTransportSpecificSessionTermination() - } else { - callback.onError(Error("Invalid byte ${value[0]} for state characteristic.")) + characteristicL2CAPUuid -> { + if (value.size == 2) { + if (channelPSM == 0) { + channelPSM = (((value[1].toULong() and 0xFFu) shl 8) or (value[0].toULong() and 0xFFu)).toInt() + reportLog("L2CAP Channel: ${channelPSM}") + + val device = gatt.getDevice() + + // The android docs recommend cancelling discovery before connecting a socket for + // perfomance reasons. + + btAdapter?.cancelDiscovery() + + val connectThread: Thread = object : Thread() { + override fun run() { + try { + // createL2capChannel() requires/initiates pairing, so we have to use + // the "insecure" version. + l2capSocket = device.createInsecureL2capChannel(channelPSM) + l2capSocket?.connect() + } catch (e: IOException) { + reportError("Error connecting to L2CAP socket: ${e.message}") + return + } + + l2capWriteThread = Thread { writeResponse() } + l2capWriteThread!!.start() + + // Let the app know we're connected. + //reportPeerConnected() + + // Reuse this thread for reading + readRequest() + } + } + connectThread.start() + } + } + } + + else -> { + dprint("Unknown Changed: ${value.size}") } } } } /** - * + * Thread for reading the request via L2CAP. */ - private fun afterIdentObtained(gatt: BluetoothGatt) { - try { - // Use L2CAP if supported by GattServer and by this OS version - usingL2CAP = characteristicL2CAP != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q + private fun readRequest() { + val payload = ByteArrayOutputStream() + + // Keep listening to the InputStream until an exception occurs. + val inStream = try { + l2capSocket!!.inputStream + } catch (e: IOException) { + reportError("Error on listening input stream from socket L2CAP: ${e}") + return + } - if (usingL2CAP) { - callback.onLog("Using L2CAP: $usingL2CAP") + while (true) { + val buf = ByteArray(L2CAP_BUFFER_SIZE) + try { + val numBytesRead = inStream.read(buf) + if (numBytesRead == -1) { + reportError("Failure reading request, peer disconnected.") + return + } + payload.writeBytes(buf) + + dprint("Currently have ${buf.count()} bytes.") - // value is returned async above in onCharacteristicRead() - if (!gatt.readCharacteristic(characteristicL2CAP)) { - callback.onError(Error("Error reading L2CAP characteristic.")) + requestTimestamp = TimeSource.Monotonic.markNow() + + Executors.newSingleThreadScheduledExecutor() + .schedule({ + val curtime = TimeSource.Monotonic.markNow() + if ((curtime - requestTimestamp) > 500.milliseconds) { + val message = payload.toByteArray() + + dprint("Request complete: ${message.count()} bytes.") + callback.onMessageReceived(message) + } + }, 500, TimeUnit.MILLISECONDS) + + } catch (e: IOException) { + reportError("Error on listening input stream from socket L2CAP: ${e}") + return + } + } + } + + /** + * Thread for writing the response via L2CAP. + */ + fun writeResponse() { + val outStream = l2capSocket!!.outputStream + try { + while (true) { + var message: ByteArray? + try { + message = responseData.poll(500, TimeUnit.MILLISECONDS) + if (message == null) { + continue + } + if (message.size == 0) { + break + } + } catch (e: InterruptedException) { + continue } + outStream.write(message) + } + } catch (e: IOException) { + reportError("Error writing response via L2CAP socket: ${e}") + } + + try { + // TODO: This is to work around a bug in L2CAP + Thread.sleep(1000) + l2capSocket!!.close() + } catch (e: IOException) { + reportError("Error closing socket: ${e}") + } catch (e: InterruptedException) { + reportError("Error closing socket: ${e}") + } + } + + /** + * Set notifications for a characteristic. This process is rather more complex than you'd think it would + * be, and isn't complete until onDescriptorWrite() is hit; it triggers an async action. + */ + private fun enableNotification(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic?, name: String) { + dprint("Enabling notifications on ${name}") + + if (characteristic == null) { + reportError("Error setting notification on ${name}; is null.") + return + } + + dprint("-- ind: ${characteristic.getProperties()}") + + + if (!gatt.setCharacteristicNotification(characteristic, true)) { + reportError("Error setting notification on ${name}; call failed.") + return + } + + val descriptor: BluetoothGattDescriptor = characteristic.getDescriptor(clientCharacteristicConfigUuid) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val res = gatt.writeDescriptor(descriptor, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE) + if(res != BluetoothStatusCodes.SUCCESS) { + reportError("Error writing to ${name}. Code: $res") return } + } else { + // Above code addresses the deprecation but requires API 33+ + @Suppress("deprecation") + descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE - if (!gatt.setCharacteristicNotification(characteristicServer2Client, true)) { - callback.onError(Error("Error setting notification on Server2Client.")) + @Suppress("deprecation") + if (!gatt.writeDescriptor(descriptor)) { + reportError("Error writing to ${name} clientCharacteristicConfig: desc.") return } + } - val descriptor: BluetoothGattDescriptor = - characteristicServer2Client!!.getDescriptor(clientCharacteristicConfigUuid) + // An onDescriptorWrite() call will come in for the pair of this characteristic and the client + // characteristic config UUID when notification setting is complete. + } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - val res = gatt.writeDescriptor(descriptor, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE) - if(res != BluetoothStatusCodes.SUCCESS) { - callback.onError(Error("Error writing to Server2Client. Code: $res")) - return - } - } else { - // Above code addresses the deprecation but requires API 33+ - @Suppress("deprecation") - descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE + /** + * Log stuff to the console. + */ + private fun dprint(text: String) { + Log.d("[GattClient]", text) + } - @Suppress("deprecation") - if (!gatt.writeDescriptor(descriptor)) { - callback.onError(Error("Error writing to Server2Client clientCharacteristicConfig: desc.")) - return - } + /** + * Log stuff both to the callback and to the console. + */ + private fun reportLog(text: String) { + dprint(text) + callback.onLog(text) + } + + /** + * Log an error both to the callback and the console. + */ + private fun reportError(text: String) { + dprint("ERROR: ${text}") + callback.onError(Error(text)) + } + + /** + * + */ + private fun afterIdentObtained(gatt: BluetoothGatt) { + try { + // Use L2CAP if supported by GattServer and by this OS version + + // L2CAP use disabled for now; the plumbing is all in place, and it ought to work, but + // when we call connect() on the socket we get a "no resources available" exception, and + // the socket remains unopen. + + //usingL2CAP = characteristicL2CAP != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q + usingL2CAP = false + + if (usingL2CAP) { + enableNotification(gatt, characteristicL2CAP, "L2CAP") + + reportLog("Using L2CAP: $usingL2CAP") + + //// value is returned async above in onCharacteristicRead() + + return } + enableNotification(gatt, characteristicServer2Client, "Server2Client") + } catch (error: SecurityException) { callback.onError(error) } @@ -438,7 +632,7 @@ class GattClient(private var callback: GattClientCallback, * Draining writing queue when the write is not outstanding. */ private fun drainWritingQueue() { - callback.onLog("drainWritingQueue: write is outstanding $writeIsOutstanding") + reportLog("drainWritingQueue: write is outstanding $writeIsOutstanding") if (writeIsOutstanding) { return @@ -446,14 +640,14 @@ class GattClient(private var callback: GattClientCallback, val chunk: ByteArray = writingQueue.poll() ?: return - callback.onLog("Sending chunk with ${chunk.size} bytes (last=${chunk[0].toInt() == 0x00})") + reportLog("Sending chunk with ${chunk.size} bytes (last=${chunk[0].toInt() == 0x00})") try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { val res = gattClient!!.writeCharacteristic(characteristicClient2Server!!, chunk, BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE) if(res != BluetoothStatusCodes.SUCCESS) { - callback.onError(Error("Error writing to Server2Client. Code: $res")) + reportError("Error writing to Client2Server. Code: $res") return } } else { @@ -462,7 +656,7 @@ class GattClient(private var callback: GattClientCallback, characteristicClient2Server!!.value = chunk @Suppress("deprecation") if (!gattClient!!.writeCharacteristic(characteristicClient2Server)) { - callback.onError(Error("Error writing to Client2Server characteristic")) + reportError("Error writing to Client2Server characteristic") return } } @@ -493,41 +687,42 @@ class GattClient(private var callback: GattClientCallback, * a byte array. */ fun sendMessage(data: ByteArray) { - callback.onLog("Sending message: $data") + reportLog("Sending message: $data") - /** - * L2CAP placeholder - when client is implemented send message via socket instead. - */ + if (usingL2CAP) { + responseData.add(data) + } else { - if (mtu == 0) { - callback.onLog("MTU not negotiated, defaulting to 23. Performance will suffer.") - mtu = 23 - } + if (mtu == 0) { + reportLog("MTU not negotiated, defaulting to 23. Performance will suffer.") + mtu = 23 + } - /** - * Three less the MTU but we also need room for the leading 0x00 or 0x01. - */ - val maxChunkSize: Int = mtu - 4 - var offset = 0 + /** + * Three less the MTU but we also need room for the leading 0x00 or 0x01. + */ + val maxChunkSize: Int = mtu - 4 + var offset = 0 - do { - val moreChunksComing = offset + maxChunkSize < data.size - var size = data.size - offset + do { + val moreChunksComing = offset + maxChunkSize < data.size + var size = data.size - offset - if (size > maxChunkSize) { - size = maxChunkSize - } + if (size > maxChunkSize) { + size = maxChunkSize + } - val chunk = ByteArray(size + 1) + val chunk = ByteArray(size + 1) - chunk[0] = if (moreChunksComing) 0x01.toByte() else 0x00.toByte() - System.arraycopy(data, offset, chunk, 1, size) - writingQueue.add(chunk) - offset += size - } while (offset < data.size) + chunk[0] = if (moreChunksComing) 0x01.toByte() else 0x00.toByte() + System.arraycopy(data, offset, chunk, 1, size) + writingQueue.add(chunk) + offset += size + } while (offset < data.size) - writingQueueTotalChunks = writingQueue.size - drainWritingQueue() + writingQueueTotalChunks = writingQueue.size + drainWritingQueue() + } } /** @@ -547,7 +742,7 @@ class GattClient(private var callback: GattClientCallback, if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { val res = gattClient!!.writeCharacteristic(characteristicState!!, terminationCode, BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE) if(res != BluetoothStatusCodes.SUCCESS) { - callback.onError(Error("Error writing to state characteristic. Code: $res")) + reportError("Error writing to state characteristic. Code: $res") return } } else { @@ -559,7 +754,7 @@ class GattClient(private var callback: GattClientCallback, @Suppress("deprecation") if (gattClient != null && !gattClient!!.writeCharacteristic(characteristicState)) { - callback.onError(Error("Error writing to state characteristic.")) + reportError("Error writing to state characteristic.") } } } catch (error: SecurityException) { @@ -580,7 +775,7 @@ class GattClient(private var callback: GattClientCallback, BluetoothDevice.TRANSPORT_LE) callback.onState(BleStates.ConnectingGattClient.string) - callback.onLog("Connecting to GATT server.") + reportLog("Connecting to GATT server.") } catch (error: SecurityException) { callback.onError(error) } @@ -597,7 +792,7 @@ class GattClient(private var callback: GattClientCallback, gattClient = null callback.onState(BleStates.DisconnectGattClient.string) - callback.onLog("Gatt Client disconnected.") + reportLog("Gatt Client disconnected.") } } catch (error: SecurityException) { callback.onError(error) @@ -614,4 +809,4 @@ class GattClient(private var callback: GattClientCallback, fun set_mtu(mtu: Int) { this.mtu = min(515, mtu) } -} \ No newline at end of file +} diff --git a/MobileSdk/src/main/java/com/spruceid/mobile/sdk/TransportBleCentralClientHolder.kt b/MobileSdk/src/main/java/com/spruceid/mobile/sdk/TransportBleCentralClientHolder.kt index 1ad75e9..375c907 100644 --- a/MobileSdk/src/main/java/com/spruceid/mobile/sdk/TransportBleCentralClientHolder.kt +++ b/MobileSdk/src/main/java/com/spruceid/mobile/sdk/TransportBleCentralClientHolder.kt @@ -159,6 +159,7 @@ class TransportBleCentralClientHolder( gattClient = GattClient( gattClientCallback, context, + bluetoothAdapter, serviceUUID, characteristicStateUuid, characteristicClient2ServerUuid, @@ -200,4 +201,4 @@ class TransportBleCentralClientHolder( gattClient.disconnect() gattClient.reset() } -} \ No newline at end of file +} diff --git a/example/src/main/java/com/spruceid/mobilesdkexample/wallet/NamespaceField.kt b/example/src/main/java/com/spruceid/mobilesdkexample/wallet/NamespaceField.kt index e364e27..9aab482 100644 --- a/example/src/main/java/com/spruceid/mobilesdkexample/wallet/NamespaceField.kt +++ b/example/src/main/java/com/spruceid/mobilesdkexample/wallet/NamespaceField.kt @@ -37,4 +37,4 @@ fun NamespaceField(namespace: Map.Entry, isChecked: Boolean, on ) } } -} \ No newline at end of file +} diff --git a/example/src/main/java/com/spruceid/mobilesdkexample/wallet/SelectiveDisclosureView.kt b/example/src/main/java/com/spruceid/mobilesdkexample/wallet/SelectiveDisclosureView.kt index 3997f05..a10889f 100644 --- a/example/src/main/java/com/spruceid/mobilesdkexample/wallet/SelectiveDisclosureView.kt +++ b/example/src/main/java/com/spruceid/mobilesdkexample/wallet/SelectiveDisclosureView.kt @@ -136,4 +136,4 @@ fun SelectiveDisclosureView( } } } -} \ No newline at end of file +} diff --git a/makefile b/makefile new file mode 100644 index 0000000..08ab306 --- /dev/null +++ b/makefile @@ -0,0 +1,44 @@ +GRADLE=./gradlew + +BINNAME=com.spruceid.mobilesdkexample + +# If you have more than one android device, you can set it by exporting its serial number as ANDROID_DEVICE +# in your shell. This will attempt to infer which to use, assuming nothing is specified. + +ifneq ($(ANDROID_DEVICE),) +ADB_DEVICE_ARG=-s $(ANDROID_DEVICE) +else +# You can think of NR as the line number (sort of) here (it's actually "number of records seen so far"). +# We have to do $$1 is the make-escaped version of $1, which is the first column. +# So, this is just taking row 2, column 1 from 'adb devices'; we do row 2 to skip the header line. +ADB_DEVICE_ARG=-s $(shell adb devices | awk 'NR==2{print $$1}') +endif + +all: install + +.phony: help +help: #@ Makefile help. + @grep -E '^[a-zA-Z_-]+:.*?#@ .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS=":.*?#@ "};{printf "%-16s %s\n", $$1, $$2}' + +.phony: clean +clean: #@ Clean the build. + @$(GRADLE) clean + +# Throws an exception for some reason I haven't looked into yet. +.phony: build +build: #@ Make the build; currently not working, use 'install' instead. + @$(GRADLE) build + +.phony: install +install: #@ Install the build to all devices. + @$(GRADLE) installDebug + +.phony: run +run: stop #@# Run the build, engage the logger. Must have been installed first. + @adb $(ADB_DEVICE_ARG) shell monkey -p $(BINNAME) 1 + @sleep 1 # TODO: loop until pidof gives us a valid number + @adb $(ADB_DEVICE_ARG) logcat --pid=`adb $(ADB_DEVICE_ARG) shell pidof $(BINNAME)` + +.phony: stop +stop: #@ Stop the running build. + @adb $(ADB_DEVICE_ARG) shell am force-stop $(BINNAME)