From 6470bef3a9a092e0451aa43c8334d9b63d2f6d7d Mon Sep 17 00:00:00 2001 From: tuuhin Date: Sat, 11 Oct 2025 22:46:54 +0530 Subject: [PATCH 01/11] Preparing the BLE server AndroidBLEClientConnector.kt opens the gatt server, configure the services and starts advertising BLEServerUUID.kt contains the server services, characteristics uuid BLEConstructorDSL.kt provides a simple way to configure a service, characteristics or description via the domain based models BLEServerGattCallback.kt manages the incomming connection read/ write execution its incomplete yet after the nus is ready it can be tested! --- .../ble_server/AndroidBLEServerConnector.kt | 101 ++++++ .../data/ble_server/BLEConstructorDSL.kt | 104 +++++++ .../data/ble_server/BLEMessageModel.kt | 6 + .../data/ble_server/BLEServerGattCallback.kt | 293 ++++++++++++++++++ .../data/ble_server/BLEServerServices.kt | 85 +++++ .../data/ble_server/BLEServerUUID.kt | 24 ++ .../domain/bluetooth_le/BLEServerConnector.kt | 16 + 7 files changed, 629 insertions(+) create mode 100644 app/src/main/java/com/eva/bluetoothterminalapp/data/ble_server/AndroidBLEServerConnector.kt create mode 100644 app/src/main/java/com/eva/bluetoothterminalapp/data/ble_server/BLEConstructorDSL.kt create mode 100644 app/src/main/java/com/eva/bluetoothterminalapp/data/ble_server/BLEMessageModel.kt create mode 100644 app/src/main/java/com/eva/bluetoothterminalapp/data/ble_server/BLEServerGattCallback.kt create mode 100644 app/src/main/java/com/eva/bluetoothterminalapp/data/ble_server/BLEServerServices.kt create mode 100644 app/src/main/java/com/eva/bluetoothterminalapp/data/ble_server/BLEServerUUID.kt create mode 100644 app/src/main/java/com/eva/bluetoothterminalapp/domain/bluetooth_le/BLEServerConnector.kt diff --git a/app/src/main/java/com/eva/bluetoothterminalapp/data/ble_server/AndroidBLEServerConnector.kt b/app/src/main/java/com/eva/bluetoothterminalapp/data/ble_server/AndroidBLEServerConnector.kt new file mode 100644 index 0000000..12231f3 --- /dev/null +++ b/app/src/main/java/com/eva/bluetoothterminalapp/data/ble_server/AndroidBLEServerConnector.kt @@ -0,0 +1,101 @@ +package com.eva.bluetoothterminalapp.data.ble_server + +import android.annotation.SuppressLint +import android.bluetooth.BluetoothGattServer +import android.bluetooth.BluetoothManager +import android.bluetooth.BluetoothStatusCodes +import android.bluetooth.le.AdvertiseCallback +import android.bluetooth.le.AdvertiseData +import android.bluetooth.le.AdvertiseSettings +import android.content.Context +import android.os.Build +import android.util.Log +import androidx.core.content.getSystemService +import com.eva.bluetoothterminalapp.data.utils.hasBTConnectPermission +import com.eva.bluetoothterminalapp.domain.bluetooth.models.BluetoothDeviceModel +import com.eva.bluetoothterminalapp.domain.bluetooth_le.BLEServerConnector +import com.eva.bluetoothterminalapp.domain.bluetooth_le.models.BLEServiceModel +import kotlinx.coroutines.flow.Flow + +private const val TAG = "BLE_SERVER" + +@SuppressLint("MissingPermission") +class AndroidBLEServerConnector( + private val context: Context, +) : BLEServerConnector { + + private val _bluetoothManager by lazy { context.getSystemService() } + private val _callback by lazy { BLEServerGattCallback() } + + private var _bleServer: BluetoothGattServer? = null + + override val connectedDevices: Flow> + get() = _callback.connectedDevices + + override val services: Flow> + get() = _callback.services + + private val _advertiseCallback = object : AdvertiseCallback() { + override fun onStartSuccess(settingsInEffect: AdvertiseSettings?) { + super.onStartSuccess(settingsInEffect) + Log.d(TAG, "ADVERTISEMENT STARTED!!") + } + + override fun onStartFailure(errorCode: Int) { + super.onStartFailure(errorCode) + Log.d(TAG, "FAILED TO START ADVERTISEMENT ERROR_CODE:$errorCode") + } + } + + @Suppress("DEPRECATION") + override suspend fun onStartServer() { + if (!context.hasBTConnectPermission) return + if (_bluetoothManager?.adapter?.isEnabled != true) return + if (_bluetoothManager?.adapter?.isMultipleAdvertisementSupported == false) return + + val advertiser = _bluetoothManager?.adapter?.bluetoothLeAdvertiser ?: return + + _bleServer = _bluetoothManager?.openGattServer(context, _callback) + + val server = _bleServer ?: return + // on response callback + _callback.setOnSendResponse(server::sendResponse) + _callback.setNotifyCharacteristicsChanged { device, characteristics, confirm, byteArray -> + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) + server.notifyCharacteristicChanged(device, characteristics, confirm, byteArray) == + BluetoothStatusCodes.SUCCESS + else server.notifyCharacteristicChanged(device, characteristics, confirm) + } + + // required services + server.addService(bleDeviceInfoService) + server.addService(echoService) + server.addService(nordicUARTService) + + // advertisement settings + val settingsBuilder = AdvertiseSettings.Builder() + .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_BALANCED) + .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_MEDIUM) + .setConnectable(true) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) + settingsBuilder.setDiscoverable(true) + + val advertiseSettings = settingsBuilder.build() + + // what to advertise + val advertiseData = AdvertiseData.Builder() + .setIncludeDeviceName(true) + .build() + + // start advertising + advertiser.startAdvertising(advertiseSettings, advertiseData, _advertiseCallback) + } + + + override suspend fun onStopServer() { + _callback.onCleanUp() + _bleServer?.close() + _bleServer = null + } +} \ No newline at end of file diff --git a/app/src/main/java/com/eva/bluetoothterminalapp/data/ble_server/BLEConstructorDSL.kt b/app/src/main/java/com/eva/bluetoothterminalapp/data/ble_server/BLEConstructorDSL.kt new file mode 100644 index 0000000..564dd5b --- /dev/null +++ b/app/src/main/java/com/eva/bluetoothterminalapp/data/ble_server/BLEConstructorDSL.kt @@ -0,0 +1,104 @@ +package com.eva.bluetoothterminalapp.data.ble_server + +import android.bluetooth.BluetoothGattCharacteristic +import android.bluetooth.BluetoothGattDescriptor +import android.bluetooth.BluetoothGattService +import com.eva.bluetoothterminalapp.domain.bluetooth_le.enums.BLEPermission +import com.eva.bluetoothterminalapp.domain.bluetooth_le.enums.BLEPropertyTypes +import com.eva.bluetoothterminalapp.domain.bluetooth_le.enums.BLEServicesTypes +import java.util.UUID + +fun bleServiceOf(uuid: UUID, serviceType: BLEServicesTypes): BluetoothGattService = + BluetoothGattService( + uuid, + when (serviceType) { + BLEServicesTypes.PRIMARY -> BluetoothGattService.SERVICE_TYPE_PRIMARY + BLEServicesTypes.SECONDARY -> BluetoothGattService.SERVICE_TYPE_SECONDARY + BLEServicesTypes.UNKNOWN -> throw Exception("Unknown service type not allowed") + } + ) + +fun bleCharacteristicsOf( + uuid: UUID, + properties: List, + permissions: List, +): BluetoothGattCharacteristic { + + require(properties.isNotEmpty() || properties.contains(BLEPropertyTypes.UNKNOWN)) { + "Required at-least a single property and the use of property unknown cannot be used" + } + + require(permissions.isNotEmpty() || permissions.contains(BLEPermission.PERMISSION_UNKNOWN)) { + "Required at-least a single permission associated with the properties" + } + + var gattProperty = 0 + var gattPermission = 0 + properties.filterNot { it == BLEPropertyTypes.UNKNOWN }.forEach { type -> + gattProperty = when (type) { + BLEPropertyTypes.PROPERTY_BROADCAST -> gattProperty or BluetoothGattCharacteristic.PROPERTY_BROADCAST + BLEPropertyTypes.PROPERTY_READ -> gattProperty or BluetoothGattCharacteristic.PROPERTY_READ + BLEPropertyTypes.PROPERTY_WRITE_NO_RESPONSE -> gattProperty or BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE + BLEPropertyTypes.PROPERTY_WRITE -> gattProperty or BluetoothGattCharacteristic.PROPERTY_WRITE + BLEPropertyTypes.PROPERTY_NOTIFY -> gattProperty or BluetoothGattCharacteristic.PROPERTY_NOTIFY + BLEPropertyTypes.PROPERTY_INDICATE -> gattProperty or BluetoothGattCharacteristic.PROPERTY_INDICATE + BLEPropertyTypes.PROPERTY_SIGNED_WRITE -> gattProperty or BluetoothGattCharacteristic.PROPERTY_SIGNED_WRITE + BLEPropertyTypes.PROPERTY_EXTENDED_PROPS -> gattProperty or BluetoothGattCharacteristic.PROPERTY_EXTENDED_PROPS + BLEPropertyTypes.UNKNOWN -> gattProperty + } + } + + permissions.filterNot { it == BLEPermission.PERMISSION_UNKNOWN }.forEach { perms -> + gattPermission = when (perms) { + BLEPermission.PERMISSION_READ -> gattPermission or BluetoothGattCharacteristic.PERMISSION_READ + BLEPermission.PERMISSION_READ_ENCRYPTED -> gattPermission or BluetoothGattCharacteristic.PERMISSION_READ_ENCRYPTED + BLEPermission.PERMISSION_READ_ENCRYPTED_MITM -> gattPermission or BluetoothGattCharacteristic.PERMISSION_READ_ENCRYPTED_MITM + BLEPermission.PERMISSION_WRITE -> gattPermission or BluetoothGattCharacteristic.PERMISSION_WRITE + BLEPermission.PERMISSION_WRITE_ENCRYPTED -> gattPermission or BluetoothGattCharacteristic.PERMISSION_WRITE_ENCRYPTED + BLEPermission.PERMISSION_WRITE_ENCRYPTED_MITM -> gattPermission or BluetoothGattCharacteristic.PERMISSION_WRITE_ENCRYPTED_MITM + BLEPermission.PERMISSION_WRITE_SIGNED -> gattPermission or BluetoothGattCharacteristic.PERMISSION_WRITE_SIGNED + BLEPermission.PERMISSION_WRITE_SIGNED_MITM -> gattPermission or BluetoothGattCharacteristic.PERMISSION_WRITE_SIGNED_MITM + BLEPermission.PERMISSION_UNKNOWN -> gattPermission + } + } + + val writeProperties = listOf( + BLEPropertyTypes.PROPERTY_WRITE, BLEPropertyTypes.PROPERTY_SIGNED_WRITE, + BLEPropertyTypes.PROPERTY_WRITE_NO_RESPONSE + ) + + return BluetoothGattCharacteristic(uuid, gattProperty, gattPermission).apply { + if (properties.containsAll(writeProperties)) + writeType = when { + BLEPropertyTypes.PROPERTY_WRITE in properties -> BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT + BLEPropertyTypes.PROPERTY_WRITE_NO_RESPONSE in properties -> BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE + BLEPropertyTypes.PROPERTY_SIGNED_WRITE in properties -> BluetoothGattCharacteristic.WRITE_TYPE_SIGNED + else -> BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT + } + } +} + +fun bleDescriptorOf(uuid: UUID, permissions: List): BluetoothGattDescriptor { + + require(permissions.isNotEmpty() || permissions.contains(BLEPermission.PERMISSION_UNKNOWN)) { + "Required at-least a single permission associated with the properties" + } + + var gattPermission = 0 + + permissions.filterNot { it == BLEPermission.PERMISSION_UNKNOWN }.forEach { perms -> + gattPermission = when (perms) { + BLEPermission.PERMISSION_READ -> gattPermission or BluetoothGattCharacteristic.PERMISSION_READ + BLEPermission.PERMISSION_READ_ENCRYPTED -> gattPermission or BluetoothGattCharacteristic.PERMISSION_READ_ENCRYPTED + BLEPermission.PERMISSION_READ_ENCRYPTED_MITM -> gattPermission or BluetoothGattCharacteristic.PERMISSION_READ_ENCRYPTED_MITM + BLEPermission.PERMISSION_WRITE -> gattPermission or BluetoothGattCharacteristic.PERMISSION_WRITE + BLEPermission.PERMISSION_WRITE_ENCRYPTED -> gattPermission or BluetoothGattCharacteristic.PERMISSION_WRITE_ENCRYPTED + BLEPermission.PERMISSION_WRITE_ENCRYPTED_MITM -> gattPermission or BluetoothGattCharacteristic.PERMISSION_WRITE_ENCRYPTED_MITM + BLEPermission.PERMISSION_WRITE_SIGNED -> gattPermission or BluetoothGattCharacteristic.PERMISSION_WRITE_SIGNED + BLEPermission.PERMISSION_WRITE_SIGNED_MITM -> gattPermission or BluetoothGattCharacteristic.PERMISSION_WRITE_SIGNED_MITM + BLEPermission.PERMISSION_UNKNOWN -> gattPermission + } + } + + return BluetoothGattDescriptor(uuid, gattPermission) +} \ No newline at end of file diff --git a/app/src/main/java/com/eva/bluetoothterminalapp/data/ble_server/BLEMessageModel.kt b/app/src/main/java/com/eva/bluetoothterminalapp/data/ble_server/BLEMessageModel.kt new file mode 100644 index 0000000..465f840 --- /dev/null +++ b/app/src/main/java/com/eva/bluetoothterminalapp/data/ble_server/BLEMessageModel.kt @@ -0,0 +1,6 @@ +package com.eva.bluetoothterminalapp.data.ble_server + +data class BLEMessageModel( + val message: String = "", + val isNotifyEnabled: Boolean = false, +) \ No newline at end of file diff --git a/app/src/main/java/com/eva/bluetoothterminalapp/data/ble_server/BLEServerGattCallback.kt b/app/src/main/java/com/eva/bluetoothterminalapp/data/ble_server/BLEServerGattCallback.kt new file mode 100644 index 0000000..37c1978 --- /dev/null +++ b/app/src/main/java/com/eva/bluetoothterminalapp/data/ble_server/BLEServerGattCallback.kt @@ -0,0 +1,293 @@ +package com.eva.bluetoothterminalapp.data.ble_server + +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothGatt +import android.bluetooth.BluetoothGattCharacteristic +import android.bluetooth.BluetoothGattDescriptor +import android.bluetooth.BluetoothGattServerCallback +import android.bluetooth.BluetoothGattService +import android.bluetooth.BluetoothProfile +import android.os.Build +import android.util.Log +import com.eva.bluetoothterminalapp.data.mapper.toDomainModel +import com.eva.bluetoothterminalapp.domain.bluetooth.models.BluetoothDeviceModel +import com.eva.bluetoothterminalapp.domain.bluetooth_le.models.BLEServiceModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import java.util.concurrent.ConcurrentHashMap + +private typealias SendResponse = (device: BluetoothDevice, requestId: Int, status: Int, offset: Int, value: ByteArray?) -> Unit +private typealias NotifyCharacteristicsChanged = (device: BluetoothDevice, characteristics: BluetoothGattCharacteristic, confirm: Boolean, value: ByteArray) -> Boolean + +private const val TAG = "BLE_SERVER_CALLBACK" + +class BLEServerGattCallback : BluetoothGattServerCallback() { + + private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + + private val _connectedDevices = MutableStateFlow>(emptyList()) + val connectedDevices = _connectedDevices.asStateFlow() + + private val _services = MutableStateFlow>(emptyList()) + val services = _services.asStateFlow() + + // key : device address and value : BLEMessageModel + private val _echoValuesMap = ConcurrentHashMap() + private val _nusValuesMap = ConcurrentHashMap() + + private var _sendResponse: SendResponse? = null + private var _notifyCharacteristicsChanged: NotifyCharacteristicsChanged? = null + + fun setOnSendResponse(callback: SendResponse) { + _sendResponse = callback + } + + fun setNotifyCharacteristicsChanged(callback: NotifyCharacteristicsChanged) { + _notifyCharacteristicsChanged = callback + } + + override fun onConnectionStateChange(device: BluetoothDevice?, status: Int, newState: Int) { + if (status != BluetoothGatt.GATT_SUCCESS) { + Log.e(TAG, "CONNECTION WITH SOME ERROR: $status") + return + } + Log.d(TAG, "CLIENT ADDRESS:${device?.address} CONNECTION: $newState") + val domainDevice = device?.toDomainModel() ?: return + + when (newState) { + BluetoothProfile.STATE_CONNECTED -> _connectedDevices.update { devices -> + val newList = devices + domainDevice + newList.distinctBy { it.address } + } + + BluetoothProfile.STATE_DISCONNECTED -> { + _connectedDevices.update { devices -> + val filteredList = devices.filterNot { it.address == domainDevice.address } + filteredList.distinctBy { it.address } + } + // remove the device + _echoValuesMap.remove(device.address) + } + + else -> {} + } + + } + + override fun onServiceAdded(status: Int, service: BluetoothGattService?) { + if (status != BluetoothGatt.GATT_SUCCESS) { + Log.e(TAG, "SOME ERROR IN ADDING THE SERVICE: $status") + return + } + if (service == null) return + val domainService = service.toDomainModel() + _services.update { previous -> (previous + domainService).distinctBy { it.serviceId } } + } + + override fun onCharacteristicReadRequest( + device: BluetoothDevice?, + requestId: Int, + offset: Int, + characteristic: BluetoothGattCharacteristic? + ) { + if (device == null || characteristic == null) return + Log.e(TAG, "READ REQUESTED FOR CHARACTERISTICS :${characteristic.uuid}") + val charset = Charsets.UTF_8 + + when (characteristic.service.uuid) { + BLEServerUUID.DEVICE_INFO_SERVICE -> { + val value = when (characteristic.uuid) { + BLEServerUUID.MANUFACTURER_NAME -> Build.MANUFACTURER.toByteArray(charset) + BLEServerUUID.MODEL_NUMBER -> Build.MODEL.toByteArray(charset) + BLEServerUUID.SOFTWARE_REVISION -> byteArrayOf(Build.VERSION.SDK_INT.toByte()) + BLEServerUUID.HARDWARE_REVISION -> Build.HARDWARE.toByteArray(charset) + else -> null + } + _sendResponse?.invoke(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, value) + } + + BLEServerUUID.ECHO_SERVICE -> { + val savedValue = _echoValuesMap[device.address]?.message ?: "null" + val value = savedValue.toByteArray(charset) + _sendResponse?.invoke(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, value) + } + + else -> { + _sendResponse?.invoke(device, requestId, BluetoothGatt.GATT_FAILURE, offset, null) + } + } + } + + override fun onCharacteristicWriteRequest( + device: BluetoothDevice?, + requestId: Int, + characteristic: BluetoothGattCharacteristic?, + preparedWrite: Boolean, + responseNeeded: Boolean, + offset: Int, + value: ByteArray? + ) { + if (device == null || characteristic == null) return + Log.e(TAG, "WRITE REQUESTED FOR CHARACTERISTICS :${characteristic.uuid}") + + if (value == null) { + Log.d(TAG, "WRITE REQUEST WITH EMPTY VALUE") + if (responseNeeded) { + _sendResponse?.invoke(device, requestId, BluetoothGatt.GATT_FAILURE, offset, null) + } + return + } + + val charset = Charsets.UTF_8 + + when (characteristic.service.uuid) { + BLEServerUUID.ECHO_SERVICE -> { + if (characteristic.uuid == BLEServerUUID.ECHO_CHARACTERISTIC) { + val current = _echoValuesMap[device.address] + _echoValuesMap[device.address] = BLEMessageModel( + message = value.toString(charset), + isNotifyEnabled = current?.isNotifyEnabled ?: false + ) + + // check if notify enabled + if (_echoValuesMap[device.address]?.isNotifyEnabled == true) { + // we are always working with notification not indication thus no client validation + _notifyCharacteristicsChanged?.invoke(device, characteristic, false, value) + } + if (!responseNeeded) return + _sendResponse + ?.invoke(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, value) + } else { + if (!responseNeeded) return + // empty value with failure + _sendResponse + ?.invoke(device, requestId, BluetoothGatt.GATT_FAILURE, offset, null) + } + } + + BLEServerUUID.NORDIC_UART_SERVICE -> { + when (characteristic.uuid) { + BLEServerUUID.NUS_RX_CHARACTERISTIC -> {} + BLEServerUUID.NUS_TX_CHARACTERISTIC -> {} + } + } + + else -> { + if (!responseNeeded) return + // empty value with failure + _sendResponse?.invoke(device, requestId, BluetoothGatt.GATT_FAILURE, offset, null) + } + } + } + + override fun onDescriptorReadRequest( + device: BluetoothDevice?, + requestId: Int, + offset: Int, + descriptor: BluetoothGattDescriptor? + ) { + if (device == null || descriptor == null) return + Log.d( + TAG, + "DESCRIPTOR READ REQUEST ${descriptor.uuid} CHARACTERISTIC : ${descriptor.characteristic.uuid}" + ) + + when (descriptor.characteristic.uuid) { + BLEServerUUID.ECHO_CHARACTERISTIC -> { + if (descriptor.uuid == BLEServerUUID.ECHO_DESCRIPTOR) { + + val isNotifyEnabled = _echoValuesMap[device.address]?.isNotifyEnabled ?: false + val value = if (isNotifyEnabled) + BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE + else BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE + + _sendResponse + ?.invoke(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, value) + } else { + _sendResponse + ?.invoke(device, requestId, BluetoothGatt.GATT_FAILURE, offset, null) + } + } + + else -> { + _sendResponse?.invoke(device, requestId, BluetoothGatt.GATT_FAILURE, offset, null) + } + } + + } + + override fun onDescriptorWriteRequest( + device: BluetoothDevice?, + requestId: Int, + descriptor: BluetoothGattDescriptor?, + preparedWrite: Boolean, + responseNeeded: Boolean, + offset: Int, + value: ByteArray? + ) { + if (device == null || descriptor == null) return + + Log.d( + TAG, + "DESCRIPTOR WRITE REQUEST ${descriptor.uuid} CHARACTERS : ${descriptor.characteristic.uuid}" + ) + + if (value == null) { + Log.d(TAG, "WRITE REQUEST WITH EMPTY VALUE") + if (responseNeeded) { + _sendResponse?.invoke(device, requestId, BluetoothGatt.GATT_FAILURE, offset, null) + } + return + } + + when (descriptor.characteristic.uuid) { + BLEServerUUID.ECHO_CHARACTERISTIC -> { + if (descriptor.uuid == BLEServerUUID.ECHO_DESCRIPTOR) { + val isNotifyEnabled = when { + value.contentEquals(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE) -> true + value.contentEquals(BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE) -> false + else -> { + Log.w(TAG, "INVALID DESCRIPTOR VALUE ") + if (!responseNeeded) return + _sendResponse?.invoke( + device, requestId, BluetoothGatt.GATT_FAILURE, offset, null + ) + return + } + } + // find the device and update it + val currentMessage = _echoValuesMap.get(device.address)?.message ?: "null" + _echoValuesMap[device.address] = BLEMessageModel( + message = currentMessage, + isNotifyEnabled = isNotifyEnabled + ) + + if (!responseNeeded) return + _sendResponse + ?.invoke(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, value) + } else { + // invalid descriptor uuid + if (!responseNeeded) return + _sendResponse + ?.invoke(device, requestId, BluetoothGatt.GATT_FAILURE, offset, null) + } + } + + else -> { + // no matching characteristics found + if (!responseNeeded) return + _sendResponse?.invoke(device, requestId, BluetoothGatt.GATT_FAILURE, offset, null) + } + } + } + + fun onCleanUp() { + _echoValuesMap.clear() + scope.cancel() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/eva/bluetoothterminalapp/data/ble_server/BLEServerServices.kt b/app/src/main/java/com/eva/bluetoothterminalapp/data/ble_server/BLEServerServices.kt new file mode 100644 index 0000000..23bd8e5 --- /dev/null +++ b/app/src/main/java/com/eva/bluetoothterminalapp/data/ble_server/BLEServerServices.kt @@ -0,0 +1,85 @@ +package com.eva.bluetoothterminalapp.data.ble_server + +import com.eva.bluetoothterminalapp.domain.bluetooth_le.enums.BLEPermission +import com.eva.bluetoothterminalapp.domain.bluetooth_le.enums.BLEPropertyTypes +import com.eva.bluetoothterminalapp.domain.bluetooth_le.enums.BLEServicesTypes + +val bleDeviceInfoService = bleServiceOf( + uuid = BLEServerUUID.DEVICE_INFO_SERVICE, + serviceType = BLEServicesTypes.PRIMARY, +).apply { + listOf( + BLEServerUUID.MANUFACTURER_NAME, + BLEServerUUID.MODEL_NUMBER, + BLEServerUUID.HARDWARE_REVISION, + BLEServerUUID.SOFTWARE_REVISION, + ).forEach { uid -> + val char = bleCharacteristicsOf( + uuid = uid, + properties = listOf(BLEPropertyTypes.PROPERTY_READ), + permissions = listOf(BLEPermission.PERMISSION_READ) + ) + addCharacteristic(char) + } +} + +val echoService = bleServiceOf( + uuid = BLEServerUUID.ECHO_SERVICE, + serviceType = BLEServicesTypes.SECONDARY +).apply { + val characteristic = bleCharacteristicsOf( + uuid = BLEServerUUID.ECHO_CHARACTERISTIC, + properties = listOf( + BLEPropertyTypes.PROPERTY_READ, + BLEPropertyTypes.PROPERTY_WRITE, + BLEPropertyTypes.PROPERTY_NOTIFY + ), + permissions = listOf(BLEPermission.PERMISSION_READ, BLEPermission.PERMISSION_WRITE) + ).apply { + val descriptor = bleDescriptorOf( + uuid = BLEServerUUID.ECHO_DESCRIPTOR, + permissions = listOf(BLEPermission.PERMISSION_READ, BLEPermission.PERMISSION_WRITE) + ) + addDescriptor(descriptor) + } + addCharacteristic(characteristic) +} + +val nordicUARTService = bleServiceOf( + uuid = BLEServerUUID.NORDIC_UART_SERVICE, + serviceType = BLEServicesTypes.PRIMARY +).apply { + val rxCharacteristic = bleCharacteristicsOf( + uuid = BLEServerUUID.NUS_RX_CHARACTERISTIC, properties = listOf( + BLEPropertyTypes.PROPERTY_READ, + BLEPropertyTypes.PROPERTY_WRITE, + BLEPropertyTypes.PROPERTY_NOTIFY + ), + permissions = listOf(BLEPermission.PERMISSION_READ, BLEPermission.PERMISSION_WRITE) + ).apply { + val descriptor = bleDescriptorOf( + uuid = BLEServerUUID.NUS_DESCRIPTOR, + permissions = listOf(BLEPermission.PERMISSION_READ, BLEPermission.PERMISSION_WRITE) + ) + addDescriptor(descriptor) + } + + val txCharacteristic = bleCharacteristicsOf( + uuid = BLEServerUUID.NUS_TX_CHARACTERISTIC, + properties = listOf( + BLEPropertyTypes.PROPERTY_READ, + BLEPropertyTypes.PROPERTY_WRITE, + BLEPropertyTypes.PROPERTY_NOTIFY + ), + permissions = listOf(BLEPermission.PERMISSION_READ, BLEPermission.PERMISSION_WRITE) + ).apply { + val descriptor = bleDescriptorOf( + uuid = BLEServerUUID.NUS_DESCRIPTOR, + permissions = listOf(BLEPermission.PERMISSION_READ, BLEPermission.PERMISSION_WRITE) + ) + addDescriptor(descriptor) + } + + addCharacteristic(rxCharacteristic) + addCharacteristic(txCharacteristic) +} \ No newline at end of file diff --git a/app/src/main/java/com/eva/bluetoothterminalapp/data/ble_server/BLEServerUUID.kt b/app/src/main/java/com/eva/bluetoothterminalapp/data/ble_server/BLEServerUUID.kt new file mode 100644 index 0000000..2712baf --- /dev/null +++ b/app/src/main/java/com/eva/bluetoothterminalapp/data/ble_server/BLEServerUUID.kt @@ -0,0 +1,24 @@ +package com.eva.bluetoothterminalapp.data.ble_server + +import java.util.UUID + +object BLEServerUUID { + + // device info service + val DEVICE_INFO_SERVICE: UUID = UUID.fromString("0000180A-0000-1000-8000-00805F9b34FB") + val MANUFACTURER_NAME: UUID = UUID.fromString("00002A29-0000-1000-8000-00805F9b34FB") + val MODEL_NUMBER: UUID = UUID.fromString("00002A24-0000-1000-8000-00805F9b34FB") + val HARDWARE_REVISION: UUID = UUID.fromString("00002A27-0000-1000-8000-00805F9b34FB") + val SOFTWARE_REVISION: UUID = UUID.fromString("00002A28-0000-1000-8000-00805F9b34FB") + + // echo service + val ECHO_SERVICE: UUID = UUID.fromString("00001234-0000-1000-8000-00805F9b34FB") + val ECHO_CHARACTERISTIC: UUID = UUID.fromString("00001235-0000-1000-8000-00805F9b34FB") + val ECHO_DESCRIPTOR: UUID = UUID.fromString("00002902-0000-1000-8000-00805F9b34FB") + + // nordic uart service (NUS) + val NORDIC_UART_SERVICE: UUID = UUID.fromString("6E400001-B5A3-F393-E0A9-E50E24DCCA9E") + val NUS_RX_CHARACTERISTIC: UUID = UUID.fromString("6E400002-B5A3-F393-E0A9-E50E24DCCA9E") + val NUS_TX_CHARACTERISTIC: UUID = UUID.fromString("6E400003-B5A3-F393-E0A9-E50E24DCCA9E") + val NUS_DESCRIPTOR: UUID = UUID.fromString("00002902-0000-1000-8000-00805F9b34FB") +} \ No newline at end of file diff --git a/app/src/main/java/com/eva/bluetoothterminalapp/domain/bluetooth_le/BLEServerConnector.kt b/app/src/main/java/com/eva/bluetoothterminalapp/domain/bluetooth_le/BLEServerConnector.kt new file mode 100644 index 0000000..0c0fab3 --- /dev/null +++ b/app/src/main/java/com/eva/bluetoothterminalapp/domain/bluetooth_le/BLEServerConnector.kt @@ -0,0 +1,16 @@ +package com.eva.bluetoothterminalapp.domain.bluetooth_le + +import com.eva.bluetoothterminalapp.domain.bluetooth.models.BluetoothDeviceModel +import com.eva.bluetoothterminalapp.domain.bluetooth_le.models.BLEServiceModel +import kotlinx.coroutines.flow.Flow + +interface BLEServerConnector { + + val connectedDevices: Flow> + + val services: Flow> + + suspend fun onStartServer() + + suspend fun onStopServer() +} \ No newline at end of file From 8ce1f2f41c456ba5674eee12c30c3cc6cfeca503 Mon Sep 17 00:00:00 2001 From: tuuhin Date: Mon, 13 Oct 2025 20:20:36 +0530 Subject: [PATCH 02/11] Included a battery service and BLEServerGattCallback.kt changes BLEServerConnector.kt included flow for is server running and errors flow as there can be some errors while starting the server BLEServerServiceQueue.kt a queue needed to be maintained as we cannot add service at one go when its added then only the next one can be added Included a battery service, this can be used to read battery of the mobile via BatteryReader.kt DeviceModule.kt will contain device specific providers Converted BLEServerServices values into functions Separated BLEMessageModel.kt into Echo and NUS message models with tx value in nus message BatteryReader.kt uses the battery manager to read the charging state and battery level BLEServerGattCallback.kt changes related to adding new service ,characteristics and descriptor and how to handle each response Battery Service notify is not ready yet will be adding that next --- .../ble_server/AndroidBLEServerConnector.kt | 82 ++++-- .../data/ble_server/BLEMessageModel.kt | 23 +- .../data/ble_server/BLEServerGattCallback.kt | 246 ++++++++++++------ .../data/ble_server/BLEServerServiceQueue.kt | 24 ++ .../data/ble_server/BLEServerServices.kt | 46 ++-- .../data/ble_server/BLEServerUUID.kt | 9 +- .../data/device/BatteryReaderImpl.kt | 77 ++++++ .../eva/bluetoothterminalapp/di/AppModules.kt | 3 +- .../eva/bluetoothterminalapp/di/BLEModule.kt | 3 + .../bluetoothterminalapp/di/DeviceModule.kt | 11 + .../domain/bluetooth_le/BLEServerConnector.kt | 11 +- .../domain/device/BatteryReader.kt | 14 + .../BLEAdvertiseUnsupportedException.kt | 4 + 13 files changed, 428 insertions(+), 125 deletions(-) create mode 100644 app/src/main/java/com/eva/bluetoothterminalapp/data/ble_server/BLEServerServiceQueue.kt create mode 100644 app/src/main/java/com/eva/bluetoothterminalapp/data/device/BatteryReaderImpl.kt create mode 100644 app/src/main/java/com/eva/bluetoothterminalapp/di/DeviceModule.kt create mode 100644 app/src/main/java/com/eva/bluetoothterminalapp/domain/device/BatteryReader.kt create mode 100644 app/src/main/java/com/eva/bluetoothterminalapp/domain/exceptions/BLEAdvertiseUnsupportedException.kt diff --git a/app/src/main/java/com/eva/bluetoothterminalapp/data/ble_server/AndroidBLEServerConnector.kt b/app/src/main/java/com/eva/bluetoothterminalapp/data/ble_server/AndroidBLEServerConnector.kt index 12231f3..bee9b0a 100644 --- a/app/src/main/java/com/eva/bluetoothterminalapp/data/ble_server/AndroidBLEServerConnector.kt +++ b/app/src/main/java/com/eva/bluetoothterminalapp/data/ble_server/AndroidBLEServerConnector.kt @@ -11,30 +11,50 @@ import android.content.Context import android.os.Build import android.util.Log import androidx.core.content.getSystemService +import com.eva.bluetoothterminalapp.data.samples.SampleUUIDReader import com.eva.bluetoothterminalapp.data.utils.hasBTConnectPermission import com.eva.bluetoothterminalapp.domain.bluetooth.models.BluetoothDeviceModel import com.eva.bluetoothterminalapp.domain.bluetooth_le.BLEServerConnector import com.eva.bluetoothterminalapp.domain.bluetooth_le.models.BLEServiceModel +import com.eva.bluetoothterminalapp.domain.device.BatteryReader +import com.eva.bluetoothterminalapp.domain.exceptions.BLEAdvertiseUnsupportedException +import com.eva.bluetoothterminalapp.domain.exceptions.BluetoothNotEnabled +import com.eva.bluetoothterminalapp.domain.exceptions.BluetoothPermissionNotProvided import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update private const val TAG = "BLE_SERVER" @SuppressLint("MissingPermission") class AndroidBLEServerConnector( private val context: Context, + private val batteryReader: BatteryReader, + private val uuidReader: SampleUUIDReader, ) : BLEServerConnector { private val _bluetoothManager by lazy { context.getSystemService() } - private val _callback by lazy { BLEServerGattCallback() } + private val _callback by lazy { BLEServerGattCallback(batteryReader, uuidReader) } private var _bleServer: BluetoothGattServer? = null + private val _isServerRunning = MutableStateFlow(false) + private val _errorsFlow = MutableSharedFlow() + override val connectedDevices: Flow> get() = _callback.connectedDevices override val services: Flow> get() = _callback.services + override val isServerRunning: StateFlow + get() = _isServerRunning + + override val errorsFlow: Flow + get() = _errorsFlow + private val _advertiseCallback = object : AdvertiseCallback() { override fun onStartSuccess(settingsInEffect: AdvertiseSettings?) { super.onStartSuccess(settingsInEffect) @@ -44,20 +64,33 @@ class AndroidBLEServerConnector( override fun onStartFailure(errorCode: Int) { super.onStartFailure(errorCode) Log.d(TAG, "FAILED TO START ADVERTISEMENT ERROR_CODE:$errorCode") + val exception = when (errorCode) { + ADVERTISE_FAILED_ALREADY_STARTED -> Exception("Advertisement is already running") + ADVERTISE_FAILED_TOO_MANY_ADVERTISERS -> Exception("Too many advertiser") + ADVERTISE_FAILED_INTERNAL_ERROR -> Exception("Android cannot start advertisement") + ADVERTISE_FAILED_FEATURE_UNSUPPORTED -> Exception("BLE not supported") + else -> Exception("Cannot start the advertisement") + } + _errorsFlow.tryEmit(exception) } } @Suppress("DEPRECATION") - override suspend fun onStartServer() { - if (!context.hasBTConnectPermission) return - if (_bluetoothManager?.adapter?.isEnabled != true) return - if (_bluetoothManager?.adapter?.isMultipleAdvertisementSupported == false) return + override fun onStartServer(): Result { + if (!context.hasBTConnectPermission) return Result.failure(BluetoothPermissionNotProvided()) + if (_bluetoothManager?.adapter?.isEnabled != true) return Result.failure(BluetoothNotEnabled()) + if (_bluetoothManager?.adapter?.isMultipleAdvertisementSupported == false) + return Result.failure(BLEAdvertiseUnsupportedException()) - val advertiser = _bluetoothManager?.adapter?.bluetoothLeAdvertiser ?: return + val advertiser = _bluetoothManager?.adapter + ?.bluetoothLeAdvertiser ?: return Result.failure(BLEAdvertiseUnsupportedException()) _bleServer = _bluetoothManager?.openGattServer(context, _callback) + Log.i(TAG, "GATT SERVER BEGUN!") + _isServerRunning.update { true } - val server = _bleServer ?: return + val server = _bleServer ?: return Result.success(false) + val bleServicesQueue = BLEServerServiceQueue(server) // on response callback _callback.setOnSendResponse(server::sendResponse) _callback.setNotifyCharacteristicsChanged { device, characteristics, confirm, byteArray -> @@ -66,12 +99,18 @@ class AndroidBLEServerConnector( BluetoothStatusCodes.SUCCESS else server.notifyCharacteristicChanged(device, characteristics, confirm) } - - // required services - server.addService(bleDeviceInfoService) - server.addService(echoService) - server.addService(nordicUARTService) - + _callback.setOnInformServiceAdded(bleServicesQueue::addNextService) + + val services = listOf( + buildBleDeviceInfoService(), + buildEchoService(), + buildNordicUARTService(), + buildBatteryService() + ) + bleServicesQueue.addServices( + services = services, + onComplete = { Log.d(TAG, "SERVICES ADDED :COUNT ${server.services.size}") }, + ) // advertisement settings val settingsBuilder = AdvertiseSettings.Builder() .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_BALANCED) @@ -88,14 +127,27 @@ class AndroidBLEServerConnector( .setIncludeDeviceName(true) .build() + Log.d(TAG, "STARTING ADVERTISEMENTS") // start advertising advertiser.startAdvertising(advertiseSettings, advertiseData, _advertiseCallback) + return Result.success(true) } - override suspend fun onStopServer() { - _callback.onCleanUp() + override fun onStopServer() { + Log.d(TAG, "STOPPING ADVERTISING") + val advertiser = _bluetoothManager?.adapter?.bluetoothLeAdvertiser ?: return + advertiser.stopAdvertising(_advertiseCallback) + + _bleServer?.clearServices() + Log.d(TAG, "STOPPING SERVER") + _isServerRunning.update { false } _bleServer?.close() _bleServer = null } + + override fun cleanUp() { + _callback.onCleanUp() + onStopServer() + } } \ No newline at end of file diff --git a/app/src/main/java/com/eva/bluetoothterminalapp/data/ble_server/BLEMessageModel.kt b/app/src/main/java/com/eva/bluetoothterminalapp/data/ble_server/BLEMessageModel.kt index 465f840..d0c1413 100644 --- a/app/src/main/java/com/eva/bluetoothterminalapp/data/ble_server/BLEMessageModel.kt +++ b/app/src/main/java/com/eva/bluetoothterminalapp/data/ble_server/BLEMessageModel.kt @@ -1,6 +1,21 @@ package com.eva.bluetoothterminalapp.data.ble_server -data class BLEMessageModel( - val message: String = "", - val isNotifyEnabled: Boolean = false, -) \ No newline at end of file +sealed class BLEMessageModel( + open val message: String = "", + open val isNotifyEnabled: Boolean = false, +) { + + data class EchoMessage( + override val message: String, + override val isNotifyEnabled: Boolean + ) : BLEMessageModel(message = message, isNotifyEnabled = isNotifyEnabled) + + data class NUSMessage( + val rxMessage: String, + override val isNotifyEnabled: Boolean + ) : BLEMessageModel(message = rxMessage, isNotifyEnabled = isNotifyEnabled) { + + val txMessage: String + get() = "TX:$message" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/eva/bluetoothterminalapp/data/ble_server/BLEServerGattCallback.kt b/app/src/main/java/com/eva/bluetoothterminalapp/data/ble_server/BLEServerGattCallback.kt index 37c1978..a35d539 100644 --- a/app/src/main/java/com/eva/bluetoothterminalapp/data/ble_server/BLEServerGattCallback.kt +++ b/app/src/main/java/com/eva/bluetoothterminalapp/data/ble_server/BLEServerGattCallback.kt @@ -10,8 +10,10 @@ import android.bluetooth.BluetoothProfile import android.os.Build import android.util.Log import com.eva.bluetoothterminalapp.data.mapper.toDomainModel +import com.eva.bluetoothterminalapp.data.samples.SampleUUIDReader import com.eva.bluetoothterminalapp.domain.bluetooth.models.BluetoothDeviceModel import com.eva.bluetoothterminalapp.domain.bluetooth_le.models.BLEServiceModel +import com.eva.bluetoothterminalapp.domain.device.BatteryReader import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -19,6 +21,7 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import java.util.concurrent.ConcurrentHashMap private typealias SendResponse = (device: BluetoothDevice, requestId: Int, status: Int, offset: Int, value: ByteArray?) -> Unit @@ -26,7 +29,10 @@ private typealias NotifyCharacteristicsChanged = (device: BluetoothDevice, chara private const val TAG = "BLE_SERVER_CALLBACK" -class BLEServerGattCallback : BluetoothGattServerCallback() { +class BLEServerGattCallback( + private val batteryReader: BatteryReader, + private val uuidReader: SampleUUIDReader, +) : BluetoothGattServerCallback() { private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) @@ -37,26 +43,38 @@ class BLEServerGattCallback : BluetoothGattServerCallback() { val services = _services.asStateFlow() // key : device address and value : BLEMessageModel - private val _echoValuesMap = ConcurrentHashMap() - private val _nusValuesMap = ConcurrentHashMap() + private val _echoValuesMap = ConcurrentHashMap() + private val _nusValuesMap = ConcurrentHashMap() + private val _batteryNotificationMap = ConcurrentHashMap() private var _sendResponse: SendResponse? = null private var _notifyCharacteristicsChanged: NotifyCharacteristicsChanged? = null + private var _onInformServiceAddedCallback: (() -> Unit)? = null fun setOnSendResponse(callback: SendResponse) { + Log.d(TAG, "ON SEND RESPONSE CALLBACK SET!!") _sendResponse = callback } fun setNotifyCharacteristicsChanged(callback: NotifyCharacteristicsChanged) { + Log.d(TAG, "ON NOTIFY CALLBACK SET!!") _notifyCharacteristicsChanged = callback } + fun setOnInformServiceAdded(onServiceAdded: () -> Unit = {}) { + Log.d(TAG, "ON SERVICE ADDED CALLBACK SET") + _onInformServiceAddedCallback = onServiceAdded + } + override fun onConnectionStateChange(device: BluetoothDevice?, status: Int, newState: Int) { if (status != BluetoothGatt.GATT_SUCCESS) { Log.e(TAG, "CONNECTION WITH SOME ERROR: $status") return } - Log.d(TAG, "CLIENT ADDRESS:${device?.address} CONNECTION: $newState") + val message = if (newState == BluetoothProfile.STATE_CONNECTED) "CONNECTED" + else "DISCONNECTED" + + Log.i(TAG, "CLIENT ADDRESS:${device?.address} $message") val domainDevice = device?.toDomainModel() ?: return when (newState) { @@ -72,6 +90,8 @@ class BLEServerGattCallback : BluetoothGattServerCallback() { } // remove the device _echoValuesMap.remove(device.address) + _nusValuesMap.remove(device.address) + _batteryNotificationMap.remove(device.address) } else -> {} @@ -85,8 +105,20 @@ class BLEServerGattCallback : BluetoothGattServerCallback() { return } if (service == null) return - val domainService = service.toDomainModel() - _services.update { previous -> (previous + domainService).distinctBy { it.serviceId } } + + Log.i(TAG, "SERVICE :${service.uuid} ADDED") + + scope.launch { + val domainService = service.toDomainModel() + val sampleUUID = uuidReader.findServiceNameForUUID(service.uuid) + domainService.probableName = sampleUUID?.name + + _services.update { previous -> (previous + domainService).distinctBy { it.serviceId } } + + }.invokeOnCompletion { + // inform new service is added + _onInformServiceAddedCallback?.invoke() + } } override fun onCharacteristicReadRequest( @@ -95,32 +127,52 @@ class BLEServerGattCallback : BluetoothGattServerCallback() { offset: Int, characteristic: BluetoothGattCharacteristic? ) { + super.onCharacteristicReadRequest(device, requestId, offset, characteristic) if (device == null || characteristic == null) return - Log.e(TAG, "READ REQUESTED FOR CHARACTERISTICS :${characteristic.uuid}") + Log.i(TAG, "READ REQUESTED FOR CHARACTERISTICS :${characteristic.uuid}") + val charset = Charsets.UTF_8 + var isFailedResponse = false when (characteristic.service.uuid) { BLEServerUUID.DEVICE_INFO_SERVICE -> { val value = when (characteristic.uuid) { BLEServerUUID.MANUFACTURER_NAME -> Build.MANUFACTURER.toByteArray(charset) BLEServerUUID.MODEL_NUMBER -> Build.MODEL.toByteArray(charset) - BLEServerUUID.SOFTWARE_REVISION -> byteArrayOf(Build.VERSION.SDK_INT.toByte()) + BLEServerUUID.SOFTWARE_REVISION -> Build.VERSION.SDK_INT.toString() + .toByteArray(charset) + BLEServerUUID.HARDWARE_REVISION -> Build.HARDWARE.toByteArray(charset) else -> null } - _sendResponse?.invoke(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, value) + val isSuccess = if (value == null) BluetoothGatt.GATT_FAILURE + else BluetoothGatt.GATT_SUCCESS + _sendResponse?.invoke(device, requestId, isSuccess, offset, value) } BLEServerUUID.ECHO_SERVICE -> { - val savedValue = _echoValuesMap[device.address]?.message ?: "null" - val value = savedValue.toByteArray(charset) - _sendResponse?.invoke(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, value) + if (characteristic.uuid == BLEServerUUID.ECHO_CHARACTERISTIC) { + val savedValue = _echoValuesMap[device.address]?.message ?: "null" + val value = savedValue.toByteArray(charset) + _sendResponse + ?.invoke(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, value) + } } - else -> { - _sendResponse?.invoke(device, requestId, BluetoothGatt.GATT_FAILURE, offset, null) + BLEServerUUID.BATTERY_SERVICE -> { + if (characteristic.uuid == BLEServerUUID.BATTERY_LEVEL_CHARACTERISTIC) { + val value = batteryReader.currentBatteryLevel.toString().toByteArray(charset) + _sendResponse + ?.invoke(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, value) + } else isFailedResponse = true } + + else -> isFailedResponse = true } + + if (!isFailedResponse) return + _sendResponse?.invoke(device, requestId, BluetoothGatt.GATT_FAILURE, offset, null) + } override fun onCharacteristicWriteRequest( @@ -136,20 +188,20 @@ class BLEServerGattCallback : BluetoothGattServerCallback() { Log.e(TAG, "WRITE REQUESTED FOR CHARACTERISTICS :${characteristic.uuid}") if (value == null) { - Log.d(TAG, "WRITE REQUEST WITH EMPTY VALUE") - if (responseNeeded) { - _sendResponse?.invoke(device, requestId, BluetoothGatt.GATT_FAILURE, offset, null) - } + Log.e(TAG, "WRITE REQUEST WITH EMPTY VALUE ,RESPONSE: $responseNeeded") + if (!responseNeeded) return + _sendResponse?.invoke(device, requestId, BluetoothGatt.GATT_FAILURE, offset, null) return } val charset = Charsets.UTF_8 + var isFailedResponse = false when (characteristic.service.uuid) { BLEServerUUID.ECHO_SERVICE -> { if (characteristic.uuid == BLEServerUUID.ECHO_CHARACTERISTIC) { val current = _echoValuesMap[device.address] - _echoValuesMap[device.address] = BLEMessageModel( + _echoValuesMap[device.address] = BLEMessageModel.EchoMessage( message = value.toString(charset), isNotifyEnabled = current?.isNotifyEnabled ?: false ) @@ -162,27 +214,39 @@ class BLEServerGattCallback : BluetoothGattServerCallback() { if (!responseNeeded) return _sendResponse ?.invoke(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, value) - } else { - if (!responseNeeded) return - // empty value with failure - _sendResponse - ?.invoke(device, requestId, BluetoothGatt.GATT_FAILURE, offset, null) } + // invalid matching characteristics id + else isFailedResponse = true } BLEServerUUID.NORDIC_UART_SERVICE -> { - when (characteristic.uuid) { - BLEServerUUID.NUS_RX_CHARACTERISTIC -> {} - BLEServerUUID.NUS_TX_CHARACTERISTIC -> {} + if (characteristic.uuid == BLEServerUUID.NUS_RX_CHARACTERISTIC) { + val receivedMessage = value.toString(charset) + val current = _nusValuesMap[device.address] + _nusValuesMap[device.address] = BLEMessageModel.NUSMessage( + rxMessage = receivedMessage, + isNotifyEnabled = current?.isNotifyEnabled ?: false, + ) + // notify value changed if enabled + if (_nusValuesMap[device.address]?.isNotifyEnabled == true) { + val transmitMessage = + _nusValuesMap[device.address]?.txMessage ?: "nothing" + val transmitValue = transmitMessage.toByteArray(charset) + _notifyCharacteristicsChanged + ?.invoke(device, characteristic, false, transmitValue) + } } + // rx should be only used to receive value, tx is for transmitting + else isFailedResponse = true } - else -> { - if (!responseNeeded) return - // empty value with failure - _sendResponse?.invoke(device, requestId, BluetoothGatt.GATT_FAILURE, offset, null) - } + else -> isFailedResponse = true } + // if this is a failed response send response as failure + if (!isFailedResponse) return + if (!responseNeeded) return + // empty value with failure + _sendResponse?.invoke(device, requestId, BluetoothGatt.GATT_FAILURE, offset, null) } override fun onDescriptorReadRequest( @@ -192,33 +256,48 @@ class BLEServerGattCallback : BluetoothGattServerCallback() { descriptor: BluetoothGattDescriptor? ) { if (device == null || descriptor == null) return - Log.d( + Log.i( TAG, "DESCRIPTOR READ REQUEST ${descriptor.uuid} CHARACTERISTIC : ${descriptor.characteristic.uuid}" ) when (descriptor.characteristic.uuid) { BLEServerUUID.ECHO_CHARACTERISTIC -> { - if (descriptor.uuid == BLEServerUUID.ECHO_DESCRIPTOR) { - - val isNotifyEnabled = _echoValuesMap[device.address]?.isNotifyEnabled ?: false - val value = if (isNotifyEnabled) - BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE + val readValue = if (descriptor.uuid == BLEServerUUID.CCC_DESCRIPTOR) { + val isEnabled = _echoValuesMap[device.address]?.isNotifyEnabled ?: false + if (isEnabled) BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE else BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE + } else null + val status = if (readValue != null) BluetoothGatt.GATT_SUCCESS + else BluetoothGatt.GATT_FAILURE + _sendResponse?.invoke(device, requestId, status, offset, readValue) + } - _sendResponse - ?.invoke(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, value) - } else { - _sendResponse - ?.invoke(device, requestId, BluetoothGatt.GATT_FAILURE, offset, null) - } + BLEServerUUID.NUS_TX_CHARACTERISTIC -> { + val readValue = if (descriptor.uuid == BLEServerUUID.CCC_DESCRIPTOR) { + val isEnabled = _nusValuesMap[device.address]?.isNotifyEnabled ?: false + if (isEnabled) BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE + else BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE + } else null + val status = if (readValue != null) BluetoothGatt.GATT_SUCCESS + else BluetoothGatt.GATT_FAILURE + _sendResponse?.invoke(device, requestId, status, offset, readValue) } - else -> { - _sendResponse?.invoke(device, requestId, BluetoothGatt.GATT_FAILURE, offset, null) + BLEServerUUID.BATTERY_LEVEL_CHARACTERISTIC -> { + val readValue = if (descriptor.uuid == BLEServerUUID.CCC_DESCRIPTOR) { + val isEnabled = _batteryNotificationMap[device.address] ?: false + if (isEnabled) BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE + else BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE + } else null + val status = if (readValue != null) BluetoothGatt.GATT_SUCCESS + else BluetoothGatt.GATT_FAILURE + _sendResponse?.invoke(device, requestId, status, offset, readValue) } - } + else -> _sendResponse + ?.invoke(device, requestId, BluetoothGatt.GATT_FAILURE, offset, null) + } } override fun onDescriptorWriteRequest( @@ -232,54 +311,49 @@ class BLEServerGattCallback : BluetoothGattServerCallback() { ) { if (device == null || descriptor == null) return - Log.d( + Log.i( TAG, - "DESCRIPTOR WRITE REQUEST ${descriptor.uuid} CHARACTERS : ${descriptor.characteristic.uuid}" + "WRITE REQUEST DESCRIPTOR ID ${descriptor.uuid} CHARACTERISTIC ID : ${descriptor.characteristic.uuid}" ) - if (value == null) { - Log.d(TAG, "WRITE REQUEST WITH EMPTY VALUE") - if (responseNeeded) { - _sendResponse?.invoke(device, requestId, BluetoothGatt.GATT_FAILURE, offset, null) - } - return - } + when (descriptor.uuid) { + BLEServerUUID.CCC_DESCRIPTOR -> { + val isNotifyEnabled = when { + value.contentEquals(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE) -> true + value.contentEquals(BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE) -> false + else -> { + if (!responseNeeded) return + _sendResponse + ?.invoke(device, requestId, BluetoothGatt.GATT_FAILURE, offset, null) + return + } + } - when (descriptor.characteristic.uuid) { - BLEServerUUID.ECHO_CHARACTERISTIC -> { - if (descriptor.uuid == BLEServerUUID.ECHO_DESCRIPTOR) { - val isNotifyEnabled = when { - value.contentEquals(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE) -> true - value.contentEquals(BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE) -> false - else -> { - Log.w(TAG, "INVALID DESCRIPTOR VALUE ") - if (!responseNeeded) return - _sendResponse?.invoke( - device, requestId, BluetoothGatt.GATT_FAILURE, offset, null - ) - return - } + when (descriptor.characteristic.uuid) { + BLEServerUUID.ECHO_CHARACTERISTIC -> { + val currentMessage = _echoValuesMap.get(device.address)?.message ?: "null" + _echoValuesMap[device.address] = BLEMessageModel.EchoMessage( + message = currentMessage, + isNotifyEnabled = isNotifyEnabled + ) } - // find the device and update it - val currentMessage = _echoValuesMap.get(device.address)?.message ?: "null" - _echoValuesMap[device.address] = BLEMessageModel( - message = currentMessage, - isNotifyEnabled = isNotifyEnabled - ) - if (!responseNeeded) return - _sendResponse - ?.invoke(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, value) - } else { - // invalid descriptor uuid - if (!responseNeeded) return - _sendResponse - ?.invoke(device, requestId, BluetoothGatt.GATT_FAILURE, offset, null) + BLEServerUUID.NUS_TX_CHARACTERISTIC -> { + val currentMessage = _nusValuesMap.get(device.address)?.message ?: "" + _nusValuesMap[device.address] = BLEMessageModel.NUSMessage( + rxMessage = currentMessage, + isNotifyEnabled = isNotifyEnabled + ) + } + + BLEServerUUID.BATTERY_LEVEL_CHARACTERISTIC -> + _batteryNotificationMap[device.address] = isNotifyEnabled } + if (!responseNeeded) return + _sendResponse?.invoke(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, null) } else -> { - // no matching characteristics found if (!responseNeeded) return _sendResponse?.invoke(device, requestId, BluetoothGatt.GATT_FAILURE, offset, null) } @@ -287,7 +361,9 @@ class BLEServerGattCallback : BluetoothGattServerCallback() { } fun onCleanUp() { + Log.i(TAG, "CLEARING OFF DEVICES MAP") _echoValuesMap.clear() + _nusValuesMap.clear() scope.cancel() } } \ No newline at end of file diff --git a/app/src/main/java/com/eva/bluetoothterminalapp/data/ble_server/BLEServerServiceQueue.kt b/app/src/main/java/com/eva/bluetoothterminalapp/data/ble_server/BLEServerServiceQueue.kt new file mode 100644 index 0000000..9bb4ad0 --- /dev/null +++ b/app/src/main/java/com/eva/bluetoothterminalapp/data/ble_server/BLEServerServiceQueue.kt @@ -0,0 +1,24 @@ +package com.eva.bluetoothterminalapp.data.ble_server + +import android.annotation.SuppressLint +import android.bluetooth.BluetoothGattServer +import android.bluetooth.BluetoothGattService + +@SuppressLint("MissingPermission") +class BLEServerServiceQueue(private val server: BluetoothGattServer) { + + private val _servicesQueue = mutableListOf() + private var _onCompleteCallback: (() -> Unit)? = null + + fun addServices(services: List, onComplete: () -> Unit) { + _servicesQueue.clear() + _servicesQueue.addAll(services) + _onCompleteCallback = onComplete + addNextService() + } + + fun addNextService() { + if (_servicesQueue.isEmpty()) _onCompleteCallback?.invoke() + else server.addService(_servicesQueue.removeAt(0)) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/eva/bluetoothterminalapp/data/ble_server/BLEServerServices.kt b/app/src/main/java/com/eva/bluetoothterminalapp/data/ble_server/BLEServerServices.kt index 23bd8e5..9270760 100644 --- a/app/src/main/java/com/eva/bluetoothterminalapp/data/ble_server/BLEServerServices.kt +++ b/app/src/main/java/com/eva/bluetoothterminalapp/data/ble_server/BLEServerServices.kt @@ -1,10 +1,11 @@ package com.eva.bluetoothterminalapp.data.ble_server +import android.bluetooth.BluetoothGattService import com.eva.bluetoothterminalapp.domain.bluetooth_le.enums.BLEPermission import com.eva.bluetoothterminalapp.domain.bluetooth_le.enums.BLEPropertyTypes import com.eva.bluetoothterminalapp.domain.bluetooth_le.enums.BLEServicesTypes -val bleDeviceInfoService = bleServiceOf( +fun buildBleDeviceInfoService() = bleServiceOf( uuid = BLEServerUUID.DEVICE_INFO_SERVICE, serviceType = BLEServicesTypes.PRIMARY, ).apply { @@ -23,7 +24,7 @@ val bleDeviceInfoService = bleServiceOf( } } -val echoService = bleServiceOf( +fun buildEchoService() = bleServiceOf( uuid = BLEServerUUID.ECHO_SERVICE, serviceType = BLEServicesTypes.SECONDARY ).apply { @@ -37,7 +38,7 @@ val echoService = bleServiceOf( permissions = listOf(BLEPermission.PERMISSION_READ, BLEPermission.PERMISSION_WRITE) ).apply { val descriptor = bleDescriptorOf( - uuid = BLEServerUUID.ECHO_DESCRIPTOR, + uuid = BLEServerUUID.CCC_DESCRIPTOR, permissions = listOf(BLEPermission.PERMISSION_READ, BLEPermission.PERMISSION_WRITE) ) addDescriptor(descriptor) @@ -45,41 +46,54 @@ val echoService = bleServiceOf( addCharacteristic(characteristic) } -val nordicUARTService = bleServiceOf( +fun buildNordicUARTService(): BluetoothGattService = bleServiceOf( uuid = BLEServerUUID.NORDIC_UART_SERVICE, serviceType = BLEServicesTypes.PRIMARY ).apply { val rxCharacteristic = bleCharacteristicsOf( - uuid = BLEServerUUID.NUS_RX_CHARACTERISTIC, properties = listOf( - BLEPropertyTypes.PROPERTY_READ, + uuid = BLEServerUUID.NUS_RX_CHARACTERISTIC, + properties = listOf( BLEPropertyTypes.PROPERTY_WRITE, + BLEPropertyTypes.PROPERTY_WRITE_NO_RESPONSE + ), + permissions = listOf(BLEPermission.PERMISSION_WRITE) + ) + + val txCharacteristic = bleCharacteristicsOf( + uuid = BLEServerUUID.NUS_TX_CHARACTERISTIC, + properties = listOf( BLEPropertyTypes.PROPERTY_NOTIFY ), - permissions = listOf(BLEPermission.PERMISSION_READ, BLEPermission.PERMISSION_WRITE) + permissions = listOf(BLEPermission.PERMISSION_READ) ).apply { val descriptor = bleDescriptorOf( - uuid = BLEServerUUID.NUS_DESCRIPTOR, + uuid = BLEServerUUID.CCC_DESCRIPTOR, permissions = listOf(BLEPermission.PERMISSION_READ, BLEPermission.PERMISSION_WRITE) ) addDescriptor(descriptor) } - val txCharacteristic = bleCharacteristicsOf( - uuid = BLEServerUUID.NUS_TX_CHARACTERISTIC, + addCharacteristic(rxCharacteristic) + addCharacteristic(txCharacteristic) +} + +fun buildBatteryService() = bleServiceOf( + uuid = BLEServerUUID.BATTERY_SERVICE, + serviceType = BLEServicesTypes.PRIMARY +).apply { + val batteryLevelCharacteristics = bleCharacteristicsOf( + uuid = BLEServerUUID.BATTERY_LEVEL_CHARACTERISTIC, properties = listOf( BLEPropertyTypes.PROPERTY_READ, - BLEPropertyTypes.PROPERTY_WRITE, BLEPropertyTypes.PROPERTY_NOTIFY ), - permissions = listOf(BLEPermission.PERMISSION_READ, BLEPermission.PERMISSION_WRITE) + permissions = listOf(BLEPermission.PERMISSION_READ) ).apply { val descriptor = bleDescriptorOf( - uuid = BLEServerUUID.NUS_DESCRIPTOR, + uuid = BLEServerUUID.CCC_DESCRIPTOR, permissions = listOf(BLEPermission.PERMISSION_READ, BLEPermission.PERMISSION_WRITE) ) addDescriptor(descriptor) } - - addCharacteristic(rxCharacteristic) - addCharacteristic(txCharacteristic) + addCharacteristic(batteryLevelCharacteristics) } \ No newline at end of file diff --git a/app/src/main/java/com/eva/bluetoothterminalapp/data/ble_server/BLEServerUUID.kt b/app/src/main/java/com/eva/bluetoothterminalapp/data/ble_server/BLEServerUUID.kt index 2712baf..5320f1e 100644 --- a/app/src/main/java/com/eva/bluetoothterminalapp/data/ble_server/BLEServerUUID.kt +++ b/app/src/main/java/com/eva/bluetoothterminalapp/data/ble_server/BLEServerUUID.kt @@ -14,11 +14,16 @@ object BLEServerUUID { // echo service val ECHO_SERVICE: UUID = UUID.fromString("00001234-0000-1000-8000-00805F9b34FB") val ECHO_CHARACTERISTIC: UUID = UUID.fromString("00001235-0000-1000-8000-00805F9b34FB") - val ECHO_DESCRIPTOR: UUID = UUID.fromString("00002902-0000-1000-8000-00805F9b34FB") // nordic uart service (NUS) val NORDIC_UART_SERVICE: UUID = UUID.fromString("6E400001-B5A3-F393-E0A9-E50E24DCCA9E") val NUS_RX_CHARACTERISTIC: UUID = UUID.fromString("6E400002-B5A3-F393-E0A9-E50E24DCCA9E") val NUS_TX_CHARACTERISTIC: UUID = UUID.fromString("6E400003-B5A3-F393-E0A9-E50E24DCCA9E") - val NUS_DESCRIPTOR: UUID = UUID.fromString("00002902-0000-1000-8000-00805F9b34FB") + + // battery service + val BATTERY_SERVICE: UUID = UUID.fromString("0000180F-0000-1000-8000-00805f9b34fb") + val BATTERY_LEVEL_CHARACTERISTIC: UUID = UUID.fromString("00002A19-0000-1000-8000-00805f9b34fb") + + // client characteristics config + val CCC_DESCRIPTOR: UUID = UUID.fromString("00002902-0000-1000-8000-00805F9b34FB") } \ No newline at end of file diff --git a/app/src/main/java/com/eva/bluetoothterminalapp/data/device/BatteryReaderImpl.kt b/app/src/main/java/com/eva/bluetoothterminalapp/data/device/BatteryReaderImpl.kt new file mode 100644 index 0000000..010af86 --- /dev/null +++ b/app/src/main/java/com/eva/bluetoothterminalapp/data/device/BatteryReaderImpl.kt @@ -0,0 +1,77 @@ +package com.eva.bluetoothterminalapp.data.device + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.BatteryManager +import androidx.core.content.ContextCompat +import androidx.core.content.getSystemService +import com.eva.bluetoothterminalapp.domain.device.BatteryReader +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow + +class BatteryReaderImpl(private val context: Context) : BatteryReader { + + private val _batteryManager by lazy { context.getSystemService() } + + override val isBatteryCharging: Boolean + get() = _batteryManager?.isCharging ?: false + + override val currentBatteryLevel: Int + get() = _batteryManager?.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY) ?: 0 + + override fun isBatteryChargingFlow(): Flow { + return callbackFlow { + + val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + when (intent?.action) { + Intent.ACTION_POWER_CONNECTED -> trySend(true) + Intent.ACTION_POWER_DISCONNECTED -> trySend(false) + } + } + } + + val intentFilter = IntentFilter().apply { + addAction(Intent.ACTION_POWER_CONNECTED) + addAction(Intent.ACTION_POWER_DISCONNECTED) + } + + ContextCompat.registerReceiver( + context, + receiver, + intentFilter, + ContextCompat.RECEIVER_EXPORTED + ) + + awaitClose { context.unregisterReceiver(receiver) } + } + } + + override fun batteryLevelFlow(): Flow { + return callbackFlow { + + val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (intent?.action != Intent.ACTION_BATTERY_CHANGED) return + + val level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) + val scale = intent.getIntExtra(BatteryManager.EXTRA_SCALE, -1) + val batteryLevel = (level * 100f / scale).coerceIn(0f..100f).toInt() + trySend(batteryLevel) + } + } + + ContextCompat.registerReceiver( + context, + receiver, + IntentFilter(Intent.ACTION_BATTERY_CHANGED), + ContextCompat.RECEIVER_EXPORTED + ) + + awaitClose { context.unregisterReceiver(receiver) } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/eva/bluetoothterminalapp/di/AppModules.kt b/app/src/main/java/com/eva/bluetoothterminalapp/di/AppModules.kt index 509a098..298ef2c 100644 --- a/app/src/main/java/com/eva/bluetoothterminalapp/di/AppModules.kt +++ b/app/src/main/java/com/eva/bluetoothterminalapp/di/AppModules.kt @@ -8,6 +8,7 @@ val appModule = module { bluetoothLEModule, bluetoothClassicModule, viewModelModule, - settingsModule + settingsModule, + deviceModule ) } \ No newline at end of file diff --git a/app/src/main/java/com/eva/bluetoothterminalapp/di/BLEModule.kt b/app/src/main/java/com/eva/bluetoothterminalapp/di/BLEModule.kt index d37bd7c..e346ec7 100644 --- a/app/src/main/java/com/eva/bluetoothterminalapp/di/BLEModule.kt +++ b/app/src/main/java/com/eva/bluetoothterminalapp/di/BLEModule.kt @@ -1,8 +1,10 @@ package com.eva.bluetoothterminalapp.di +import com.eva.bluetoothterminalapp.data.ble_server.AndroidBLEServerConnector import com.eva.bluetoothterminalapp.data.bluetooth_le.AndroidBLEClientConnector import com.eva.bluetoothterminalapp.data.bluetooth_le.AndroidBluetoothLEScanner import com.eva.bluetoothterminalapp.data.samples.SampleUUIDReader +import com.eva.bluetoothterminalapp.domain.bluetooth_le.BLEServerConnector import com.eva.bluetoothterminalapp.domain.bluetooth_le.BluetoothLEClientConnector import com.eva.bluetoothterminalapp.domain.bluetooth_le.BluetoothLEScanner import org.koin.core.module.dsl.factoryOf @@ -16,4 +18,5 @@ val bluetoothLEModule = module { factoryOf(::AndroidBluetoothLEScanner).bind() factoryOf(::AndroidBLEClientConnector).bind() + factoryOf(::AndroidBLEServerConnector).bind() } \ No newline at end of file diff --git a/app/src/main/java/com/eva/bluetoothterminalapp/di/DeviceModule.kt b/app/src/main/java/com/eva/bluetoothterminalapp/di/DeviceModule.kt new file mode 100644 index 0000000..9629078 --- /dev/null +++ b/app/src/main/java/com/eva/bluetoothterminalapp/di/DeviceModule.kt @@ -0,0 +1,11 @@ +package com.eva.bluetoothterminalapp.di + +import com.eva.bluetoothterminalapp.data.device.BatteryReaderImpl +import com.eva.bluetoothterminalapp.domain.device.BatteryReader +import org.koin.core.module.dsl.singleOf +import org.koin.dsl.bind +import org.koin.dsl.module + +val deviceModule = module { + singleOf(::BatteryReaderImpl).bind() +} \ No newline at end of file diff --git a/app/src/main/java/com/eva/bluetoothterminalapp/domain/bluetooth_le/BLEServerConnector.kt b/app/src/main/java/com/eva/bluetoothterminalapp/domain/bluetooth_le/BLEServerConnector.kt index 0c0fab3..60ac11b 100644 --- a/app/src/main/java/com/eva/bluetoothterminalapp/domain/bluetooth_le/BLEServerConnector.kt +++ b/app/src/main/java/com/eva/bluetoothterminalapp/domain/bluetooth_le/BLEServerConnector.kt @@ -3,6 +3,7 @@ package com.eva.bluetoothterminalapp.domain.bluetooth_le import com.eva.bluetoothterminalapp.domain.bluetooth.models.BluetoothDeviceModel import com.eva.bluetoothterminalapp.domain.bluetooth_le.models.BLEServiceModel import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow interface BLEServerConnector { @@ -10,7 +11,13 @@ interface BLEServerConnector { val services: Flow> - suspend fun onStartServer() + val isServerRunning: StateFlow - suspend fun onStopServer() + val errorsFlow: Flow + + fun onStartServer(): Result + + fun onStopServer() + + fun cleanUp() } \ No newline at end of file diff --git a/app/src/main/java/com/eva/bluetoothterminalapp/domain/device/BatteryReader.kt b/app/src/main/java/com/eva/bluetoothterminalapp/domain/device/BatteryReader.kt new file mode 100644 index 0000000..7418ec8 --- /dev/null +++ b/app/src/main/java/com/eva/bluetoothterminalapp/domain/device/BatteryReader.kt @@ -0,0 +1,14 @@ +package com.eva.bluetoothterminalapp.domain.device + +import kotlinx.coroutines.flow.Flow + +interface BatteryReader { + + val isBatteryCharging: Boolean + + val currentBatteryLevel: Int + + fun isBatteryChargingFlow(): Flow + + fun batteryLevelFlow(): Flow +} \ No newline at end of file diff --git a/app/src/main/java/com/eva/bluetoothterminalapp/domain/exceptions/BLEAdvertiseUnsupportedException.kt b/app/src/main/java/com/eva/bluetoothterminalapp/domain/exceptions/BLEAdvertiseUnsupportedException.kt new file mode 100644 index 0000000..85633b8 --- /dev/null +++ b/app/src/main/java/com/eva/bluetoothterminalapp/domain/exceptions/BLEAdvertiseUnsupportedException.kt @@ -0,0 +1,4 @@ +package com.eva.bluetoothterminalapp.domain.exceptions + +class BLEAdvertiseUnsupportedException : + Exception("Cannot start multiple ble server advertisements") \ No newline at end of file From 0d69487795fa1de4038967eb3ab00ae73f1205ba Mon Sep 17 00:00:00 2001 From: tuuhin Date: Tue, 14 Oct 2025 20:49:05 +0530 Subject: [PATCH 03/11] Sending notifications are working now and new service A new environmental sensing service included to read and notify about the light sensor values LightSensorReader.kt reads the ambient light In AndroidBLEServerConnector.kt and BLEServerGattCallback.kt included notifications the values are read and the map is maintained for subscribers according to the last notification client list is made and notified for notification corrected some portion in AndroidBLEServerConnector::onStartServer BLEMessageModel.kt updated and for notification we use BLENotificationModel.kt Included using sensor.light in AndroidManifest.xml --- app/src/main/AndroidManifest.xml | 5 +- .../ble_server/AndroidBLEServerConnector.kt | 58 +++++-- .../data/ble_server/BLEMessageModel.kt | 11 +- .../data/ble_server/BLENotificationModel.kt | 19 +++ .../data/ble_server/BLEServerGattCallback.kt | 151 +++++++++++++++--- .../data/ble_server/BLEServerServices.kt | 24 +++ .../data/ble_server/BLEServerUUID.kt | 5 + .../data/device/LightSensorReaderImpl.kt | 92 +++++++++++ .../bluetoothterminalapp/di/DeviceModule.kt | 3 + .../domain/device/LightSensorReader.kt | 10 ++ 10 files changed, 342 insertions(+), 36 deletions(-) create mode 100644 app/src/main/java/com/eva/bluetoothterminalapp/data/ble_server/BLENotificationModel.kt create mode 100644 app/src/main/java/com/eva/bluetoothterminalapp/data/device/LightSensorReaderImpl.kt create mode 100644 app/src/main/java/com/eva/bluetoothterminalapp/domain/device/LightSensorReader.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index cd689c8..0fc849b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -20,12 +20,15 @@ - + + () } - private val _callback by lazy { BLEServerGattCallback(batteryReader, uuidReader) } + private val _callback by lazy { + BLEServerGattCallback( + context = context, + batteryReader = batteryReader, + lightSensorReader = lightSensorReader, + uuidReader = uuidReader + ) + } private var _bleServer: BluetoothGattServer? = null private val _isServerRunning = MutableStateFlow(false) - private val _errorsFlow = MutableSharedFlow() + private val _errorsFlow = MutableSharedFlow( + extraBufferCapacity = 4, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) override val connectedDevices: Flow> get() = _callback.connectedDevices @@ -90,23 +103,50 @@ class AndroidBLEServerConnector( _isServerRunning.update { true } val server = _bleServer ?: return Result.success(false) + + // services queue val bleServicesQueue = BLEServerServiceQueue(server) - // on response callback + _callback.setOnServiceAdded(bleServicesQueue::addNextService) + + // on response callback for read and write _callback.setOnSendResponse(server::sendResponse) + // on notify callback for notify and indicate _callback.setNotifyCharacteristicsChanged { device, characteristics, confirm, byteArray -> - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) - server.notifyCharacteristicChanged(device, characteristics, confirm, byteArray) == - BluetoothStatusCodes.SUCCESS - else server.notifyCharacteristicChanged(device, characteristics, confirm) + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + // values are set independent + val status = server + .notifyCharacteristicChanged(device, characteristics, confirm, byteArray) + status == BluetoothStatusCodes.SUCCESS + } else { + // set the characteristic value then notify + characteristics.value = byteArray + server.notifyCharacteristicChanged(device, characteristics, confirm) + } + } catch (_: IllegalStateException) { + _errorsFlow.tryEmit(Exception("Characteristic value is blank")) + false + } } - _callback.setOnInformServiceAdded(bleServicesQueue::addNextService) + + // services + val batteryService = buildBatteryService() + val environmentService = buildEnvironmentSensingService() val services = listOf( buildBleDeviceInfoService(), buildEchoService(), buildNordicUARTService(), - buildBatteryService() + batteryService, + environmentService ) + + // initiate broadcast + batteryService.characteristics.find { it.uuid == BLEServerUUID.BATTERY_LEVEL_CHARACTERISTIC } + ?.let(_callback::broadcastBatteryInfo) + environmentService.characteristics.find { it.uuid == BLEServerUUID.ILLUMINANCE_CHARACTERISTIC } + ?.let(_callback::broadcastIlluminanceInfo) + bleServicesQueue.addServices( services = services, onComplete = { Log.d(TAG, "SERVICES ADDED :COUNT ${server.services.size}") }, diff --git a/app/src/main/java/com/eva/bluetoothterminalapp/data/ble_server/BLEMessageModel.kt b/app/src/main/java/com/eva/bluetoothterminalapp/data/ble_server/BLEMessageModel.kt index d0c1413..bb470ec 100644 --- a/app/src/main/java/com/eva/bluetoothterminalapp/data/ble_server/BLEMessageModel.kt +++ b/app/src/main/java/com/eva/bluetoothterminalapp/data/ble_server/BLEMessageModel.kt @@ -1,19 +1,18 @@ package com.eva.bluetoothterminalapp.data.ble_server -sealed class BLEMessageModel( - open val message: String = "", - open val isNotifyEnabled: Boolean = false, -) { +sealed class BLEMessageModel(open val message: String) { + + abstract val isNotifyEnabled: Boolean data class EchoMessage( override val message: String, override val isNotifyEnabled: Boolean - ) : BLEMessageModel(message = message, isNotifyEnabled = isNotifyEnabled) + ) : BLEMessageModel(message = message) data class NUSMessage( val rxMessage: String, override val isNotifyEnabled: Boolean - ) : BLEMessageModel(message = rxMessage, isNotifyEnabled = isNotifyEnabled) { + ) : BLEMessageModel(message = rxMessage) { val txMessage: String get() = "TX:$message" diff --git a/app/src/main/java/com/eva/bluetoothterminalapp/data/ble_server/BLENotificationModel.kt b/app/src/main/java/com/eva/bluetoothterminalapp/data/ble_server/BLENotificationModel.kt new file mode 100644 index 0000000..d066612 --- /dev/null +++ b/app/src/main/java/com/eva/bluetoothterminalapp/data/ble_server/BLENotificationModel.kt @@ -0,0 +1,19 @@ +package com.eva.bluetoothterminalapp.data.ble_server + +sealed class BLENotificationModel(val type: BLENotification) { + + abstract val isEnabled: Boolean + + data class BLEBatteryNotification( + override val isEnabled: Boolean, + ) : BLENotificationModel(type = BLENotification.BATTERY) + + data class BLEIlluminanceNotification( + override val isEnabled: Boolean, + ) : BLENotificationModel(type = BLENotification.ILLUMINANCE) + + enum class BLENotification { + BATTERY, + ILLUMINANCE + } +} diff --git a/app/src/main/java/com/eva/bluetoothterminalapp/data/ble_server/BLEServerGattCallback.kt b/app/src/main/java/com/eva/bluetoothterminalapp/data/ble_server/BLEServerGattCallback.kt index a35d539..07ac68a 100644 --- a/app/src/main/java/com/eva/bluetoothterminalapp/data/ble_server/BLEServerGattCallback.kt +++ b/app/src/main/java/com/eva/bluetoothterminalapp/data/ble_server/BLEServerGattCallback.kt @@ -1,25 +1,38 @@ package com.eva.bluetoothterminalapp.data.ble_server +import android.bluetooth.BluetoothAdapter import android.bluetooth.BluetoothDevice import android.bluetooth.BluetoothGatt import android.bluetooth.BluetoothGattCharacteristic import android.bluetooth.BluetoothGattDescriptor import android.bluetooth.BluetoothGattServerCallback import android.bluetooth.BluetoothGattService +import android.bluetooth.BluetoothManager import android.bluetooth.BluetoothProfile +import android.content.Context import android.os.Build import android.util.Log +import androidx.core.content.getSystemService import com.eva.bluetoothterminalapp.data.mapper.toDomainModel import com.eva.bluetoothterminalapp.data.samples.SampleUUIDReader import com.eva.bluetoothterminalapp.domain.bluetooth.models.BluetoothDeviceModel import com.eva.bluetoothterminalapp.domain.bluetooth_le.models.BLEServiceModel import com.eva.bluetoothterminalapp.domain.device.BatteryReader +import com.eva.bluetoothterminalapp.domain.device.LightSensorReader import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import java.util.concurrent.ConcurrentHashMap @@ -30,10 +43,13 @@ private typealias NotifyCharacteristicsChanged = (device: BluetoothDevice, chara private const val TAG = "BLE_SERVER_CALLBACK" class BLEServerGattCallback( + private val context: Context, private val batteryReader: BatteryReader, + private val lightSensorReader: LightSensorReader, private val uuidReader: SampleUUIDReader, ) : BluetoothGattServerCallback() { + private val _btManager by lazy { context.getSystemService() } private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) private val _connectedDevices = MutableStateFlow>(emptyList()) @@ -45,27 +61,100 @@ class BLEServerGattCallback( // key : device address and value : BLEMessageModel private val _echoValuesMap = ConcurrentHashMap() private val _nusValuesMap = ConcurrentHashMap() - private val _batteryNotificationMap = ConcurrentHashMap() + + // notifications : latest one and the notification map + private val _latestNotification = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 32, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + private val _notificationsMap = ConcurrentHashMap() private var _sendResponse: SendResponse? = null private var _notifyCharacteristicsChanged: NotifyCharacteristicsChanged? = null - private var _onInformServiceAddedCallback: (() -> Unit)? = null + private var _onServiceAdded: (() -> Unit)? = null fun setOnSendResponse(callback: SendResponse) { - Log.d(TAG, "ON SEND RESPONSE CALLBACK SET!!") _sendResponse = callback } fun setNotifyCharacteristicsChanged(callback: NotifyCharacteristicsChanged) { - Log.d(TAG, "ON NOTIFY CALLBACK SET!!") _notifyCharacteristicsChanged = callback } - fun setOnInformServiceAdded(onServiceAdded: () -> Unit = {}) { - Log.d(TAG, "ON SERVICE ADDED CALLBACK SET") - _onInformServiceAddedCallback = onServiceAdded + fun setOnServiceAdded(onServiceAdded: () -> Unit = {}) { + _onServiceAdded = onServiceAdded } + fun broadcastBatteryInfo(characteristics: BluetoothGattCharacteristic) { + + val batteryLevelFlow = batteryReader.batteryLevelFlow() + val batteryNotification = _latestNotification + .filterIsInstance() + + combine(batteryLevelFlow, batteryNotification) { level, _ -> level } + .onEach { level -> + val clients = _notificationsMap.mapNotNull { (address, notification) -> + if (!notification.isEnabled) return@mapNotNull null + if (!BluetoothAdapter.checkBluetoothAddress(address)) return@mapNotNull null + _btManager?.adapter?.getRemoteDevice(address) + } + + if (clients.isEmpty()) { + Log.d(TAG, "NO NOTIFICATION ENABLED CLIENT ") + return@onEach + } + + Log.i(TAG, "BATTERY LEVEL :$level") + Log.d(TAG, "BROADCASTING BATTERY LEVEL DATA TO :${clients.size} CLIENTS") + + val value = "$level".toByteArray(Charsets.UTF_8) + + clients.forEach { device -> + _notifyCharacteristicsChanged?.invoke(device, characteristics, false, value) + } + + } + .flowOn(Dispatchers.IO) + .catch { err -> Log.e(TAG, "ISSUES IN SENDING BATTERY NOTIFICATION", err) } + .launchIn(scope) + } + + // TODO: Fix this function call only to read light sensor readings if at-least a device present + fun broadcastIlluminanceInfo(characteristics: BluetoothGattCharacteristic) { + val lightReadings = lightSensorReader.readValuesFlow() + val lightNotifications = _latestNotification + .filterIsInstance() + + combine(lightReadings, lightNotifications) { illuminance, _ -> illuminance } + .onEach { illuminance -> + val clients = _notificationsMap.mapNotNull { (address, notification) -> + if (!notification.isEnabled) return@mapNotNull null + if (!BluetoothAdapter.checkBluetoothAddress(address)) return@mapNotNull null + _btManager?.adapter?.getRemoteDevice(address) + } + + if (clients.isEmpty()) { + Log.d(TAG, "NO ILLUMINANCE NOTIFICATION ENABLED CLIENT ") + return@onEach + } + + Log.i(TAG, "ILLUMINANCE :$illuminance") + Log.d(TAG, "BROADCASTING ILLUMINANCE DATA TO :${clients.size} CLIENTS") + + val illuminanceValue = (illuminance * 100).toInt() / 100f + val value = "$illuminanceValue".toByteArray(charset = Charsets.UTF_8) + + clients.forEach { device -> + _notifyCharacteristicsChanged?.invoke(device, characteristics, false, value) + } + } + .flowOn(Dispatchers.IO) + .catch { err -> Log.e(TAG, "ISSUES IN ILLUMINANCE NOTIFICATION", err) } + .launchIn(scope) + } + + override fun onConnectionStateChange(device: BluetoothDevice?, status: Int, newState: Int) { if (status != BluetoothGatt.GATT_SUCCESS) { Log.e(TAG, "CONNECTION WITH SOME ERROR: $status") @@ -91,7 +180,7 @@ class BLEServerGattCallback( // remove the device _echoValuesMap.remove(device.address) _nusValuesMap.remove(device.address) - _batteryNotificationMap.remove(device.address) + _notificationsMap.remove(device.address) } else -> {} @@ -117,7 +206,7 @@ class BLEServerGattCallback( }.invokeOnCompletion { // inform new service is added - _onInformServiceAddedCallback?.invoke() + _onServiceAdded?.invoke() } } @@ -167,6 +256,17 @@ class BLEServerGattCallback( } else isFailedResponse = true } + BLEServerUUID.ENVIRONMENTAL_SENSING_SERVICE -> { + if (characteristic.uuid == BLEServerUUID.ILLUMINANCE_CHARACTERISTIC) { + scope.launch { + val value = lightSensorReader.readCurrentValue().toString() + .toByteArray(charset) + _sendResponse + ?.invoke(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, value) + } + } else isFailedResponse = true + } + else -> isFailedResponse = true } @@ -229,11 +329,9 @@ class BLEServerGattCallback( ) // notify value changed if enabled if (_nusValuesMap[device.address]?.isNotifyEnabled == true) { - val transmitMessage = - _nusValuesMap[device.address]?.txMessage ?: "nothing" - val transmitValue = transmitMessage.toByteArray(charset) - _notifyCharacteristicsChanged - ?.invoke(device, characteristic, false, transmitValue) + val transmitMessage = _nusValuesMap[device.address]?.txMessage ?: "nothing" + val tValue = transmitMessage.toByteArray(charset) + _notifyCharacteristicsChanged?.invoke(device, characteristic, false, tValue) } } // rx should be only used to receive value, tx is for transmitting @@ -284,9 +382,9 @@ class BLEServerGattCallback( _sendResponse?.invoke(device, requestId, status, offset, readValue) } - BLEServerUUID.BATTERY_LEVEL_CHARACTERISTIC -> { + BLEServerUUID.BATTERY_LEVEL_CHARACTERISTIC, BLEServerUUID.ILLUMINANCE_CHARACTERISTIC -> { val readValue = if (descriptor.uuid == BLEServerUUID.CCC_DESCRIPTOR) { - val isEnabled = _batteryNotificationMap[device.address] ?: false + val isEnabled = _notificationsMap[device.address]?.isEnabled ?: false if (isEnabled) BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE else BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE } else null @@ -331,7 +429,8 @@ class BLEServerGattCallback( when (descriptor.characteristic.uuid) { BLEServerUUID.ECHO_CHARACTERISTIC -> { - val currentMessage = _echoValuesMap.get(device.address)?.message ?: "null" + val currentMessage = _echoValuesMap.getOrDefault(device.address, null) + ?.message ?: "null" _echoValuesMap[device.address] = BLEMessageModel.EchoMessage( message = currentMessage, isNotifyEnabled = isNotifyEnabled @@ -339,15 +438,27 @@ class BLEServerGattCallback( } BLEServerUUID.NUS_TX_CHARACTERISTIC -> { - val currentMessage = _nusValuesMap.get(device.address)?.message ?: "" + val currentMessage = _nusValuesMap.getOrDefault(device.address, null) + ?.message ?: "" _nusValuesMap[device.address] = BLEMessageModel.NUSMessage( rxMessage = currentMessage, isNotifyEnabled = isNotifyEnabled ) } - BLEServerUUID.BATTERY_LEVEL_CHARACTERISTIC -> - _batteryNotificationMap[device.address] = isNotifyEnabled + BLEServerUUID.BATTERY_LEVEL_CHARACTERISTIC -> { + val newState = + BLENotificationModel.BLEBatteryNotification(isEnabled = isNotifyEnabled) + _notificationsMap[device.address] = newState + _latestNotification.tryEmit(newState) + } + + BLEServerUUID.ILLUMINANCE_CHARACTERISTIC -> { + val newState = + BLENotificationModel.BLEIlluminanceNotification(isEnabled = isNotifyEnabled) + _notificationsMap[device.address] = newState + _latestNotification.tryEmit(newState) + } } if (!responseNeeded) return _sendResponse?.invoke(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, null) diff --git a/app/src/main/java/com/eva/bluetoothterminalapp/data/ble_server/BLEServerServices.kt b/app/src/main/java/com/eva/bluetoothterminalapp/data/ble_server/BLEServerServices.kt index 9270760..3179ef8 100644 --- a/app/src/main/java/com/eva/bluetoothterminalapp/data/ble_server/BLEServerServices.kt +++ b/app/src/main/java/com/eva/bluetoothterminalapp/data/ble_server/BLEServerServices.kt @@ -96,4 +96,28 @@ fun buildBatteryService() = bleServiceOf( addDescriptor(descriptor) } addCharacteristic(batteryLevelCharacteristics) +} + +fun buildEnvironmentSensingService() = bleServiceOf( + uuid = BLEServerUUID.ENVIRONMENTAL_SENSING_SERVICE, + serviceType = BLEServicesTypes.PRIMARY +).apply { + val illuminanceCharacteristics = bleCharacteristicsOf( + uuid = BLEServerUUID.ILLUMINANCE_CHARACTERISTIC, + properties = listOf( + BLEPropertyTypes.PROPERTY_NOTIFY, + BLEPropertyTypes.PROPERTY_READ + ), + permissions = listOf(BLEPermission.PERMISSION_READ) + ).apply { + val descriptor = bleDescriptorOf( + uuid = BLEServerUUID.CCC_DESCRIPTOR, + permissions = listOf( + BLEPermission.PERMISSION_READ, + BLEPermission.PERMISSION_WRITE + ) + ) + addDescriptor(descriptor) + } + addCharacteristic(illuminanceCharacteristics) } \ No newline at end of file diff --git a/app/src/main/java/com/eva/bluetoothterminalapp/data/ble_server/BLEServerUUID.kt b/app/src/main/java/com/eva/bluetoothterminalapp/data/ble_server/BLEServerUUID.kt index 5320f1e..6e3e9cd 100644 --- a/app/src/main/java/com/eva/bluetoothterminalapp/data/ble_server/BLEServerUUID.kt +++ b/app/src/main/java/com/eva/bluetoothterminalapp/data/ble_server/BLEServerUUID.kt @@ -24,6 +24,11 @@ object BLEServerUUID { val BATTERY_SERVICE: UUID = UUID.fromString("0000180F-0000-1000-8000-00805f9b34fb") val BATTERY_LEVEL_CHARACTERISTIC: UUID = UUID.fromString("00002A19-0000-1000-8000-00805f9b34fb") + // environment service + val ENVIRONMENTAL_SENSING_SERVICE: UUID = + UUID.fromString("0000181A-0000-1000-8000-00805f9b34fb") + val ILLUMINANCE_CHARACTERISTIC: UUID = UUID.fromString("00002AFB-0000-1000-8000-00805f9b34fb") + // client characteristics config val CCC_DESCRIPTOR: UUID = UUID.fromString("00002902-0000-1000-8000-00805F9b34FB") } \ No newline at end of file diff --git a/app/src/main/java/com/eva/bluetoothterminalapp/data/device/LightSensorReaderImpl.kt b/app/src/main/java/com/eva/bluetoothterminalapp/data/device/LightSensorReaderImpl.kt new file mode 100644 index 0000000..f801ea9 --- /dev/null +++ b/app/src/main/java/com/eva/bluetoothterminalapp/data/device/LightSensorReaderImpl.kt @@ -0,0 +1,92 @@ +package com.eva.bluetoothterminalapp.data.device + +import android.content.Context +import android.content.pm.PackageManager +import android.hardware.Sensor +import android.hardware.SensorEvent +import android.hardware.SensorEventListener +import android.hardware.SensorManager +import android.util.Log +import androidx.core.content.getSystemService +import com.eva.bluetoothterminalapp.domain.device.LightSensorReader +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.time.Duration.Companion.seconds +import kotlin.time.DurationUnit + +private const val TAG = "LIGHT_SENSOR_READER" + +class LightSensorReaderImpl(private val context: Context) : LightSensorReader { + + private val _sensorManager by lazy { context.getSystemService() } + + private val _isSensorAvailable: Boolean + get() = context.packageManager.hasSystemFeature(PackageManager.FEATURE_SENSOR_LIGHT) + + private val _lightSensor: Sensor? + get() = _sensorManager?.getDefaultSensor(Sensor.TYPE_LIGHT) + + + @OptIn(ExperimentalCoroutinesApi::class) + override suspend fun readCurrentValue(): Float? { + return suspendCancellableCoroutine { cont -> + if (!_isSensorAvailable) { + cont.resume(null, onCancellation = {}) + return@suspendCancellableCoroutine + } + val sensor = _lightSensor ?: run { + cont.resume(null, onCancellation = {}) + return@suspendCancellableCoroutine + } + + val listener = object : SensorEventListener { + override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit + override fun onSensorChanged(event: SensorEvent?) { + val lux = event?.values?.firstOrNull() + if (cont.isActive) cont.resume(lux, onCancellation = {}) + Log.d(TAG, "LIGHT SENSOR LISTENER UN-REGISTERED") + _sensorManager?.unregisterListener(this) + } + } + + Log.d(TAG, "LIGHT SENSOR LISTENER REGISTERED") + _sensorManager?.registerListener(listener, sensor, SensorManager.SENSOR_DELAY_NORMAL) + + cont.invokeOnCancellation { + Log.d(TAG, "LIGHT SENSOR LISTENER UN-REGISTERED (CANCELLED)") + _sensorManager?.unregisterListener(listener) + } + } + + } + + override fun readValuesFlow(): Flow { + val sensor = _lightSensor ?: return flowOf(0f) + if (!_isSensorAvailable) return flowOf(0f) + return callbackFlow { + + val listener = object : SensorEventListener { + override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit + override fun onSensorChanged(event: SensorEvent?) { + if (event?.sensor?.type != Sensor.TYPE_LIGHT) return + event.values.firstOrNull()?.let { value -> trySend(value) } + } + } + + Log.d(TAG, "LIGHT SENSOR LISTENER REGISTERED") + _sensorManager?.registerListener( + listener, + sensor, + 1.seconds.toInt(DurationUnit.MICROSECONDS) + ) + awaitClose { + Log.d(TAG, "LIGHT SENSOR LISTENER UNREGISTERED") + _sensorManager?.unregisterListener(listener) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/eva/bluetoothterminalapp/di/DeviceModule.kt b/app/src/main/java/com/eva/bluetoothterminalapp/di/DeviceModule.kt index 9629078..1677f58 100644 --- a/app/src/main/java/com/eva/bluetoothterminalapp/di/DeviceModule.kt +++ b/app/src/main/java/com/eva/bluetoothterminalapp/di/DeviceModule.kt @@ -1,11 +1,14 @@ package com.eva.bluetoothterminalapp.di import com.eva.bluetoothterminalapp.data.device.BatteryReaderImpl +import com.eva.bluetoothterminalapp.data.device.LightSensorReaderImpl import com.eva.bluetoothterminalapp.domain.device.BatteryReader +import com.eva.bluetoothterminalapp.domain.device.LightSensorReader import org.koin.core.module.dsl.singleOf import org.koin.dsl.bind import org.koin.dsl.module val deviceModule = module { singleOf(::BatteryReaderImpl).bind() + singleOf(::LightSensorReaderImpl).bind() } \ No newline at end of file diff --git a/app/src/main/java/com/eva/bluetoothterminalapp/domain/device/LightSensorReader.kt b/app/src/main/java/com/eva/bluetoothterminalapp/domain/device/LightSensorReader.kt new file mode 100644 index 0000000..ba4862e --- /dev/null +++ b/app/src/main/java/com/eva/bluetoothterminalapp/domain/device/LightSensorReader.kt @@ -0,0 +1,10 @@ +package com.eva.bluetoothterminalapp.domain.device + +import kotlinx.coroutines.flow.Flow + +interface LightSensorReader { + + suspend fun readCurrentValue(): Float? + + fun readValuesFlow(): Flow +} \ No newline at end of file From 829971da4ff56d94b58252e80c7bbcb4b65cd2b8 Mon Sep 17 00:00:00 2001 From: tuuhin Date: Wed, 15 Oct 2025 22:50:58 +0530 Subject: [PATCH 04/11] Checking out client code for issues Updating the BLE Client code rename exceptions, included permission checking before calling connect . In BLE Scan logic the auto cancel before timeout logic updated, using the finally clause the stop is always called in-case the scan is stopped or some error is thrown Using locks in SampleUUIDReader.kt so that loading the sample uuid's in memory doesn't happen twice --- .../bluetooth_le/AndroidBLEClientConnector.kt | 86 ++++----- .../bluetooth_le/AndroidBluetoothLEScanner.kt | 73 ++++---- .../bluetooth_le/BLEClientGattCallback.kt | 55 +++--- .../data/bluetooth_le/BLEClientUUID.kt | 7 + .../data/samples/SampleUUIDReader.kt | 165 ++++++++++-------- .../data/utils/PermissionsExt.kt | 8 + .../domain/bluetooth_le/BluetoothLEScanner.kt | 5 +- .../bluetooth_le/models/BLEServiceModel.kt | 2 +- ...=> BLEMissingNotifyPropertiesException.kt} | 2 +- .../BTAdvertisePermissionNotFound.kt | 4 + ...kt => InvalidBLEConfigurationException.kt} | 2 +- .../InvalidDeviceAddressException.kt | 4 + .../feature_devices/BTDeviceViewmodel.kt | 16 +- .../presentation/util/BTConstants.kt | 2 - 14 files changed, 226 insertions(+), 205 deletions(-) create mode 100644 app/src/main/java/com/eva/bluetoothterminalapp/data/bluetooth_le/BLEClientUUID.kt rename app/src/main/java/com/eva/bluetoothterminalapp/domain/exceptions/{BLECharacteristicIndicateOrNotifyPropertyException.kt => BLEMissingNotifyPropertiesException.kt} (73%) create mode 100644 app/src/main/java/com/eva/bluetoothterminalapp/domain/exceptions/BTAdvertisePermissionNotFound.kt rename app/src/main/java/com/eva/bluetoothterminalapp/domain/exceptions/{BLEServiceAndCharacteristicMatchNotFoundException.kt => InvalidBLEConfigurationException.kt} (68%) create mode 100644 app/src/main/java/com/eva/bluetoothterminalapp/domain/exceptions/InvalidDeviceAddressException.kt diff --git a/app/src/main/java/com/eva/bluetoothterminalapp/data/bluetooth_le/AndroidBLEClientConnector.kt b/app/src/main/java/com/eva/bluetoothterminalapp/data/bluetooth_le/AndroidBLEClientConnector.kt index 04211fe..675a403 100644 --- a/app/src/main/java/com/eva/bluetoothterminalapp/data/bluetooth_le/AndroidBLEClientConnector.kt +++ b/app/src/main/java/com/eva/bluetoothterminalapp/data/bluetooth_le/AndroidBLEClientConnector.kt @@ -15,16 +15,19 @@ import com.eva.bluetoothterminalapp.data.mapper.canIndicate import com.eva.bluetoothterminalapp.data.mapper.canNotify import com.eva.bluetoothterminalapp.data.mapper.toDomainModel import com.eva.bluetoothterminalapp.data.samples.SampleUUIDReader +import com.eva.bluetoothterminalapp.data.utils.hasBTConnectPermission import com.eva.bluetoothterminalapp.domain.bluetooth.models.BluetoothDeviceModel import com.eva.bluetoothterminalapp.domain.bluetooth_le.BluetoothLEClientConnector import com.eva.bluetoothterminalapp.domain.bluetooth_le.enums.BLEConnectionState import com.eva.bluetoothterminalapp.domain.bluetooth_le.models.BLECharacteristicsModel import com.eva.bluetoothterminalapp.domain.bluetooth_le.models.BLEDescriptorModel import com.eva.bluetoothterminalapp.domain.bluetooth_le.models.BLEServiceModel -import com.eva.bluetoothterminalapp.domain.exceptions.BLECharacteristicIndicateOrNotifyPropertyException import com.eva.bluetoothterminalapp.domain.exceptions.BLEIndicationOrNotifyRunningException -import com.eva.bluetoothterminalapp.domain.exceptions.BLEServiceAndCharacteristicMatchNotFoundException -import com.eva.bluetoothterminalapp.presentation.util.BTConstants +import com.eva.bluetoothterminalapp.domain.exceptions.BLEMissingNotifyPropertiesException +import com.eva.bluetoothterminalapp.domain.exceptions.BluetoothNotEnabled +import com.eva.bluetoothterminalapp.domain.exceptions.BluetoothPermissionNotProvided +import com.eva.bluetoothterminalapp.domain.exceptions.InvalidBLEConfigurationException +import com.eva.bluetoothterminalapp.domain.exceptions.InvalidDeviceAddressException import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -40,30 +43,28 @@ class AndroidBLEClientConnector( private val reader: SampleUUIDReader, ) : BluetoothLEClientConnector { - private val gattCallback = BLEClientGattCallback(reader, echoWrite = true) - private val _bluetoothManager by lazy { context.getSystemService() } + private val _gattCallback by lazy { BLEClientGattCallback(reader = reader, echoWrite = true) } private val _btAdapter: BluetoothAdapter? get() = _bluetoothManager?.adapter override val connectionState: StateFlow - get() = gattCallback.connectionState + get() = _gattCallback.connectionState override val deviceRssi: StateFlow - get() = gattCallback.deviceRssi + get() = _gattCallback.deviceRssi override val bleServices: Flow> - get() = gattCallback.bleServicesFlowAsDomainModel + get() = _gattCallback.bleGattServices override val readForCharacteristic: Flow - get() = gattCallback.readCharacteristics + get() = _gattCallback.readCharacteristics private var _connectedDevice: BluetoothDeviceModel? = null override val connectedDevice: BluetoothDeviceModel? get() = _connectedDevice - private val _isNotifyOrIndicationRunning = MutableStateFlow(false) override val isNotifyOrIndicationRunning: StateFlow get() = _isNotifyOrIndicationRunning.asStateFlow() @@ -72,17 +73,22 @@ class AndroidBLEClientConnector( private var _bLEGatt: BluetoothGatt? = null override suspend fun connect(address: String, autoConnect: Boolean): Result { - return try { + if (!context.hasBTConnectPermission) + return Result.failure(BluetoothPermissionNotProvided()) + if (_bluetoothManager?.adapter?.isEnabled != true) + return Result.failure(BluetoothNotEnabled()) + if (!BluetoothAdapter.checkBluetoothAddress(address)) + return Result.failure(InvalidDeviceAddressException()) - val device = _btAdapter?.getRemoteDevice(address) - ?: return Result.success(false) + return try { + val device = _btAdapter?.getRemoteDevice(address) ?: return Result.success(false) // update the device _connectedDevice = device.toDomainModel() // connect to the gatt server _bLEGatt = device.connectGatt( context, autoConnect, - gattCallback, + _gattCallback, BluetoothDevice.TRANSPORT_LE ) Log.d(TAG, "CONNECT GATT") @@ -128,10 +134,10 @@ class AndroidBLEClientConnector( override fun read(service: BLEServiceModel, characteristic: BLECharacteristicsModel) : Result { return try { - val gattCharacteristic = gattCallback.findCharacteristicFromDomainModel( + val gattCharacteristic = _gattCallback.findCharacteristicFromDomainModel( service = service, characteristic = characteristic - ) ?: return Result.failure(BLEServiceAndCharacteristicMatchNotFoundException()) + ) ?: return Result.failure(InvalidBLEConfigurationException()) val isSuccess = _bLEGatt?.readCharacteristic(gattCharacteristic) ?: false @@ -144,7 +150,6 @@ class AndroidBLEClientConnector( } } - @Suppress("DEPRECATION") override fun write( service: BLEServiceModel, characteristic: BLECharacteristicsModel, @@ -153,10 +158,10 @@ class AndroidBLEClientConnector( return try { val bytes = value.encodeToByteArray() - val gattCharacteristic = gattCallback.findCharacteristicFromDomainModel( + val gattCharacteristic = _gattCallback.findCharacteristicFromDomainModel( service = service, characteristic = characteristic - ) ?: return Result.failure(BLEServiceAndCharacteristicMatchNotFoundException()) + ) ?: return Result.failure(InvalidBLEConfigurationException()) val isSuccess = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { val operation = _bLEGatt?.writeCharacteristic( @@ -191,17 +196,17 @@ class AndroidBLEClientConnector( // if enable true and notify or indication is already running then no need to // start indication or notify if (!characteristic.isIndicateOrNotify) - return Result.failure(BLECharacteristicIndicateOrNotifyPropertyException()) + return Result.failure(BLEMissingNotifyPropertiesException()) if (enable && _isNotifyOrIndicationRunning.value) { Log.i(TAG, "INDICATION OR NOTIFICATION ALREADY RUNNING") return Result.failure(BLEIndicationOrNotifyRunningException()) } - val gattCharacteristic = gattCallback.findCharacteristicFromDomainModel( + val gattCharacteristic = _gattCallback.findCharacteristicFromDomainModel( service = service, characteristic = characteristic - ) ?: return Result.failure(BLEServiceAndCharacteristicMatchNotFoundException()) + ) ?: return Result.failure(InvalidBLEConfigurationException()) // set characteristic notifications val isSuccess = _bLEGatt?.setCharacteristicNotification(gattCharacteristic, enable) @@ -219,18 +224,17 @@ class AndroidBLEClientConnector( _isNotifyOrIndicationRunning.update { enable } Log.i(TAG, "INDICATION OR NOTIFICATION MARKED AS $enable") - // set the descriptor value for client config val descriptorValue = when { enable && characteristic.canNotify -> BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE enable && characteristic.canIndicate -> BluetoothGattDescriptor.ENABLE_INDICATION_VALUE !enable -> BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE - else -> return Result.failure(BLECharacteristicIndicateOrNotifyPropertyException()) + else -> return Result.failure(BLEMissingNotifyPropertiesException()) } // needs a client config descriptor if not found then it's a failure val isWriteDescriptor = gattCharacteristic - .getDescriptor(BTConstants.CLIENT_CONFIG_DESCRIPTOR_UUID) + .getDescriptor(BLEClientUUID.CCC_DESCRIPTOR_UUID) ?.let { desc -> writeToDescriptor(descriptor = desc, bytes = descriptorValue) } Log.d(TAG, "IS WRITE DESC SUCCESS ${isWriteDescriptor?.isSuccess}") @@ -248,16 +252,15 @@ class AndroidBLEClientConnector( descriptor: BLEDescriptorModel ): Result { return try { - - val probableDescriptor = gattCallback.findDescriptorFromDomainModel( + val gattDescriptor = _gattCallback.findDescriptorFromDomainModel( service = service, characteristic = characteristic, descriptor = descriptor - ) + ) ?: return Result.failure(InvalidBLEConfigurationException()) - val isSuccess = _bLEGatt?.readDescriptor(probableDescriptor) ?: false + val isSuccess = _bLEGatt?.readDescriptor(gattDescriptor) ?: false - Log.i(TAG, "READ DESCRIPTOR $probableDescriptor SUCCESS:$isSuccess") + Log.i(TAG, "READ DESCRIPTOR $gattDescriptor SUCCESS:$isSuccess") Result.success(isSuccess) } catch (e: Exception) { @@ -275,26 +278,13 @@ class AndroidBLEClientConnector( return try { val bytes = value.encodeToByteArray() - val probableDescriptor = gattCallback.findDescriptorFromDomainModel( + val gattDescriptor = _gattCallback.findDescriptorFromDomainModel( service = service, characteristic = characteristic, descriptor = descriptor - ) ?: return Result.failure(BLEServiceAndCharacteristicMatchNotFoundException()) + ) ?: return Result.failure(InvalidBLEConfigurationException()) - val isSuccess = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - val operation = _bLEGatt?.writeDescriptor(probableDescriptor, bytes) - operation == BluetoothStatusCodes.SUCCESS - } else { - probableDescriptor.value = bytes - _bLEGatt?.writeDescriptor(probableDescriptor) ?: false - } - - Log.i( - TAG, - "WRITE TO DESCRIPTOR ${characteristic.uuid} SUCCESS:$isSuccess" - ) - - Result.success(isSuccess) + writeToDescriptor(gattDescriptor, bytes) } catch (e: Exception) { e.printStackTrace() Result.failure(e) @@ -316,7 +306,8 @@ class AndroidBLEClientConnector( try { _connectedDevice = null // cancels the scope - gattCallback.cancelAwaitingTasks() + _gattCallback.cleanUp() + reader.clearCache() // close the gatt server _bLEGatt?.close() _bLEGatt = null @@ -326,7 +317,6 @@ class AndroidBLEClientConnector( } } - @Suppress("DEPRECATION") private fun writeToDescriptor(descriptor: BluetoothGattDescriptor, bytes: ByteArray) : Result { return try { diff --git a/app/src/main/java/com/eva/bluetoothterminalapp/data/bluetooth_le/AndroidBluetoothLEScanner.kt b/app/src/main/java/com/eva/bluetoothterminalapp/data/bluetooth_le/AndroidBluetoothLEScanner.kt index 749101a..951d49b 100644 --- a/app/src/main/java/com/eva/bluetoothterminalapp/data/bluetooth_le/AndroidBluetoothLEScanner.kt +++ b/app/src/main/java/com/eva/bluetoothterminalapp/data/bluetooth_le/AndroidBluetoothLEScanner.kt @@ -1,6 +1,5 @@ package com.eva.bluetoothterminalapp.data.bluetooth_le -import android.Manifest import android.annotation.SuppressLint import android.bluetooth.BluetoothAdapter import android.bluetooth.BluetoothDevice @@ -11,12 +10,11 @@ import android.bluetooth.le.ScanResult import android.bluetooth.le.ScanSettings import android.content.Context import android.content.pm.PackageManager -import android.os.Build import android.util.Log -import androidx.core.content.ContextCompat -import androidx.core.content.PermissionChecker import androidx.core.content.getSystemService import com.eva.bluetoothterminalapp.data.mapper.toDomainModel +import com.eva.bluetoothterminalapp.data.utils.hasBTScanPermission +import com.eva.bluetoothterminalapp.data.utils.hasLocationPermission import com.eva.bluetoothterminalapp.domain.bluetooth_le.BluetoothLEScanner import com.eva.bluetoothterminalapp.domain.bluetooth_le.enums.ScanError import com.eva.bluetoothterminalapp.domain.bluetooth_le.models.BluetoothLEDeviceModel @@ -31,7 +29,6 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.onFailure -import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -39,10 +36,9 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import kotlin.time.Duration +import kotlinx.coroutines.withContext -private const val LOGGER_TAG = "BLE_SCANNER_TAG" +private const val TAG = "BLE_SCANNER_TAG" private typealias BluetoothDevices = List @@ -64,16 +60,10 @@ class AndroidBluetoothLEScanner( get() = context.packageManager.hasSystemFeature(PackageManager.FEATURE_BLUETOOTH_LE) private val _hasScanPermission: Boolean - get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) - ContextCompat.checkSelfPermission( - context, Manifest.permission.BLUETOOTH_SCAN - ) == PermissionChecker.PERMISSION_GRANTED - else true + get() = context.hasBTScanPermission private val _hasLocationPermission: Boolean - get() = ContextCompat.checkSelfPermission( - context, Manifest.permission.ACCESS_FINE_LOCATION - ) == PermissionChecker.PERMISSION_GRANTED + get() = context.hasLocationPermission private val _devices = MutableStateFlow(emptyList()) override val leDevices: StateFlow @@ -83,14 +73,10 @@ class AndroidBluetoothLEScanner( override val isScanning: StateFlow get() = _isScanning.asStateFlow() - private val _scanError = Channel() + private val _scanError = Channel(capacity = Channel.CONFLATED) override val scanErrorCode: Flow get() = _scanError.receiveAsFlow() - private val _deviceAddresses: List - get() = _devices.value.map { it.deviceModel.address } - - private val _bLeScanCallback = object : ScanCallback() { override fun onScanResult(callbackType: Int, result: ScanResult?) { @@ -99,8 +85,9 @@ class AndroidBluetoothLEScanner( if (result?.isConnectable == false) return // if results has no address skip val address = result?.device?.address ?: return + val deviceAddresses = _devices.value.map { it.deviceModel.address } // if it's a new device - if (address !in _deviceAddresses) { + if (address !in deviceAddresses) { val newDevice = result.toDomainModel() // add it to devices _devices.update { devices -> devices + newDevice } @@ -129,38 +116,40 @@ class AndroidBluetoothLEScanner( } val result = _scanError.trySend(error) result.onFailure { - Log.d(LOGGER_TAG, "FAILED TO SEND ERROR CODE : $error") + Log.d(TAG, "FAILED TO SEND ERROR CODE : $error") } } } - override suspend fun startDiscovery(duration: Duration) { + override suspend fun startDiscovery() { //checking for failures val result = checkIfPermissionAndBTEnabled() - result.onFailure { err -> Log.d(LOGGER_TAG, err.message ?: "") } if (result.isFailure || _isScanning.value) return // if normal scan is running then stop it - if (_btAdapter?.isDiscovering == true) _btAdapter?.cancelDiscovery() + if (_btAdapter?.isDiscovering == true) + _btAdapter?.cancelDiscovery() val settings = bleSettings.getSettings() val breakTime = settings.scanPeriod.duration - coroutineScope { - try { - val postJob = launch(Dispatchers.Main) { - delay(breakTime) - stopScanCallback() - } - startScanCallBack() - // the coroutine is queued wait for duration to stop the scan - postJob.join() - } catch (_: CancellationException) { - // if there is a cancellation exception then stop the scan - stopScanCallback() - } catch (e: Exception) { - e.printStackTrace() + try { + // starts the scan + startScanCallBack() + // the function suspends for breaktime + withContext(Dispatchers.Main) { + delay(breakTime) + } + } catch (e: Exception) { + if (e is CancellationException) { + Log.d(TAG, "SCAN CANCELLED") + throw e } + e.printStackTrace() + } finally { + Log.d(TAG, "STOPING SCAN") + // if exception is thrown or the try block executed this will be oke + stopScanCallback() } } @@ -211,7 +200,7 @@ class AndroidBluetoothLEScanner( .build() _btAdapter?.bluetoothLeScanner?.startScan(filters, scanSettings, _bLeScanCallback) - Log.d(LOGGER_TAG, "SCAN STARTED") + Log.d(TAG, "SCAN STARTED ") } @@ -221,7 +210,7 @@ class AndroidBluetoothLEScanner( // stop the scan details _isScanning.update { false } _btAdapter?.bluetoothLeScanner?.stopScan(_bLeScanCallback) - Log.d(LOGGER_TAG, "SCAN STOPPED") + Log.d(TAG, "SCAN STOPPED") } private fun checkIfPermissionAndBTEnabled(): Result = when { diff --git a/app/src/main/java/com/eva/bluetoothterminalapp/data/bluetooth_le/BLEClientGattCallback.kt b/app/src/main/java/com/eva/bluetoothterminalapp/data/bluetooth_le/BLEClientGattCallback.kt index e8f7f3e..a349674 100644 --- a/app/src/main/java/com/eva/bluetoothterminalapp/data/bluetooth_le/BLEClientGattCallback.kt +++ b/app/src/main/java/com/eva/bluetoothterminalapp/data/bluetooth_le/BLEClientGattCallback.kt @@ -16,7 +16,6 @@ import com.eva.bluetoothterminalapp.domain.bluetooth_le.enums.BLEConnectionState import com.eva.bluetoothterminalapp.domain.bluetooth_le.models.BLECharacteristicsModel import com.eva.bluetoothterminalapp.domain.bluetooth_le.models.BLEDescriptorModel import com.eva.bluetoothterminalapp.domain.bluetooth_le.models.BLEServiceModel -import com.eva.bluetoothterminalapp.presentation.util.BTConstants import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -24,8 +23,6 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.cancellable -import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -48,14 +45,12 @@ class BLEClientGattCallback( val connectionState = _connectionState.asStateFlow() private val _bleGattServices = MutableStateFlow>(emptyList()) + val bleGattServices = _bleGattServices + .map { services -> services.toDomainModelWithNames(reader = reader) } + private val bleGattServicesValue: List get() = _bleGattServices.value - val bleServicesFlowAsDomainModel = _bleGattServices - .map { services -> services.toDomainModelWithNames(reader = reader) } - .cancellable() - .flowOn(Dispatchers.IO) - private val _readCharacteristic = MutableStateFlow(null) val readCharacteristics = _readCharacteristic.asStateFlow() @@ -67,19 +62,19 @@ class BLEClientGattCallback( return } - Log.d(GATT_LOGGER, "CONNECTION STATE DISCOVERY") - - val connectionState = when (newState) { + val newConnectionState = when (newState) { BluetoothProfile.STATE_CONNECTED -> BLEConnectionState.CONNECTED BluetoothProfile.STATE_DISCONNECTED -> BLEConnectionState.DISCONNECTED BluetoothProfile.STATE_CONNECTING -> BLEConnectionState.CONNECTING BluetoothProfile.STATE_DISCONNECTING -> BLEConnectionState.DISCONNECTING - else -> BLEConnectionState.FAILED + else -> null } - _connectionState.update { connectionState } + Log.d(GATT_LOGGER, "NEW CONNECTION STATE :$newConnectionState") + + _connectionState.update { newConnectionState ?: BLEConnectionState.FAILED } - if (connectionState != BLEConnectionState.CONNECTED) return + if (newConnectionState != BLEConnectionState.CONNECTED) return // signal strength this can change gatt?.readRemoteRssi() @@ -91,7 +86,7 @@ class BLEClientGattCallback( override fun onReadRemoteRssi(gatt: BluetoothGatt?, rssi: Int, status: Int) { if (status != BluetoothGatt.GATT_SUCCESS) return - Log.d(GATT_LOGGER, "RSSI READ") + Log.d(GATT_LOGGER, "READING RSSI SUCCESSFUL") // update rssi value _deviceRssi.update { rssi } } @@ -99,7 +94,7 @@ class BLEClientGattCallback( override fun onServicesDiscovered(gatt: BluetoothGatt?, status: Int) { if (status != BluetoothGatt.GATT_SUCCESS) return - Log.d(GATT_LOGGER, "SERVICES DISCOVERED ") + Log.d(GATT_LOGGER, "SERVICES DISCOVERED") val services = gatt?.services ?: emptyList() @@ -110,14 +105,11 @@ class BLEClientGattCallback( override fun onServiceChanged(gatt: BluetoothGatt) { // re-discover services - Log.d(GATT_LOGGER, "DEVICES CHANGED") + Log.d(GATT_LOGGER, "SERVICES CHANGED RE-DISCOVERING SERVICES") gatt.discoverServices() } - @Deprecated( - message = "Used natively in Android 12 and lower", - replaceWith = ReplaceWith("onCharacteristicChanged(gatt, characteristic, characteristic.value)") - ) + @Deprecated("Deprecated in Java") override fun onCharacteristicRead( gatt: BluetoothGatt?, characteristic: BluetoothGattCharacteristic?, @@ -137,6 +129,8 @@ class BLEClientGattCallback( ) { if (status != BluetoothGatt.GATT_SUCCESS) return + Log.d(GATT_LOGGER, "READ CHARACTERISTICS :${characteristic.uuid}") + scope.launch { try { // decode the received value and decide it @@ -168,10 +162,7 @@ class BLEClientGattCallback( Log.d(GATT_LOGGER, "UPDATING THE CHARACTERISTIC VALUE $isSuccess") } - @Deprecated( - message = "Used natively in Android 12 and lower", - replaceWith = ReplaceWith("onDescriptorRead(gatt, descriptor, descriptor.value)") - ) + @Deprecated("Deprecated in Java") override fun onDescriptorRead( gatt: BluetoothGatt?, descriptor: BluetoothGattDescriptor?, @@ -191,6 +182,11 @@ class BLEClientGattCallback( ) { if (status != BluetoothGatt.GATT_SUCCESS) return + Log.d( + GATT_LOGGER, + "READING DESCRIPTOR :${descriptor.uuid} CHARACTERISTICS :${descriptor.characteristic.uuid}" + ) + scope.launch { try { val characteristic = _readCharacteristic.value @@ -231,10 +227,7 @@ class BLEClientGattCallback( Log.d(GATT_LOGGER, "UPDATED DESCRIPTOR VALUE $isSuccess") } - @Deprecated( - message = "Used natively in Android 12 and lower", - replaceWith = ReplaceWith("onCharacteristicChanged(gatt, characteristic, value)") - ) + @Deprecated("Deprecated in Java") override fun onCharacteristicChanged( gatt: BluetoothGatt?, characteristic: BluetoothGattCharacteristic? @@ -261,7 +254,7 @@ class BLEClientGattCallback( _readCharacteristic.update { domainModel } // read the get descriptor then val configDescriptor = characteristic - .getDescriptor(BTConstants.CLIENT_CONFIG_DESCRIPTOR_UUID) + .getDescriptor(BLEClientUUID.CCC_DESCRIPTOR_UUID) gatt.readDescriptor(configDescriptor) // set the read value once @@ -281,7 +274,7 @@ class BLEClientGattCallback( } } - fun cancelAwaitingTasks() = scope.cancel() + fun cleanUp() = scope.cancel() fun findCharacteristicFromDomainModel( service: BLEServiceModel, diff --git a/app/src/main/java/com/eva/bluetoothterminalapp/data/bluetooth_le/BLEClientUUID.kt b/app/src/main/java/com/eva/bluetoothterminalapp/data/bluetooth_le/BLEClientUUID.kt new file mode 100644 index 0000000..7440e0f --- /dev/null +++ b/app/src/main/java/com/eva/bluetoothterminalapp/data/bluetooth_le/BLEClientUUID.kt @@ -0,0 +1,7 @@ +package com.eva.bluetoothterminalapp.data.bluetooth_le + +import java.util.UUID + +object BLEClientUUID { + val CCC_DESCRIPTOR_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805F9B34FB") +} \ No newline at end of file diff --git a/app/src/main/java/com/eva/bluetoothterminalapp/data/samples/SampleUUIDReader.kt b/app/src/main/java/com/eva/bluetoothterminalapp/data/samples/SampleUUIDReader.kt index 66ed1fc..e542d91 100644 --- a/app/src/main/java/com/eva/bluetoothterminalapp/data/samples/SampleUUIDReader.kt +++ b/app/src/main/java/com/eva/bluetoothterminalapp/data/samples/SampleUUIDReader.kt @@ -9,6 +9,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.SerializationException @@ -18,7 +20,7 @@ import java.io.FileNotFoundException import java.util.UUID import kotlin.time.measureTime -private const val READER_LOGGER = "SAMPLE_SERVICE_READER" +private const val TAG = "SAMPLE_SERVICE_READER" @OptIn(ExperimentalSerializationApi::class) class SampleUUIDReader(private val context: Context) { @@ -27,110 +29,129 @@ class SampleUUIDReader(private val context: Context) { get() = context.assets private val _serviceUUIDCache = mutableScatterMapOf() - private val _characteristicsUUIDCache = mutableScatterMapOf() - private val _descriptorUUIDCache = mutableScatterMapOf() - private suspend fun loadServiceUUIDFromFile() = withContext(Dispatchers.IO) { - try { - assetsManager.open("ble_service_uuids.json").use { stream -> - val samples = Json.decodeFromStream>(stream) - val data = samples.map { model -> model.uuid128Bits to model } - .distinctBy { it.first } - _serviceUUIDCache.putAll(data) - } + private val _serviceMutex = Mutex() + private val _characteristicsMutex = Mutex() + private val _descriptorMutex = Mutex() - Log.d(READER_LOGGER, "LOADED SERVICES SAMPLES") - } catch (e: CancellationException) { - throw e - } catch (_: FileNotFoundException) { - Log.d(READER_LOGGER, "FILE_NOT_FOUND") - } catch (_: SerializationException) { - Log.d(READER_LOGGER, "JSON SERIALIZATION EXCEPTION") - } - } + private suspend fun loadServiceUUIDFromFile() = _serviceMutex.withLock { + // if cache is already present no need to load again + if (_serviceUUIDCache.isNotEmpty()) return@withLock - private suspend fun loadCharacteristicsFromFile() = withContext(Dispatchers.IO) { - try { - assetsManager.open("ble_characteristics_uuids.json") - .use { stream -> + withContext(Dispatchers.IO) { + try { + assetsManager.open("ble_service_uuids.json").use { stream -> val samples = Json.decodeFromStream>(stream) - val data = samples.map { model -> model.uuid128Bits to model } - .distinctBy { it.first } - _characteristicsUUIDCache.putAll(data) + samples + .distinctBy { item -> item.id } + .forEach { model -> _serviceUUIDCache[model.uuid128Bits] = model } } - Log.d(READER_LOGGER, "LOADED SAMPLES CHARACTERISTICS") - } catch (e: CancellationException) { - throw e - } catch (_: FileNotFoundException) { - Log.d(READER_LOGGER, "FILE_NOT_FOUND") - } catch (_: SerializationException) { - Log.d(READER_LOGGER, "JSON SERIALIZATION EXCEPTION") + + Log.d(TAG, "LOADED SERVICES SAMPLES") + } catch (e: CancellationException) { + throw e + } catch (_: FileNotFoundException) { + Log.d(TAG, "FILE_NOT_FOUND") + } catch (_: SerializationException) { + Log.d(TAG, "JSON SERIALIZATION EXCEPTION") + } + } + } + + + private suspend fun loadCharacteristicsFromFile() = _characteristicsMutex.withLock { + // if characteristics are already in memory then skip + if (_characteristicsUUIDCache.isNotEmpty()) return@withLock + + withContext(Dispatchers.IO) { + try { + assetsManager.open("ble_characteristics_uuids.json") + .use { stream -> + val samples = Json.decodeFromStream>(stream) + samples + .distinctBy { item -> item.id } + .forEach { model -> + _characteristicsUUIDCache[model.uuid128Bits] = model + } + } + Log.d(TAG, "LOADED SAMPLES CHARACTERISTICS") + } catch (e: CancellationException) { + throw e + } catch (_: FileNotFoundException) { + Log.d(TAG, "FILE_NOT_FOUND") + } catch (_: SerializationException) { + Log.d(TAG, "JSON SERIALIZATION EXCEPTION") + } } } - private suspend fun loadDescriptorsFromFile() = withContext(Dispatchers.IO) { - try { - assetsManager.open("ble_descriptor_uuids.json") - .use { inputStream -> - val samples = Json.decodeFromStream>(inputStream) - val data = samples.map { model -> model.uuid128Bits to model } - .distinctBy { it.first } - _descriptorUUIDCache.putAll(data) + + private suspend fun loadDescriptorsFromFile() = _descriptorMutex.withLock { + // if descriptor are loaded then cancel loading + if (_descriptorUUIDCache.isNotEmpty()) return@withLock + + withContext(Dispatchers.IO) { + try { + assetsManager.open("ble_descriptor_uuids.json").use { stream -> + val samples = Json.decodeFromStream>(stream) + samples + .distinctBy { item -> item.id } + .forEach { model -> + _descriptorUUIDCache[model.uuid128Bits] = model + } } - Log.d(READER_LOGGER, "LOADED DESCRIPTORS") - } catch (e: CancellationException) { - throw e - } catch (_: FileNotFoundException) { - Log.d(READER_LOGGER, "FILE_NOT_FOUND") - } catch (_: SerializationException) { - Log.d(READER_LOGGER, "JSON SERIALIZATION EXCEPTION") + Log.d(TAG, "LOADED DESCRIPTORS") + } catch (e: CancellationException) { + throw e + } catch (_: FileNotFoundException) { + Log.d(TAG, "FILE_NOT_FOUND") + } catch (_: SerializationException) { + Log.d(TAG, "JSON SERIALIZATION EXCEPTION") + } } } + /** * Loads all the files and save them on the hashmap * This should be used when you bulk check the uuids * this loads all the cache */ - suspend fun loadFromFiles() { - coroutineScope { - val loaders = buildList { - if (_serviceUUIDCache.isEmpty()) - add(async(Dispatchers.IO) { loadServiceUUIDFromFile() }) - if (_characteristicsUUIDCache.isEmpty()) - add(async(Dispatchers.IO) { loadCharacteristicsFromFile() }) - if (_descriptorUUIDCache.isEmpty()) - add(async(Dispatchers.IO) { loadDescriptorsFromFile() }) - } - if (loaders.isNotEmpty()) { - val time = measureTime { loaders.awaitAll() } - Log.d(READER_LOGGER, "TIME TO LOAD DATA :$time") - } + suspend fun loadFromFiles() = coroutineScope { + val loaders = buildList { + add(async { loadServiceUUIDFromFile() }) + add(async { loadCharacteristicsFromFile() }) + add(async { loadDescriptorsFromFile() }) } + val time = measureTime { loaders.awaitAll() } + Log.d(TAG, "TIME TO LOAD DATA :$time") + Unit } + suspend fun findServiceNameForUUID(uuid: UUID): SampleBLEUUIDModel? { - // if the files are not loaded then services will be loaded in the memory if (_serviceUUIDCache.isEmpty()) loadServiceUUIDFromFile() - return if (!_serviceUUIDCache.isEmpty()) null - else _serviceUUIDCache[uuid] + return _serviceUUIDCache[uuid] } suspend fun findDescriptorNameForUUID(uuid: UUID): SampleBLEUUIDModel? { - // if the files are not loaded then services will be loaded in the memory if (_descriptorUUIDCache.isEmpty()) loadDescriptorsFromFile() - return if (!_descriptorUUIDCache.contains(uuid)) null - else _descriptorUUIDCache[uuid] + return _descriptorUUIDCache[uuid] } suspend fun findCharacteristicsNameForUUID(uuid: UUID): SampleBLEUUIDModel? { - // if the file is not loaded if (_characteristicsUUIDCache.isEmpty()) loadCharacteristicsFromFile() - return if (!_characteristicsUUIDCache.contains(uuid)) null - else _characteristicsUUIDCache[uuid] + return _characteristicsUUIDCache[uuid] + } + + fun clearCache() { + Log.d(TAG, "CLEARING CACHE") + _descriptorUUIDCache.clear() + _characteristicsUUIDCache.clear() + _descriptorUUIDCache.clear() } } \ No newline at end of file diff --git a/app/src/main/java/com/eva/bluetoothterminalapp/data/utils/PermissionsExt.kt b/app/src/main/java/com/eva/bluetoothterminalapp/data/utils/PermissionsExt.kt index 9369ce3..44d822b 100644 --- a/app/src/main/java/com/eva/bluetoothterminalapp/data/utils/PermissionsExt.kt +++ b/app/src/main/java/com/eva/bluetoothterminalapp/data/utils/PermissionsExt.kt @@ -26,3 +26,11 @@ val Context.hasBTConnectPermission: Boolean ) == PermissionChecker.PERMISSION_GRANTED } else true +val Context.hasBTAdvertisePermission: Boolean + get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + ContextCompat.checkSelfPermission( + this, + Manifest.permission.BLUETOOTH_ADVERTISE + ) == PermissionChecker.PERMISSION_GRANTED + } else true + diff --git a/app/src/main/java/com/eva/bluetoothterminalapp/domain/bluetooth_le/BluetoothLEScanner.kt b/app/src/main/java/com/eva/bluetoothterminalapp/domain/bluetooth_le/BluetoothLEScanner.kt index 681370a..75a68ae 100644 --- a/app/src/main/java/com/eva/bluetoothterminalapp/domain/bluetooth_le/BluetoothLEScanner.kt +++ b/app/src/main/java/com/eva/bluetoothterminalapp/domain/bluetooth_le/BluetoothLEScanner.kt @@ -4,8 +4,6 @@ import com.eva.bluetoothterminalapp.domain.bluetooth_le.enums.ScanError import com.eva.bluetoothterminalapp.domain.bluetooth_le.models.BluetoothLEDeviceModel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow -import kotlin.time.Duration -import kotlin.time.Duration.Companion.seconds /** * Bluetooth Low Energy Scanner @@ -35,9 +33,8 @@ interface BluetoothLEScanner { /** * Start the scan for device discovery - * @param duration Determines the duration for which the scan should run */ - suspend fun startDiscovery(duration: Duration = 8.seconds) + suspend fun startDiscovery() /** * Stops the running discovery diff --git a/app/src/main/java/com/eva/bluetoothterminalapp/domain/bluetooth_le/models/BLEServiceModel.kt b/app/src/main/java/com/eva/bluetoothterminalapp/domain/bluetooth_le/models/BLEServiceModel.kt index 02602ec..9a0d765 100644 --- a/app/src/main/java/com/eva/bluetoothterminalapp/domain/bluetooth_le/models/BLEServiceModel.kt +++ b/app/src/main/java/com/eva/bluetoothterminalapp/domain/bluetooth_le/models/BLEServiceModel.kt @@ -27,6 +27,6 @@ data class BLEServiceModel( } - val charisticsCount: Int + val characteristicsCount: Int get() = _characteristic.size } \ No newline at end of file diff --git a/app/src/main/java/com/eva/bluetoothterminalapp/domain/exceptions/BLECharacteristicIndicateOrNotifyPropertyException.kt b/app/src/main/java/com/eva/bluetoothterminalapp/domain/exceptions/BLEMissingNotifyPropertiesException.kt similarity index 73% rename from app/src/main/java/com/eva/bluetoothterminalapp/domain/exceptions/BLECharacteristicIndicateOrNotifyPropertyException.kt rename to app/src/main/java/com/eva/bluetoothterminalapp/domain/exceptions/BLEMissingNotifyPropertiesException.kt index 9e1037a..ad63e4a 100644 --- a/app/src/main/java/com/eva/bluetoothterminalapp/domain/exceptions/BLECharacteristicIndicateOrNotifyPropertyException.kt +++ b/app/src/main/java/com/eva/bluetoothterminalapp/domain/exceptions/BLEMissingNotifyPropertiesException.kt @@ -1,4 +1,4 @@ package com.eva.bluetoothterminalapp.domain.exceptions -class BLECharacteristicIndicateOrNotifyPropertyException : +class BLEMissingNotifyPropertiesException : Exception("Missing properties indication or notify, these are required to allow BLE notify or indication") \ No newline at end of file diff --git a/app/src/main/java/com/eva/bluetoothterminalapp/domain/exceptions/BTAdvertisePermissionNotFound.kt b/app/src/main/java/com/eva/bluetoothterminalapp/domain/exceptions/BTAdvertisePermissionNotFound.kt new file mode 100644 index 0000000..b042875 --- /dev/null +++ b/app/src/main/java/com/eva/bluetoothterminalapp/domain/exceptions/BTAdvertisePermissionNotFound.kt @@ -0,0 +1,4 @@ +package com.eva.bluetoothterminalapp.domain.exceptions + +class BTAdvertisePermissionNotFound + : Exception("Required Bluetooth advertise permission") \ No newline at end of file diff --git a/app/src/main/java/com/eva/bluetoothterminalapp/domain/exceptions/BLEServiceAndCharacteristicMatchNotFoundException.kt b/app/src/main/java/com/eva/bluetoothterminalapp/domain/exceptions/InvalidBLEConfigurationException.kt similarity index 68% rename from app/src/main/java/com/eva/bluetoothterminalapp/domain/exceptions/BLEServiceAndCharacteristicMatchNotFoundException.kt rename to app/src/main/java/com/eva/bluetoothterminalapp/domain/exceptions/InvalidBLEConfigurationException.kt index 1bd08b0..5970253 100644 --- a/app/src/main/java/com/eva/bluetoothterminalapp/domain/exceptions/BLEServiceAndCharacteristicMatchNotFoundException.kt +++ b/app/src/main/java/com/eva/bluetoothterminalapp/domain/exceptions/InvalidBLEConfigurationException.kt @@ -1,4 +1,4 @@ package com.eva.bluetoothterminalapp.domain.exceptions -class BLEServiceAndCharacteristicMatchNotFoundException : +class InvalidBLEConfigurationException : Exception("Invalid Service or characteristic for the connected device") \ No newline at end of file diff --git a/app/src/main/java/com/eva/bluetoothterminalapp/domain/exceptions/InvalidDeviceAddressException.kt b/app/src/main/java/com/eva/bluetoothterminalapp/domain/exceptions/InvalidDeviceAddressException.kt new file mode 100644 index 0000000..7207a83 --- /dev/null +++ b/app/src/main/java/com/eva/bluetoothterminalapp/domain/exceptions/InvalidDeviceAddressException.kt @@ -0,0 +1,4 @@ +package com.eva.bluetoothterminalapp.domain.exceptions + +class InvalidDeviceAddressException : + Exception("Invalid hardware address, must be upper case, in big endian byte order") diff --git a/app/src/main/java/com/eva/bluetoothterminalapp/presentation/feature_devices/BTDeviceViewmodel.kt b/app/src/main/java/com/eva/bluetoothterminalapp/presentation/feature_devices/BTDeviceViewmodel.kt index 285022a..65eb5ea 100644 --- a/app/src/main/java/com/eva/bluetoothterminalapp/presentation/feature_devices/BTDeviceViewmodel.kt +++ b/app/src/main/java/com/eva/bluetoothterminalapp/presentation/feature_devices/BTDeviceViewmodel.kt @@ -8,6 +8,7 @@ import com.eva.bluetoothterminalapp.presentation.feature_devices.state.BTDevices import com.eva.bluetoothterminalapp.presentation.util.AppViewModel import com.eva.bluetoothterminalapp.presentation.util.UiEvents import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -72,6 +73,8 @@ class BTDeviceViewmodel( override val uiEvents: SharedFlow get() = _uiEvents.asSharedFlow() + private var _leScanJob: Job? = null + fun onEvents(event: BTDevicesScreenEvents) { when (event) { @@ -98,11 +101,18 @@ class BTDeviceViewmodel( } - private fun startLEScan() = viewModelScope.launch { - bLEScanner.startDiscovery() + private fun startLEScan() { + _leScanJob?.cancel() + _leScanJob = viewModelScope.launch { + bLEScanner.startDiscovery() + } } - private fun stopLEScan() = bLEScanner.stopDiscovery() + private fun stopLEScan() { + _leScanJob?.cancel() + _leScanJob = null + bLEScanner.stopDiscovery() + } private fun checkLEScanFailedReasons() = viewModelScope.launch { bLEScanner.scanErrorCode.onEach { error -> diff --git a/app/src/main/java/com/eva/bluetoothterminalapp/presentation/util/BTConstants.kt b/app/src/main/java/com/eva/bluetoothterminalapp/presentation/util/BTConstants.kt index 177c76e..dc640b0 100644 --- a/app/src/main/java/com/eva/bluetoothterminalapp/presentation/util/BTConstants.kt +++ b/app/src/main/java/com/eva/bluetoothterminalapp/presentation/util/BTConstants.kt @@ -6,8 +6,6 @@ object BTConstants { const val SERVICE_NAME = "bt-communication" val SERVICE_UUID: UUID = UUID.fromString("3fe6c764-029f-48f0-a2d0-a43d9b1df5c8") - val CLIENT_CONFIG_DESCRIPTOR_UUID: UUID = - UUID.fromString("00002902-0000-1000-8000-00805F9B34FB") const val BLE_ASSIGNED_NUMBERS_WEBSITE = "https://www.bluetooth.com/specifications/assigned-numbers" From b64c58caf6c7e357afc0f901d8b7d9714a9c98f4 Mon Sep 17 00:00:00 2001 From: tuuhin Date: Wed, 15 Oct 2025 23:24:59 +0530 Subject: [PATCH 05/11] While checking the client part Also made some Ui Changes Notable changes include adding the service and characteristics count, Using surface in place of card in BluetoothDeviceCard.kt and BluetoothLEDeviceCard.kt is the device is not paired using a different icon color BluetoothLEDeviceCard.kt also include a signal indicator based on the rssi values. In BluetoothPermissionButton.kt checking all the bluetooth based permission include advertise which is used for advertising the server services --- .../composables/BTDeviceIconLarge.kt | 46 +++--- .../composables/BluetoothPermissionButton.kt | 50 ++++--- .../composables/BTDeviceIcon.kt | 8 +- .../composables/BluetoothDeviceCard.kt | 42 +++--- .../composables/BluetoothDevicesList.kt | 2 + .../composables/BluetoothLEDeviceCard.kt | 140 ++++++++++++------ .../feature_le_connect/BLEDeviceRoute.kt | 7 +- .../composables/BLEDeviceServiceCard.kt | 37 +++-- .../composables/BLEServicesList.kt | 33 ++++- .../SelectableBLECharacteristics.kt | 3 +- app/src/main/res/values/strings.xml | 8 +- 11 files changed, 249 insertions(+), 127 deletions(-) diff --git a/app/src/main/java/com/eva/bluetoothterminalapp/presentation/composables/BTDeviceIconLarge.kt b/app/src/main/java/com/eva/bluetoothterminalapp/presentation/composables/BTDeviceIconLarge.kt index 81f1800..86e84dc 100644 --- a/app/src/main/java/com/eva/bluetoothterminalapp/presentation/composables/BTDeviceIconLarge.kt +++ b/app/src/main/java/com/eva/bluetoothterminalapp/presentation/composables/BTDeviceIconLarge.kt @@ -1,11 +1,12 @@ package com.eva.bluetoothterminalapp.presentation.composables -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.sizeIn import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -14,6 +15,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import androidx.graphics.shapes.CornerRounding import androidx.graphics.shapes.RoundedPolygon @@ -21,6 +23,8 @@ import androidx.graphics.shapes.star import com.eva.bluetoothterminalapp.R import com.eva.bluetoothterminalapp.domain.bluetooth.models.BluetoothDeviceModel import com.eva.bluetoothterminalapp.presentation.feature_devices.util.imageVector +import com.eva.bluetoothterminalapp.presentation.util.PreviewFakes +import com.eva.bluetoothterminalapp.ui.theme.BlueToothTerminalAppTheme import com.eva.bluetoothterminalapp.ui.theme.RoundedPolygonShape @Composable @@ -39,27 +43,33 @@ fun BTDeviceIconLarge( ) } - Box( + Surface( + color = containerColor, + contentColor = contentColor, modifier = modifier + .clip(RoundedPolygonShape(polygon = pillShape, rotate = 45f)) .defaultMinSize( minWidth = dimensionResource(R.dimen.min_bl_image_container_size), minHeight = dimensionResource(R.dimen.min_bl_image_container_size), - ) - .clip( - RoundedPolygonShape(polygon = pillShape, rotate = 45f) - ) - .background(containerColor), - contentAlignment = Alignment.Center + ), ) { - Icon( - imageVector = device.imageVector, - contentDescription = deviceName?.let { - stringResource(id = R.string.devices_image_type, it) - }, - tint = contentColor, - modifier = Modifier - .defaultMinSize(60.dp, 60.dp) - .padding(12.dp), - ) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.padding(24.dp) + ) { + Icon( + imageVector = device.imageVector, + contentDescription = deviceName?.let { + stringResource(id = R.string.devices_image_type, it) + }, + modifier = Modifier.sizeIn(minWidth = 60.dp, minHeight = 60.dp), + ) + } } } + +@PreviewLightDark +@Composable +private fun BTDeviceIconLargePreview() = BlueToothTerminalAppTheme { + BTDeviceIconLarge(device = PreviewFakes.FAKE_DEVICE_MODEL) +} \ No newline at end of file diff --git a/app/src/main/java/com/eva/bluetoothterminalapp/presentation/composables/BluetoothPermissionButton.kt b/app/src/main/java/com/eva/bluetoothterminalapp/presentation/composables/BluetoothPermissionButton.kt index 38537f7..7334b02 100644 --- a/app/src/main/java/com/eva/bluetoothterminalapp/presentation/composables/BluetoothPermissionButton.kt +++ b/app/src/main/java/com/eva/bluetoothterminalapp/presentation/composables/BluetoothPermissionButton.kt @@ -2,6 +2,7 @@ package com.eva.bluetoothterminalapp.presentation.composables import android.Manifest import android.os.Build +import android.util.Log import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Spacer @@ -21,8 +22,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp -import androidx.core.content.ContextCompat -import androidx.core.content.PermissionChecker +import com.eva.bluetoothterminalapp.data.utils.hasBTAdvertisePermission +import com.eva.bluetoothterminalapp.data.utils.hasBTConnectPermission +import com.eva.bluetoothterminalapp.data.utils.hasBTScanPermission import com.eva.bluetoothterminalapp.ui.theme.BlueToothTerminalAppTheme @Composable @@ -34,32 +36,31 @@ fun BluetoothPermissionButton( val context = LocalContext.current - var hasScanPermission by remember { - mutableStateOf( - ContextCompat.checkSelfPermission( - context, - Manifest.permission.BLUETOOTH_SCAN - ) == PermissionChecker.PERMISSION_GRANTED - ) - } - - var hasConnectPermission by remember { - mutableStateOf( - ContextCompat.checkSelfPermission( - context, - Manifest.permission.BLUETOOTH_SCAN - ) == PermissionChecker.PERMISSION_GRANTED - ) - } + var hasScanPermission by remember { mutableStateOf(context.hasBTScanPermission) } + var hasConnectPermission by remember { mutableStateOf(context.hasBTConnectPermission) } + var hasAdvertisePermission by remember { mutableStateOf(context.hasBTAdvertisePermission) } val permissionLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestMultiplePermissions() ) { perms -> - hasScanPermission = perms.getOrDefault(Manifest.permission.BLUETOOTH_SCAN, false) - hasConnectPermission = perms.getOrDefault(Manifest.permission.BLUETOOTH_CONNECT, false) + if (perms.containsKey(Manifest.permission.BLUETOOTH_SCAN)) { + hasScanPermission = perms.getOrDefault(Manifest.permission.BLUETOOTH_SCAN, false) + } + if (perms.containsKey(Manifest.permission.BLUETOOTH_CONNECT)) { + hasConnectPermission = perms.getOrDefault(Manifest.permission.BLUETOOTH_CONNECT, false) + } + if (perms.containsKey(Manifest.permission.BLUETOOTH_ADVERTISE)) { + hasAdvertisePermission = + perms.getOrDefault(Manifest.permission.BLUETOOTH_ADVERTISE, false) + } + + Log.d( + "PERMISSIONS", + "SCAN: $hasScanPermission CONNECT:$hasConnectPermission ADVERTISE: $hasAdvertisePermission" + ) - val hasBothPermission = hasScanPermission && hasConnectPermission - onResults(hasBothPermission) + val hasAllPermissions = hasScanPermission && hasConnectPermission && hasAdvertisePermission + onResults(hasAllPermissions) } Button( @@ -67,7 +68,8 @@ fun BluetoothPermissionButton( permissionLauncher.launch( arrayOf( Manifest.permission.BLUETOOTH_SCAN, - Manifest.permission.BLUETOOTH_CONNECT + Manifest.permission.BLUETOOTH_CONNECT, + Manifest.permission.BLUETOOTH_ADVERTISE, ) ) }, diff --git a/app/src/main/java/com/eva/bluetoothterminalapp/presentation/feature_devices/composables/BTDeviceIcon.kt b/app/src/main/java/com/eva/bluetoothterminalapp/presentation/feature_devices/composables/BTDeviceIcon.kt index 67590bc..51079f9 100644 --- a/app/src/main/java/com/eva/bluetoothterminalapp/presentation/feature_devices/composables/BTDeviceIcon.kt +++ b/app/src/main/java/com/eva/bluetoothterminalapp/presentation/feature_devices/composables/BTDeviceIcon.kt @@ -2,9 +2,9 @@ package com.eva.bluetoothterminalapp.presentation.feature_devices.composables import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.sizeIn import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -38,7 +38,7 @@ fun BTDeviceIcon( contentColor: Color = MaterialTheme.colorScheme.primaryContainer, borderColor: Color = MaterialTheme.colorScheme.primaryFixed, showBorder: Boolean = false, - innerIconSize: DpSize = DpSize(32.dp, 32.dp), + innerIconSize: DpSize = DpSize(28.dp, 28.dp), ) { val pillShape = remember { @@ -60,14 +60,14 @@ fun BTDeviceIcon( RoundedPolygonShape(pillShape) ) else Modifier ) - .defaultMinSize( + .sizeIn( minWidth = dimensionResource(id = R.dimen.min_device_image_size), minHeight = dimensionResource(id = R.dimen.min_device_image_size), ) ) { Box( contentAlignment = Alignment.Center, - modifier = Modifier.padding(8.dp) + modifier = Modifier.padding(12.dp) ) { Icon( imageVector = device.imageVector, diff --git a/app/src/main/java/com/eva/bluetoothterminalapp/presentation/feature_devices/composables/BluetoothDeviceCard.kt b/app/src/main/java/com/eva/bluetoothterminalapp/presentation/feature_devices/composables/BluetoothDeviceCard.kt index 0a4cfc6..3b9557e 100644 --- a/app/src/main/java/com/eva/bluetoothterminalapp/presentation/feature_devices/composables/BluetoothDeviceCard.kt +++ b/app/src/main/java/com/eva/bluetoothterminalapp/presentation/feature_devices/composables/BluetoothDeviceCard.kt @@ -7,17 +7,15 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.outlined.Cable -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.contentColorFor import androidx.compose.runtime.Composable @@ -30,6 +28,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import com.eva.bluetoothterminalapp.R @@ -46,6 +49,7 @@ fun BluetoothDeviceCard( onConnect: () -> Unit, modifier: Modifier = Modifier, shape: Shape = MaterialTheme.shapes.medium, + isPaired: Boolean = false, selectedColor: Color = MaterialTheme.colorScheme.surfaceContainerHighest, containerColor: Color = MaterialTheme.colorScheme.surfaceContainerHigh ) { @@ -57,42 +61,44 @@ fun BluetoothDeviceCard( animationSpec = tween(durationMillis = 200) ) - Card( + Surface( onClick = { isExpanded = true }, shape = shape, - colors = CardDefaults.cardColors( - containerColor = cardContainerColor, - contentColor = contentColorFor(if (isExpanded) selectedColor else containerColor) - ), - elevation = CardDefaults.elevatedCardElevation(), + color = cardContainerColor, + contentColor = contentColorFor(containerColor), modifier = modifier.sharedBoundsWrapper(SharedElementTransitionKeys.btProfileScreen(device.address)) ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.padding(vertical = 8.dp, horizontal = 12.dp) + modifier = Modifier.padding(vertical = 8.dp, horizontal = 10.dp) ) { BTDeviceIcon( device = device, deviceName = device.name, - contentColor = MaterialTheme.colorScheme.secondaryContainer, - containerColor = MaterialTheme.colorScheme.onSecondaryContainer + contentColor = if (isPaired) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.secondaryContainer, + containerColor = if (isPaired) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSecondaryContainer ) - Column { + Column(modifier = Modifier.weight(1f)) { Text( text = device.name, + maxLines = 1, + overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface ) Text( - text = stringResource( - id = R.string.bluetooth_device_mac_address, device.address - ), + text = buildAnnotatedString { + append(stringResource(id = R.string.bluetooth_device_mac_address_title)) + append(" ") + withStyle(SpanStyle(fontFamily = FontFamily.Monospace)) { + append(device.address) + } + }, style = MaterialTheme.typography.bodyMedium, ) } - Spacer(modifier = Modifier.weight(1f)) - Box(modifier = Modifier) { + Box { Icon( imageVector = Icons.Default.MoreVert, contentDescription = stringResource(id = R.string.menu_option_more) diff --git a/app/src/main/java/com/eva/bluetoothterminalapp/presentation/feature_devices/composables/BluetoothDevicesList.kt b/app/src/main/java/com/eva/bluetoothterminalapp/presentation/feature_devices/composables/BluetoothDevicesList.kt index 799a7e3..cf2e5a0 100644 --- a/app/src/main/java/com/eva/bluetoothterminalapp/presentation/feature_devices/composables/BluetoothDevicesList.kt +++ b/app/src/main/java/com/eva/bluetoothterminalapp/presentation/feature_devices/composables/BluetoothDevicesList.kt @@ -82,6 +82,7 @@ fun BluetoothDevicesList( ) { _, device -> BluetoothDeviceCard( device = device, + isPaired = true, onConnect = { onSelectDevice(device) }, modifier = Modifier .fillMaxWidth() @@ -109,6 +110,7 @@ fun BluetoothDevicesList( ) { _, device -> BluetoothDeviceCard( device = device, + isPaired = false, onConnect = { onSelectDevice(device) }, modifier = Modifier .fillMaxWidth() diff --git a/app/src/main/java/com/eva/bluetoothterminalapp/presentation/feature_devices/composables/BluetoothLEDeviceCard.kt b/app/src/main/java/com/eva/bluetoothterminalapp/presentation/feature_devices/composables/BluetoothLEDeviceCard.kt index 9643dee..02aa2a2 100644 --- a/app/src/main/java/com/eva/bluetoothterminalapp/presentation/feature_devices/composables/BluetoothLEDeviceCard.kt +++ b/app/src/main/java/com/eva/bluetoothterminalapp/presentation/feature_devices/composables/BluetoothLEDeviceCard.kt @@ -10,16 +10,17 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.SignalCellularAlt +import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.outlined.Cable -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MenuDefaults +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.contentColorFor import androidx.compose.runtime.Composable @@ -32,14 +33,19 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import com.eva.bluetoothterminalapp.R import com.eva.bluetoothterminalapp.domain.bluetooth.models.BluetoothDeviceModel import com.eva.bluetoothterminalapp.domain.bluetooth_le.models.BluetoothLEDeviceModel @@ -79,64 +85,71 @@ fun BluetoothLEDeviceCard( animationSpec = tween(durationMillis = 200) ) - Card( + Surface( onClick = { isMenuExpanded = true }, - colors = CardDefaults.cardColors( - containerColor = cardContainerColor, - contentColor = contentColorFor(backgroundColor = cardContainerColor) - ), - elevation = CardDefaults.elevatedCardElevation(), + color = cardContainerColor, + contentColor = contentColorFor(containerColor), shape = shape, - modifier = modifier - .sharedBoundsWrapper( - key = SharedElementTransitionKeys.leDeviceCardToLeDeviceProfile( - leDeviceModel.deviceModel.address - ) - ), + modifier = modifier.sharedBoundsWrapper( + key = SharedElementTransitionKeys.leDeviceCardToLeDeviceProfile(leDeviceModel.deviceModel.address) + ), ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.padding(vertical = 8.dp, horizontal = 12.dp), + modifier = Modifier.padding(vertical = 8.dp, horizontal = 10.dp), ) { BTDeviceIcon( device = leDeviceModel.deviceModel, - deviceName = leDeviceModel.deviceName + deviceName = leDeviceModel.deviceName, + containerColor = MaterialTheme.colorScheme.onSecondaryContainer, + contentColor = MaterialTheme.colorScheme.secondaryContainer, ) - Column { + Column(modifier = Modifier.weight(1f)) { Text( text = leDeviceModel.deviceName, style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, color = MaterialTheme.colorScheme.onSurface ) Text( - text = stringResource( - id = R.string.bluetooth_device_mac_address, - leDeviceModel.deviceModel.address - ), + text = buildAnnotatedString { + append(stringResource(id = R.string.bluetooth_device_mac_address_title)) + append(" ") + withStyle(SpanStyle(fontFamily = FontFamily.Monospace)) { + append(leDeviceModel.deviceModel.address) + } + }, style = MaterialTheme.typography.bodyMedium, - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Medium, - letterSpacing = .25.sp ) + Row( + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.spacedBy(2.dp), + modifier = Modifier + .wrapContentSize() + .padding(top = 4.dp) + ) { + SignalsBars( + rssi = { localRssi }, + color = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(width = 20.dp, height = 20.dp) + ) + Text( + text = buildString { + append(localRssi) + append(" ") + append(BluetoothDeviceModel.RSSI_UNIT) + }, + style = MaterialTheme.typography.labelLarge + ) + } } - Spacer(modifier = Modifier.weight(1f)) - Row( - verticalAlignment = Alignment.Bottom, - horizontalArrangement = Arrangement.spacedBy(2.dp) - ) { + Box { Icon( - imageVector = Icons.Default.SignalCellularAlt, - contentDescription = stringResource(R.string.device_rssi_value), - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.onPrimaryContainer + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(id = R.string.menu_option_more) ) - Text( - text = "$localRssi ${BluetoothDeviceModel.RSSI_UNIT}", - style = MaterialTheme.typography.labelLarge - ) - } - Box(modifier = Modifier) { DropdownMenu( expanded = isMenuExpanded, onDismissRequest = { isMenuExpanded = false }, @@ -160,6 +173,49 @@ fun BluetoothLEDeviceCard( } } +@Composable +private fun SignalsBars( + rssi: () -> Int, + modifier: Modifier = Modifier, + color: Color = MaterialTheme.colorScheme.primary +) { + Spacer( + modifier = modifier + .sizeIn(minWidth = 24.dp, minHeight = 24.dp) + .drawWithCache { + val blockWidth = size.width / 3 + val blockHeight = size.height / 3 + + val strokeWidth = maxOf(2.dp.toPx(), blockWidth * .5f) + val maxHeight = size.height - 4.dp.toPx() + val noOfBars = 3 + + onDrawBehind { + val computedRssi = rssi() + val count = if (computedRssi >= -50) 3 + else if (computedRssi >= -71) 2 + else if (computedRssi >= -90) 1 + else 0 + + for (idx in 0.. Unit = {}, navigation: @Composable () -> Unit = {}, ) { - val scrollConnection = TopAppBarDefaults.pinnedScrollBehavior() + val snackBarHostState = LocalSnackBarProvider.current + val scrollConnection = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() Scaffold( topBar = { @@ -66,6 +69,7 @@ fun BLEDeviceRoute( scrollConnection = scrollConnection, ) }, + snackbarHost = { SnackbarHost(hostState = snackBarHostState) }, modifier = modifier .nestedScroll(scrollConnection.nestedScrollConnection) .sharedBoundsWrapper( @@ -88,6 +92,7 @@ fun BLEDeviceRoute( visible = profile.connectionState == BLEConnectionState.CONNECTED, enter = slideInVertically(animationSpec = tween(easing = FastOutLinearInEasing)) { height -> height } + fadeIn(), exit = slideOutVertically(animationSpec = tween(easing = FastOutLinearInEasing)) { height -> height } + fadeOut(), + modifier = Modifier.weight(1f) ) { BLEServicesList( services = profile.services, diff --git a/app/src/main/java/com/eva/bluetoothterminalapp/presentation/feature_le_connect/composables/BLEDeviceServiceCard.kt b/app/src/main/java/com/eva/bluetoothterminalapp/presentation/feature_le_connect/composables/BLEDeviceServiceCard.kt index 7ffdc14..8bd2e0f 100644 --- a/app/src/main/java/com/eva/bluetoothterminalapp/presentation/feature_le_connect/composables/BLEDeviceServiceCard.kt +++ b/app/src/main/java/com/eva/bluetoothterminalapp/presentation/feature_le_connect/composables/BLEDeviceServiceCard.kt @@ -1,13 +1,13 @@ package com.eva.bluetoothterminalapp.presentation.feature_le_connect.composables import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.animateContentSize import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.contentColorFor import androidx.compose.runtime.Composable @@ -22,6 +23,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape @@ -64,7 +66,7 @@ fun BLEDeviceServiceCard( contentColor = contentColorFor(containerColor) ), elevation = CardDefaults.cardElevation(), - modifier = modifier.animateContentSize(), + modifier = modifier, ) { Column( modifier = Modifier @@ -101,7 +103,7 @@ fun BLEDeviceServiceCard( Spacer(modifier = Modifier.height(4.dp)) - when (bleService.charisticsCount) { + when (bleService.characteristicsCount) { 0 -> Text( text = stringResource(id = R.string.le_characteristics_not_present), style = MaterialTheme.typography.labelLarge, @@ -116,12 +118,29 @@ fun BLEDeviceServiceCard( Column( verticalArrangement = Arrangement.spacedBy(4.dp) ) { - Text( - text = "Characteristics: ${bleService.charisticsCount}", - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.tertiary - ) + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Characteristics", + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.tertiary + ) + Surface( + color = MaterialTheme.colorScheme.tertiaryContainer, + contentColor = MaterialTheme.colorScheme.onTertiaryContainer, + shape = MaterialTheme.shapes.small, + ) { + Text( + text = "${bleService.characteristicsCount}", + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) + ) + } + } bleService.characteristic.forEach { characteristic -> SelectableBLECharacteristics( characteristic = characteristic, diff --git a/app/src/main/java/com/eva/bluetoothterminalapp/presentation/feature_le_connect/composables/BLEServicesList.kt b/app/src/main/java/com/eva/bluetoothterminalapp/presentation/feature_le_connect/composables/BLEServicesList.kt index e6108b2..84bb27a 100644 --- a/app/src/main/java/com/eva/bluetoothterminalapp/presentation/feature_le_connect/composables/BLEServicesList.kt +++ b/app/src/main/java/com/eva/bluetoothterminalapp/presentation/feature_le_connect/composables/BLEServicesList.kt @@ -1,21 +1,27 @@ package com.eva.bluetoothterminalapp.presentation.feature_le_connect.composables +import androidx.compose.animation.animateContentSize import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.eva.bluetoothterminalapp.R import com.eva.bluetoothterminalapp.domain.bluetooth_le.models.BLECharacteristicsModel @@ -28,9 +34,11 @@ fun BLEServicesList( onCharacteristicSelect: (service: BLEServiceModel, characteristic: BLECharacteristicsModel) -> Unit, modifier: Modifier = Modifier, selectedCharacteristic: BLECharacteristicsModel? = null, - contentPaddingValues: PaddingValues = PaddingValues(0.dp) + contentPadding: PaddingValues = PaddingValues.Zero ) { + val servicesCount by remember(services) { derivedStateOf { services.size } } + val isLocalInspectionMode = LocalInspectionMode.current val lazyColumKeys: ((Int, BLEServiceModel) -> Any)? = remember { @@ -40,11 +48,13 @@ fun BLEServicesList( LazyColumn( verticalArrangement = Arrangement.spacedBy(8.dp), - contentPadding = contentPaddingValues, + contentPadding = contentPadding, modifier = modifier, ) { stickyHeader { - Box( + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, modifier = Modifier .background(MaterialTheme.colorScheme.surface) .fillMaxWidth() @@ -52,9 +62,21 @@ fun BLEServicesList( ) { Text( text = stringResource(id = R.string.le_available_devices), - style = MaterialTheme.typography.titleMedium, + style = MaterialTheme.typography.titleLarge, modifier = Modifier.padding(horizontal = 4.dp, vertical = 6.dp) ) + Surface( + color = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + shape = MaterialTheme.shapes.small, + ) { + Text( + text = "$servicesCount", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) + ) + } } } itemsIndexed( @@ -69,6 +91,7 @@ fun BLEServicesList( modifier = Modifier .fillMaxWidth() .animateItem() + .animateContentSize() ) } } diff --git a/app/src/main/java/com/eva/bluetoothterminalapp/presentation/feature_le_connect/composables/SelectableBLECharacteristics.kt b/app/src/main/java/com/eva/bluetoothterminalapp/presentation/feature_le_connect/composables/SelectableBLECharacteristics.kt index bab775e..e0e7432 100644 --- a/app/src/main/java/com/eva/bluetoothterminalapp/presentation/feature_le_connect/composables/SelectableBLECharacteristics.kt +++ b/app/src/main/java/com/eva/bluetoothterminalapp/presentation/feature_le_connect/composables/SelectableBLECharacteristics.kt @@ -71,7 +71,8 @@ fun SelectableBLECharacteristics( withStyle( SpanStyle( fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Medium + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) ) { append("${characteristic.uuid}") diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bd4ef18..e8bd2c2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -77,7 +77,6 @@ Write (Signed) Extended Props Unknown Characteristic - Device No characteristics found with this service BLE Device Profile Arrow back @@ -90,8 +89,7 @@ Select a characteristic in order to continue Write Characteristic Enter text value in utf-8 to write to. - Current Device RSSI value - MAC : %s + MAC : Notification are disabled Indications are enabled Notifications are enabled @@ -175,7 +173,6 @@ Auto Scroll To End Auto Scroll to the End of terminal - Start Stop Server A running server must be stopped before leaving Start Server @@ -191,10 +188,11 @@ Refresh Services Servers Others - Classic Server BLE Server Make use the other device is discoverable, otherwise the other peer cannot be connected\n Stopped Not ready Charset + Stop + Start \ No newline at end of file From e662086ba09268bc4db839ebe13a66f5c4e2d461 Mon Sep 17 00:00:00 2001 From: tuuhin Date: Thu, 16 Oct 2025 21:48:18 +0530 Subject: [PATCH 06/11] Server UI is prepared User is prompted to start the server with service configuration (yet to build) On starting the server the services and connected client will be shown the user then can stop the server when done, Included BLEServer route in Routes.kt Included BLEServerViewModel.kt in ViewModelModule.kt Drawer navigation to BLEServer screen --- .idea/deploymentTargetSelector.xml | 4 +- .../di/ViewModelModule.kt | 18 +-- .../composables/BTAppNavigationDrawer.kt | 5 - .../composables/BLEDeviceServiceCard.kt | 6 +- .../util/BLEPropertyText.kt | 4 +- .../feature_le_server/BLEServerRoute.kt | 135 ++++++++++++++++++ .../feature_le_server/BLEServerViewModel.kt | 83 +++++++++++ .../composable/AdvertisePermissionDialog.kt | 67 +++++++++ .../composable/BLEAdvertisingServices.kt | 84 +++++++++++ .../composable/BLEConnectedClients.kt | 123 ++++++++++++++++ .../composable/BLEServerScreenContent.kt | 81 +++++++++++ .../composable/ConnectedDeviceCard.kt | 65 +++++++++ .../composable/StartBLEServer.kt | 79 ++++++++++ .../state/BLEServerScreenEvents.kt | 10 ++ .../presentation/navigation/config/Routes.kt | 3 + .../navigation/screens/BTDevicesScreen.kt | 6 +- .../screens/bt_le/BLEServerScreen.kt | 64 +++++++++ app/src/main/res/drawable/ic_network_mesh.xml | 11 ++ app/src/main/res/values/strings.xml | 8 +- 19 files changed, 831 insertions(+), 25 deletions(-) create mode 100644 app/src/main/java/com/eva/bluetoothterminalapp/presentation/feature_le_server/BLEServerRoute.kt create mode 100644 app/src/main/java/com/eva/bluetoothterminalapp/presentation/feature_le_server/BLEServerViewModel.kt create mode 100644 app/src/main/java/com/eva/bluetoothterminalapp/presentation/feature_le_server/composable/AdvertisePermissionDialog.kt create mode 100644 app/src/main/java/com/eva/bluetoothterminalapp/presentation/feature_le_server/composable/BLEAdvertisingServices.kt create mode 100644 app/src/main/java/com/eva/bluetoothterminalapp/presentation/feature_le_server/composable/BLEConnectedClients.kt create mode 100644 app/src/main/java/com/eva/bluetoothterminalapp/presentation/feature_le_server/composable/BLEServerScreenContent.kt create mode 100644 app/src/main/java/com/eva/bluetoothterminalapp/presentation/feature_le_server/composable/ConnectedDeviceCard.kt create mode 100644 app/src/main/java/com/eva/bluetoothterminalapp/presentation/feature_le_server/composable/StartBLEServer.kt create mode 100644 app/src/main/java/com/eva/bluetoothterminalapp/presentation/feature_le_server/state/BLEServerScreenEvents.kt create mode 100644 app/src/main/java/com/eva/bluetoothterminalapp/presentation/navigation/screens/bt_le/BLEServerScreen.kt create mode 100644 app/src/main/res/drawable/ic_network_mesh.xml diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml index 95e5fb3..eefb8ba 100644 --- a/.idea/deploymentTargetSelector.xml +++ b/.idea/deploymentTargetSelector.xml @@ -4,10 +4,10 @@