diff --git a/android/app/src/main/java/nl/eddydevink/sylda/BleRelayManager.kt b/android/app/src/main/java/nl/eddydevink/sylda/BleRelayManager.kt index f74686d..1f35149 100644 --- a/android/app/src/main/java/nl/eddydevink/sylda/BleRelayManager.kt +++ b/android/app/src/main/java/nl/eddydevink/sylda/BleRelayManager.kt @@ -47,7 +47,13 @@ class BleRelayManager( private val VERSION_UUID: UUID = UUID.fromString("beb5483e-36e1-4688-b7f5-ea07361f26db") private val OTA_UUID: UUID = UUID.fromString("beb5483e-36e1-4688-b7f5-ea07361f26da") private val ECDH_UUID: UUID = UUID.fromString("beb5483e-36e1-4688-b7f5-ea07361f26dc") + private val CLIENT_REGISTRY_UUID: UUID = UUID.fromString("beb5483e-36e1-4688-b7f5-ea07361f26dd") + private val CLIENT_CONTROL_UUID: UUID = UUID.fromString("beb5483e-36e1-4688-b7f5-ea07361f26de") private val OTA_CCCD_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") + private const val BROKER_PLAIN_SIZE = 20 + private const val BROKER_PKT_SIZE = 4 + 8 + BROKER_PLAIN_SIZE + 16 + private const val BROKER_CMD_UPDATE_AVAILABLE: Byte = 0x10 + private const val BROKER_CMD_CLIENT_READY: Byte = 0x20 private const val SCAN_TIMEOUT_MS = 30000L private const val RECONNECT_DELAY_MS = 2000L @@ -83,6 +89,13 @@ class BleRelayManager( @Volatile private var ecdhDeferred: CompletableDeferred? = null private var ecdhNotifyBuf: ByteArray = ByteArray(0) private var ecdhCccdPending = false + private var registryCharacteristic: BluetoothGattCharacteristic? = null + private var controlCharacteristic: BluetoothGattCharacteristic? = null + private var brokerNotifyBuf: ByteArray = ByteArray(0) + private var brokerDeferred: CompletableDeferred? = null + private var clientUuid: ByteArray? = null + private var clientVersion: Int = 0 + private var clientStatus: Int = 0 interface OtaNotifyCallback { fun onOtaNotify(status: Int, seq: Int) @@ -279,6 +292,95 @@ class BleRelayManager( fun getNegotiatedMtu(): Int = negotiatedMtu + fun getClientUuid(): ByteArray? = clientUuid + fun getClientVersion(): Int = clientVersion + fun getClientStatus(): Int = clientStatus + + @SuppressLint("MissingPermission") + fun sendUpdateAvailable(targetUuid: ByteArray): Boolean { + val gatt = bluetoothGatt ?: return false + val key = aesKey ?: return false + val ctrlChar = controlCharacteristic ?: return false + + val plainPayload = ByteArray(BROKER_PLAIN_SIZE) + plainPayload[0] = BROKER_CMD_UPDATE_AVAILABLE + System.arraycopy(targetUuid, 0, plainPayload, 1, targetUuid.size) + + val pkt = buildBrokerPacket(plainPayload, key) + + ctrlChar.value = pkt + ctrlChar.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE + return gatt.writeCharacteristic(ctrlChar) + } + + @SuppressLint("MissingPermission") + fun subscribeToBrokerNotifications(): Boolean { + val gatt = bluetoothGatt ?: return false + val ctrlChar = controlCharacteristic ?: return false + + if (!gatt.setCharacteristicNotification(ctrlChar, true)) { + Log.e(TAG, "Broker: setCharacteristicNotification returned false") + return false + } + val desc = ctrlChar.getDescriptor(OTA_CCCD_UUID) + if (desc != null) { + desc.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE + if (!gatt.writeDescriptor(desc)) { + Log.w(TAG, "Broker: writeDescriptor for CCCD returned false") + } + } + return true + } + + private fun buildBrokerPacket(plainPayload: ByteArray, key: ByteArray): ByteArray { + val seqBytes = ByteBuffer.allocate(4).order(ByteOrder.BIG_ENDIAN).putInt(seq).array() + val nonceRandom = ByteArray(8) + SecureRandom().nextBytes(nonceRandom) + + val nonce = ByteArray(GCM_NONCE_LEN) + System.arraycopy(seqBytes, 0, nonce, 0, 4) + System.arraycopy(nonceRandom, 0, nonce, 4, 8) + + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, + SecretKeySpec(key, "AES"), + GCMParameterSpec(GCM_TAG_LEN * 8, nonce)) + + val cipherOutput = cipher.doFinal(plainPayload) + + val packet = ByteArray(BROKER_PKT_SIZE) + System.arraycopy(seqBytes, 0, packet, 0, 4) + System.arraycopy(nonceRandom, 0, packet, 4, 8) + System.arraycopy(cipherOutput, 0, packet, 12, cipherOutput.size) + return packet + } + + private fun decryptBrokerPacket(data: ByteArray, key: ByteArray): ByteArray? { + if (data.size != BROKER_PKT_SIZE) return null + + val nonce = ByteArray(GCM_NONCE_LEN) + System.arraycopy(data, 0, nonce, 0, 4) + System.arraycopy(data, 4, nonce, 4, 8) + + val ciphertext = ByteArray(BROKER_PLAIN_SIZE) + System.arraycopy(data, 12, ciphertext, 0, BROKER_PLAIN_SIZE) + + val tag = ByteArray(GCM_TAG_LEN) + System.arraycopy(data, 12 + BROKER_PLAIN_SIZE, tag, 0, GCM_TAG_LEN) + + return try { + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + val gcmSpec = GCMParameterSpec(GCM_TAG_LEN * 8, nonce) + cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), gcmSpec) + cipher.update(ciphertext) + val result = cipher.doFinal(tag) + result + } catch (e: Exception) { + Log.e(TAG, "Broker: decrypt failed", e) + null + } + } + @SuppressLint("MissingPermission") private fun connectToDevice(device: android.bluetooth.BluetoothDevice) { handler.post { callbacks.onStatusChanged(BleStatus.Connecting) } @@ -336,11 +438,16 @@ class BleRelayManager( relayCharacteristic = service.getCharacteristic(CHARACTERISTIC_UUID) otaCharacteristic = service.getCharacteristic(OTA_UUID) ecdhCharacteristic = service.getCharacteristic(ECDH_UUID) + registryCharacteristic = service.getCharacteristic(CLIENT_REGISTRY_UUID) + controlCharacteristic = service.getCharacteristic(CLIENT_CONTROL_UUID) val uuidChar = service.getCharacteristic(UUID_CHAR_UUID) val versionChar = service.getCharacteristic(VERSION_UUID) if (relayCharacteristic != null && uuidChar != null) { Log.d(TAG, "Reading device UUID characteristic...") gatt.readCharacteristic(uuidChar) + if (controlCharacteristic != null) { + subscribeToBrokerNotifications() + } } else { Log.e(TAG, "Service or characteristics not found") handler.post { callbacks.onStatusChanged(BleStatus.ServiceNotFound) } @@ -407,6 +514,23 @@ class BleRelayManager( ecdhNotifyBuf = value.copyOf() ecdhDeferred?.complete(true) Log.d(TAG, "ECDH notify received (${value.size} bytes)") + } else if (characteristic.uuid == CLIENT_CONTROL_UUID) { + Log.d(TAG, "Broker: control notification received (${value.size} bytes)") + val key = aesKey + if (key != null) { + val plainPayload = decryptBrokerPacket(value, key) + if (plainPayload != null && plainPayload.size >= BROKER_PLAIN_SIZE) { + val cmd = plainPayload[0].toInt() and 0xFF + Log.d(TAG, "Broker: decrypted cmd=0x${cmd.toString(16)}") + if (cmd == BROKER_CMD_CLIENT_READY.toInt()) { + val uuid = plainPayload.copyOfRange(1, 1 + 16) + clientUuid = uuid + clientStatus = 1 + Log.d(TAG, "Broker: client ready for OTA, uuid=${uuid.joinToString("") { "%02X".format(it) }}") + handler.post { callbacks.onClientReady(uuid) } + } + } + } } } diff --git a/android/app/src/main/java/nl/eddydevink/sylda/BleStatus.kt b/android/app/src/main/java/nl/eddydevink/sylda/BleStatus.kt index ee44abd..5d3dd95 100644 --- a/android/app/src/main/java/nl/eddydevink/sylda/BleStatus.kt +++ b/android/app/src/main/java/nl/eddydevink/sylda/BleStatus.kt @@ -19,4 +19,5 @@ enum class BleStatus { interface BleCallbacks { fun onStatusChanged(status: BleStatus) fun onFirmwareVersion(espVersion: String, appVersion: String) + fun onClientReady(uuid: ByteArray) } diff --git a/android/app/src/main/java/nl/eddydevink/sylda/MainActivity.kt b/android/app/src/main/java/nl/eddydevink/sylda/MainActivity.kt index e18839c..f1181e7 100644 --- a/android/app/src/main/java/nl/eddydevink/sylda/MainActivity.kt +++ b/android/app/src/main/java/nl/eddydevink/sylda/MainActivity.kt @@ -226,4 +226,14 @@ class MainActivity : AppCompatActivity(), BleCallbacks { } } } + + override fun onClientReady(uuid: ByteArray) { + runOnUiThread { + if (isFinishing || isDestroyed || !lifecycle.currentState.isAtLeast(androidx.lifecycle.Lifecycle.State.RESUMED)) return@runOnUiThread + Toast.makeText(this, "Client ready for OTA update", Toast.LENGTH_SHORT).show() + val intent = Intent(this, SettingsActivity::class.java) + intent.putExtra("client_ota_uuid", uuid) + settingsLauncher.launch(intent) + } + } }