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(":profile-parsers"))
implementation(project(":profile_manager"))
implementation(project(":profile"))
api(project(":profile"))
implementation(project(":profile_data"))
implementation(project(":lib_ui"))
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.UiEvent
import no.nordicsemi.android.toolbox.lib.utils.Profile
import timber.log.Timber
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -82,240 +83,250 @@ internal fun HomeView() {
.padding(start = 16.dp, end = 16.dp, top = 16.dp),
)
if (state.connectedDevices.isNotEmpty()) {
Timber.tag("AAA").d("Connected devices: ${state.connectedDevices.keys}")
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxWidth()
) {
state.connectedDevices.values.forEach { (peripheral, services) ->
// Skip if no services
if (services.isEmpty()) return@forEach
// Case 1: If only one service, show it directly like battery service
if (services.size == 1 && services.first().profile == Profile.BATTERY) {
FeatureButton(
iconId = R.drawable.ic_battery,
description = R.string.battery_module_full,
deviceName = peripheral.name,
deviceAddress = peripheral.address,
onClick = {
onEvent(
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,
state.connectedDevices.keys.forEach {
state.connectedDevices[it]?.let { deviceData ->
if (deviceData.connectionState.isConnected) {
// Skip if no services
if (deviceData.services.isEmpty()) return@forEach
// Case 1: If only one service, show it directly like battery service
if (deviceData.services.size == 1 && deviceData.services.first().profile == Profile.BATTERY) {
FeatureButton(
iconId = R.drawable.ic_battery,
description = R.string.battery_module_full,
deviceName = deviceData.peripheral.name,
deviceAddress = deviceData.peripheral.address,
onClick = {
onEvent(
UiEvent.OnDeviceClick(
peripheral.address,
serviceManager.profile
deviceData.peripheral.address,
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 {

View File

@@ -14,14 +14,14 @@ import no.nordicsemi.android.analytics.Link
import no.nordicsemi.android.analytics.ProfileOpenEvent
import no.nordicsemi.android.common.navigation.Navigator
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.repository.DeviceRepository
import no.nordicsemi.kotlin.ble.client.android.Peripheral
import timber.log.Timber
import javax.inject.Inject
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"
@@ -39,6 +39,7 @@ internal class HomeViewModel @Inject constructor(
init {
// Observe connected devices from the repository
deviceRepository.connectedDevices.onEach { devices ->
Timber.tag("AAA").d("Connected devices updated: ${devices.keys}")
_state.update { currentState ->
currentState.copy(connectedDevices = devices)
}

View File

@@ -5,26 +5,23 @@ import android.os.Binder
import android.os.IBinder
import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import no.nordicsemi.android.log.timber.nRFLoggerTree
import no.nordicsemi.android.service.NotificationService
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.ServiceManagerFactory
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.ConnectionOptions
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.ConnectionState
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 timber.log.Timber
import javax.inject.Inject
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.toKotlinUuid
@AndroidEntryPoint
internal class ProfileService : NotificationService() {
@@ -46,15 +40,13 @@ internal class ProfileService : NotificationService() {
@Inject
lateinit var centralManager: CentralManager
private var logger: nRFLoggerTree? = null
private val binder = LocalBinder()
private val managedConnections = mutableMapOf<String, Job>()
private val _connectedDevices =
MutableStateFlow<Map<String, Pair<Peripheral, List<ServiceManager>>>>(emptyMap())
private val _isMissingServices = MutableStateFlow(false)
private val _disconnectionReason = MutableStateFlow<DeviceDisconnectionReason?>(null)
private val connectionJobs = mutableMapOf<String, Job>()
private val serviceHandlingJob = mutableMapOf<String, Job>()
private val _devices = MutableStateFlow<Map<String, ServiceApi.DeviceData>>(emptyMap())
private val _isMissingServices = MutableStateFlow<Map<String, Boolean>>(emptyMap())
private val _disconnectionEvent = MutableStateFlow<ServiceApi.DisconnectionEvent?>(null)
override fun onBind(intent: Intent): IBinder {
super.onBind(intent)
@@ -63,260 +55,244 @@ internal class ProfileService : NotificationService() {
override fun onCreate() {
super.onCreate()
// Observe the Bluetooth state
// Observe the Bluetooth state to handle global disconnection reasons.
centralManager.state.onEach { state ->
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)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId)
intent?.getStringExtra(DEVICE_ADDRESS)?.let { deviceAddress ->
initLogger(deviceAddress)
initiateConnection(deviceAddress)
intent?.getStringExtra(DEVICE_ADDRESS)?.let { address ->
connect(address)
}
return START_REDELIVER_INTENT
}
inner class LocalBinder : Binder(), ServiceApi {
override val connectedDevices: Flow<Map<String, Pair<Peripheral, List<ServiceManager>>>>
get() = _connectedDevices.asSharedFlow()
override fun onDestroy() {
managedConnections.values.forEach { it.cancel() }
uprootLogger()
super.onDestroy()
}
override val isMissingServices: Flow<Boolean>
get() = _isMissingServices.asStateFlow()
private fun connect(address: String) {
// Return if already managed to avoid multiple connection jobs.
if (managedConnections.containsKey(address)) return
override val disconnectionReason: StateFlow<DeviceDisconnectionReason?>
get() = _disconnectionReason.asStateFlow()
initLogger(address) // Initialize logger for the new device.
override suspend fun getMaxWriteValue(address: String, writeType: WriteType): Int? {
val peripheral = getPeripheralById(address) ?: return null
if (!peripheral.isConnected) return null
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
}
val peripheral = centralManager.getPeripheralById(address) ?: run {
Timber.w("Peripheral with address $address not found.")
return
}
override suspend fun createBonding(address: String) {
val peripheral = getPeripheralById(address)
peripheral?.bondState
?.onEach { state ->
if (state == BondState.NONE) {
peripheral.createBond()
val job = lifecycleScope.launch {
// Launch the initial connection attempt.
launch {
try {
centralManager.connect(peripheral, options = ConnectionOptions.Direct())
} 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 }
?.first() // suspend until bonded
}.launchIn(this)
}
@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? =
address?.let { centralManager.getPeripheralById(it) }
override fun disconnect(deviceAddress: String) {
private fun disconnect(address: String) {
centralManager.getPeripheralById(address)?.let { peripheral ->
lifecycleScope.launch {
try {
getPeripheralById(deviceAddress)
?.let { peripheral ->
if (peripheral.isConnected) peripheral.disconnect()
handleDisconnection(deviceAddress)
}
peripheral.disconnect()
handleDisconnection(address, "Disconnected by user")
} catch (e: Exception) {
Timber.e(e, "Couldn't disconnect from the $deviceAddress")
Timber.e(e, "Failed to disconnect from $address")
}
}
}
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
}
}
managedConnections[address]?.cancel()
}
/**
* Connect to the peripheral and observe its state.
*/
private fun initiateConnection(deviceAddress: String) {
centralManager.getPeripheralById(deviceAddress)?.let { peripheral ->
lifecycleScope.launch { connectPeripheral(peripheral) }
}
private fun handleDisconnection(address: String, reason: String) {
Timber.d("Handling disconnection for $address, reason: $reason")
_devices.update { it - address }
Timber.tag("AAA").d("Devices after disconnection: ${_devices.value.keys}")
_isMissingServices.update { it - address }
}
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() {
if (_connectedDevices.value.isEmpty()) {
if (_devices.value.isEmpty()) {
stopForegroundService()
stopSelf()
}
}
/**
* Initialize the logger for the specified device.
*/
private fun initLogger(device: String) {
logger?.let { Timber.uproot(it) }
logger = nRFLoggerTree(this, this.getString(R.string.app_name), device)
// Logger and other helper functions remain largely the same.
private fun initLogger(deviceAddress: String) {
if (logger != null) return
logger = nRFLoggerTree(this, getString(R.string.app_name), deviceAddress)
.also { Timber.plant(it) }
}
/**
* Uproot the logger and clear the logger instance.
*/
private fun uprootLogger() {
logger?.let { Timber.uproot(it) }
logger = null
}
/**
* Clear the missing services and battery level flags.
*/
private fun clearFlags() {
_isMissingServices.tryEmit(false)
uprootLogger()
}
// The Binder providing the public API.
inner class LocalBinder : Binder(), ServiceApi {
override val devices: StateFlow<Map<String, ServiceApi.DeviceData>>
get() = _devices.asStateFlow()
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
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import no.nordicsemi.android.toolbox.profile.manager.ServiceManager
import no.nordicsemi.kotlin.ble.client.android.Peripheral
import no.nordicsemi.kotlin.ble.core.ConnectionState
import no.nordicsemi.kotlin.ble.core.WriteType
/**
* Represents the public-facing API for the ProfileService.
*/
interface ServiceApi {
/** Flow of connected devices. */
val connectedDevices: Flow<Map<String, Pair<Peripheral, List<ServiceManager>>>>
/** A data class to hold all relevant information about a connected device. */
data class DeviceData(
val peripheral: Peripheral,
val connectionState: ConnectionState = ConnectionState.Connecting,
val services: List<ServiceManager> = emptyList()
)
/** Missing services flag. */
val isMissingServices: Flow<Boolean>
/** A data class to represent a disconnection event. */
data class DisconnectionEvent(val address: String, val reason: DeviceDisconnectionReason)
/**
* Get the peripheral by its [address].
*
* @return the peripheral instance.
* A flow that emits the current state of all managed devices.
* The map key is the device address.
*/
fun getPeripheralById(address: String?): Peripheral?
val devices: StateFlow<Map<String, DeviceData>>
/**
* Disconnect the device with the given [deviceAddress].
*
* @param deviceAddress the device address.
* A flow that emits whether a specific device is missing its required services.
* The map key is the device address.
*/
fun disconnect(deviceAddress: String)
val isMissingServices: StateFlow<Map<String, Boolean>>
/**
* Get the connection state of the device with the given [address].
*
* @return the connection state flow.
* A flow that emits the reason for the last disconnection event for any device.
*/
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.
* For [WriteType.WITHOUT_RESPONSE] it is equal to *ATT MTU - 3 bytes*.
* Retrieves a peripheral instance by its address.
*
* @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(
address: String,
writeType: WriteType = WriteType.WITHOUT_RESPONSE
): 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_utils"))
implementation(project(":profile-parsers"))
implementation(project(":lib_service"))
api(project(":lib_service"))
implementation(project(":profile_manager"))
implementation(project(":lib_storage"))
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.RequireLocation
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.profile.data.toReason
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.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.uart.UARTScreen
import no.nordicsemi.android.toolbox.profile.viewmodel.ConnectionEvent
import no.nordicsemi.android.toolbox.profile.viewmodel.DeviceConnectionState
import no.nordicsemi.android.toolbox.profile.viewmodel.DeviceData
import no.nordicsemi.android.toolbox.profile.viewmodel.ProfileUiState
import no.nordicsemi.android.toolbox.profile.viewmodel.ProfileViewModel
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.LoadingView
import no.nordicsemi.android.ui.view.internal.ServiceDiscoveryView
@Composable
internal fun ProfileScreen() {
val profileViewModel: ProfileViewModel = hiltViewModel()
val uiState by profileViewModel.uiState.collectAsStateWithLifecycle()
val deviceAddress = profileViewModel.address
val deviceDataState by profileViewModel.deviceState.collectAsStateWithLifecycle()
val onClickEvent: (ConnectionEvent) -> Unit = { event ->
profileViewModel.onConnectionEvent(event)
// Event handler now sends simpler, context-free events.
val onEvent: (ConnectionEvent) -> Unit = { event ->
profileViewModel.onEvent(event)
}
// Handle back press to navigate up.
BackHandler {
onClickEvent(ConnectionEvent.NavigateUp)
onEvent(ConnectionEvent.NavigateUp)
}
Scaffold(
contentWindowInsets = WindowInsets.displayCutout
.only(WindowInsetsSides.Horizontal),
topBar = {
// The device name is derived directly from the current state.
val deviceName = (uiState as? ProfileUiState.Connected)
?.deviceData?.peripheral?.name
?: deviceAddress
ProfileAppBar(
deviceName = when (val state = deviceDataState) {
is DeviceConnectionState.Connected -> state.data.peripheral?.name
?: deviceAddress
is DeviceConnectionState.Disconnected -> state.device?.name ?: deviceAddress
else -> deviceAddress
},
deviceName = deviceName,
title = deviceAddress,
connectionState = deviceDataState,
navigateUp = { onClickEvent(ConnectionEvent.NavigateUp) },
disconnect = { onClickEvent(ConnectionEvent.DisconnectEvent(deviceAddress)) },
openLogger = { onClickEvent(ConnectionEvent.OpenLoggerEvent) }
// The AppBar needs to be updated to accept the new ProfileUiState
connectionState = uiState,
navigateUp = { onEvent(ConnectionEvent.NavigateUp) },
disconnect = { onEvent(ConnectionEvent.DisconnectEvent) },
openLogger = { onEvent(ConnectionEvent.OpenLoggerEvent) }
)
},
) { paddingValues ->
@@ -101,30 +97,31 @@ internal fun ProfileScreen() {
.imePadding(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
when (val state = deviceDataState) {
is DeviceConnectionState.Connected -> {
DeviceConnectedView(
state.data,
onClickEvent
)
}
DeviceConnectionState.Connecting -> DeviceConnectingView(
modifier = Modifier
.padding(16.dp)
// The main content switches based on the UI state.
when (val state = uiState) {
is ProfileUiState.Connected -> DeviceConnectedView(
state = state,
onEvent = onEvent
)
is DeviceConnectionState.Disconnected -> {
state.reason?.let {
DeviceDisconnectedView(
it,
deviceAddress,
onClickEvent
)
is ProfileUiState.Disconnected -> {
DeviceDisconnectedView(
disconnectedReason = state.reason.toString(),
isMissingService = false,
modifier = Modifier.padding(16.dp),
) {
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
internal fun DeviceConnectedView(
deviceData: DeviceData,
onClickEvent: (ConnectionEvent) -> Unit,
state: ProfileUiState.Connected,
onEvent: (ConnectionEvent) -> Unit,
) {
// Is missing services?
deviceData.peripheral?.let { peripheral ->
when {
deviceData.isMissingServices -> {
no.nordicsemi.android.ui.view.internal.DeviceDisconnectedView(
reason = DisconnectReason.MISSING_SERVICE,
modifier = Modifier
.padding(16.dp)
)
}
// Check for missing services directly from the state object.
if (state.isMissingServices) {
DeviceDisconnectedView(
reason = DisconnectReason.MISSING_SERVICE,
modifier = Modifier.padding(16.dp)
)
return
}
else -> {
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier
.padding(16.dp)
.imePadding()
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier
.padding(16.dp)
.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 ->
Column(
modifier = Modifier
.imePadding()
) {
// Requires max value length to be set.
val needsMaxValueLength = profile.profile == Profile.THROUGHPUT ||
profile.profile == Profile.UART
if (needsMaxValueLength) {
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)
Text(text = stringResource(id = R.string.cancel))
}
}
} else {
// Iterate through the available service managers.
state.deviceData.services.forEach { serviceManager ->
Column(modifier = Modifier.imePadding()) {
val needsMaxValueLength = serviceManager.profile in listOf(
Profile.CHANNEL_SOUNDING, Profile.UART, Profile.THROUGHPUT
)
else -> {
// Do nothing.
}
}
if (profile.profile == Profile.BATTERY) {
// 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))
// Request max value length if needed and not already set.
if (needsMaxValueLength) {
LaunchedEffect(Unit) {
if (state.maxValueLength == null) {
onEvent(ConnectionEvent.RequestMaxValueLength)
}
}
}
// 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 no.nordicsemi.android.analytics.AppAnalytics
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.lib.utils.Profile
import no.nordicsemi.kotlin.ble.client.android.Peripheral
@@ -17,7 +18,7 @@ class DeviceRepository @Inject constructor(
private val analytics: AppAnalytics,
) {
private val _connectedDevices =
MutableStateFlow<Map<String, Pair<Peripheral, List<ServiceManager>>>>(emptyMap())
MutableStateFlow<Map<String, ServiceApi.DeviceData>>(emptyMap())
val connectedDevices = _connectedDevices.asStateFlow()
private val _profilePeripheralPair =
@@ -26,7 +27,7 @@ class DeviceRepository @Inject constructor(
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 }
}

View File

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

View File

@@ -1,23 +1,29 @@
package no.nordicsemi.android.toolbox.profile.viewmodel
import no.nordicsemi.android.service.profile.DeviceDisconnectionReason
import no.nordicsemi.android.toolbox.profile.manager.ServiceManager
import no.nordicsemi.kotlin.ble.client.android.Peripheral
import no.nordicsemi.android.service.profile.ServiceApi
internal data class DeviceData(
val peripheral: Peripheral? = null,
val peripheralProfileMap: Map<Peripheral, List<ServiceManager>> = emptyMap(),
val isMissingServices: Boolean = false,
val maxValueLength: Int? = null,
)
internal sealed class DeviceConnectionState {
data object Idle : DeviceConnectionState()
data object Connecting : DeviceConnectionState()
data object Disconnecting : DeviceConnectionState()
data class Connected(val data: DeviceData) : DeviceConnectionState()
data class Disconnected(
val device: Peripheral? = null,
val reason: DeviceDisconnectionReason?
) : DeviceConnectionState()
/**
* Events triggered by the user from the UI.
*/
internal sealed interface ConnectionEvent {
data object OnRetryClicked : ConnectionEvent
data object NavigateUp : ConnectionEvent
data object DisconnectEvent : ConnectionEvent
data object OpenLoggerEvent : ConnectionEvent
data object RequestMaxValueLength : ConnectionEvent
}
/**
* Represents the state of the UI for the profile screen.
*/
internal sealed interface ProfileUiState {
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 dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
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.service.profile.ProfileServiceManager
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.R
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 java.lang.ref.WeakReference
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
internal class ProfileViewModel @Inject constructor(
private val profileServiceManager: ProfileServiceManager,
@@ -57,239 +40,132 @@ internal class ProfileViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
) : SimpleNavigationViewModel(navigator, savedStateHandle) {
val address: String = parameterOf(ProfileDestinationId)
private val _deviceState = MutableStateFlow<DeviceConnectionState>(DeviceConnectionState.Idle)
val deviceState = _deviceState.asStateFlow()
private var serviceApi: ServiceApi? = null
private val logger: nRFLoggerTree =
nRFLoggerTree(context, address, context.getString(R.string.app_name))
private var logger: nRFLoggerTree? = null
private var serviceApi: WeakReference<ServiceApi>? = null
private var peripheral: Peripheral? = null
private var job: Job? = null
private val _uiState = MutableStateFlow<ProfileUiState>(ProfileUiState.Loading)
val uiState: StateFlow<ProfileUiState> = _uiState.asStateFlow()
init {
connectToPeripheral(address)
Timber.tag("AAA PVM").d("Initializing ViewModel for device: $address")
connectToPeripheral()
observeConnectedDevices()
initLogger()
Timber.plant(logger)
}
private suspend fun getServiceApi(): ServiceApi? {
if (serviceApi == null) {
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 suspend fun getServiceApi() =
profileServiceManager.bindService().also { serviceApi = it }
private fun observeConnectedDevices() = viewModelScope.launch {
getServiceApi()?.let { api ->
peripheral = api.getPeripheralById(address)
// Bind the service and get the API
val api = getServiceApi()
api.connectedDevices
.onEach { peripheralProfileMap ->
deviceRepository.updateConnectedDevices(peripheralProfileMap)
// Combine flows from the service to create a single UI state.
combine(
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 ->
deviceRepository.updateProfilePeripheralPair(pair.first, pair.second)
_deviceState.update {
DeviceConnectionState.Connected(
DeviceData(
peripheral = pair.first,
peripheralProfileMap = mapOf(pair.first to pair.second),
)
)
// Determine the UI state based on the service's state
if (deviceData != null) {
// Update connected device info in the repository
deviceRepository.updateProfilePeripheralPair(
deviceData.peripheral,
deviceData.services
)
deviceData.services.forEach {
deviceRepository.updateAnalytics(
address,
it.profile
)
}
deviceRepository.updateConnectedDevices(devices)
}
}
// Send each profile handler to a shared flow that profile ViewModels can observe
peripheralProfileMap[peripheral?.address]?.second?.forEach { handler ->
deviceRepository.updateAnalytics(address, handler.profile)
}
}.launchIn(viewModelScope)
updateConnectionState(api, address, peripheral?.isConnected == true)
// // Create the Connected state
val currentMaxVal =
(_uiState.value as? ProfileUiState.Connected)?.maxValueLength
ProfileUiState.Connected(deviceData, isMissingServices, currentMaxVal)
} else {
// If the device is not in the map, it's disconnected.
// Check if there's a specific disconnection event for this device.
val reason =
if (disconnection?.address == address) disconnection.reason else null
deviceRepository.removeLoggedProfile(address)
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.
* 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
getServiceApi()?.let {
if (peripheral == null) peripheral = it.getPeripheralById(address)
if (peripheral?.isConnected != true) {
profileServiceManager.connectToPeripheral(deviceAddress)
}
}
}
/**
* 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)
)
}
getServiceApi().devices.onEach {
if (it[address]?.connectionState?.isConnected != true) {
Timber.tag("AAA PVM").d("Not connected to $address, connecting...")
profileServiceManager.connectToPeripheral(address)
return@onEach
} else {
Timber.tag("AAA PVM").d("Already connected to $address")
}
}.launchIn(viewModelScope)
/**
* Unbind the service.
*/
private fun unbindService() {
serviceApi?.let { profileServiceManager.unbindService() }
serviceApi = null
}
fun onConnectionEvent(event: ConnectionEvent) {
fun onEvent(event: ConnectionEvent) {
when (event) {
is ConnectionEvent.DisconnectEvent -> disconnect(event.device)
ConnectionEvent.DisconnectEvent -> {
serviceApi?.disconnect(address)
}
ConnectionEvent.NavigateUp -> {
// If the device is connected and missing services, disconnect it before navigating up.
if ((_deviceState.value as? DeviceConnectionState.Connected)?.data?.isMissingServices == true) {
disconnect(address)
// Disconnect only if services are missing, otherwise leave connected
if ((_uiState.value as? ProfileUiState.Connected)?.isMissingServices == true) {
Timber.tag("BBB").d("Disconnecting due to missing services")
serviceApi?.disconnect(address)
}
navigator.navigateUp()
}
is ConnectionEvent.OnRetryClicked -> reconnectDevice(event.device)
ConnectionEvent.OpenLoggerEvent -> openLogger()
ConnectionEvent.RequestMaxValueLength -> viewModelScope.launch(Dispatchers.IO) {
// 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.OnRetryClicked -> {
_uiState.value = ProfileUiState.Loading
connectToPeripheral()
}
ConnectionEvent.OpenLoggerEvent -> openLogger()
ConnectionEvent.RequestMaxValueLength -> requestMaxWriteValue()
}
}
/**
* Disconnect the device with the given address and navigate back.
* @param device the address of the device to disconnect.
*/
private fun disconnect(device: String) = viewModelScope.launch {
getServiceApi()?.disconnect(device)
unbindService()
private fun requestMaxWriteValue() = viewModelScope.launch {
val mtu = serviceApi?.getMaxWriteValue(address)
_uiState.update {
(it as? ProfileUiState.Connected)?.copy(maxValueLength = mtu) ?: it
}
}
/**
* Launch the logger activity.
*/
private fun openLogger() {
// Log the event in the analytics.
analytics.logEvent(ProfileOpenEvent(Link.LOGGER))
LoggerLauncher.launch(context, logger?.session as? LogSession)
LoggerLauncher.launch(context, logger.session as? LogSession)
}
/**
* Reconnect to the device with the given address.
*
* @param deviceAddress the address of the device to reconnect to.
*/
private fun reconnectDevice(deviceAddress: String) = viewModelScope.launch {
getServiceApi()?.let {
connectToPeripheral(deviceAddress)
updateConnectionState(it, deviceAddress, false)
}
override fun onCleared() {
Timber.uproot(logger)
profileServiceManager.unbindService()
serviceApi = null
super.onCleared()
}
}
}