Changed profile service api style

This commit is contained in:
himalia416
2025-08-26 21:06:34 +02:00
committed by Himali Aryal
parent d7064ca619
commit 80d0bee84e
11 changed files with 698 additions and 870 deletions

View File

@@ -47,7 +47,7 @@ dependencies {
implementation(project(":lib_analytics")) implementation(project(":lib_analytics"))
implementation(project(":profile-parsers")) implementation(project(":profile-parsers"))
implementation(project(":profile_manager")) implementation(project(":profile_manager"))
implementation(project(":profile")) api(project(":profile"))
implementation(project(":profile_data")) implementation(project(":profile_data"))
implementation(project(":lib_ui")) implementation(project(":lib_ui"))
implementation(project(":lib_utils")) implementation(project(":lib_utils"))

View File

@@ -35,6 +35,7 @@ import no.nordicsemi.android.nrftoolbox.R
import no.nordicsemi.android.nrftoolbox.viewmodel.HomeViewModel import no.nordicsemi.android.nrftoolbox.viewmodel.HomeViewModel
import no.nordicsemi.android.nrftoolbox.viewmodel.UiEvent import no.nordicsemi.android.nrftoolbox.viewmodel.UiEvent
import no.nordicsemi.android.toolbox.lib.utils.Profile import no.nordicsemi.android.toolbox.lib.utils.Profile
import timber.log.Timber
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -82,240 +83,250 @@ internal fun HomeView() {
.padding(start = 16.dp, end = 16.dp, top = 16.dp), .padding(start = 16.dp, end = 16.dp, top = 16.dp),
) )
if (state.connectedDevices.isNotEmpty()) { if (state.connectedDevices.isNotEmpty()) {
Timber.tag("AAA").d("Connected devices: ${state.connectedDevices.keys}")
Column( Column(
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
state.connectedDevices.values.forEach { (peripheral, services) -> state.connectedDevices.keys.forEach {
// Skip if no services state.connectedDevices[it]?.let { deviceData ->
if (services.isEmpty()) return@forEach if (deviceData.connectionState.isConnected) {
// Case 1: If only one service, show it directly like battery service // Skip if no services
if (services.size == 1 && services.first().profile == Profile.BATTERY) { if (deviceData.services.isEmpty()) return@forEach
FeatureButton( // Case 1: If only one service, show it directly like battery service
iconId = R.drawable.ic_battery, if (deviceData.services.size == 1 && deviceData.services.first().profile == Profile.BATTERY) {
description = R.string.battery_module_full, FeatureButton(
deviceName = peripheral.name, iconId = R.drawable.ic_battery,
deviceAddress = peripheral.address, description = R.string.battery_module_full,
onClick = { deviceName = deviceData.peripheral.name,
onEvent( deviceAddress = deviceData.peripheral.address,
UiEvent.OnDeviceClick(
peripheral.address,
services.first().profile
)
)
},
)
}
// Case 2: Show the first *non-Battery* profile.
// This ensures only one service is shown per peripheral when multiple services are available.
services.firstOrNull { it.profile != Profile.BATTERY }
?.let { serviceManager ->
when (serviceManager.profile) {
Profile.HRS -> FeatureButton(
iconId = R.drawable.ic_hrs,
description = R.string.hrs_module_full,
deviceName = peripheral.name,
profileNames = services.map { it.profile.toString() },
deviceAddress = peripheral.address,
onClick = { onClick = {
onEvent( onEvent(
UiEvent.OnDeviceClick( UiEvent.OnDeviceClick(
peripheral.address, deviceData.peripheral.address,
serviceManager.profile deviceData.services.first().profile
) )
) )
}, },
) )
Profile.HTS -> FeatureButton(
iconId = R.drawable.ic_hts,
description = R.string.hts_module_full,
deviceName = peripheral.name,
deviceAddress = peripheral.address,
profileNames = services.map { it.profile.toString() },
onClick = {
onEvent(
UiEvent.OnDeviceClick(
peripheral.address,
serviceManager.profile
)
)
},
)
Profile.BPS -> FeatureButton(
iconId = R.drawable.ic_bps,
description = R.string.bps_module_full,
deviceName = peripheral.name,
deviceAddress = peripheral.address,
profileNames = services.map { it.profile.toString() },
onClick = {
onEvent(
UiEvent.OnDeviceClick(
peripheral.address,
serviceManager.profile
)
)
},
)
Profile.GLS -> FeatureButton(
iconId = R.drawable.ic_gls,
description = R.string.gls_module_full,
deviceName = peripheral.name,
deviceAddress = peripheral.address,
profileNames = services.map { it.profile.toString() },
onClick = {
onEvent(
UiEvent.OnDeviceClick(
peripheral.address,
serviceManager.profile
)
)
},
)
Profile.CGM -> FeatureButton(
iconId = R.drawable.ic_cgm,
description = R.string.cgm_module_full,
deviceName = peripheral.name,
deviceAddress = peripheral.address,
profileNames = services.map { it.profile.toString() },
onClick = {
onEvent(
UiEvent.OnDeviceClick(
peripheral.address,
serviceManager.profile
)
)
},
)
Profile.RSCS -> FeatureButton(
iconId = R.drawable.ic_rscs,
description = R.string.rscs_module_full,
deviceName = peripheral.name,
deviceAddress = peripheral.address,
profileNames = services.map { it.profile.toString() },
onClick = {
onEvent(
UiEvent.OnDeviceClick(
peripheral.address,
serviceManager.profile
)
)
},
)
Profile.DFS -> FeatureButton(
iconId = R.drawable.ic_distance,
description = R.string.direction_module_full,
deviceName = peripheral.name,
deviceAddress = peripheral.address,
profileNames = services.map { it.profile.toString() },
onClick = {
onEvent(
UiEvent.OnDeviceClick(
peripheral.address,
serviceManager.profile
)
)
},
)
Profile.CSC -> FeatureButton(
iconId = R.drawable.ic_csc,
description = R.string.csc_module_full,
deviceName = peripheral.name,
deviceAddress = peripheral.address,
profileNames = services.map { it.profile.toString() },
onClick = {
onEvent(
UiEvent.OnDeviceClick(
peripheral.address,
serviceManager.profile
)
)
},
)
Profile.THROUGHPUT -> {
FeatureButton(
iconId = Icons.Default.SyncAlt,
description = R.string.throughput_module,
deviceName = peripheral.name,
deviceAddress = peripheral.address,
profileNames = services.map { it.profile.toString() },
onClick = {
onEvent(
UiEvent.OnDeviceClick(
peripheral.address,
serviceManager.profile
)
)
},
)
}
Profile.UART -> {
FeatureButton(
iconId = R.drawable.ic_uart,
description = R.string.uart_module_full,
deviceName = peripheral.name,
deviceAddress = peripheral.address,
profileNames = services.map { it.profile.toString() },
onClick = {
onEvent(
UiEvent.OnDeviceClick(
peripheral.address,
serviceManager.profile
)
)
},
)
}
Profile.CHANNEL_SOUNDING -> {
FeatureButton(
iconId = Icons.Default.SocialDistance,
description = R.string.channel_sounding_module,
deviceName = peripheral.name,
deviceAddress = peripheral.address,
profileNames = services.map { it.profile.toString() },
onClick = {
onEvent(
UiEvent.OnDeviceClick(
peripheral.address,
serviceManager.profile
)
)
},
)
}
Profile.LBS -> {
FeatureButton(
iconId = Icons.Default.Lightbulb,
description = R.string.lbs_blinky_module,
deviceName = peripheral.name,
deviceAddress = peripheral.address,
profileNames = services.map { it.profile.toString() },
onClick = {
onEvent(
UiEvent.OnDeviceClick(
peripheral.address,
serviceManager.profile
)
)
},
)
}
else -> {
// TODO: Add more profiles
}
} }
// Case 2: Show the first *non-Battery* profile.
// This ensures only one service is shown per peripheral when multiple services are available.
deviceData.services.firstOrNull { it.profile != Profile.BATTERY }
?.let { serviceManager ->
val peripheral = deviceData.peripheral
val services = deviceData.services
Timber.tag("AAA")
.d("Displaying device: ${peripheral.address} with services: ${services.map { it.profile }}")
when (serviceManager.profile) {
Profile.HRS -> FeatureButton(
iconId = R.drawable.ic_hrs,
description = R.string.hrs_module_full,
deviceName = peripheral.name,
profileNames = services.map { it.profile.toString() },
deviceAddress = peripheral.address,
onClick = {
onEvent(
UiEvent.OnDeviceClick(
peripheral.address,
serviceManager.profile
)
)
},
)
Profile.HTS -> FeatureButton(
iconId = R.drawable.ic_hts,
description = R.string.hts_module_full,
deviceName = peripheral.name,
deviceAddress = peripheral.address,
profileNames = services.map { it.profile.toString() },
onClick = {
onEvent(
UiEvent.OnDeviceClick(
peripheral.address,
serviceManager.profile
)
)
},
)
Profile.BPS -> FeatureButton(
iconId = R.drawable.ic_bps,
description = R.string.bps_module_full,
deviceName = peripheral.name,
deviceAddress = peripheral.address,
profileNames = services.map { it.profile.toString() },
onClick = {
onEvent(
UiEvent.OnDeviceClick(
peripheral.address,
serviceManager.profile
)
)
},
)
Profile.GLS -> FeatureButton(
iconId = R.drawable.ic_gls,
description = R.string.gls_module_full,
deviceName = peripheral.name,
deviceAddress = peripheral.address,
profileNames = services.map { it.profile.toString() },
onClick = {
onEvent(
UiEvent.OnDeviceClick(
peripheral.address,
serviceManager.profile
)
)
},
)
Profile.CGM -> FeatureButton(
iconId = R.drawable.ic_cgm,
description = R.string.cgm_module_full,
deviceName = peripheral.name,
deviceAddress = peripheral.address,
profileNames = services.map { it.profile.toString() },
onClick = {
onEvent(
UiEvent.OnDeviceClick(
peripheral.address,
serviceManager.profile
)
)
},
)
Profile.RSCS -> FeatureButton(
iconId = R.drawable.ic_rscs,
description = R.string.rscs_module_full,
deviceName = peripheral.name,
deviceAddress = peripheral.address,
profileNames = services.map { it.profile.toString() },
onClick = {
onEvent(
UiEvent.OnDeviceClick(
peripheral.address,
serviceManager.profile
)
)
},
)
Profile.DFS -> FeatureButton(
iconId = R.drawable.ic_distance,
description = R.string.direction_module_full,
deviceName = peripheral.name,
deviceAddress = peripheral.address,
profileNames = services.map { it.profile.toString() },
onClick = {
onEvent(
UiEvent.OnDeviceClick(
peripheral.address,
serviceManager.profile
)
)
},
)
Profile.CSC -> FeatureButton(
iconId = R.drawable.ic_csc,
description = R.string.csc_module_full,
deviceName = peripheral.name,
deviceAddress = peripheral.address,
profileNames = services.map { it.profile.toString() },
onClick = {
onEvent(
UiEvent.OnDeviceClick(
peripheral.address,
serviceManager.profile
)
)
},
)
Profile.THROUGHPUT -> {
FeatureButton(
iconId = Icons.Default.SyncAlt,
description = R.string.throughput_module,
deviceName = peripheral.name,
deviceAddress = peripheral.address,
profileNames = services.map { it.profile.toString() },
onClick = {
onEvent(
UiEvent.OnDeviceClick(
peripheral.address,
serviceManager.profile
)
)
},
)
}
Profile.UART -> {
FeatureButton(
iconId = R.drawable.ic_uart,
description = R.string.uart_module_full,
deviceName = peripheral.name,
deviceAddress = peripheral.address,
profileNames = services.map { it.profile.toString() },
onClick = {
onEvent(
UiEvent.OnDeviceClick(
peripheral.address,
serviceManager.profile
)
)
},
)
}
Profile.CHANNEL_SOUNDING -> {
FeatureButton(
iconId = Icons.Default.SocialDistance,
description = R.string.channel_sounding_module,
deviceName = peripheral.name,
deviceAddress = peripheral.address,
profileNames = services.map { it.profile.toString() },
onClick = {
onEvent(
UiEvent.OnDeviceClick(
peripheral.address,
serviceManager.profile
)
)
},
)
}
Profile.LBS -> {
FeatureButton(
iconId = Icons.Default.Lightbulb,
description = R.string.lbs_blinky_module,
deviceName = peripheral.name,
deviceAddress = peripheral.address,
profileNames = services.map { it.profile.toString() },
onClick = {
onEvent(
UiEvent.OnDeviceClick(
peripheral.address,
serviceManager.profile
)
)
},
)
}
else -> {
// TODO: Add more profiles
}
}
}
} }
}
} }
} }
} else { } else {

View File

@@ -14,14 +14,14 @@ import no.nordicsemi.android.analytics.Link
import no.nordicsemi.android.analytics.ProfileOpenEvent import no.nordicsemi.android.analytics.ProfileOpenEvent
import no.nordicsemi.android.common.navigation.Navigator import no.nordicsemi.android.common.navigation.Navigator
import no.nordicsemi.android.nrftoolbox.ScannerDestinationId import no.nordicsemi.android.nrftoolbox.ScannerDestinationId
import no.nordicsemi.android.toolbox.profile.manager.ServiceManager import no.nordicsemi.android.service.profile.ServiceApi
import no.nordicsemi.android.toolbox.profile.ProfileDestinationId import no.nordicsemi.android.toolbox.profile.ProfileDestinationId
import no.nordicsemi.android.toolbox.profile.repository.DeviceRepository import no.nordicsemi.android.toolbox.profile.repository.DeviceRepository
import no.nordicsemi.kotlin.ble.client.android.Peripheral import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
internal data class HomeViewState( internal data class HomeViewState(
val connectedDevices: Map<String, Pair<Peripheral, List<ServiceManager>>> = emptyMap(), val connectedDevices: Map<String, ServiceApi.DeviceData> = emptyMap(),
) )
private const val GITHUB_REPO_URL = "https://github.com/NordicSemiconductor/Android-nRF-Toolbox.git" private const val GITHUB_REPO_URL = "https://github.com/NordicSemiconductor/Android-nRF-Toolbox.git"
@@ -39,6 +39,7 @@ internal class HomeViewModel @Inject constructor(
init { init {
// Observe connected devices from the repository // Observe connected devices from the repository
deviceRepository.connectedDevices.onEach { devices -> deviceRepository.connectedDevices.onEach { devices ->
Timber.tag("AAA").d("Connected devices updated: ${devices.keys}")
_state.update { currentState -> _state.update { currentState ->
currentState.copy(connectedDevices = devices) currentState.copy(connectedDevices = devices)
} }

View File

@@ -5,26 +5,23 @@ import android.os.Binder
import android.os.IBinder import android.os.IBinder
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import no.nordicsemi.android.log.timber.nRFLoggerTree import no.nordicsemi.android.log.timber.nRFLoggerTree
import no.nordicsemi.android.service.NotificationService import no.nordicsemi.android.service.NotificationService
import no.nordicsemi.android.service.R import no.nordicsemi.android.service.R
import no.nordicsemi.android.toolbox.lib.utils.spec.CGMS_SERVICE_UUID
import no.nordicsemi.android.toolbox.profile.manager.ServiceManager import no.nordicsemi.android.toolbox.profile.manager.ServiceManager
import no.nordicsemi.android.toolbox.profile.manager.ServiceManagerFactory import no.nordicsemi.android.toolbox.profile.manager.ServiceManagerFactory
import no.nordicsemi.android.ui.view.internal.DisconnectReason import no.nordicsemi.android.ui.view.internal.DisconnectReason
import no.nordicsemi.kotlin.ble.client.RemoteService
import no.nordicsemi.kotlin.ble.client.android.CentralManager import no.nordicsemi.kotlin.ble.client.android.CentralManager
import no.nordicsemi.kotlin.ble.client.android.CentralManager.ConnectionOptions import no.nordicsemi.kotlin.ble.client.android.CentralManager.ConnectionOptions
import no.nordicsemi.kotlin.ble.client.android.ConnectionPriority import no.nordicsemi.kotlin.ble.client.android.ConnectionPriority
@@ -32,13 +29,10 @@ import no.nordicsemi.kotlin.ble.client.android.Peripheral
import no.nordicsemi.kotlin.ble.core.BondState import no.nordicsemi.kotlin.ble.core.BondState
import no.nordicsemi.kotlin.ble.core.ConnectionState import no.nordicsemi.kotlin.ble.core.ConnectionState
import no.nordicsemi.kotlin.ble.core.Manager import no.nordicsemi.kotlin.ble.core.Manager
import no.nordicsemi.kotlin.ble.core.Phy
import no.nordicsemi.kotlin.ble.core.PhyOption
import no.nordicsemi.kotlin.ble.core.WriteType import no.nordicsemi.kotlin.ble.core.WriteType
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.toKotlinUuid
@AndroidEntryPoint @AndroidEntryPoint
internal class ProfileService : NotificationService() { internal class ProfileService : NotificationService() {
@@ -46,15 +40,13 @@ internal class ProfileService : NotificationService() {
@Inject @Inject
lateinit var centralManager: CentralManager lateinit var centralManager: CentralManager
private var logger: nRFLoggerTree? = null private var logger: nRFLoggerTree? = null
private val binder = LocalBinder() private val binder = LocalBinder()
private val managedConnections = mutableMapOf<String, Job>()
private val _connectedDevices = private val _devices = MutableStateFlow<Map<String, ServiceApi.DeviceData>>(emptyMap())
MutableStateFlow<Map<String, Pair<Peripheral, List<ServiceManager>>>>(emptyMap()) private val _isMissingServices = MutableStateFlow<Map<String, Boolean>>(emptyMap())
private val _isMissingServices = MutableStateFlow(false) private val _disconnectionEvent = MutableStateFlow<ServiceApi.DisconnectionEvent?>(null)
private val _disconnectionReason = MutableStateFlow<DeviceDisconnectionReason?>(null)
private val connectionJobs = mutableMapOf<String, Job>()
private val serviceHandlingJob = mutableMapOf<String, Job>()
override fun onBind(intent: Intent): IBinder { override fun onBind(intent: Intent): IBinder {
super.onBind(intent) super.onBind(intent)
@@ -63,260 +55,244 @@ internal class ProfileService : NotificationService() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
// Observe the Bluetooth state // Observe the Bluetooth state to handle global disconnection reasons.
centralManager.state.onEach { state -> centralManager.state.onEach { state ->
if (state == Manager.State.POWERED_OFF) { if (state == Manager.State.POWERED_OFF) {
_disconnectionReason.tryEmit(CustomReason(DisconnectReason.BLUETOOTH_OFF)) _disconnectionEvent.value = ServiceApi.DisconnectionEvent(
"all_devices", // Generic address
CustomReason(DisconnectReason.BLUETOOTH_OFF)
)
// Optionally disconnect all devices
_devices.value.keys.forEach { disconnect(it) }
} }
}.launchIn(lifecycleScope) }.launchIn(lifecycleScope)
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId) super.onStartCommand(intent, flags, startId)
intent?.getStringExtra(DEVICE_ADDRESS)?.let { deviceAddress -> intent?.getStringExtra(DEVICE_ADDRESS)?.let { address ->
initLogger(deviceAddress) connect(address)
initiateConnection(deviceAddress)
} }
return START_REDELIVER_INTENT return START_REDELIVER_INTENT
} }
inner class LocalBinder : Binder(), ServiceApi { override fun onDestroy() {
override val connectedDevices: Flow<Map<String, Pair<Peripheral, List<ServiceManager>>>> managedConnections.values.forEach { it.cancel() }
get() = _connectedDevices.asSharedFlow() uprootLogger()
super.onDestroy()
}
override val isMissingServices: Flow<Boolean> private fun connect(address: String) {
get() = _isMissingServices.asStateFlow() // Return if already managed to avoid multiple connection jobs.
if (managedConnections.containsKey(address)) return
override val disconnectionReason: StateFlow<DeviceDisconnectionReason?> initLogger(address) // Initialize logger for the new device.
get() = _disconnectionReason.asStateFlow()
override suspend fun getMaxWriteValue(address: String, writeType: WriteType): Int? { val peripheral = centralManager.getPeripheralById(address) ?: run {
val peripheral = getPeripheralById(address) ?: return null Timber.w("Peripheral with address $address not found.")
if (!peripheral.isConnected) return null return
return try {
peripheral.requestHighestValueLength()
peripheral.requestConnectionPriority(ConnectionPriority.HIGH)
peripheral.setPreferredPhy(Phy.PHY_LE_2M, Phy.PHY_LE_2M, PhyOption.S2)
peripheral.maximumWriteValueLength(writeType)
} catch (e: Exception) {
Timber.e("Failed to configure $address for MTU change with reason: ${e.message}")
null
}
} }
override suspend fun createBonding(address: String) { val job = lifecycleScope.launch {
val peripheral = getPeripheralById(address) // Launch the initial connection attempt.
peripheral?.bondState launch {
?.onEach { state -> try {
if (state == BondState.NONE) { centralManager.connect(peripheral, options = ConnectionOptions.Direct())
peripheral.createBond() } catch (e: Exception) {
Timber.e(e, "Failed to connect to $address")
}
}
// Observe connection state changes and react accordingly.
observeConnectionState(peripheral)
}
managedConnections[address] = job
job.invokeOnCompletion {
// Clean up when the management coroutine is cancelled.
handleDisconnection(address, "Job cancelled")
managedConnections.remove(address)
stopServiceIfNoDevices()
}
}
@OptIn(ExperimentalCoroutinesApi::class)
private fun CoroutineScope.observeConnectionState(peripheral: Peripheral) {
peripheral.state
.onEach { state ->
_devices.update {
it + (peripheral.address to (it[peripheral.address]?.copy(connectionState = state)
?: ServiceApi.DeviceData(peripheral, state)))
}
when (state) {
ConnectionState.Connected -> {
try {
discoverAndObserveServices(peripheral, this)
} catch (e: Exception) {
Timber.e(e, "Service discovery failed for ${peripheral.address}")
}
}
is ConnectionState.Disconnected -> {
Timber.tag("AAAA").d("Disconnected State: ${peripheral.address}")
val reason = state.reason ?: DisconnectReason.UNKNOWN
_disconnectionEvent.value =
ServiceApi.DisconnectionEvent(
peripheral.address,
StateReason(reason as ConnectionState.Disconnected.Reason)
)
_devices.update { it - peripheral.address }
Timber.tag("AAA").d("Devices after disconnection: ${_devices.value.keys}")
handleDisconnection(peripheral.address, reason.toString())
}
else -> {
// Handle connecting/disconnecting states if needed
} }
} }
?.filter { it == BondState.BONDED } }.launchIn(this)
?.first() // suspend until bonded }
@OptIn(ExperimentalUuidApi::class)
private fun discoverAndObserveServices(
peripheral: Peripheral,
scope: CoroutineScope
) {
peripheral
.services()
.onEach { service ->
var isMissing: Boolean? = null
service?.map { removeService ->
ServiceManagerFactory
.createServiceManager(removeService.uuid)
?.also { manager ->
Timber.tag("AAA")
.d("Found ServiceManager for service ${removeService.uuid}")
isMissing = false
_devices.update {
it + (peripheral.address to it[peripheral.address]!!.copy(
services = it[peripheral.address]?.services?.plus(
manager
) ?: listOf(manager)
))
}
// _isMissingServices.update { it - peripheral.address }
scope.launch { // Launch observation for each service.
observeService(peripheral, removeService, manager)
}
}
}
if (isMissing != false) {
Timber.tag("AAA").w("Peripheral ${peripheral.address} is missing services")
_isMissingServices.update { it + (peripheral.address to true) }
} else {
_isMissingServices.update { it - peripheral.address }
// If all required services are found, log it.
Timber.tag("AAA")
.d("Peripheral ${peripheral.address} has all required services")
}
}.launchIn(scope)
}
private suspend fun observeService(
peripheral: Peripheral,
service: RemoteService,
manager: ServiceManager
) {
try {
// if (manager.requiresBonding(service.uuid) && peripheral.bondingState != BondState.BONDED) {
// peripheral.ensureBonded()
// }
manager.observeServiceInteractions(peripheral.address, service, lifecycleScope)
} catch (e: Exception) {
Timber.tag("ObserveServices").e(e)
} }
}
override fun getPeripheralById(address: String?): Peripheral? = private fun disconnect(address: String) {
address?.let { centralManager.getPeripheralById(it) } centralManager.getPeripheralById(address)?.let { peripheral ->
override fun disconnect(deviceAddress: String) {
lifecycleScope.launch { lifecycleScope.launch {
try { try {
getPeripheralById(deviceAddress) peripheral.disconnect()
?.let { peripheral -> handleDisconnection(address, "Disconnected by user")
if (peripheral.isConnected) peripheral.disconnect()
handleDisconnection(deviceAddress)
}
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "Couldn't disconnect from the $deviceAddress") Timber.e(e, "Failed to disconnect from $address")
} }
} }
} }
managedConnections[address]?.cancel()
override fun connectionState(address: String): StateFlow<ConnectionState>? {
val peripheral = getPeripheralById(address) ?: return null
return peripheral.state.also { stateFlow ->
connectionJobs[address]?.cancel()
val job = stateFlow.onEach { state ->
when (state) {
ConnectionState.Connected -> {
_isMissingServices.tryEmit(false)
// Discover services if not already discovered
if (_connectedDevices.value[address] == null) {
discoverServices(peripheral)
}
}
ConnectionState.Connecting, ConnectionState.Disconnecting -> {
// No action needed, just observing the state
}
is ConnectionState.Disconnected -> {
if (state.reason == null) {
_disconnectionReason.tryEmit(null)
return@onEach
} else
_disconnectionReason.tryEmit(StateReason(state.reason!!))
connectionJobs[address]?.cancel()
handleDisconnection(address)
}
}
}.onCompletion {
connectionJobs[address]?.cancel()
connectionJobs.remove(address)
}.launchIn(lifecycleScope)
connectionJobs[address] = job
}
}
} }
/** private fun handleDisconnection(address: String, reason: String) {
* Connect to the peripheral and observe its state. Timber.d("Handling disconnection for $address, reason: $reason")
*/ _devices.update { it - address }
private fun initiateConnection(deviceAddress: String) { Timber.tag("AAA").d("Devices after disconnection: ${_devices.value.keys}")
centralManager.getPeripheralById(deviceAddress)?.let { peripheral -> _isMissingServices.update { it - address }
lifecycleScope.launch { connectPeripheral(peripheral) }
}
} }
private suspend fun connectPeripheral(peripheral: Peripheral) {
try {
centralManager.connect(peripheral, options = ConnectionOptions.Direct())
} catch (e: Exception) {
Timber.e(e, "Failed to connect to the ${peripheral.address}")
}
}
/**
* Discover services and characteristics for the connected [peripheral].
*/
@OptIn(ExperimentalUuidApi::class)
private fun discoverServices(peripheral: Peripheral) {
val discoveredServices = mutableListOf<ServiceManager>()
serviceHandlingJob[peripheral.address]?.cancel()
val job = peripheral.services().onEach { remoteServices ->
remoteServices?.forEach { remoteService ->
val serviceManager = ServiceManagerFactory.createServiceManager(remoteService.uuid)
serviceManager?.let { manager ->
Timber.tag("DiscoverServices").i("${manager.profile}")
discoveredServices.add(manager)
lifecycleScope.launch {
try {
val requiresBonding =
remoteService.uuid == CGMS_SERVICE_UUID.toKotlinUuid() && peripheral.hasBondInformation
if (requiresBonding) {
peripheral.bondState
.onEach { if (it == BondState.NONE) peripheral.createBond() }
.filter { it == BondState.BONDED }
.first()
}
manager.observeServiceInteractions(
peripheral.address,
remoteService,
this
)
} catch (e: Exception) {
Timber.tag("ObserveServices").e(e)
}
}
}
}
when {
discoveredServices.isEmpty() -> {
if (remoteServices?.isNotEmpty() == true) {
_isMissingServices.tryEmit(true)
serviceHandlingJob[peripheral.address]?.cancel()
serviceHandlingJob.remove(peripheral.address)
}
}
peripheral.isConnected -> {
_isMissingServices.tryEmit(false)
updateConnectedDevices(peripheral, discoveredServices)
}
}
}
.onCompletion {
serviceHandlingJob[peripheral.address]?.cancel()
serviceHandlingJob.remove(peripheral.address)
}
.launchIn(lifecycleScope)
serviceHandlingJob[peripheral.address] = job
}
/**
* Update the connected devices with the latest state.
*/
private fun updateConnectedDevices(peripheral: Peripheral, handlers: List<ServiceManager>) {
_connectedDevices.update {
it.toMutableMap().apply { this[peripheral.address] = peripheral to handlers }
}
}
/**
* Handle disconnection and cleanup for the given peripheral.
*/
private fun handleDisconnection(device: String) {
val currentDevices = _connectedDevices.value.toMutableMap()
currentDevices[device]?.let {
currentDevices.remove(device)
_connectedDevices.tryEmit(currentDevices)
}
clearJobs(device)
clearFlags()
stopServiceIfNoDevices()
}
/**
* Clear any active jobs for connection and service handling.
*/
private fun clearJobs(peripheral: String) {
connectionJobs[peripheral]?.cancel()
connectionJobs.remove(peripheral)
serviceHandlingJob[peripheral]?.cancel()
serviceHandlingJob.remove(peripheral)
}
/**
* Stop the service if no devices are connected.
*/
private fun stopServiceIfNoDevices() { private fun stopServiceIfNoDevices() {
if (_connectedDevices.value.isEmpty()) { if (_devices.value.isEmpty()) {
stopForegroundService() stopForegroundService()
stopSelf() stopSelf()
} }
} }
/** // Logger and other helper functions remain largely the same.
* Initialize the logger for the specified device. private fun initLogger(deviceAddress: String) {
*/ if (logger != null) return
private fun initLogger(device: String) { logger = nRFLoggerTree(this, getString(R.string.app_name), deviceAddress)
logger?.let { Timber.uproot(it) }
logger = nRFLoggerTree(this, this.getString(R.string.app_name), device)
.also { Timber.plant(it) } .also { Timber.plant(it) }
} }
/**
* Uproot the logger and clear the logger instance.
*/
private fun uprootLogger() { private fun uprootLogger() {
logger?.let { Timber.uproot(it) } logger?.let { Timber.uproot(it) }
logger = null logger = null
} }
/** // The Binder providing the public API.
* Clear the missing services and battery level flags. inner class LocalBinder : Binder(), ServiceApi {
*/ override val devices: StateFlow<Map<String, ServiceApi.DeviceData>>
private fun clearFlags() { get() = _devices.asStateFlow()
_isMissingServices.tryEmit(false)
uprootLogger()
}
override val isMissingServices: StateFlow<Map<String, Boolean>>
get() = _isMissingServices.asStateFlow()
override val disconnectionEvent: StateFlow<ServiceApi.DisconnectionEvent?>
get() = _disconnectionEvent.asStateFlow()
override fun disconnect(address: String) = this@ProfileService.disconnect(address)
override fun getPeripheral(address: String?): Peripheral? =
address?.let { centralManager.getPeripheralById(it) }
override suspend fun getMaxWriteValue(address: String, writeType: WriteType): Int? {
val peripheral = getPeripheral(address) ?: return null
if (peripheral.state.value != ConnectionState.Connected) return null
return try {
peripheral.requestHighestValueLength() // Request highest possible MTU
peripheral.requestConnectionPriority(ConnectionPriority.HIGH)
peripheral.readPhy()
peripheral.maximumWriteValueLength(writeType)
} catch (e: Exception) {
Timber.e(e, "Failed to configure MTU for $address")
null
}
}
override suspend fun createBond(address: String) {
getPeripheral(address)?.ensureBonded()
}
}
}
// Helper extension function for bonding
private suspend fun Peripheral.ensureBonded() {
if (this.bondState.value == BondState.BONDED) return
// Create bond and wait until bonded.
createBond()
} }

View File

@@ -1,59 +1,74 @@
package no.nordicsemi.android.service.profile package no.nordicsemi.android.service.profile
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import no.nordicsemi.android.toolbox.profile.manager.ServiceManager import no.nordicsemi.android.toolbox.profile.manager.ServiceManager
import no.nordicsemi.kotlin.ble.client.android.Peripheral import no.nordicsemi.kotlin.ble.client.android.Peripheral
import no.nordicsemi.kotlin.ble.core.ConnectionState import no.nordicsemi.kotlin.ble.core.ConnectionState
import no.nordicsemi.kotlin.ble.core.WriteType import no.nordicsemi.kotlin.ble.core.WriteType
/**
* Represents the public-facing API for the ProfileService.
*/
interface ServiceApi { interface ServiceApi {
/** Flow of connected devices. */ /** A data class to hold all relevant information about a connected device. */
val connectedDevices: Flow<Map<String, Pair<Peripheral, List<ServiceManager>>>> data class DeviceData(
val peripheral: Peripheral,
val connectionState: ConnectionState = ConnectionState.Connecting,
val services: List<ServiceManager> = emptyList()
)
/** Missing services flag. */ /** A data class to represent a disconnection event. */
val isMissingServices: Flow<Boolean> data class DisconnectionEvent(val address: String, val reason: DeviceDisconnectionReason)
/** /**
* Get the peripheral by its [address]. * A flow that emits the current state of all managed devices.
* * The map key is the device address.
* @return the peripheral instance.
*/ */
fun getPeripheralById(address: String?): Peripheral? val devices: StateFlow<Map<String, DeviceData>>
/** /**
* Disconnect the device with the given [deviceAddress]. * A flow that emits whether a specific device is missing its required services.
* * The map key is the device address.
* @param deviceAddress the device address.
*/ */
fun disconnect(deviceAddress: String) val isMissingServices: StateFlow<Map<String, Boolean>>
/** /**
* Get the connection state of the device with the given [address]. * A flow that emits the reason for the last disconnection event for any device.
*
* @return the connection state flow.
*/ */
fun connectionState(address: String): StateFlow<ConnectionState>? val disconnectionEvent: StateFlow<DisconnectionEvent?>
/** /**
* Get the disconnection reason of the device with the given address. * Disconnects from a Bluetooth device and stops managing it.
* *
* @return the disconnection reason flow. * @param address The address of the device to disconnect from.
*/ */
val disconnectionReason: StateFlow<DeviceDisconnectionReason?> fun disconnect(address: String)
/** /**
* Request maximum write value length. * Retrieves a peripheral instance by its address.
* For [WriteType.WITHOUT_RESPONSE] it is equal to *ATT MTU - 3 bytes*. *
* @param address The device address.
* @return The [Peripheral] instance, or null if not found.
*/
fun getPeripheral(address: String?): Peripheral?
/**
* Requests the maximum possible value length for a write operation.
*
* @param address The device address.
* @param writeType The type of write operation.
* @return The maximum number of bytes that can be sent in a single write.
*/ */
suspend fun getMaxWriteValue( suspend fun getMaxWriteValue(
address: String, address: String,
writeType: WriteType = WriteType.WITHOUT_RESPONSE writeType: WriteType = WriteType.WITHOUT_RESPONSE
): Int? ): Int?
suspend fun createBonding( /**
address: String * Initiates and waits for the bonding process to complete with a device.
) *
* @param address The device address.
*/
suspend fun createBond(address: String)
} }

View File

@@ -12,7 +12,7 @@ dependencies {
implementation(project(":lib_ui")) implementation(project(":lib_ui"))
implementation(project(":lib_utils")) implementation(project(":lib_utils"))
implementation(project(":profile-parsers")) implementation(project(":profile-parsers"))
implementation(project(":lib_service")) api(project(":lib_service"))
implementation(project(":profile_manager")) implementation(project(":profile_manager"))
implementation(project(":lib_storage")) implementation(project(":lib_storage"))
implementation(project(":permissions-ranging")) implementation(project(":permissions-ranging"))

View File

@@ -27,11 +27,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import no.nordicsemi.android.common.permissions.ble.RequireBluetooth import no.nordicsemi.android.common.permissions.ble.RequireBluetooth
import no.nordicsemi.android.common.permissions.ble.RequireLocation import no.nordicsemi.android.common.permissions.ble.RequireLocation
import no.nordicsemi.android.common.permissions.notification.RequestNotificationPermission import no.nordicsemi.android.common.permissions.notification.RequestNotificationPermission
import no.nordicsemi.android.service.profile.CustomReason
import no.nordicsemi.android.service.profile.DeviceDisconnectionReason
import no.nordicsemi.android.service.profile.StateReason
import no.nordicsemi.android.toolbox.lib.utils.Profile import no.nordicsemi.android.toolbox.lib.utils.Profile
import no.nordicsemi.android.toolbox.profile.data.toReason
import no.nordicsemi.android.toolbox.profile.view.battery.BatteryScreen import no.nordicsemi.android.toolbox.profile.view.battery.BatteryScreen
import no.nordicsemi.android.toolbox.profile.view.bps.BPSScreen import no.nordicsemi.android.toolbox.profile.view.bps.BPSScreen
import no.nordicsemi.android.toolbox.profile.view.cgms.CGMScreen import no.nordicsemi.android.toolbox.profile.view.cgms.CGMScreen
@@ -47,45 +43,45 @@ import no.nordicsemi.android.toolbox.profile.view.rscs.RSCSScreen
import no.nordicsemi.android.toolbox.profile.view.throughput.ThroughputScreen import no.nordicsemi.android.toolbox.profile.view.throughput.ThroughputScreen
import no.nordicsemi.android.toolbox.profile.view.uart.UARTScreen import no.nordicsemi.android.toolbox.profile.view.uart.UARTScreen
import no.nordicsemi.android.toolbox.profile.viewmodel.ConnectionEvent import no.nordicsemi.android.toolbox.profile.viewmodel.ConnectionEvent
import no.nordicsemi.android.toolbox.profile.viewmodel.DeviceConnectionState import no.nordicsemi.android.toolbox.profile.viewmodel.ProfileUiState
import no.nordicsemi.android.toolbox.profile.viewmodel.DeviceData
import no.nordicsemi.android.toolbox.profile.viewmodel.ProfileViewModel import no.nordicsemi.android.toolbox.profile.viewmodel.ProfileViewModel
import no.nordicsemi.android.ui.view.internal.DeviceConnectingView import no.nordicsemi.android.ui.view.internal.DeviceConnectingView
import no.nordicsemi.android.ui.view.internal.DeviceDisconnectedView
import no.nordicsemi.android.ui.view.internal.DisconnectReason import no.nordicsemi.android.ui.view.internal.DisconnectReason
import no.nordicsemi.android.ui.view.internal.LoadingView
import no.nordicsemi.android.ui.view.internal.ServiceDiscoveryView import no.nordicsemi.android.ui.view.internal.ServiceDiscoveryView
@Composable @Composable
internal fun ProfileScreen() { internal fun ProfileScreen() {
val profileViewModel: ProfileViewModel = hiltViewModel() val profileViewModel: ProfileViewModel = hiltViewModel()
val uiState by profileViewModel.uiState.collectAsStateWithLifecycle()
val deviceAddress = profileViewModel.address val deviceAddress = profileViewModel.address
val deviceDataState by profileViewModel.deviceState.collectAsStateWithLifecycle()
val onClickEvent: (ConnectionEvent) -> Unit = { event -> // Event handler now sends simpler, context-free events.
profileViewModel.onConnectionEvent(event) val onEvent: (ConnectionEvent) -> Unit = { event ->
profileViewModel.onEvent(event)
} }
// Handle back press to navigate up. // Handle back press to navigate up.
BackHandler { BackHandler {
onClickEvent(ConnectionEvent.NavigateUp) onEvent(ConnectionEvent.NavigateUp)
} }
Scaffold( Scaffold(
contentWindowInsets = WindowInsets.displayCutout contentWindowInsets = WindowInsets.displayCutout
.only(WindowInsetsSides.Horizontal), .only(WindowInsetsSides.Horizontal),
topBar = { topBar = {
// The device name is derived directly from the current state.
val deviceName = (uiState as? ProfileUiState.Connected)
?.deviceData?.peripheral?.name
?: deviceAddress
ProfileAppBar( ProfileAppBar(
deviceName = when (val state = deviceDataState) { deviceName = deviceName,
is DeviceConnectionState.Connected -> state.data.peripheral?.name
?: deviceAddress
is DeviceConnectionState.Disconnected -> state.device?.name ?: deviceAddress
else -> deviceAddress
},
title = deviceAddress, title = deviceAddress,
connectionState = deviceDataState, // The AppBar needs to be updated to accept the new ProfileUiState
navigateUp = { onClickEvent(ConnectionEvent.NavigateUp) }, connectionState = uiState,
disconnect = { onClickEvent(ConnectionEvent.DisconnectEvent(deviceAddress)) }, navigateUp = { onEvent(ConnectionEvent.NavigateUp) },
openLogger = { onClickEvent(ConnectionEvent.OpenLoggerEvent) } disconnect = { onEvent(ConnectionEvent.DisconnectEvent) },
openLogger = { onEvent(ConnectionEvent.OpenLoggerEvent) }
) )
}, },
) { paddingValues -> ) { paddingValues ->
@@ -101,30 +97,31 @@ internal fun ProfileScreen() {
.imePadding(), .imePadding(),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
when (val state = deviceDataState) { // The main content switches based on the UI state.
is DeviceConnectionState.Connected -> { when (val state = uiState) {
DeviceConnectedView( is ProfileUiState.Connected -> DeviceConnectedView(
state.data, state = state,
onClickEvent onEvent = onEvent
)
}
DeviceConnectionState.Connecting -> DeviceConnectingView(
modifier = Modifier
.padding(16.dp)
) )
is DeviceConnectionState.Disconnected -> { is ProfileUiState.Disconnected -> {
state.reason?.let { DeviceDisconnectedView(
DeviceDisconnectedView( disconnectedReason = state.reason.toString(),
it, isMissingService = false,
deviceAddress, modifier = Modifier.padding(16.dp),
onClickEvent ) {
) Button(
onClick = { onEvent(ConnectionEvent.OnRetryClicked) },
) {
Text(text = stringResource(id = R.string.reconnect))
}
} }
} }
DeviceConnectionState.Idle, DeviceConnectionState.Disconnecting -> LoadingView() ProfileUiState.Loading -> DeviceConnectingView(
modifier = Modifier.padding(16.dp)
)
} }
} }
} }
@@ -133,124 +130,69 @@ internal fun ProfileScreen() {
} }
} }
@Composable
internal fun DeviceDisconnectedView(
reason: DeviceDisconnectionReason,
deviceAddress: String,
onClickEvent: (ConnectionEvent) -> Unit
) {
when (reason) {
is CustomReason -> {
no.nordicsemi.android.ui.view.internal.DeviceDisconnectedView(
reason = reason.reason,
modifier = Modifier
.padding(16.dp)
) {
Button(
onClick = { onClickEvent(ConnectionEvent.OnRetryClicked(deviceAddress)) },
modifier = Modifier.padding(16.dp)
) {
Text(text = stringResource(id = R.string.reconnect))
}
}
}
is StateReason -> {
no.nordicsemi.android.ui.view.internal.DeviceDisconnectedView(
disconnectedReason = toReason(reason.reason),
modifier = Modifier
.padding(16.dp)
) {
Button(
onClick = { onClickEvent(ConnectionEvent.OnRetryClicked(deviceAddress)) },
modifier = Modifier.padding(16.dp)
) {
Text(text = stringResource(id = R.string.reconnect))
}
}
}
}
}
@Composable @Composable
internal fun DeviceConnectedView( internal fun DeviceConnectedView(
deviceData: DeviceData, state: ProfileUiState.Connected,
onClickEvent: (ConnectionEvent) -> Unit, onEvent: (ConnectionEvent) -> Unit,
) { ) {
// Is missing services? // Check for missing services directly from the state object.
deviceData.peripheral?.let { peripheral -> if (state.isMissingServices) {
when { DeviceDisconnectedView(
deviceData.isMissingServices -> { reason = DisconnectReason.MISSING_SERVICE,
no.nordicsemi.android.ui.view.internal.DeviceDisconnectedView( modifier = Modifier.padding(16.dp)
reason = DisconnectReason.MISSING_SERVICE, )
modifier = Modifier return
.padding(16.dp) }
)
}
else -> { Column(
Column( verticalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier
modifier = Modifier .padding(16.dp)
.padding(16.dp) .imePadding()
.imePadding() ) {
// Show service discovery view if services are not yet available.
if (state.deviceData.services.isEmpty()) {
ServiceDiscoveryView(modifier = Modifier) {
Button(
onClick = { onEvent(ConnectionEvent.DisconnectEvent) },
modifier = Modifier.padding(16.dp)
) { ) {
deviceData.peripheralProfileMap[deviceData.peripheral]?.forEach { profile -> Text(text = stringResource(id = R.string.cancel))
Column( }
modifier = Modifier }
.imePadding() } else {
) { // Iterate through the available service managers.
// Requires max value length to be set. state.deviceData.services.forEach { serviceManager ->
val needsMaxValueLength = profile.profile == Profile.THROUGHPUT || Column(modifier = Modifier.imePadding()) {
profile.profile == Profile.UART val needsMaxValueLength = serviceManager.profile in listOf(
if (needsMaxValueLength) { Profile.CHANNEL_SOUNDING, Profile.UART, Profile.THROUGHPUT
LaunchedEffect(key1 = true) { )
if (deviceData.maxValueLength == null) {
onClickEvent(ConnectionEvent.RequestMaxValueLength)
}
}
}
when (profile.profile) {
Profile.HTS -> HTSScreen()
Profile.CHANNEL_SOUNDING -> ChannelSoundingScreen()
Profile.BPS -> BPSScreen()
Profile.CSC -> CSCScreen()
Profile.CGM -> CGMScreen()
Profile.DFS -> DFSScreen()
Profile.GLS -> GLSScreen()
Profile.HRS -> HRSScreen()
Profile.LBS -> BlinkyScreen()
Profile.RSCS -> RSCSScreen()
Profile.THROUGHPUT -> ThroughputScreen(deviceData.maxValueLength)
Profile.UART -> UARTScreen(deviceData.maxValueLength)
else -> { // Request max value length if needed and not already set.
// Do nothing. if (needsMaxValueLength) {
} LaunchedEffect(Unit) {
} if (state.maxValueLength == null) {
if (profile.profile == Profile.BATTERY) { onEvent(ConnectionEvent.RequestMaxValueLength)
// Battery level will be added at the end.
BatteryScreen()
}
}
} ?: run {
ServiceDiscoveryView(
modifier = Modifier
) {
Button(
onClick = {
onClickEvent(
ConnectionEvent.DisconnectEvent(
peripheral.address
)
)
},
modifier = Modifier.padding(16.dp)
) {
Text(text = stringResource(id = R.string.cancel))
} }
} }
} }
// Display the appropriate screen for each profile.
when (serviceManager.profile) {
Profile.HTS -> HTSScreen()
Profile.CHANNEL_SOUNDING -> ChannelSoundingScreen()
Profile.BPS -> BPSScreen()
Profile.CSC -> CSCScreen()
Profile.CGM -> CGMScreen()
Profile.DFS -> DFSScreen()
Profile.GLS -> GLSScreen()
Profile.HRS -> HRSScreen()
Profile.LBS -> BlinkyScreen()
Profile.RSCS -> RSCSScreen()
Profile.BATTERY -> BatteryScreen()
Profile.THROUGHPUT -> ThroughputScreen(state.maxValueLength)
Profile.UART -> UARTScreen(state.maxValueLength)
}
} }
} }
} }

View File

@@ -6,6 +6,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import no.nordicsemi.android.analytics.AppAnalytics import no.nordicsemi.android.analytics.AppAnalytics
import no.nordicsemi.android.analytics.ProfileConnectedEvent import no.nordicsemi.android.analytics.ProfileConnectedEvent
import no.nordicsemi.android.service.profile.ServiceApi
import no.nordicsemi.android.toolbox.profile.manager.ServiceManager import no.nordicsemi.android.toolbox.profile.manager.ServiceManager
import no.nordicsemi.android.toolbox.lib.utils.Profile import no.nordicsemi.android.toolbox.lib.utils.Profile
import no.nordicsemi.kotlin.ble.client.android.Peripheral import no.nordicsemi.kotlin.ble.client.android.Peripheral
@@ -17,7 +18,7 @@ class DeviceRepository @Inject constructor(
private val analytics: AppAnalytics, private val analytics: AppAnalytics,
) { ) {
private val _connectedDevices = private val _connectedDevices =
MutableStateFlow<Map<String, Pair<Peripheral, List<ServiceManager>>>>(emptyMap()) MutableStateFlow<Map<String, ServiceApi.DeviceData>>(emptyMap())
val connectedDevices = _connectedDevices.asStateFlow() val connectedDevices = _connectedDevices.asStateFlow()
private val _profilePeripheralPair = private val _profilePeripheralPair =
@@ -26,7 +27,7 @@ class DeviceRepository @Inject constructor(
private val _loggedProfiles = mutableListOf<Pair<String, String>>() private val _loggedProfiles = mutableListOf<Pair<String, String>>()
fun updateConnectedDevices(devices: Map<String, Pair<Peripheral, List<ServiceManager>>>) { fun updateConnectedDevices(devices: Map<String, ServiceApi.DeviceData>) {
_connectedDevices.update { devices } _connectedDevices.update { devices }
} }

View File

@@ -3,7 +3,7 @@ package no.nordicsemi.android.toolbox.profile.view.internal
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import no.nordicsemi.android.common.theme.NordicTheme import no.nordicsemi.android.common.theme.NordicTheme
import no.nordicsemi.android.toolbox.profile.viewmodel.DeviceConnectionState import no.nordicsemi.android.toolbox.profile.viewmodel.ProfileUiState
import no.nordicsemi.android.ui.view.BackIconAppBar import no.nordicsemi.android.ui.view.BackIconAppBar
import no.nordicsemi.android.ui.view.LoggerBackIconAppBar import no.nordicsemi.android.ui.view.LoggerBackIconAppBar
import no.nordicsemi.android.ui.view.LoggerIconAppBar import no.nordicsemi.android.ui.view.LoggerIconAppBar
@@ -12,13 +12,13 @@ import no.nordicsemi.android.ui.view.LoggerIconAppBar
internal fun ProfileAppBar( internal fun ProfileAppBar(
deviceName: String?, deviceName: String?,
title: String, title: String,
connectionState: DeviceConnectionState, connectionState: ProfileUiState,
navigateUp: () -> Unit, navigateUp: () -> Unit,
disconnect: () -> Unit, disconnect: () -> Unit,
openLogger: () -> Unit openLogger: () -> Unit
) { ) {
if (deviceName?.isNotBlank() == true) { if (deviceName?.isNotBlank() == true) {
if (connectionState !is DeviceConnectionState.Disconnected) { if (connectionState !is ProfileUiState.Disconnected) {
LoggerIconAppBar(deviceName, navigateUp, disconnect, openLogger) LoggerIconAppBar(deviceName, navigateUp, disconnect, openLogger)
} else { } else {
LoggerBackIconAppBar(deviceName, navigateUp) { openLogger() } LoggerBackIconAppBar(deviceName, navigateUp) { openLogger() }
@@ -35,7 +35,7 @@ private fun ProfileAppBarPreview() {
ProfileAppBar( ProfileAppBar(
deviceName = "DE", deviceName = "DE",
title = "nRF Toolbox", title = "nRF Toolbox",
connectionState = DeviceConnectionState.Connecting, connectionState = ProfileUiState.Loading,
navigateUp = {}, navigateUp = {},
disconnect = {}, disconnect = {},
openLogger = {}, openLogger = {},

View File

@@ -1,23 +1,29 @@
package no.nordicsemi.android.toolbox.profile.viewmodel package no.nordicsemi.android.toolbox.profile.viewmodel
import no.nordicsemi.android.service.profile.DeviceDisconnectionReason import no.nordicsemi.android.service.profile.DeviceDisconnectionReason
import no.nordicsemi.android.toolbox.profile.manager.ServiceManager import no.nordicsemi.android.service.profile.ServiceApi
import no.nordicsemi.kotlin.ble.client.android.Peripheral
internal data class DeviceData( /**
val peripheral: Peripheral? = null, * Events triggered by the user from the UI.
val peripheralProfileMap: Map<Peripheral, List<ServiceManager>> = emptyMap(), */
val isMissingServices: Boolean = false, internal sealed interface ConnectionEvent {
val maxValueLength: Int? = null, data object OnRetryClicked : ConnectionEvent
) data object NavigateUp : ConnectionEvent
data object DisconnectEvent : ConnectionEvent
internal sealed class DeviceConnectionState { data object OpenLoggerEvent : ConnectionEvent
data object Idle : DeviceConnectionState() data object RequestMaxValueLength : ConnectionEvent
data object Connecting : DeviceConnectionState() }
data object Disconnecting : DeviceConnectionState()
data class Connected(val data: DeviceData) : DeviceConnectionState() /**
data class Disconnected( * Represents the state of the UI for the profile screen.
val device: Peripheral? = null, */
val reason: DeviceDisconnectionReason? internal sealed interface ProfileUiState {
) : DeviceConnectionState() data object Loading : ProfileUiState
data class Disconnected(val reason: DeviceDisconnectionReason?) : ProfileUiState
data class Connected(
val deviceData: ServiceApi.DeviceData,
val isMissingServices: Boolean = false,
val maxValueLength: Int? = null,
) : ProfileUiState
} }

View File

@@ -5,12 +5,12 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -24,29 +24,12 @@ import no.nordicsemi.android.log.LogSession
import no.nordicsemi.android.log.timber.nRFLoggerTree import no.nordicsemi.android.log.timber.nRFLoggerTree
import no.nordicsemi.android.service.profile.ProfileServiceManager import no.nordicsemi.android.service.profile.ProfileServiceManager
import no.nordicsemi.android.service.profile.ServiceApi import no.nordicsemi.android.service.profile.ServiceApi
import no.nordicsemi.android.service.profile.StateReason
import no.nordicsemi.android.toolbox.profile.ProfileDestinationId import no.nordicsemi.android.toolbox.profile.ProfileDestinationId
import no.nordicsemi.android.toolbox.profile.R import no.nordicsemi.android.toolbox.profile.R
import no.nordicsemi.android.toolbox.profile.repository.DeviceRepository import no.nordicsemi.android.toolbox.profile.repository.DeviceRepository
import no.nordicsemi.kotlin.ble.client.android.Peripheral
import no.nordicsemi.kotlin.ble.core.ConnectionState
import timber.log.Timber import timber.log.Timber
import java.lang.ref.WeakReference
import javax.inject.Inject import javax.inject.Inject
internal sealed interface ConnectionEvent {
data class OnRetryClicked(val device: String) : ConnectionEvent
data object NavigateUp : ConnectionEvent
data class DisconnectEvent(val device: String) : ConnectionEvent
data object OpenLoggerEvent : ConnectionEvent
data object RequestMaxValueLength : ConnectionEvent
}
@HiltViewModel @HiltViewModel
internal class ProfileViewModel @Inject constructor( internal class ProfileViewModel @Inject constructor(
private val profileServiceManager: ProfileServiceManager, private val profileServiceManager: ProfileServiceManager,
@@ -57,239 +40,132 @@ internal class ProfileViewModel @Inject constructor(
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
) : SimpleNavigationViewModel(navigator, savedStateHandle) { ) : SimpleNavigationViewModel(navigator, savedStateHandle) {
val address: String = parameterOf(ProfileDestinationId) val address: String = parameterOf(ProfileDestinationId)
private val _deviceState = MutableStateFlow<DeviceConnectionState>(DeviceConnectionState.Idle) private var serviceApi: ServiceApi? = null
val deviceState = _deviceState.asStateFlow() private val logger: nRFLoggerTree =
nRFLoggerTree(context, address, context.getString(R.string.app_name))
private var logger: nRFLoggerTree? = null private val _uiState = MutableStateFlow<ProfileUiState>(ProfileUiState.Loading)
private var serviceApi: WeakReference<ServiceApi>? = null val uiState: StateFlow<ProfileUiState> = _uiState.asStateFlow()
private var peripheral: Peripheral? = null
private var job: Job? = null
init { init {
connectToPeripheral(address) Timber.tag("AAA PVM").d("Initializing ViewModel for device: $address")
connectToPeripheral()
observeConnectedDevices() observeConnectedDevices()
initLogger() Timber.plant(logger)
} }
private suspend fun getServiceApi(): ServiceApi? { private suspend fun getServiceApi() =
if (serviceApi == null) { profileServiceManager.bindService().also { serviceApi = it }
serviceApi = WeakReference(profileServiceManager.bindService())
}
return serviceApi?.get()
}
private fun initLogger() {
logger = nRFLoggerTree(context, address, context.getString(R.string.app_name)).also {
Timber.plant(it)
}
}
private fun observeConnectedDevices() = viewModelScope.launch { private fun observeConnectedDevices() = viewModelScope.launch {
getServiceApi()?.let { api -> // Bind the service and get the API
peripheral = api.getPeripheralById(address) val api = getServiceApi()
api.connectedDevices // Combine flows from the service to create a single UI state.
.onEach { peripheralProfileMap -> combine(
deviceRepository.updateConnectedDevices(peripheralProfileMap) api.devices,
api.isMissingServices,
api.disconnectionEvent
) { devices, missingServicesMap, disconnection ->
val deviceData = devices[address]
val isMissingServices = missingServicesMap[address] ?: false
Timber.tag("AAA PVM")
.d("DeviceData for $address: $deviceData, MissingServices: $isMissingServices, $deviceData")
peripheralProfileMap[peripheral?.address]?.let { pair -> // Determine the UI state based on the service's state
deviceRepository.updateProfilePeripheralPair(pair.first, pair.second) if (deviceData != null) {
_deviceState.update { // Update connected device info in the repository
DeviceConnectionState.Connected( deviceRepository.updateProfilePeripheralPair(
DeviceData( deviceData.peripheral,
peripheral = pair.first, deviceData.services
peripheralProfileMap = mapOf(pair.first to pair.second), )
) deviceData.services.forEach {
) deviceRepository.updateAnalytics(
address,
it.profile
)
}
deviceRepository.updateConnectedDevices(devices)
} // // Create the Connected state
} val currentMaxVal =
(_uiState.value as? ProfileUiState.Connected)?.maxValueLength
// Send each profile handler to a shared flow that profile ViewModels can observe ProfileUiState.Connected(deviceData, isMissingServices, currentMaxVal)
peripheralProfileMap[peripheral?.address]?.second?.forEach { handler -> } else {
deviceRepository.updateAnalytics(address, handler.profile) // If the device is not in the map, it's disconnected.
// Check if there's a specific disconnection event for this device.
} val reason =
}.launchIn(viewModelScope) if (disconnection?.address == address) disconnection.reason else null
deviceRepository.removeLoggedProfile(address)
updateConnectionState(api, address, peripheral?.isConnected == true) ProfileUiState.Disconnected(reason)
}
}.catch { e ->
Timber.e(e, "Error observing profile state")
// You could emit a generic error state here if needed
}.collect { state ->
_uiState.value = state
} }
} }
/** /**
* Connect to the peripheral with the given address. Before connecting, the service must be bound. * Connect to the peripheral with the given address. Before connecting, the service must be bound.
* The service will be started if not already running. * The service will be started if not already running.
* @param deviceAddress the address of the peripheral to connect to.
*/ */
private fun connectToPeripheral(deviceAddress: String) = viewModelScope.launch { private fun connectToPeripheral() = viewModelScope.launch {
// Connect to the peripheral // Connect to the peripheral
getServiceApi()?.let { getServiceApi().devices.onEach {
if (peripheral == null) peripheral = it.getPeripheralById(address) if (it[address]?.connectionState?.isConnected != true) {
if (peripheral?.isConnected != true) { Timber.tag("AAA PVM").d("Not connected to $address, connecting...")
profileServiceManager.connectToPeripheral(deviceAddress) profileServiceManager.connectToPeripheral(address)
} return@onEach
} } else {
} Timber.tag("AAA PVM").d("Already connected to $address")
/**
* Update the service data, including connection state and peripheral data.
* @param api the service API.
* @param deviceAddress the address of the connected device.
* @param isAlreadyConnected true if the device is already connected, false otherwise.
*/
private fun updateConnectionState(
api: ServiceApi,
deviceAddress: String,
isAlreadyConnected: Boolean
) {
// Drop the first default state (Closed) before connection.
job = api.connectionState(deviceAddress)
?.onEach { connectionState ->
if (peripheral == null) peripheral = api.getPeripheralById(address)
when (connectionState) {
ConnectionState.Connected -> {
_deviceState.update { currentState ->
val currentData =
(currentState as? DeviceConnectionState.Connected)?.data
DeviceConnectionState.Connected(
currentData?.copy(
peripheral = peripheral
) ?: DeviceData(peripheral = peripheral)
)
}.apply { checkForMissingServices(api) }
}
is ConnectionState.Disconnected -> {
// If disconnected reason is null, it means that the connection was never initiated.
if (connectionState.reason == null) {
_deviceState.update {
DeviceConnectionState.Idle
}
return@onEach
} else {
_deviceState.update {
DeviceConnectionState.Disconnected(
peripheral,
StateReason(connectionState.reason!!)
)
}.also {
// Remove the analytics logged profiles for the disconnected device.
deviceRepository.removeLoggedProfile(deviceAddress)
}
job?.cancel()
}
}
ConnectionState.Connecting -> {
_deviceState.update {
DeviceConnectionState.Connecting
}
}
ConnectionState.Disconnecting -> {
// Update the state to disconnecting.
_deviceState.update {
DeviceConnectionState.Disconnecting
}
}
}
}
?.onCompletion {
job?.cancel()
job = null
}?.launchIn(viewModelScope)
}
/**
* Check for missing services.
*/
private fun checkForMissingServices(api: ServiceApi) =
api.isMissingServices.onEach { isMissing ->
(_deviceState.value as? DeviceConnectionState.Connected)?.let { connectedState ->
_deviceState.update {
connectedState.copy(
data = connectedState.data.copy(isMissingServices = isMissing)
)
}
} }
}.launchIn(viewModelScope) }.launchIn(viewModelScope)
/**
* Unbind the service.
*/
private fun unbindService() {
serviceApi?.let { profileServiceManager.unbindService() }
serviceApi = null
} }
fun onConnectionEvent(event: ConnectionEvent) {
fun onEvent(event: ConnectionEvent) {
when (event) { when (event) {
is ConnectionEvent.DisconnectEvent -> disconnect(event.device) ConnectionEvent.DisconnectEvent -> {
serviceApi?.disconnect(address)
}
ConnectionEvent.NavigateUp -> { ConnectionEvent.NavigateUp -> {
// If the device is connected and missing services, disconnect it before navigating up. // Disconnect only if services are missing, otherwise leave connected
if ((_deviceState.value as? DeviceConnectionState.Connected)?.data?.isMissingServices == true) { if ((_uiState.value as? ProfileUiState.Connected)?.isMissingServices == true) {
disconnect(address) Timber.tag("BBB").d("Disconnecting due to missing services")
serviceApi?.disconnect(address)
} }
navigator.navigateUp() navigator.navigateUp()
} }
is ConnectionEvent.OnRetryClicked -> reconnectDevice(event.device) ConnectionEvent.OnRetryClicked -> {
ConnectionEvent.OpenLoggerEvent -> openLogger() _uiState.value = ProfileUiState.Loading
ConnectionEvent.RequestMaxValueLength -> viewModelScope.launch(Dispatchers.IO) { connectToPeripheral()
// Request maximum MTU size if it is not already set.
val mtuSize = getServiceApi()?.getMaxWriteValue(address)
_deviceState.update { currentState ->
val currentData =
(currentState as? DeviceConnectionState.Connected)?.data
if (currentData != null && currentData.maxValueLength == mtuSize) {
// No need to update if the max value length is already set.
return@update currentState
}
DeviceConnectionState.Connected(
currentData?.copy(
maxValueLength = mtuSize
) ?: DeviceData(
peripheral = peripheral,
maxValueLength = mtuSize
)
)
}
} }
ConnectionEvent.OpenLoggerEvent -> openLogger()
ConnectionEvent.RequestMaxValueLength -> requestMaxWriteValue()
} }
} }
/** private fun requestMaxWriteValue() = viewModelScope.launch {
* Disconnect the device with the given address and navigate back. val mtu = serviceApi?.getMaxWriteValue(address)
* @param device the address of the device to disconnect. _uiState.update {
*/ (it as? ProfileUiState.Connected)?.copy(maxValueLength = mtu) ?: it
private fun disconnect(device: String) = viewModelScope.launch { }
getServiceApi()?.disconnect(device)
unbindService()
} }
/**
* Launch the logger activity.
*/
private fun openLogger() { private fun openLogger() {
// Log the event in the analytics.
analytics.logEvent(ProfileOpenEvent(Link.LOGGER)) analytics.logEvent(ProfileOpenEvent(Link.LOGGER))
LoggerLauncher.launch(context, logger?.session as? LogSession) LoggerLauncher.launch(context, logger.session as? LogSession)
} }
/** override fun onCleared() {
* Reconnect to the device with the given address. Timber.uproot(logger)
* profileServiceManager.unbindService()
* @param deviceAddress the address of the device to reconnect to. serviceApi = null
*/ super.onCleared()
private fun reconnectDevice(deviceAddress: String) = viewModelScope.launch {
getServiceApi()?.let {
connectToPeripheral(deviceAddress)
updateConnectionState(it, deviceAddress, false)
}
} }
} }