Add connecting view to profiles

This commit is contained in:
Sylwester Zieliński
2022-01-18 09:59:30 +01:00
parent 2a9b66c357
commit 2c304e80f6
104 changed files with 834 additions and 951 deletions

View File

@@ -1,13 +1,11 @@
package no.nordicsemi.android.hrs
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*

View File

@@ -1,7 +1,12 @@
package no.nordicsemi.android.hrs.data
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import no.nordicsemi.android.service.BleManagerStatus
import javax.inject.Inject
import javax.inject.Singleton
@@ -11,6 +16,12 @@ internal class HRSRepository @Inject constructor() {
private val _data = MutableStateFlow(HRSData())
val data: StateFlow<HRSData> = _data
private val _command = MutableSharedFlow<DisconnectCommand>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_LATEST)
val command = _command.asSharedFlow()
private val _status = MutableStateFlow(BleManagerStatus.CONNECTING)
val status = _status.asStateFlow()
fun addNewHeartRate(heartRate: Int) {
val result = _data.value.heartRates.toMutableList().apply {
add(heartRate)
@@ -26,7 +37,16 @@ internal class HRSRepository @Inject constructor() {
_data.tryEmit(_data.value.copy(batteryLevel = batteryLevel))
}
fun sendDisconnectCommand() {
_command.tryEmit(DisconnectCommand)
}
fun setNewStatus(status: BleManagerStatus) {
_status.value = status
}
fun clear() {
_status.value = BleManagerStatus.CONNECTING
_data.tryEmit(HRSData())
}
}

View File

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

View File

@@ -1,39 +0,0 @@
/*
* Copyright (c) 2015, Nordic Semiconductor
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this
* software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
* HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
* USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package no.nordicsemi.android.hrs.service
import no.nordicsemi.android.ble.data.Data
internal object BodySensorLocationParser {
fun parse(data: Data): String {
return when (data.getIntValue(Data.FORMAT_UINT8, 0)!!) {
6 -> "Foot"
5 -> "Ear Lobe"
4 -> "Hand"
3 -> "Finger"
2 -> "Wrist"
1 -> "Chest"
0 -> "Other"
else -> "Other"
}
}
}

View File

@@ -30,9 +30,7 @@ import androidx.annotation.IntRange
import no.nordicsemi.android.ble.common.callback.hr.BodySensorLocationDataCallback
import no.nordicsemi.android.ble.common.callback.hr.HeartRateMeasurementDataCallback
import no.nordicsemi.android.ble.common.profile.hr.BodySensorLocation
import no.nordicsemi.android.ble.data.Data
import no.nordicsemi.android.hrs.data.HRSRepository
import no.nordicsemi.android.log.LogContract
import no.nordicsemi.android.service.BatteryManager
import java.util.*
@@ -53,11 +51,6 @@ internal class HRSManager(context: Context, private val dataHolder: HRSRepositor
private val bodySensorLocationDataCallback = object : BodySensorLocationDataCallback() {
override fun onDataReceived(device: BluetoothDevice, data: Data) {
log(LogContract.Log.Level.APPLICATION, "\"" + BodySensorLocationParser.parse(data) + "\" received")
super.onDataReceived(device, data)
}
override fun onBodySensorLocationReceived(
device: BluetoothDevice,
@BodySensorLocation sensorLocation: Int
@@ -68,11 +61,6 @@ internal class HRSManager(context: Context, private val dataHolder: HRSRepositor
private val heartRateMeasurementDataCallback = object : HeartRateMeasurementDataCallback() {
override fun onDataReceived(device: BluetoothDevice, data: Data) {
log(LogContract.Log.Level.APPLICATION, "\"" + HeartRateMeasurementParser.parse(data) + "\" received")
super.onDataReceived(device, data)
}
override fun onHeartRateMeasurementReceived(
device: BluetoothDevice,
@IntRange(from = 0) heartRate: Int,

View File

@@ -1,6 +1,8 @@
package no.nordicsemi.android.hrs.service
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 javax.inject.Inject
@@ -9,7 +11,19 @@ import javax.inject.Inject
internal class HRSService : ForegroundBleService() {
@Inject
lateinit var dataHolder: HRSRepository
lateinit var repository: HRSRepository
override val manager: HRSManager by lazy { HRSManager(this, dataHolder) }
override val manager: HRSManager by lazy { HRSManager(this, repository) }
override fun onCreate() {
super.onCreate()
status.onEach {
repository.setNewStatus(it)
}.launchIn(scope)
repository.command.onEach {
stopSelf()
}.launchIn(scope)
}
}

View File

@@ -1,115 +0,0 @@
/*
* Copyright (c) 2015, Nordic Semiconductor
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this
* software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
* HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
* USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package no.nordicsemi.android.hrs.service
import no.nordicsemi.android.ble.data.Data
import java.util.*
internal object HeartRateMeasurementParser {
private const val HEART_RATE_VALUE_FORMAT: Byte = 0x01 // 1 bit
private const val SENSOR_CONTACT_STATUS: Byte = 0x06 // 2 bits
private const val ENERGY_EXPANDED_STATUS: Byte = 0x08 // 1 bit
private const val RR_INTERVAL: Byte = 0x10 // 1 bit
fun parse(data: Data): String {
var offset = 0
val flags = data.getIntValue(Data.FORMAT_UINT8, offset++)!!
/*
* false Heart Rate Value Format is set to UINT8. Units: beats per minute (bpm)
* true Heart Rate Value Format is set to UINT16. Units: beats per minute (bpm)
*/
val value16bit = flags and HEART_RATE_VALUE_FORMAT.toInt() > 0
/*
* 0 Sensor Contact feature is not supported in the current connection
* 1 Sensor Contact feature is not supported in the current connection
* 2 Sensor Contact feature is supported, but contact is not detected
* 3 Sensor Contact feature is supported and contact is detected
*/
val sensorContactStatus = flags and SENSOR_CONTACT_STATUS.toInt() shr 1
/*
* false Energy Expended field is not present
* true Energy Expended field is present. Units: kilo Joules
*/
val energyExpandedStatus = flags and ENERGY_EXPANDED_STATUS.toInt() > 0
/*
* false RR-Interval values are not present.
* true One or more RR-Interval values are present. Units: 1/1024 seconds
*/
val rrIntervalStatus = flags and RR_INTERVAL.toInt() > 0
// heart rate value is 8 or 16 bit long
val heartRateValue = data.getIntValue(
if (value16bit) {
Data.FORMAT_UINT16
} else {
Data.FORMAT_UINT8
},
offset++
) // bits per minute
if (value16bit) offset++
// energy expanded value is present if a flag was set
var energyExpanded = -1
if (energyExpandedStatus) energyExpanded = data.getIntValue(Data.FORMAT_UINT16, offset)!!
offset += 2
// RR-interval is set when a flag is set
val rrIntervals: MutableList<Float> = ArrayList()
if (rrIntervalStatus) {
var o = offset
while (o < data.value!!.size) {
val units = data.getIntValue(Data.FORMAT_UINT16, o)!!
rrIntervals.add(units * 1000.0f / 1024.0f) // RR interval is in [1/1024s]
o += 2
}
}
val builder = StringBuilder()
builder.append("Heart Rate Measurement: ").append(heartRateValue).append(" bpm")
when (sensorContactStatus) {
0, 1 -> builder.append(",\nSensor Contact Not Supported")
2 -> builder.append(",\nContact is NOT Detected")
3 -> builder.append(",\nContact is Detected")
}
if (energyExpandedStatus) {
builder.append(",\nEnergy Expanded: ")
.append(energyExpanded)
.append(" kJ")
}
if (rrIntervalStatus) {
builder.append(",\nRR Interval: ")
for (interval in rrIntervals) builder.append(
String.format(
Locale.US,
"%.02f ms, ",
interval
)
)
builder.setLength(builder.length - 2) // remove the ", " at the end
}
return builder.toString()
}
}

View File

@@ -9,46 +9,40 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel
import no.nordicsemi.android.hrs.R
import no.nordicsemi.android.hrs.data.HRSData
import no.nordicsemi.android.hrs.service.HRSService
import no.nordicsemi.android.hrs.viewmodel.HRSViewModel
import no.nordicsemi.android.theme.view.BackIconAppBar
import no.nordicsemi.android.utils.isServiceRunning
import no.nordicsemi.android.theme.view.DeviceConnectingView
import no.nordicsemi.android.utils.exhaustive
@Composable
fun HRSScreen(finishAction: () -> Unit) {
val viewModel: HRSViewModel = hiltViewModel()
val state = viewModel.state.collectAsState().value
val isActive = viewModel.isActive.collectAsState().value
val context = LocalContext.current
LaunchedEffect(isActive) {
if (!isActive) {
finishAction()
}
if (context.isServiceRunning(HRSService::class.java.name)) {
val intent = Intent(context, HRSService::class.java)
context.stopService(intent)
}
}
LaunchedEffect("start-service") {
if (!context.isServiceRunning(HRSService::class.java.name)) {
LaunchedEffect(state.isActive) {
if (state.isActive) {
val intent = Intent(context, HRSService::class.java)
context.startService(intent)
} else {
finishAction()
}
}
HRSView(state) { viewModel.onEvent(it) }
HRSView(state.viewState) { viewModel.onEvent(it) }
}
@Composable
private fun HRSView(state: HRSData, onEvent: (HRSScreenViewEvent) -> Unit) {
private fun HRSView(state: HRSViewState, onEvent: (HRSScreenViewEvent) -> Unit) {
Column {
BackIconAppBar(stringResource(id = R.string.hrs_title)) {
onEvent(DisconnectEvent)
}
HRSContentView(state) { onEvent(it) }
when (state) {
is DisplayDataState -> HRSContentView(state.data) { onEvent(it) }
LoadingState -> DeviceConnectingView()
}.exhaustive
}
}

View File

@@ -0,0 +1,14 @@
package no.nordicsemi.android.hrs.view
import no.nordicsemi.android.hrs.data.HRSData
internal data class HRSState(
val viewState: HRSViewState,
val isActive: Boolean = true
)
internal sealed class HRSViewState
internal object LoadingState : HRSViewState()
internal data class DisplayDataState(val data: HRSData) : HRSViewState()

View File

@@ -1,18 +1,32 @@
package no.nordicsemi.android.hrs.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import no.nordicsemi.android.hrs.data.HRSRepository
import no.nordicsemi.android.hrs.view.DisconnectEvent
import no.nordicsemi.android.hrs.view.DisplayDataState
import no.nordicsemi.android.hrs.view.HRSScreenViewEvent
import no.nordicsemi.android.theme.viewmodel.CloseableViewModel
import no.nordicsemi.android.hrs.view.HRSState
import no.nordicsemi.android.hrs.view.LoadingState
import no.nordicsemi.android.service.BleManagerStatus
import javax.inject.Inject
@HiltViewModel
internal class HRSViewModel @Inject constructor(
private val dataHolder: HRSRepository
) : CloseableViewModel() {
private val repository: HRSRepository
) : ViewModel() {
val state = dataHolder.data
val state = repository.data.combine(repository.status) { data, status ->
when (status) {
BleManagerStatus.CONNECTING -> HRSState(LoadingState)
BleManagerStatus.OK -> HRSState(DisplayDataState(data))
BleManagerStatus.DISCONNECTED -> HRSState(DisplayDataState(data), false)
}
}.stateIn(viewModelScope, SharingStarted.Lazily, HRSState(LoadingState))
fun onEvent(event: HRSScreenViewEvent) {
(event as? DisconnectEvent)?.let {
@@ -21,7 +35,12 @@ internal class HRSViewModel @Inject constructor(
}
private fun onDisconnectButtonClick() {
finish()
dataHolder.clear()
repository.sendDisconnectCommand()
repository.clear()
}
override fun onCleared() {
super.onCleared()
repository.clear()
}
}

View File

@@ -1,9 +1,8 @@
package no.nordicsemi.android.hrs
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*