diff --git a/MobileSdk/build.gradle.kts b/MobileSdk/build.gradle.kts index 34b3cfa..9d528a8 100644 --- a/MobileSdk/build.gradle.kts +++ b/MobileSdk/build.gradle.kts @@ -1,138 +1,140 @@ plugins { - id("com.android.library") - id("org.jetbrains.kotlin.android") - `maven-publish` - id("signing") - id("com.gradleup.nmcp") + id("com.android.library") + id("org.jetbrains.kotlin.android") + `maven-publish` + id("signing") + id("com.gradleup.nmcp") } publishing { - repositories { - maven { - name = "GitHubPackages" - url = uri("https://maven.pkg.github.com/spruceid/mobile-sdk-kt") - credentials { - username = System.getenv("GITHUB_ACTOR") - password = System.getenv("GITHUB_TOKEN") - } - } + repositories { + maven { + name = "GitHubPackages" + url = uri("https://maven.pkg.github.com/spruceid/mobile-sdk-kt") + credentials { + username = System.getenv("GITHUB_ACTOR") + password = System.getenv("GITHUB_TOKEN") + } + } + } + publications { + create("debug") { + groupId = "com.spruceid.mobile.sdk" + artifactId = "mobilesdk" + version = System.getenv("VERSION") + + afterEvaluate { from(components["release"]) } } - publications { - // Creates a Maven publication called "release". - create("release") { - groupId = "com.spruceid.mobile.sdk" - artifactId = "mobilesdk" - version = System.getenv("VERSION") - - afterEvaluate { - from(components["release"]) - } - - pom { - packaging = "aar" - name.set("mobilesdk") - description.set("Android SpruceID Mobile SDK") - url.set("https://github.com/spruceid/mobile-sdk-kt") - licenses { - license { - name.set("MIT License") - url.set("https://opensource.org/license/mit/") - } - license { - name.set("Apache License, Version 2.0") - url.set("http://www.apache.org/licenses/LICENSE-2.0.txt") - } - } - developers { - developer { - name.set("Spruce Systems, Inc.") - email.set("hello@spruceid.com") - } - } - scm { - url.set(pom.url.get()) - connection.set("scm:git:${url.get()}.git") - developerConnection.set("scm:git:${url.get()}.git") - } - } + // Creates a Maven publication called "release". + create("release") { + groupId = "com.spruceid.mobile.sdk" + artifactId = "mobilesdk" + version = System.getenv("VERSION") + + afterEvaluate { from(components["release"]) } + + pom { + packaging = "aar" + name.set("mobilesdk") + description.set("Android SpruceID Mobile SDK") + url.set("https://github.com/spruceid/mobile-sdk-kt") + licenses { + license { + name.set("MIT License") + url.set("https://opensource.org/license/mit/") + } + license { + name.set("Apache License, Version 2.0") + url.set("http://www.apache.org/licenses/LICENSE-2.0.txt") + } + } + developers { + developer { + name.set("Spruce Systems, Inc.") + email.set("hello@spruceid.com") + } } + scm { + url.set(pom.url.get()) + connection.set("scm:git:${url.get()}.git") + developerConnection.set("scm:git:${url.get()}.git") + } + } } + } } signing { - useGpgCmd() - sign(publishing.publications["release"]) + useGpgCmd() + sign(publishing.publications["release"]) } nmcp { - afterEvaluate { - publish("release") { - username = System.getenv("MAVEN_USERNAME") - password = System.getenv("MAVEN_PASSWORD") - publicationType = "AUTOMATIC" - } + afterEvaluate { + publish("release") { + username = System.getenv("MAVEN_USERNAME") + password = System.getenv("MAVEN_PASSWORD") + publicationType = "AUTOMATIC" } + } } - android { namespace = "com.spruceid.mobile.sdk" compileSdk = 35 - defaultConfig { - minSdk = 26 + defaultConfig { + minSdk = 26 - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - consumerProguardFiles("consumer-rules.pro") - } + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } - buildTypes { - release { - isMinifyEnabled = false - proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") - } + buildTypes { + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") } + } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 - } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } - kotlinOptions { - jvmTarget = "1.8" - } + kotlinOptions { jvmTarget = "1.8" } - buildFeatures { - compose = true - viewBinding = true - } + buildFeatures { + compose = true + viewBinding = true + } - composeOptions { kotlinCompilerExtensionVersion = "1.5.11" } + composeOptions { kotlinCompilerExtensionVersion = "1.5.11" } - publishing { - singleVariant("release") { - withSourcesJar() - withJavadocJar() - } + publishing { + singleVariant("release") { + withSourcesJar() + withJavadocJar() } + } } dependencies { - api("com.spruceid.mobile.sdk.rs:mobilesdkrs:0.0.32") - //noinspection GradleCompatible - implementation("com.android.support:appcompat-v7:28.0.0") - /* Begin UI dependencies */ - implementation("androidx.compose.material3:material3:1.2.1") - implementation("androidx.camera:camera-camera2:1.3.2") - implementation("androidx.camera:camera-lifecycle:1.3.2") - implementation("androidx.camera:camera-view:1.3.2") - implementation("com.google.zxing:core:3.5.1") - implementation("com.google.accompanist:accompanist-permissions:0.34.0") - implementation("androidx.camera:camera-mlkit-vision:1.3.0-alpha06") - implementation("com.google.android.gms:play-services-mlkit-text-recognition:19.0.0") - /* End UI dependencies */ - implementation("androidx.datastore:datastore-preferences:1.1.1") - testImplementation("junit:junit:4.13.2") - androidTestImplementation("com.android.support.test:runner:1.0.2") - androidTestImplementation("com.android.support.test.espresso:espresso-core:3.0.2") + api("com.spruceid.mobile.sdk.rs:mobilesdkrs:0.0.32") + //noinspection GradleCompatible + implementation("com.android.support:appcompat-v7:28.0.0") + /* Begin UI dependencies */ + implementation("androidx.compose.material3:material3:1.2.1") + implementation("androidx.camera:camera-camera2:1.3.2") + implementation("androidx.camera:camera-lifecycle:1.3.2") + implementation("androidx.camera:camera-view:1.3.2") + implementation("com.google.zxing:core:3.5.1") + implementation("com.google.accompanist:accompanist-permissions:0.34.0") + implementation("androidx.camera:camera-mlkit-vision:1.3.0-alpha06") + implementation("com.google.android.gms:play-services-mlkit-text-recognition:19.0.0") + /* End UI dependencies */ + implementation("androidx.datastore:datastore-preferences:1.1.1") + testImplementation("junit:junit:4.13.2") + androidTestImplementation("com.android.support.test:runner:1.0.2") + androidTestImplementation("com.android.support.test.espresso:espresso-core:3.0.2") } 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..1df2192 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,13 @@ 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 + + enum class UseL2CAP { IfAvailable, Yes, No } + + private var useL2CAP = UseL2CAP.IfAvailable var gattClient: BluetoothGatt? = null @@ -46,11 +61,16 @@ 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 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 +80,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 +96,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 +106,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 +119,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 +175,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 +193,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 +208,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 +262,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 +272,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 +281,27 @@ 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 if (charUuid.equals(characteristicL2CAPUuid)) { + if (descriptor.uuid.equals(clientCharacteristicConfigUuid)) { + + if (setL2CAPNotify) { + reportLog("Notify already set for l2cap characteristic, doing nothing.") + } else { + setL2CAPNotify = true + if (!gatt.readCharacteristic(characteristicL2CAP)) { + reportError("Error reading L2CAP characteristic.") + } } + } else { + reportError("Unexpected onDescriptorWrite: char $charUuid desc ${descriptor.uuid}.") } - } else { - callback.onError(Error("Unexpected onDescriptorWrite for characteristic UUID $charUuid " + - "and descriptor UUID ${descriptor.uuid}.")) } } catch (error: SecurityException) { callback.onError(error) } - } /** @@ -295,11 +312,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 +324,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,101 +350,316 @@ 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 + } - 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. + + try { + btAdapter?.cancelDiscovery() + } catch (e: SecurityException) { + reportLog("Unable to cancel discovery.") + } + + val connectThread: Thread = object : Thread() { + override fun run() { + try { + // createL2capChannel() requires/initiates pairing, so we have to use + // the "insecure" version. This requires at least API 29, which we did + // check elsewhere (we'd never have got this far on a lower API), but + // the linter isn't smart enough to know that, and we have PR merging + // gated on a clean bill of health from the linter... + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + l2capSocket = device.createInsecureL2capChannel(channelPSM) + l2capSocket?.connect() + } + } catch (e: IOException) { + reportError("Error connecting to L2CAP socket: ${e.message}") + + // Something went wrong. Fall back to the old flow, don't try L2CAP + // again for this run. + useL2CAP = UseL2CAP.No + enableNotification(gatt, characteristicServer2Client, "Server2Client") + + return + } catch (e: SecurityException) { + reportError("Not authorized to connect to L2CAP socket.") + return + } + + l2capWriteThread = Thread { writeResponse() } + l2capWriteThread!!.start() + + // Let the app know we're connected. + //reportPeerConnected() + + // Reuse this thread for reading + readRequest() + } + } + connectThread.start() + } + } + } + + else -> { + reportLog("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 - - if (usingL2CAP) { - callback.onLog("Using L2CAP: $usingL2CAP") + 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 + } - // value is returned async above in onCharacteristicRead() - if (!gatt.readCharacteristic(characteristicL2CAP)) { - callback.onError(Error("Error reading L2CAP characteristic.")) + 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.write(buf, 0, buf.count()) + + dprint("Currently have ${buf.count()} bytes.") + + // We are receiving this data over a stream socket and do not know how large the + // message is; there is no framing information provided, the only way we have to + // know whether we have the full message is whether any more data comes in after. + // To determine this, we take a timestamp, and schedule an event for half a second + // later; if nothing has come in the interim, we assume that to be the full + // message. + // + // Technically, we could also attempt to decode the message (it's CBOR-encoded) + // to see if it decodes properly. Unfortunately, this is potentially subject to + // false positives; CBOR has several primitives which have unbounded length. For + // messages unsing those primitives, the message length is inferred from the + // source data length, so if the (incomplete) message end happened to fall on a + // primitive boundary (which is quite likely if a higher MTU isn't negotiated) an + // incomplete message could "cleanly" decode. + + requestTimestamp = TimeSource.Monotonic.markNow() + + Executors.newSingleThreadScheduledExecutor() + .schedule({ + val curtime = TimeSource.Monotonic.markNow() + if ((curtime - requestTimestamp) > 500.milliseconds) { + val message = payload.toByteArray() + + reportLog("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}") + } - if (!gatt.setCharacteristicNotification(characteristicServer2Client, true)) { - callback.onError(Error("Error setting notification on Server2Client.")) + try { + // Workaround for L2CAP socket behaviour; attempting to close it too quickly can + // result in an error return from .close(), and then potentially leave the socket hanging + // open indefinitely if not caught. + 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) { + reportLog("Enabling notifications on ${name}") + + if (characteristic == null) { + reportError("Error setting notification on ${name}; is null.") + return + } + + try { + if (!gatt.setCharacteristicNotification(characteristic, true)) { + reportError("Error setting notification on ${name}; call failed.") return } - val descriptor: BluetoothGattDescriptor = - characteristicServer2Client!!.getDescriptor(clientCharacteristicConfigUuid) + 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) { - callback.onError(Error("Error writing to Server2Client. Code: $res")) + 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 - + @Suppress("deprecation") if (!gatt.writeDescriptor(descriptor)) { - callback.onError(Error("Error writing to Server2Client clientCharacteristicConfig: desc.")) + reportError("Error writing to ${name} clientCharacteristicConfig: desc.") return } } + } catch (e: SecurityException) { + reportError("Not authorized to enable notification on ${name}") + } + + // An onDescriptorWrite() call will come in for the pair of this characteristic and the client + // characteristic config UUID when notification setting is complete. + } + + /** + * Log stuff to the console without hitting the callback. + */ + private fun dprint(text: String) { + Log.d("[GattClient]", text) + } + + /** + * Log stuff both to the callback and to the console. + */ + private fun reportLog(text: String) { + Log.d("[GattClient]", "${text}") + + //callback.onLog(text) // Appears to mess with transfer timing, disabled for now. + } + + /** + * Log an error both to the callback and the console. + */ + private fun reportError(text: String) { + Log.e("[GattClient]", "ERROR: ${text}") + callback.onError(Error(text)) + } + + /** + * + */ + private fun afterIdentObtained(gatt: BluetoothGatt) { + try { + // Use L2CAP if supported by GattServer and by this OS version + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && characteristicL2CAP != null) { + if (useL2CAP == UseL2CAP.IfAvailable) { + useL2CAP = UseL2CAP.Yes + } + } else { + useL2CAP = UseL2CAP.No + } + + if (useL2CAP == UseL2CAP.Yes) { + enableNotification(gatt, characteristicL2CAP, "L2CAP") + + reportLog("Using L2CAP: $useL2CAP") + + //// value is returned async above in onCharacteristicRead() + + return + } + + enableNotification(gatt, characteristicServer2Client, "Server2Client") } catch (error: SecurityException) { callback.onError(error) @@ -438,7 +670,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 +678,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 +694,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,48 +725,49 @@ 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 (useL2CAP == UseL2CAP.Yes) { + 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() + } } /** * When using L2CAP it doesn't support characteristics notification. */ fun supportsTransportSpecificTerminationMessage(): Boolean { - return !usingL2CAP + return useL2CAP != UseL2CAP.Yes } /** @@ -547,7 +780,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 +792,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 +813,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 +830,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 +847,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/build.gradle.kts b/example/build.gradle.kts index 940f8b6..b485fda 100644 --- a/example/build.gradle.kts +++ b/example/build.gradle.kts @@ -12,8 +12,8 @@ android { applicationId = "com.spruceid.mobilesdkexample" minSdk = 26 targetSdk = 34 - versionCode = 3 - versionName = "1.0.0+3" + versionCode = 5 + versionName = "1.0.0+5" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/example/src/main/java/com/spruceid/mobilesdkexample/verifier/VerifierHomeView.kt b/example/src/main/java/com/spruceid/mobilesdkexample/verifier/VerifierHomeView.kt index 783fdfc..d644cb2 100644 --- a/example/src/main/java/com/spruceid/mobilesdkexample/verifier/VerifierHomeView.kt +++ b/example/src/main/java/com/spruceid/mobilesdkexample/verifier/VerifierHomeView.kt @@ -108,24 +108,24 @@ fun VerifierHomeBody( .fillMaxWidth() .verticalScroll(rememberScrollState()) ) { - VerifierListItem( - title = "Driver's License Document", - description = "Verifies physical driver's licenses issued by the state of Utopia", - binary = true, - fields = 0, - modifier = Modifier.clickable { - navController.navigate(Screen.VerifyDLScreen.route) - } - ) - VerifierListItem( - title = "Employment Authorization Document", - description = "Verifies physical Employment Authorization issued by the state of Utopia", - binary = true, - fields = 0, - modifier = Modifier.clickable { - navController.navigate(Screen.VerifyEAScreen.route) - } - ) +// VerifierListItem( +// title = "Driver's License Document", +// description = "Verifies physical driver's licenses issued by the state of Utopia", +// binary = true, +// fields = 0, +// modifier = Modifier.clickable { +// navController.navigate(Screen.VerifyDLScreen.route) +// } +// ) +// VerifierListItem( +// title = "Employment Authorization Document", +// description = "Verifies physical Employment Authorization issued by the state of Utopia", +// binary = true, +// fields = 0, +// modifier = Modifier.clickable { +// navController.navigate(Screen.VerifyEAScreen.route) +// } +// ) VerifierListItem( title = "Verifiable Credential", description = "Verifies a verifiable credential by reading the verifiable presentation QR code", @@ -161,7 +161,7 @@ fun VerifierListItem( color = TextHeader, modifier = Modifier.weight(2f) ) - VerifierListItemTag(binary = binary, fields = fields) +// VerifierListItemTag(binary = binary, fields = fields) Spacer(modifier = Modifier.weight(1f)) Image( painter = painterResource(id = R.drawable.arrow_right), 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 e01dc70..864678b 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/example/src/main/res/values/strings.xml b/example/src/main/res/values/strings.xml index a3db3b1..042ea17 100644 --- a/example/src/main/res/values/strings.xml +++ b/example/src/main/res/values/strings.xml @@ -1,5 +1,5 @@ - SpruceKit + SpruceKit Demo User profile Scan QR Code Valid diff --git a/makefile b/makefile new file mode 100644 index 0000000..3edd36c --- /dev/null +++ b/makefile @@ -0,0 +1,50 @@ +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 use $$1 as 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 + +# The help target here reads the makefile (MAKEFILE_LIST is the name of the file `make` ate), looks for +# lines matching commands, and pulls out the command name and anything following it containing a comment +# starting with #@. +.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 + +.phony: build +build: #@ Make the release build. + @$(GRADLE) assembleRelease + +.phony: lint +lint: #@ Lint the build. + @$(GRADLE) lintDebug + +.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)