Change HRS module

This commit is contained in:
Sylwester Zieliński
2022-02-11 14:27:12 +01:00
parent 01ed437d45
commit 771717224e
10 changed files with 160 additions and 132 deletions

View File

@@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import no.nordicsemi.android.cgms.data.CGMRepository
import no.nordicsemi.android.csc.data.CSCRepository
import no.nordicsemi.android.hrs.data.HRSRepository
import no.nordicsemi.android.navigation.NavigationManager
import no.nordicsemi.android.nrftoolbox.ProfileDestination
import no.nordicsemi.android.nrftoolbox.view.HomeViewState
@@ -18,7 +19,8 @@ import javax.inject.Inject
class HomeViewModel @Inject constructor(
private val navigationManager: NavigationManager,
cgmRepository: CGMRepository,
cscRepository: CSCRepository
cscRepository: CSCRepository,
hrsRepository: HRSRepository
) : ViewModel() {
private val _state = MutableStateFlow(HomeViewState())
@@ -32,6 +34,10 @@ class HomeViewModel @Inject constructor(
cscRepository.isRunning.onEach {
_state.value = _state.value.copy(isCSCModuleRunning = it)
}.launchIn(viewModelScope)
hrsRepository.isRunning.onEach {
_state.value = _state.value.copy(isHRSModuleRunning = it)
}.launchIn(viewModelScope)
}
fun openProfile(destination: ProfileDestination) {

View File

@@ -1,7 +0,0 @@
package no.nordicsemi.android.csc.data
internal sealed class CSCServiceCommand
internal data class SetWheelSizeCommand(val wheelSize: WheelSize) : CSCServiceCommand()
internal object DisconnectCommand : CSCServiceCommand()

View File

@@ -24,18 +24,14 @@ package no.nordicsemi.android.csc.repository
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCharacteristic
import android.content.Context
import android.util.Log
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import no.nordicsemi.android.ble.BleManager
import no.nordicsemi.android.ble.common.callback.battery.BatteryLevelResponse
import no.nordicsemi.android.ble.common.callback.csc.CyclingSpeedAndCadenceMeasurementResponse
import no.nordicsemi.android.ble.ktx.asValidResponseFlow
import no.nordicsemi.android.ble.ktx.suspend
import no.nordicsemi.android.csc.data.CSCData
import no.nordicsemi.android.csc.data.WheelSize
import no.nordicsemi.android.service.ConnectionObserverAdapter
@@ -61,10 +57,6 @@ internal class CSCManager(
private val data = MutableStateFlow(CSCData())
val dataHolder = ConnectionObserverAdapter<CSCData>()
private val exceptionHandler = CoroutineExceptionHandler { _, t ->
Log.e("COROUTINE-EXCEPTION", "Uncaught exception", t)
}
init {
setConnectionObserver(dataHolder)
@@ -107,18 +99,12 @@ internal class CSCManager(
previousResponse = it
}.launchIn(scope)
scope.launch(exceptionHandler) {
enableNotifications(cscMeasurementCharacteristic).suspend()
}
enableNotifications(cscMeasurementCharacteristic).enqueue()
setNotificationCallback(batteryLevelCharacteristic).asValidResponseFlow<BatteryLevelResponse>().onEach {
data.value = data.value.copy(batteryLevel = it.batteryLevel)
}.launchIn(scope)
scope.launch {
enableNotifications(batteryLevelCharacteristic).suspend()
}
enableNotifications(batteryLevelCharacteristic).enqueue()
}
public override fun isRequiredServiceSupported(gatt: BluetoothGatt): Boolean {

View File

@@ -1,52 +1,70 @@
package no.nordicsemi.android.hrs.data
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.*
import no.nordicsemi.android.service.BleManagerStatus
import android.bluetooth.BluetoothDevice
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import no.nordicsemi.android.ble.ktx.suspend
import no.nordicsemi.android.hrs.service.HRSManager
import no.nordicsemi.android.hrs.service.HRSService
import no.nordicsemi.android.service.BleManagerResult
import no.nordicsemi.android.service.ConnectingResult
import no.nordicsemi.android.service.ServiceManager
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
internal class HRSRepository @Inject constructor() {
class HRSRepository @Inject constructor(
@ApplicationContext
private val context: Context,
private val serviceManager: ServiceManager,
) {
private var manager: HRSManager? = null
private val _data = MutableStateFlow(HRSData())
val data: StateFlow<HRSData> = _data
private val _data = MutableStateFlow<BleManagerResult<HRSData>>(ConnectingResult())
internal val data = _data.asStateFlow()
private val _command = MutableSharedFlow<DisconnectCommand>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_LATEST)
val command = _command.asSharedFlow()
private val _isRunning = MutableStateFlow(false)
val isRunning = _isRunning.asStateFlow()
private val _status = MutableStateFlow(BleManagerStatus.CONNECTING)
val status = _status.asStateFlow()
fun addNewHeartRate(heartRate: Int) {
val result = _data.value.heartRates.toMutableList().apply {
add(heartRate)
}
_data.tryEmit(_data.value.copy(heartRates = result))
fun launch(device: BluetoothDevice) {
serviceManager.startService(HRSService::class.java, device)
}
fun setSensorLocation(sensorLocation: Int) {
_data.tryEmit(_data.value.copy(sensorLocation = sensorLocation))
}
fun start(device: BluetoothDevice, scope: CoroutineScope) {
val manager = HRSManager(context, scope)
this.manager = manager
fun setBatteryLevel(batteryLevel: Int) {
_data.tryEmit(_data.value.copy(batteryLevel = batteryLevel))
}
manager.dataHolder.status.onEach {
_data.value = it
}.launchIn(scope)
fun sendDisconnectCommand() {
if (_command.subscriptionCount.value > 0) {
_command.tryEmit(DisconnectCommand)
} else {
_status.tryEmit(BleManagerStatus.DISCONNECTED)
scope.launch {
manager.start(device)
}
}
fun setNewStatus(status: BleManagerStatus) {
_status.value = status
private suspend fun HRSManager.start(device: BluetoothDevice) {
try {
connect(device)
.useAutoConnect(false)
.retry(3, 100)
.suspend()
_isRunning.value = true
} catch (e: Exception) {
e.printStackTrace()
}
}
fun clear() {
_status.value = BleManagerStatus.CONNECTING
_data.tryEmit(HRSData())
fun release() {
serviceManager.stopService(HRSService::class.java)
manager?.disconnect()?.enqueue()
manager = null
_isRunning.value = false
}
}

View File

@@ -1,3 +0,0 @@
package no.nordicsemi.android.hrs.data
internal object DisconnectCommand

View File

@@ -24,74 +24,95 @@ package no.nordicsemi.android.hrs.service
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCharacteristic
import android.content.Context
import android.util.Log
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import no.nordicsemi.android.ble.BleManager
import no.nordicsemi.android.ble.common.callback.battery.BatteryLevelResponse
import no.nordicsemi.android.ble.common.callback.hr.BodySensorLocationResponse
import no.nordicsemi.android.ble.common.callback.hr.HeartRateMeasurementResponse
import no.nordicsemi.android.ble.ktx.asValidResponseFlow
import no.nordicsemi.android.ble.ktx.suspend
import no.nordicsemi.android.ble.ktx.suspendForValidResponse
import no.nordicsemi.android.hrs.data.HRSRepository
import no.nordicsemi.android.service.BatteryManager
import no.nordicsemi.android.hrs.data.HRSData
import no.nordicsemi.android.service.ConnectionObserverAdapter
import no.nordicsemi.android.utils.launchWithCatch
import java.util.*
val HRS_SERVICE_UUID: UUID = UUID.fromString("0000180D-0000-1000-8000-00805f9b34fb")
private val BODY_SENSOR_LOCATION_CHARACTERISTIC_UUID = UUID.fromString("00002A38-0000-1000-8000-00805f9b34fb")
private val HEART_RATE_MEASUREMENT_CHARACTERISTIC_UUID = UUID.fromString("00002A37-0000-1000-8000-00805f9b34fb")
private val BATTERY_SERVICE_UUID = UUID.fromString("0000180F-0000-1000-8000-00805f9b34fb")
private val BATTERY_LEVEL_CHARACTERISTIC_UUID = UUID.fromString("00002A19-0000-1000-8000-00805f9b34fb")
internal class HRSManager(
context: Context,
scope: CoroutineScope,
private val dataHolder: HRSRepository
) : BatteryManager(context, scope) {
private val scope: CoroutineScope,
) : BleManager(context) {
private var batteryLevelCharacteristic: BluetoothGattCharacteristic? = null
private var heartRateCharacteristic: BluetoothGattCharacteristic? = null
private var bodySensorLocationCharacteristic: BluetoothGattCharacteristic? = null
override fun onBatteryLevelChanged(batteryLevel: Int) {
dataHolder.setBatteryLevel(batteryLevel)
private val data = MutableStateFlow(HRSData())
val dataHolder = ConnectionObserverAdapter<HRSData>()
init {
setConnectionObserver(dataHolder)
data.onEach {
dataHolder.setValue(it)
}.launchIn(scope)
}
override fun getGattCallback(): BatteryManagerGattCallback {
override fun getGattCallback(): BleManagerGattCallback {
return HeartRateManagerCallback()
}
private inner class HeartRateManagerCallback : BatteryManagerGattCallback() {
private inner class HeartRateManagerCallback : BleManagerGattCallback() {
override fun initialize() {
super.initialize()
scope.launch {
val data = readCharacteristic(bodySensorLocationCharacteristic)
scope.launchWithCatch {
val readData = readCharacteristic(bodySensorLocationCharacteristic)
.suspendForValidResponse<BodySensorLocationResponse>()
dataHolder.setSensorLocation(data.sensorLocation)
data.value = data.value.copy(sensorLocation = readData.sensorLocation)
}
setNotificationCallback(heartRateCharacteristic).asValidResponseFlow<HeartRateMeasurementResponse>()
.onEach {
dataHolder.addNewHeartRate(it.heartRate)
val result = data.value.heartRates.toMutableList().apply {
add(it.heartRate)
}
data.tryEmit(data.value.copy(heartRates = result))
}.launchIn(scope)
enableNotifications(heartRateCharacteristic).enqueue()
scope.launch {
enableNotifications(heartRateCharacteristic).suspend()
}
setNotificationCallback(batteryLevelCharacteristic).asValidResponseFlow<BatteryLevelResponse>().onEach {
data.value = data.value.copy(batteryLevel = it.batteryLevel)
}.launchIn(scope)
enableNotifications(batteryLevelCharacteristic).enqueue()
}
override fun isRequiredServiceSupported(gatt: BluetoothGatt): Boolean {
val service = gatt.getService(HRS_SERVICE_UUID)
if (service != null) {
heartRateCharacteristic = service.getCharacteristic(HEART_RATE_MEASUREMENT_CHARACTERISTIC_UUID)
gatt.getService(HRS_SERVICE_UUID)?.run {
heartRateCharacteristic = getCharacteristic(HEART_RATE_MEASUREMENT_CHARACTERISTIC_UUID)
}
return heartRateCharacteristic != null
gatt.getService(BATTERY_SERVICE_UUID)?.run {
batteryLevelCharacteristic = getCharacteristic(BATTERY_LEVEL_CHARACTERISTIC_UUID)
}
return heartRateCharacteristic != null && batteryLevelCharacteristic != null
}
override fun isOptionalServiceSupported(gatt: BluetoothGatt): Boolean {
super.isOptionalServiceSupported(gatt)
val service = gatt.getService(HRS_SERVICE_UUID)
if (service != null) {
bodySensorLocationCharacteristic = service.getCharacteristic(BODY_SENSOR_LOCATION_CHARACTERISTIC_UUID)
gatt.getService(HRS_SERVICE_UUID)?.run {
bodySensorLocationCharacteristic = getCharacteristic(BODY_SENSOR_LOCATION_CHARACTERISTIC_UUID)
}
return bodySensorLocationCharacteristic != null
}

View File

@@ -1,25 +1,27 @@
package no.nordicsemi.android.hrs.service
import android.bluetooth.BluetoothDevice
import android.content.Intent
import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import no.nordicsemi.android.hrs.data.HRSRepository
import no.nordicsemi.android.service.ForegroundBleService
import no.nordicsemi.android.service.DEVICE_DATA
import no.nordicsemi.android.service.NotificationService
import javax.inject.Inject
@AndroidEntryPoint
internal class HRSService : ForegroundBleService() {
internal class HRSService : NotificationService() {
@Inject
lateinit var repository: HRSRepository
override val manager: HRSManager by lazy { HRSManager(this, scope, repository) }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId)
override fun onCreate() {
super.onCreate()
val device = intent!!.getParcelableExtra<BluetoothDevice>(DEVICE_DATA)!!
repository.command.onEach {
stopSelf()
}.launchIn(scope)
repository.start(device, lifecycleScope)
return START_REDELIVER_INTENT
}
}

View File

@@ -4,14 +4,19 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel
import no.nordicsemi.android.hrs.R
import no.nordicsemi.android.hrs.viewmodel.HRSViewModel
import no.nordicsemi.android.service.*
import no.nordicsemi.android.theme.view.BackIconAppBar
import no.nordicsemi.android.theme.view.scanner.DeviceConnectingView
import no.nordicsemi.android.theme.view.scanner.DeviceDisconnectedView
import no.nordicsemi.android.theme.view.scanner.NoDeviceView
import no.nordicsemi.android.theme.view.scanner.Reason
import no.nordicsemi.android.utils.exhaustive
@Composable
fun HRSScreen() {
@@ -19,15 +24,22 @@ fun HRSScreen() {
val state = viewModel.state.collectAsState().value
Column {
BackIconAppBar(stringResource(id = R.string.hrs_title)) {
viewModel.onEvent(DisconnectEvent)
}
val navigateUp = { viewModel.onEvent(NavigateUpEvent) }
BackIconAppBar(stringResource(id = R.string.hrs_title), navigateUp)
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
// when (state) {
// is DisplayDataState -> HRSContentView(state.data) { viewModel.onEvent(it) }
// LoadingState -> DeviceConnectingView()
// }.exhaustive
when (state) {
NoDeviceState -> NoDeviceView()
is WorkingState -> when (state.result) {
is ConnectingResult,
is ReadyResult -> DeviceConnectingView { viewModel.onEvent(DisconnectEvent) }
is DisconnectedResult -> DeviceDisconnectedView(Reason.USER, navigateUp)
is LinkLossResult -> DeviceDisconnectedView(Reason.LINK_LOSS, navigateUp)
is MissingServiceResult -> DeviceDisconnectedView(Reason.MISSING_SERVICE, navigateUp)
is SuccessResult -> HRSContentView(state.result.data) { viewModel.onEvent(it) }
}
}.exhaustive
}
}
}

View File

@@ -1,9 +1,9 @@
package no.nordicsemi.android.hrs.view
import no.nordicsemi.android.hrs.data.HRSData
import no.nordicsemi.android.service.BleManagerResult
internal sealed class HRSViewState
internal object LoadingState : HRSViewState()
internal data class DisplayDataState(val data: HRSData) : HRSViewState()
internal data class WorkingState(val result: BleManagerResult<HRSData>) : HRSViewState()
internal object NoDeviceState : HRSViewState()

View File

@@ -3,14 +3,14 @@ package no.nordicsemi.android.hrs.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import no.nordicsemi.android.hrs.data.HRSRepository
import no.nordicsemi.android.hrs.service.HRSService
import no.nordicsemi.android.hrs.service.HRS_SERVICE_UUID
import no.nordicsemi.android.hrs.view.*
import no.nordicsemi.android.navigation.*
import no.nordicsemi.android.service.BleManagerStatus
import no.nordicsemi.android.service.ServiceManager
import no.nordicsemi.android.utils.exhaustive
import no.nordicsemi.android.utils.getDevice
import no.nordicsemi.ui.scanner.ScannerDestinationId
@@ -19,19 +19,23 @@ import javax.inject.Inject
@HiltViewModel
internal class HRSViewModel @Inject constructor(
private val repository: HRSRepository,
private val serviceManager: ServiceManager,
private val navigationManager: NavigationManager
) : ViewModel() {
val state = repository.data.combine(repository.status) { data, status ->
// when (status) {
// BleManagerStatus.CONNECTING -> LoadingState
// BleManagerStatus.OK,
// BleManagerStatus.DISCONNECTED -> DisplayDataState(data)
// }
}.stateIn(viewModelScope, SharingStarted.Lazily, LoadingState)
private val _state = MutableStateFlow<HRSViewState>(NoDeviceState)
val state = _state.asStateFlow()
init {
if (!repository.isRunning.value) {
requestBluetoothDevice()
}
repository.data.onEach {
_state.value = WorkingState(it)
}.launchIn(viewModelScope)
}
private fun requestBluetoothDevice() {
navigationManager.navigateTo(ScannerDestinationId, UUIDArgument(HRS_SERVICE_UUID))
navigationManager.recentResult.onEach {
@@ -39,35 +43,24 @@ internal class HRSViewModel @Inject constructor(
handleArgs(it)
}
}.launchIn(viewModelScope)
repository.status.onEach {
if (it == BleManagerStatus.DISCONNECTED) {
navigationManager.navigateUp()
}
}.launchIn(viewModelScope)
}
private fun handleArgs(args: DestinationResult) {
when (args) {
is CancelDestinationResult -> navigationManager.navigateUp()
is SuccessDestinationResult -> serviceManager.startService(HRSService::class.java, args.getDevice())
is SuccessDestinationResult -> repository.launch(args.getDevice().device)
}.exhaustive
}
fun onEvent(event: HRSScreenViewEvent) {
when (event) {
DisconnectEvent -> onDisconnectButtonClick()
DisconnectEvent -> disconnect()
NavigateUpEvent -> navigationManager.navigateUp()
}.exhaustive
}
private fun onDisconnectButtonClick() {
repository.sendDisconnectCommand()
repository.clear()
}
override fun onCleared() {
super.onCleared()
repository.clear()
private fun disconnect() {
repository.release()
navigationManager.navigateUp()
}
}