diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c0d67a60..3a984674 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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")) diff --git a/app/src/main/java/no/nordicsemi/android/nrftoolbox/view/HomeView.kt b/app/src/main/java/no/nordicsemi/android/nrftoolbox/view/HomeView.kt index c2d0273f..6f544132 100644 --- a/app/src/main/java/no/nordicsemi/android/nrftoolbox/view/HomeView.kt +++ b/app/src/main/java/no/nordicsemi/android/nrftoolbox/view/HomeView.kt @@ -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 { diff --git a/app/src/main/java/no/nordicsemi/android/nrftoolbox/viewmodel/HomeViewModel.kt b/app/src/main/java/no/nordicsemi/android/nrftoolbox/viewmodel/HomeViewModel.kt index 74216336..2d875321 100644 --- a/app/src/main/java/no/nordicsemi/android/nrftoolbox/viewmodel/HomeViewModel.kt +++ b/app/src/main/java/no/nordicsemi/android/nrftoolbox/viewmodel/HomeViewModel.kt @@ -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>> = emptyMap(), + val connectedDevices: Map = 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) } diff --git a/lib_service/src/main/java/no/nordicsemi/android/service/profile/ProfileService.kt b/lib_service/src/main/java/no/nordicsemi/android/service/profile/ProfileService.kt index 76bbcaeb..806f9613 100644 --- a/lib_service/src/main/java/no/nordicsemi/android/service/profile/ProfileService.kt +++ b/lib_service/src/main/java/no/nordicsemi/android/service/profile/ProfileService.kt @@ -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() - private val _connectedDevices = - MutableStateFlow>>>(emptyMap()) - private val _isMissingServices = MutableStateFlow(false) - private val _disconnectionReason = MutableStateFlow(null) - - private val connectionJobs = mutableMapOf() - private val serviceHandlingJob = mutableMapOf() + private val _devices = MutableStateFlow>(emptyMap()) + private val _isMissingServices = MutableStateFlow>(emptyMap()) + private val _disconnectionEvent = MutableStateFlow(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>>> - get() = _connectedDevices.asSharedFlow() + override fun onDestroy() { + managedConnections.values.forEach { it.cancel() } + uprootLogger() + super.onDestroy() + } - override val isMissingServices: Flow - 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 - 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? { - 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() - 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) { - _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> + get() = _devices.asStateFlow() + override val isMissingServices: StateFlow> + get() = _isMissingServices.asStateFlow() + + override val disconnectionEvent: StateFlow + 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() +} \ No newline at end of file diff --git a/lib_service/src/main/java/no/nordicsemi/android/service/profile/ServiceApi.kt b/lib_service/src/main/java/no/nordicsemi/android/service/profile/ServiceApi.kt index eab9e72e..f2d7e8de 100644 --- a/lib_service/src/main/java/no/nordicsemi/android/service/profile/ServiceApi.kt +++ b/lib_service/src/main/java/no/nordicsemi/android/service/profile/ServiceApi.kt @@ -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>>> + /** 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 = emptyList() + ) - /** Missing services flag. */ - val isMissingServices: Flow + /** 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> /** - * 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> /** - * 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? + val disconnectionEvent: StateFlow /** - * 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 + 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) +} \ No newline at end of file diff --git a/profile/build.gradle.kts b/profile/build.gradle.kts index ff7e789e..149b03ae 100644 --- a/profile/build.gradle.kts +++ b/profile/build.gradle.kts @@ -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")) diff --git a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/ProfileScreen.kt b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/ProfileScreen.kt index 800a47e1..4f33c7a6 100644 --- a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/ProfileScreen.kt +++ b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/ProfileScreen.kt @@ -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) + } } } } diff --git a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/repository/DeviceRepository.kt b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/repository/DeviceRepository.kt index 21a58041..4644d7d7 100644 --- a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/repository/DeviceRepository.kt +++ b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/repository/DeviceRepository.kt @@ -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>>>(emptyMap()) + MutableStateFlow>(emptyMap()) val connectedDevices = _connectedDevices.asStateFlow() private val _profilePeripheralPair = @@ -26,7 +27,7 @@ class DeviceRepository @Inject constructor( private val _loggedProfiles = mutableListOf>() - fun updateConnectedDevices(devices: Map>>) { + fun updateConnectedDevices(devices: Map) { _connectedDevices.update { devices } } diff --git a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/internal/ProfileAppBar.kt b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/internal/ProfileAppBar.kt index 46ac01cd..c4ad4f22 100644 --- a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/internal/ProfileAppBar.kt +++ b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/internal/ProfileAppBar.kt @@ -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 = {}, diff --git a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/viewmodel/DeviceConnectionState.kt b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/viewmodel/DeviceConnectionState.kt index aa626305..02cdee16 100644 --- a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/viewmodel/DeviceConnectionState.kt +++ b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/viewmodel/DeviceConnectionState.kt @@ -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> = 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 +} \ No newline at end of file diff --git a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/viewmodel/ProfileViewModel.kt b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/viewmodel/ProfileViewModel.kt index 3791b195..743e0877 100644 --- a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/viewmodel/ProfileViewModel.kt +++ b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/viewmodel/ProfileViewModel.kt @@ -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.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? = null - private var peripheral: Peripheral? = null - private var job: Job? = null + private val _uiState = MutableStateFlow(ProfileUiState.Loading) + val uiState: StateFlow = _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() } - -} +} \ No newline at end of file