mirror of
https://github.com/aljazceru/Android-nRF-Toolbox.git
synced 2025-12-18 23:14:22 +01:00
Changed profile service api style
This commit is contained in:
@@ -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"))
|
||||
|
||||
@@ -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,25 +83,28 @@ 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) ->
|
||||
state.connectedDevices.keys.forEach {
|
||||
state.connectedDevices[it]?.let { deviceData ->
|
||||
if (deviceData.connectionState.isConnected) {
|
||||
// Skip if no services
|
||||
if (services.isEmpty()) return@forEach
|
||||
if (deviceData.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) {
|
||||
if (deviceData.services.size == 1 && deviceData.services.first().profile == Profile.BATTERY) {
|
||||
FeatureButton(
|
||||
iconId = R.drawable.ic_battery,
|
||||
description = R.string.battery_module_full,
|
||||
deviceName = peripheral.name,
|
||||
deviceAddress = peripheral.address,
|
||||
deviceName = deviceData.peripheral.name,
|
||||
deviceAddress = deviceData.peripheral.address,
|
||||
onClick = {
|
||||
onEvent(
|
||||
UiEvent.OnDeviceClick(
|
||||
peripheral.address,
|
||||
services.first().profile
|
||||
deviceData.peripheral.address,
|
||||
deviceData.services.first().profile
|
||||
)
|
||||
)
|
||||
},
|
||||
@@ -108,8 +112,12 @@ internal fun HomeView() {
|
||||
}
|
||||
// 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 }
|
||||
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,
|
||||
@@ -317,6 +325,9 @@ internal fun HomeView() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
NoConnectedDeviceView()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 val isMissingServices: Flow<Boolean>
|
||||
get() = _isMissingServices.asStateFlow()
|
||||
|
||||
override val disconnectionReason: StateFlow<DeviceDisconnectionReason?>
|
||||
get() = _disconnectionReason.asStateFlow()
|
||||
|
||||
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
|
||||
}
|
||||
override fun onDestroy() {
|
||||
managedConnections.values.forEach { it.cancel() }
|
||||
uprootLogger()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override suspend fun createBonding(address: String) {
|
||||
val peripheral = getPeripheralById(address)
|
||||
peripheral?.bondState
|
||||
?.onEach { state ->
|
||||
if (state == BondState.NONE) {
|
||||
peripheral.createBond()
|
||||
}
|
||||
}
|
||||
?.filter { it == BondState.BONDED }
|
||||
?.first() // suspend until bonded
|
||||
private fun connect(address: String) {
|
||||
// Return if already managed to avoid multiple connection jobs.
|
||||
if (managedConnections.containsKey(address)) return
|
||||
|
||||
initLogger(address) // Initialize logger for the new device.
|
||||
|
||||
val peripheral = centralManager.getPeripheralById(address) ?: run {
|
||||
Timber.w("Peripheral with address $address not found.")
|
||||
return
|
||||
}
|
||||
|
||||
override fun getPeripheralById(address: String?): Peripheral? =
|
||||
address?.let { centralManager.getPeripheralById(it) }
|
||||
|
||||
override fun disconnect(deviceAddress: String) {
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
getPeripheralById(deviceAddress)
|
||||
?.let { peripheral ->
|
||||
if (peripheral.isConnected) peripheral.disconnect()
|
||||
handleDisconnection(deviceAddress)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Couldn't disconnect from the $deviceAddress")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the peripheral and observe its state.
|
||||
*/
|
||||
private fun initiateConnection(deviceAddress: String) {
|
||||
centralManager.getPeripheralById(deviceAddress)?.let { peripheral ->
|
||||
lifecycleScope.launch { connectPeripheral(peripheral) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun connectPeripheral(peripheral: Peripheral) {
|
||||
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 the ${peripheral.address}")
|
||||
Timber.e(e, "Failed to connect to $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 {
|
||||
// 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 {
|
||||
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()
|
||||
discoverAndObserveServices(peripheral, this)
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Service discovery failed for ${peripheral.address}")
|
||||
}
|
||||
|
||||
manager.observeServiceInteractions(
|
||||
}
|
||||
|
||||
is ConnectionState.Disconnected -> {
|
||||
Timber.tag("AAAA").d("Disconnected State: ${peripheral.address}")
|
||||
val reason = state.reason ?: DisconnectReason.UNKNOWN
|
||||
_disconnectionEvent.value =
|
||||
ServiceApi.DisconnectionEvent(
|
||||
peripheral.address,
|
||||
remoteService,
|
||||
this
|
||||
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
|
||||
}
|
||||
}
|
||||
}.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)
|
||||
}
|
||||
}
|
||||
|
||||
private fun disconnect(address: String) {
|
||||
centralManager.getPeripheralById(address)?.let { peripheral ->
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
peripheral.disconnect()
|
||||
handleDisconnection(address, "Disconnected by user")
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to disconnect from $address")
|
||||
}
|
||||
}
|
||||
when {
|
||||
discoveredServices.isEmpty() -> {
|
||||
if (remoteServices?.isNotEmpty() == true) {
|
||||
_isMissingServices.tryEmit(true)
|
||||
serviceHandlingJob[peripheral.address]?.cancel()
|
||||
serviceHandlingJob.remove(peripheral.address)
|
||||
}
|
||||
managedConnections[address]?.cancel()
|
||||
}
|
||||
|
||||
peripheral.isConnected -> {
|
||||
_isMissingServices.tryEmit(false)
|
||||
updateConnectedDevices(peripheral, discoveredServices)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onCompletion {
|
||||
serviceHandlingJob[peripheral.address]?.cancel()
|
||||
serviceHandlingJob.remove(peripheral.address)
|
||||
}
|
||||
.launchIn(lifecycleScope)
|
||||
serviceHandlingJob[peripheral.address] = job
|
||||
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 }
|
||||
}
|
||||
|
||||
/**
|
||||
* 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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"))
|
||||
|
||||
@@ -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 = {
|
||||
ProfileAppBar(
|
||||
deviceName = when (val state = deviceDataState) {
|
||||
is DeviceConnectionState.Connected -> state.data.peripheral?.name
|
||||
// The device name is derived directly from the current state.
|
||||
val deviceName = (uiState as? ProfileUiState.Connected)
|
||||
?.deviceData?.peripheral?.name
|
||||
?: deviceAddress
|
||||
|
||||
is DeviceConnectionState.Disconnected -> state.device?.name ?: deviceAddress
|
||||
|
||||
else -> deviceAddress
|
||||
},
|
||||
ProfileAppBar(
|
||||
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,71 +97,33 @@ 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 {
|
||||
is ProfileUiState.Disconnected -> {
|
||||
DeviceDisconnectedView(
|
||||
it,
|
||||
deviceAddress,
|
||||
onClickEvent
|
||||
disconnectedReason = state.reason.toString(),
|
||||
isMissingService = false,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
) {
|
||||
Button(
|
||||
onClick = { onEvent(ConnectionEvent.OnRetryClicked) },
|
||||
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.reconnect))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ProfileUiState.Loading -> DeviceConnectingView(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
DeviceConnectionState.Idle, DeviceConnectionState.Disconnecting -> LoadingView()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -174,43 +132,53 @@ internal fun DeviceDisconnectedView(
|
||||
|
||||
@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(
|
||||
// Check for missing services directly from the state object.
|
||||
if (state.isMissingServices) {
|
||||
DeviceDisconnectedView(
|
||||
reason = DisconnectReason.MISSING_SERVICE,
|
||||
modifier = Modifier
|
||||
.padding(16.dp)
|
||||
modifier = Modifier.padding(16.dp)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
else -> {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier
|
||||
.padding(16.dp)
|
||||
.imePadding()
|
||||
) {
|
||||
deviceData.peripheralProfileMap[deviceData.peripheral]?.forEach { profile ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.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)
|
||||
) {
|
||||
// Requires max value length to be set.
|
||||
val needsMaxValueLength = profile.profile == Profile.THROUGHPUT ||
|
||||
profile.profile == Profile.UART
|
||||
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
|
||||
)
|
||||
|
||||
// Request max value length if needed and not already set.
|
||||
if (needsMaxValueLength) {
|
||||
LaunchedEffect(key1 = true) {
|
||||
if (deviceData.maxValueLength == null) {
|
||||
onClickEvent(ConnectionEvent.RequestMaxValueLength)
|
||||
LaunchedEffect(Unit) {
|
||||
if (state.maxValueLength == null) {
|
||||
onEvent(ConnectionEvent.RequestMaxValueLength)
|
||||
}
|
||||
}
|
||||
}
|
||||
when (profile.profile) {
|
||||
|
||||
// Display the appropriate screen for each profile.
|
||||
when (serviceManager.profile) {
|
||||
Profile.HTS -> HTSScreen()
|
||||
Profile.CHANNEL_SOUNDING -> ChannelSoundingScreen()
|
||||
Profile.BPS -> BPSScreen()
|
||||
@@ -221,35 +189,9 @@ internal fun DeviceConnectedView(
|
||||
Profile.HRS -> HRSScreen()
|
||||
Profile.LBS -> BlinkyScreen()
|
||||
Profile.RSCS -> RSCSScreen()
|
||||
Profile.THROUGHPUT -> ThroughputScreen(deviceData.maxValueLength)
|
||||
Profile.UART -> UARTScreen(deviceData.maxValueLength)
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
Profile.BATTERY -> BatteryScreen()
|
||||
Profile.THROUGHPUT -> ThroughputScreen(state.maxValueLength)
|
||||
Profile.UART -> UARTScreen(state.maxValueLength)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {},
|
||||
|
||||
@@ -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(),
|
||||
/**
|
||||
* 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,
|
||||
)
|
||||
|
||||
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()
|
||||
) : ProfileUiState
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
// // 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
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
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 {
|
||||
_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)
|
||||
)
|
||||
}
|
||||
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.OnRetryClicked -> {
|
||||
_uiState.value = ProfileUiState.Loading
|
||||
connectToPeripheral()
|
||||
}
|
||||
|
||||
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.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()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user