package com.rtnbleserver import android.annotation.SuppressLint import android.bluetooth.BluetoothDevice import android.bluetooth.BluetoothGatt import android.bluetooth.BluetoothGattCallback import android.bluetooth.BluetoothGattCharacteristic import android.bluetooth.BluetoothGattDescriptor import android.bluetooth.BluetoothManager import android.bluetooth.BluetoothProfile import android.bluetooth.le.BluetoothLeScanner import android.bluetooth.le.ScanCallback import android.bluetooth.le.ScanFilter import android.bluetooth.le.ScanResult import android.bluetooth.le.ScanSettings import android.content.Context import android.os.Handler import android.os.Looper import android.os.ParcelUuid import android.util.Log import java.nio.charset.StandardCharsets import java.util.UUID import org.json.JSONObject /** * One-shot GATT client: scans for the service, authenticates with the OTP, sends * this device's DID, and reports the DID and timestamp sent back by the server. * It never opens a GATT server. */ @SuppressLint("MissingPermission") internal class RTNbleGattClient( private val context: Context, private val did: String, private val otp: String, private val onRecord: (serverDid: String, createdAt: String) -> Unit, private val onError: (message: String) -> Unit ) { private enum class State { IDLE, SCANNING, CONNECTING, DISCOVERING, ENABLING_DID, ENABLING_RECORD, WRITING_OTP, WRITING_DID, WAITING_RECORD } private val mainHandler = Handler(Looper.getMainLooper()) private val lock = Any() private val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager private var state = State.IDLE private var running = true private var scanner: BluetoothLeScanner? = null private var gatt: BluetoothGatt? = null private var otpCharacteristic: BluetoothGattCharacteristic? = null private var didCharacteristic: BluetoothGattCharacteristic? = null private var recordCharacteristic: BluetoothGattCharacteristic? = null private val didAssembler = ChunkAssembler() private val recordAssembler = ChunkAssembler() private var serverDid: String? = null private var recordCreatedAt: String? = null private var didFrames: List = emptyList() private var didFrameIndex = 0 val isRunning: Boolean get() = synchronized(lock) { running } fun start() { val adapter = manager?.adapter ?: throw IllegalStateException("Bluetooth is not supported on this device") if (!BluetoothPermissions.clientGranted(context)) { throw SecurityException("BLUETOOTH_SCAN and BLUETOOTH_CONNECT permissions are required") } if (!adapter.isEnabled) { throw IllegalStateException("Bluetooth is turned off") } val bleScanner = adapter.bluetoothLeScanner ?: throw IllegalStateException("Bluetooth LE scanning is not supported") scanner = bleScanner synchronized(lock) { state = State.SCANNING } bleScanner.startScan( listOf(ScanFilter.Builder().setServiceUuid(ParcelUuid(GattProfile.SERVICE_UUID)).build()), ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build(), scanCallback ) mainHandler.postDelayed(timeoutRunnable, TIMEOUT_MS) } fun stop() { synchronized(lock) { if (!running) { return } running = false state = State.IDLE } mainHandler.removeCallbacks(timeoutRunnable) stopTransport() } private fun stopTransport() { try { scanner?.stopScan(scanCallback) } catch (_: SecurityException) { // The adapter may already be off. } scanner = null val current = gatt gatt = null try { current?.disconnect() } catch (_: SecurityException) { // The adapter may already be off. } try { current?.close() } catch (_: SecurityException) { // The adapter may already be off. } } private fun fail(message: String) { synchronized(lock) { if (!running) { return } running = false state = State.IDLE } mainHandler.removeCallbacks(timeoutRunnable) stopTransport() Log.e(TAG, message) onError(message) } private fun complete() { val result = synchronized(lock) { if (!running) { return } val serverDID = serverDid ?: return val createdAt = recordCreatedAt ?: return running = false state = State.IDLE serverDID to createdAt } mainHandler.removeCallbacks(timeoutRunnable) stopTransport() onRecord(result.first, result.second) } private fun maybeComplete() { synchronized(lock) { if (!running || serverDid == null || recordCreatedAt == null) { return } } complete() } private val timeoutRunnable = Runnable { fail("Timed out waiting for the GATT server") } private val scanCallback = object : ScanCallback() { override fun onScanResult(callbackType: Int, result: ScanResult) { synchronized(lock) { if (!running || state != State.SCANNING) { return } state = State.CONNECTING } try { scanner?.stopScan(this) } catch (_: SecurityException) { // Ignore; the connection below reports the real failure. } val connection = try { result.device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE) } catch (error: SecurityException) { null } if (connection == null) { fail("Unable to connect to the GATT server") return } gatt = connection } override fun onScanFailed(errorCode: Int) { fail("BLE scan failed with error $errorCode") } } private val gattCallback = object : BluetoothGattCallback() { override fun onConnectionStateChange( gatt: BluetoothGatt, status: Int, newState: Int ) { if (newState == BluetoothProfile.STATE_CONNECTED) { mainHandler.post { gatt.discoverServices() } } else if (newState == BluetoothProfile.STATE_DISCONNECTED) { if (isRunning) { fail("Disconnected from the GATT server") } } } override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { if (status != BluetoothGatt.GATT_SUCCESS) { fail("Service discovery failed with status $status") return } val service = gatt.getService(GattProfile.SERVICE_UUID) ?: return fail("GATT service not found") val otp = service.getCharacteristic(GattProfile.OTP_UUID) ?: return fail("OTP characteristic not found") val didCharacteristic = service.getCharacteristic(GattProfile.DID_UUID) ?: return fail("DID characteristic not found") val recordCharacteristic = service.getCharacteristic(GattProfile.RECORD_UUID) ?: return fail("Record characteristic not found") otpCharacteristic = otp this@RTNbleGattClient.didCharacteristic = didCharacteristic this@RTNbleGattClient.recordCharacteristic = recordCharacteristic synchronized(lock) { state = State.DISCOVERING } enableNotifications(didCharacteristic, State.ENABLING_DID) } override fun onDescriptorWrite( gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int ) { if (status != BluetoothGatt.GATT_SUCCESS) { fail("Unable to enable notifications: status $status") return } when (synchronized(lock) { state }) { State.ENABLING_DID -> recordCharacteristic?.let { enableNotifications(it, State.ENABLING_RECORD) } ?: fail("Record characteristic not found") State.ENABLING_RECORD -> writeOTP() else -> Unit } } override fun onCharacteristicWrite( gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int ) { if (status != BluetoothGatt.GATT_SUCCESS) { fail("GATT write failed with status $status") return } when (characteristic.uuid) { GattProfile.OTP_UUID, GattProfile.DID_UUID -> writeNextDIDFrame() else -> Unit } } @Deprecated("Deprecated in API 33") override fun onCharacteristicChanged( gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic ) { handleNotification(characteristic.uuid, characteristic.value ?: ByteArray(0)) } override fun onCharacteristicChanged( gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray ) { handleNotification(characteristic.uuid, value) } } private fun enableNotifications( characteristic: BluetoothGattCharacteristic, nextState: State ) { val current = gatt ?: return fail("Connection lost") current.setCharacteristicNotification(characteristic, true) val descriptor = characteristic.getDescriptor(GattProfile.CCCD_UUID) ?: return fail("Notification descriptor not found") descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE synchronized(lock) { state = nextState } if (!current.writeDescriptor(descriptor)) { fail("Unable to enable notifications") } } private fun writeOTP() { val current = gatt ?: return fail("Connection lost") val characteristic = otpCharacteristic ?: return fail("OTP characteristic not found") characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT characteristic.value = otp.toByteArray(StandardCharsets.UTF_8) synchronized(lock) { state = State.WRITING_OTP } if (!current.writeCharacteristic(characteristic)) { fail("Unable to write the OTP") } } private fun writeNextDIDFrame() { val current = gatt ?: return fail("Connection lost") val characteristic = didCharacteristic ?: return fail("DID characteristic not found") if (didFrames.isEmpty()) { didFrames = buildChunkFrames(did.toByteArray(StandardCharsets.UTF_8)) } val index = synchronized(lock) { if (didFrameIndex >= didFrames.size) { state = State.WAITING_RECORD return } val currentIndex = didFrameIndex didFrameIndex += 1 currentIndex } characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT characteristic.value = didFrames[index] synchronized(lock) { state = State.WRITING_DID } if (!current.writeCharacteristic(characteristic)) { fail("Unable to write the DID") } } private fun handleNotification(uuid: UUID, value: ByteArray) { when (uuid) { GattProfile.DID_UUID -> { val complete = didAssembler.append(value) if (complete != null) { synchronized(lock) { serverDid = String(complete, StandardCharsets.UTF_8) } } maybeComplete() } GattProfile.RECORD_UUID -> { val complete = recordAssembler.append(value) if (complete != null) { parseCreatedAt(String(complete, StandardCharsets.UTF_8))?.let { createdAt -> synchronized(lock) { recordCreatedAt = createdAt } } } maybeComplete() } } } private fun parseCreatedAt(raw: String): String? { return try { val value = JSONObject(raw).optString("createdAt") if (value.isEmpty()) null else IsoDatetime.parse(value) } catch (_: Exception) { null } } companion object { private const val TAG = "RTNbleGattClient" private const val TIMEOUT_MS = 15_000L } }