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.nrftoolbox
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

@@ -2,7 +2,6 @@ package no.nordicsemi.android.nrftoolbox
import android.app.Activity
import android.os.ParcelUuid
import android.util.Log
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -46,15 +45,15 @@ internal fun HomeScreen() {
navController = navController,
startDestination = NavigationId.HOME.id
) {
composable(NavigationId.HOME.id) {
HomeView(viewModel)
}
composable(NavigationId.SCANNER.id) {
val profile = viewModel.profile!!
FindDeviceScreen(ParcelUuid(profile.uuid)) {
viewModel.onScannerFlowResult(it)
}
}
composable(NavigationId.HOME.id) {
HomeView(viewModel)
}
composable(NavigationId.CSC.id) {
CSCScreen(navigateUp)
}

View File

@@ -1,9 +1,8 @@
package no.nordicsemi.android.nrftoolbox
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).
*

View File

@@ -1,13 +1,11 @@
package no.nordicsemi.android.service
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

@@ -21,6 +21,8 @@ import java.util.*
</T> */
abstract class BatteryManager(context: Context) : BleManager(context) {
private val TAG = "BLE-MANAGER"
private var batteryLevelCharacteristic: BluetoothGattCharacteristic? = null
private val batteryLevelDataCallback: DataReceivedCallback =
@@ -67,6 +69,11 @@ abstract class BatteryManager(context: Context) : BleManager(context) {
}
}
override fun log(priority: Int, message: String) {
super.log(priority, message)
Log.println(priority, TAG, message)
}
protected abstract inner class BatteryManagerGattCallback : BleManagerGattCallback() {
override fun initialize() {
readBatteryLevelCharacteristic()

View File

@@ -26,9 +26,7 @@ import android.bluetooth.BluetoothDevice
import android.content.Intent
import android.os.Handler
import android.os.IBinder
import android.util.Log
import android.widget.Toast
import androidx.lifecycle.LifecycleService
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -90,6 +88,13 @@ abstract class BleProfileService : Service() {
_status.value = BleManagerStatus.OK
}
override fun onDeviceFailedToConnect(device: BluetoothDevice, reason: Int) {
super.onDeviceFailedToConnect(device, reason)
_status.value = BleManagerStatus.DISCONNECTED
stopSelf()
scope.close()
}
override fun onDeviceDisconnected(device: BluetoothDevice, reason: Int) {
super.onDeviceDisconnected(device, reason)
_status.value = BleManagerStatus.DISCONNECTED

View File

@@ -1,7 +1,7 @@
package no.nordicsemi.android.service
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.cancelChildren
import java.io.Closeable
import kotlin.coroutines.CoroutineContext
@@ -9,6 +9,6 @@ class CloseableCoroutineScope(context: CoroutineContext) : Closeable, CoroutineS
override val coroutineContext: CoroutineContext = context
override fun close() {
coroutineContext.cancel()
coroutineContext.cancelChildren()
}
}

View File

@@ -1,19 +1,34 @@
package no.nordicsemi.android.service
import android.bluetooth.BluetoothDevice
import android.util.Log
import no.nordicsemi.android.ble.observer.ConnectionObserver
abstract class ConnectionObserverAdapter : ConnectionObserver {
override fun onDeviceConnecting(device: BluetoothDevice) { }
private val TAG = "BLE-CONNECTION"
override fun onDeviceConnected(device: BluetoothDevice) { }
override fun onDeviceConnecting(device: BluetoothDevice) {
Log.d(TAG, "onDeviceConnecting()")
}
override fun onDeviceFailedToConnect(device: BluetoothDevice, reason: Int) { }
override fun onDeviceConnected(device: BluetoothDevice) {
Log.d(TAG, "onDeviceConnected()")
}
override fun onDeviceReady(device: BluetoothDevice) { }
override fun onDeviceFailedToConnect(device: BluetoothDevice, reason: Int) {
Log.d(TAG, "onDeviceFailedToConnect()")
}
override fun onDeviceDisconnecting(device: BluetoothDevice) { }
override fun onDeviceReady(device: BluetoothDevice) {
Log.d(TAG, "onDeviceReady()")
}
override fun onDeviceDisconnected(device: BluetoothDevice, reason: Int) { }
override fun onDeviceDisconnecting(device: BluetoothDevice) {
Log.d(TAG, "onDeviceDisconnecting()")
}
override fun onDeviceDisconnected(device: BluetoothDevice, reason: Int) {
Log.d(TAG, "onDeviceDisconnected()")
}
}

View File

@@ -1,9 +1,8 @@
package no.nordicsemi.android.service
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).
*

View File

@@ -3,7 +3,12 @@ package no.nordicsemi.android.theme.view
import androidx.annotation.DrawableRes
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme

View File

@@ -5,7 +5,6 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -19,6 +18,7 @@ import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
@@ -62,18 +62,18 @@ fun StringListView(config: StringListDialogConfig) {
) {
config.items.forEachIndexed { i, entry ->
Column(modifier = Modifier.clickable { config.onResult(ItemSelectedResult(i)) }) {
Spacer(modifier = Modifier.height(16.dp))
Column(
modifier = Modifier
.clip(RoundedCornerShape(10.dp))
.clickable { config.onResult(ItemSelectedResult(i)) }
.padding(8.dp),
) {
Row {
config.leftIcon?.let {
Image(
modifier = Modifier.padding(horizontal = 4.dp),
painter = painterResource(it),
contentDescription = "Content image",
// colorFilter = ColorFilter.tint(
// NordicColors.NordicDarkGray.value()
// )
)
}
Text(
@@ -83,10 +83,6 @@ fun StringListView(config: StringListDialogConfig) {
.fillMaxWidth()
)
}
if (i != config.items.size - 1) {
Spacer(modifier = Modifier.height(16.dp))
}
}
}

View File

@@ -1,13 +1,11 @@
package no.nordicsemi.android.utils
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.*
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,8 +1,7 @@
package no.nordicsemi.android.utils
import org.junit.Test
import org.junit.Assert.*
import org.junit.Test
/**
* Example local unit test, which will execute on the development machine (host).

View File

@@ -1,13 +1,11 @@
package no.nordicsemi.android.bps
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,5 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="no.nordicsemi.android.bps">
<manifest package="no.nordicsemi.android.bps">
</manifest>

View File

@@ -63,6 +63,7 @@ internal class BPSRepository @Inject constructor() {
}
fun clear() {
_status.value = BleManagerStatus.CONNECTING
_data.tryEmit(BPSData())
}

View File

@@ -32,7 +32,6 @@ import no.nordicsemi.android.ble.common.callback.bps.IntermediateCuffPressureDat
import no.nordicsemi.android.ble.common.profile.bp.BloodPressureTypes
import no.nordicsemi.android.ble.data.Data
import no.nordicsemi.android.bps.data.BPSRepository
import no.nordicsemi.android.log.LogContract
import no.nordicsemi.android.service.BatteryManager
import java.util.*
import javax.inject.Inject

View File

@@ -42,6 +42,11 @@ internal class BPSViewModel @Inject constructor(
repository.setNewStatus(BleManagerStatus.OK)
}
override fun onDeviceFailedToConnect(device: BluetoothDevice, reason: Int) {
super.onDeviceFailedToConnect(device, reason)
repository.setNewStatus(BleManagerStatus.DISCONNECTED)
}
override fun onDeviceDisconnected(device: BluetoothDevice, reason: Int) {
super.onDeviceDisconnected(device, reason)
repository.setNewStatus(BleManagerStatus.DISCONNECTED)
@@ -63,7 +68,13 @@ internal class BPSViewModel @Inject constructor(
}
private fun onDisconnectButtonClick() {
deviceHolder.forgetDevice()
bpsManager.disconnect().enqueue()
deviceHolder.forgetDevice()
repository.clear()
}
override fun onCleared() {
super.onCleared()
repository.clear()
}
}

View File

@@ -1,9 +1,8 @@
package no.nordicsemi.android.bps
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).
*

View File

@@ -1,13 +1,11 @@
package no.nordicsemi.android.cgms
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,11 @@
package no.nordicsemi.android.cgms.data
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.*
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
@@ -39,6 +43,7 @@ internal class CGMRepository @Inject constructor() {
}
fun clear() {
_status.value = BleManagerStatus.CONNECTING
_data.tryEmit(CGMData())
}
}

View File

@@ -1,6 +1,14 @@
package no.nordicsemi.android.cgms.view
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
@@ -17,8 +25,8 @@ import androidx.compose.ui.unit.dp
import no.nordicsemi.android.cgms.R
import no.nordicsemi.android.cgms.data.CGMData
import no.nordicsemi.android.cgms.data.CGMRecord
import no.nordicsemi.android.cgms.data.RequestStatus
import no.nordicsemi.android.cgms.data.CGMServiceCommand
import no.nordicsemi.android.cgms.data.RequestStatus
import no.nordicsemi.android.material.you.CircularProgressIndicator
import no.nordicsemi.android.theme.view.BatteryLevelView
import no.nordicsemi.android.theme.view.ScreenSection

View File

@@ -4,7 +4,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import no.nordicsemi.android.cgms.R
import no.nordicsemi.android.cgms.data.CGMRecord
import no.nordicsemi.android.cgms.data.CGMServiceCommand
import java.text.SimpleDateFormat
import java.util.*

View File

@@ -39,7 +39,12 @@ internal class CGMScreenViewModel @Inject constructor(
}
private fun disconnect() {
repository.clear()
repository.sendNewServiceCommand(CGMServiceCommand.DISCONNECT)
repository.clear()
}
override fun onCleared() {
super.onCleared()
repository.clear()
}
}

View File

@@ -1,9 +1,8 @@
package no.nordicsemi.android.cgms
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).
*

View File

@@ -1,13 +1,11 @@
package no.nordicsemi.android.csc
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,6 +1,5 @@
package no.nordicsemi.android.csc.data
import no.nordicsemi.android.csc.view.CSCSettings
import no.nordicsemi.android.csc.view.SpeedUnit
import no.nordicsemi.android.material.you.RadioButtonItem
import no.nordicsemi.android.material.you.RadioGroupViewEntity
@@ -11,7 +10,6 @@ private const val DISPLAY_KM_H = "km/h"
private const val DISPLAY_MPH = "mph"
internal data class CSCData(
val showDialog: Boolean = false,
val scanDevices: Boolean = false,
val selectedSpeedUnit: SpeedUnit = SpeedUnit.M_S,
val speed: Float = 0f,
@@ -20,8 +18,7 @@ internal data class CSCData(
val totalDistance: Float = 0f,
val gearRatio: Float = 0f,
val batteryLevel: Int = 0,
val wheelSize: Int = CSCSettings.DefaultWheelSize.VALUE,
val wheelSizeDisplay: String = CSCSettings.DefaultWheelSize.NAME
val wheelSize: WheelSize = WheelSize()
) {
private val speedWithUnit = when (selectedSpeedUnit) {

View File

@@ -5,7 +5,9 @@ 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.csc.view.SpeedUnit
import no.nordicsemi.android.service.BleManagerStatus
import javax.inject.Inject
import javax.inject.Singleton
@@ -13,37 +15,38 @@ import javax.inject.Singleton
internal class CSCRepository @Inject constructor() {
private val _data = MutableStateFlow(CSCData())
val data: StateFlow<CSCData> = _data
val data: StateFlow<CSCData> = _data.asStateFlow()
private val _command = MutableSharedFlow<CSCServiceCommand>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_LATEST)
val command = _command.asSharedFlow()
fun setWheelSize(wheelSize: Int, wheelSizeDisplay: String) {
_data.tryEmit(_data.value.copy(
wheelSize = wheelSize,
wheelSizeDisplay = wheelSizeDisplay,
showDialog = false
))
}
private val _status = MutableStateFlow(BleManagerStatus.CONNECTING)
val status = _status.asStateFlow()
fun setSpeedUnit(selectedSpeedUnit: SpeedUnit) {
_data.tryEmit(_data.value.copy(selectedSpeedUnit = selectedSpeedUnit))
}
fun setHideWheelSizeDialog() {
_data.tryEmit(_data.value.copy(showDialog = false))
fun setNewDistance(
totalDistance: Float,
distance: Float,
speed: Float,
wheelSize: WheelSize
) {
_data.tryEmit(_data.value.copy(
totalDistance = totalDistance,
distance = distance,
speed = speed,
wheelSize = wheelSize
))
}
fun setDisplayWheelSizeDialog() {
_data.tryEmit(_data.value.copy(showDialog = true))
}
fun setNewDistance(totalDistance: Float, distance: Float, speed: Float) {
_data.tryEmit(_data.value.copy(totalDistance = totalDistance, distance = distance, speed = speed))
}
fun setNewCrankCadence(crankCadence: Float, gearRatio: Float) {
_data.tryEmit(_data.value.copy(cadence = crankCadence, gearRatio = gearRatio))
fun setNewCrankCadence(
crankCadence: Float,
gearRatio: Float,
wheelSize: WheelSize
) {
_data.tryEmit(_data.value.copy(cadence = crankCadence, gearRatio = gearRatio, wheelSize = wheelSize))
}
fun setBatteryLevel(batteryLevel: Int) {
@@ -54,7 +57,12 @@ internal class CSCRepository @Inject constructor() {
_command.tryEmit(workingMode)
}
fun setNewStatus(status: BleManagerStatus) {
_status.value = status
}
fun clear() {
_status.value = BleManagerStatus.CONNECTING
_data.tryEmit(CSCData())
}
}

View File

@@ -2,6 +2,6 @@ package no.nordicsemi.android.csc.data
internal sealed class CSCServiceCommand
internal data class SetWheelSizeCommand(val size: Int) : CSCServiceCommand()
internal data class SetWheelSizeCommand(val wheelSize: WheelSize) : CSCServiceCommand()
internal object DisconnectCommand : CSCServiceCommand()

View File

@@ -0,0 +1,8 @@
package no.nordicsemi.android.csc.data
import no.nordicsemi.android.csc.view.CSCSettings
data class WheelSize(
val value: Int = CSCSettings.DefaultWheelSize.VALUE,
val name: String = CSCSettings.DefaultWheelSize.NAME
)

View File

@@ -30,9 +30,7 @@ import androidx.annotation.FloatRange
import no.nordicsemi.android.ble.common.callback.csc.CyclingSpeedAndCadenceMeasurementDataCallback
import no.nordicsemi.android.ble.data.Data
import no.nordicsemi.android.csc.data.CSCRepository
import no.nordicsemi.android.csc.repository.CSCMeasurementParser.parse
import no.nordicsemi.android.csc.view.CSCSettings
import no.nordicsemi.android.log.LogContract
import no.nordicsemi.android.csc.data.WheelSize
import no.nordicsemi.android.service.BatteryManager
import java.util.*
@@ -42,20 +40,20 @@ val CYCLING_SPEED_AND_CADENCE_SERVICE_UUID: UUID = UUID.fromString("00001816-000
/** Cycling Speed and Cadence Measurement characteristic UUID. */
private val CSC_MEASUREMENT_CHARACTERISTIC_UUID = UUID.fromString("00002A5B-0000-1000-8000-00805f9b34fb")
internal class CSCManager(context: Context, private val dataHolder: CSCRepository) : BatteryManager(context) {
internal class CSCManager(context: Context, private val repository: CSCRepository) : BatteryManager(context) {
private var cscMeasurementCharacteristic: BluetoothGattCharacteristic? = null
private var wheelSize = CSCSettings.DefaultWheelSize.VALUE
private var wheelSize: WheelSize = WheelSize()
override fun onBatteryLevelChanged(batteryLevel: Int) {
dataHolder.setBatteryLevel(batteryLevel)
repository.setBatteryLevel(batteryLevel)
}
override fun getGattCallback(): BatteryManagerGattCallback {
return CSCManagerGattCallback()
}
fun setWheelSize(value: Int) {
fun setWheelSize(value: WheelSize) {
wheelSize = value
}
@@ -72,7 +70,7 @@ internal class CSCManager(context: Context, private val dataHolder: CSCRepositor
.with(object : CyclingSpeedAndCadenceMeasurementDataCallback() {
override fun getWheelCircumference(): Float {
return wheelSize.toFloat()
return wheelSize.value.toFloat()
}
override fun onDistanceChanged(
@@ -81,7 +79,7 @@ internal class CSCManager(context: Context, private val dataHolder: CSCRepositor
@FloatRange(from = 0.0) distance: Float,
@FloatRange(from = 0.0) speed: Float
) {
dataHolder.setNewDistance(totalDistance, distance, speed)
repository.setNewDistance(totalDistance, distance, speed, wheelSize)
}
override fun onCrankDataChanged(
@@ -89,7 +87,7 @@ internal class CSCManager(context: Context, private val dataHolder: CSCRepositor
@FloatRange(from = 0.0) crankCadence: Float,
gearRatio: Float
) {
dataHolder.setNewCrankCadence(crankCadence, gearRatio)
repository.setNewCrankCadence(crankCadence, gearRatio, wheelSize)
}
override fun onInvalidDataReceived(

View File

@@ -1,5 +1,6 @@
package no.nordicsemi.android.csc.repository
import android.util.Log
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@@ -21,10 +22,14 @@ internal class CSCService : ForegroundBleService() {
override fun onCreate() {
super.onCreate()
status.onEach {
repository.setNewStatus(it)
}.launchIn(scope)
repository.command.onEach {
when (it) {
DisconnectCommand -> stopSelf()
is SetWheelSizeCommand -> manager.setWheelSize(it.size)
is SetWheelSizeCommand -> manager.setWheelSize(it.wheelSize)
}.exhaustive
}.launchIn(scope)
}

View File

@@ -11,21 +11,41 @@ import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import no.nordicsemi.android.csc.R
import no.nordicsemi.android.csc.data.CSCData
import no.nordicsemi.android.csc.data.WheelSize
import no.nordicsemi.android.material.you.RadioButtonGroup
import no.nordicsemi.android.theme.view.ScreenSection
import no.nordicsemi.android.theme.view.SectionTitle
import no.nordicsemi.android.theme.view.dialog.FlowCanceled
import no.nordicsemi.android.theme.view.dialog.ItemSelectedResult
import no.nordicsemi.android.utils.exhaustive
@Composable
internal fun CSCContentView(state: CSCData, onEvent: (CSCViewEvent) -> Unit) {
if (state.showDialog) {
SelectWheelSizeDialog { onEvent(it) }
val showDialog = rememberSaveable { mutableStateOf(false) }
if (showDialog.value) {
val wheelEntries = stringArrayResource(R.array.wheel_entries)
val wheelValues = stringArrayResource(R.array.wheel_values)
SelectWheelSizeDialog {
when (it) {
FlowCanceled -> showDialog.value = false
is ItemSelectedResult -> {
onEvent(OnWheelSizeSelected(WheelSize(wheelValues[it.index].toInt(), wheelEntries[it.index])))
showDialog.value = false
}
}.exhaustive
}
}
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
@@ -33,7 +53,7 @@ internal fun CSCContentView(state: CSCData, onEvent: (CSCViewEvent) -> Unit) {
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(16.dp)
) {
SettingsSection(state, onEvent)
SettingsSection(state, onEvent) { showDialog.value = true }
Spacer(modifier = Modifier.height(16.dp))
@@ -51,7 +71,7 @@ internal fun CSCContentView(state: CSCData, onEvent: (CSCViewEvent) -> Unit) {
}
@Composable
private fun SettingsSection(state: CSCData, onEvent: (CSCViewEvent) -> Unit) {
private fun SettingsSection(state: CSCData, onEvent: (CSCViewEvent) -> Unit, onWheelButtonClick: () -> Unit) {
ScreenSection {
Column(
horizontalAlignment = Alignment.CenterHorizontally
@@ -60,7 +80,7 @@ private fun SettingsSection(state: CSCData, onEvent: (CSCViewEvent) -> Unit) {
Spacer(modifier = Modifier.height(16.dp))
WheelSizeView(state, onEvent)
WheelSizeView(state, onWheelButtonClick)
Spacer(modifier = Modifier.height(16.dp))

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.csc.R
import no.nordicsemi.android.csc.data.CSCData
import no.nordicsemi.android.csc.repository.CSCService
import no.nordicsemi.android.csc.viewmodel.CSCViewModel
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 CSCScreen(finishAction: () -> Unit) {
val viewModel: CSCViewModel = hiltViewModel()
val state = viewModel.state.collectAsState().value
val isScreenActive = viewModel.isActive.collectAsState().value
val context = LocalContext.current
LaunchedEffect(isScreenActive) {
if (!isScreenActive) {
finishAction()
}
if (context.isServiceRunning(CSCService::class.java.name)) {
val intent = Intent(context, CSCService::class.java)
context.stopService(intent)
}
}
LaunchedEffect("start-service") {
if (!context.isServiceRunning(CSCService::class.java.name)) {
LaunchedEffect(state.isActive) {
if (state.isActive) {
val intent = Intent(context, CSCService::class.java)
context.startService(intent)
} else {
finishAction()
}
}
CSCView(state) { viewModel.onEvent(it) }
CSCView(state.viewState) { viewModel.onEvent(it) }
}
@Composable
private fun CSCView(state: CSCData, onEvent: (CSCViewEvent) -> Unit) {
private fun CSCView(state: CSCViewState, onEvent: (CSCViewEvent) -> Unit) {
Column {
BackIconAppBar(stringResource(id = R.string.csc_title)) {
onEvent(OnDisconnectButtonClick)
}
CSCContentView(state) { onEvent(it) }
when (state) {
is DisplayDataState -> CSCContentView(state.data, onEvent)
LoadingState -> DeviceConnectingView()
}.exhaustive
}
}

View File

@@ -0,0 +1,14 @@
package no.nordicsemi.android.csc.view
import no.nordicsemi.android.csc.data.CSCData
internal data class CSCState(
val viewState: CSCViewState,
val isActive: Boolean = true
)
internal sealed class CSCViewState
internal object LoadingState : CSCViewState()
internal data class DisplayDataState(val data: CSCData) : CSCViewState()

View File

@@ -1,12 +1,10 @@
package no.nordicsemi.android.csc.view
import no.nordicsemi.android.csc.data.WheelSize
internal sealed class CSCViewEvent
internal object OnShowEditWheelSizeDialogButtonClick : CSCViewEvent()
internal data class OnWheelSizeSelected(val wheelSize: Int, val wheelSizeDisplayInfo: String) : CSCViewEvent()
internal object OnCloseSelectWheelSizeDialog : CSCViewEvent()
internal data class OnWheelSizeSelected(val wheelSize: WheelSize) : CSCViewEvent()
internal data class OnSelectedSpeedUnitSelected(val selectedSpeedUnit: SpeedUnit) : CSCViewEvent()

View File

@@ -5,6 +5,7 @@ import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import no.nordicsemi.android.csc.R
import no.nordicsemi.android.csc.data.WheelSize
import no.nordicsemi.android.material.you.NordicTheme
import no.nordicsemi.android.theme.view.dialog.FlowCanceled
import no.nordicsemi.android.theme.view.dialog.ItemSelectedResult
@@ -15,16 +16,12 @@ import no.nordicsemi.android.theme.view.dialog.toAnnotatedString
import no.nordicsemi.android.utils.exhaustive
@Composable
internal fun SelectWheelSizeDialog(onEvent: (CSCViewEvent) -> Unit) {
internal fun SelectWheelSizeDialog(onEvent: (StringListDialogResult) -> Unit) {
val wheelEntries = stringArrayResource(R.array.wheel_entries)
val wheelValues = stringArrayResource(R.array.wheel_values)
StringListDialog(createConfig(wheelEntries) {
when (it) {
FlowCanceled -> onEvent(OnCloseSelectWheelSizeDialog)
is ItemSelectedResult ->
onEvent(OnWheelSizeSelected(wheelValues[it.index].toInt(), wheelEntries[it.index]))
}.exhaustive
onEvent(it)
})
}

View File

@@ -19,8 +19,8 @@ import no.nordicsemi.android.csc.R
import no.nordicsemi.android.csc.data.CSCData
@Composable
internal fun WheelSizeView(state: CSCData, onEvent: (CSCViewEvent) -> Unit) {
OutlinedButton(onClick = { onEvent(OnShowEditWheelSizeDialogButtonClick) }) {
internal fun WheelSizeView(state: CSCData, onClick: () -> Unit) {
OutlinedButton(onClick = { onClick() }) {
Row(
modifier = Modifier.fillMaxWidth(0.5f),
verticalAlignment = Alignment.CenterVertically,
@@ -31,7 +31,7 @@ internal fun WheelSizeView(state: CSCData, onEvent: (CSCViewEvent) -> Unit) {
text = stringResource(id = R.string.csc_field_wheel_size),
style = MaterialTheme.typography.labelSmall
)
Text(text = state.wheelSizeDisplay, style = MaterialTheme.typography.bodyMedium)
Text(text = state.wheelSize.name, style = MaterialTheme.typography.bodyMedium)
}
Icon(Icons.Default.ArrowDropDown, contentDescription = "")

View File

@@ -1,52 +1,61 @@
package no.nordicsemi.android.csc.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.csc.data.CSCRepository
import no.nordicsemi.android.csc.data.DisconnectCommand
import no.nordicsemi.android.csc.data.SetWheelSizeCommand
import no.nordicsemi.android.csc.view.CSCState
import no.nordicsemi.android.csc.view.CSCViewEvent
import no.nordicsemi.android.csc.view.OnCloseSelectWheelSizeDialog
import no.nordicsemi.android.csc.view.DisplayDataState
import no.nordicsemi.android.csc.view.LoadingState
import no.nordicsemi.android.csc.view.OnDisconnectButtonClick
import no.nordicsemi.android.csc.view.OnSelectedSpeedUnitSelected
import no.nordicsemi.android.csc.view.OnShowEditWheelSizeDialogButtonClick
import no.nordicsemi.android.csc.view.OnWheelSizeSelected
import no.nordicsemi.android.service.BleManagerStatus
import no.nordicsemi.android.utils.exhaustive
import javax.inject.Inject
@HiltViewModel
internal class CSCViewModel @Inject constructor(
private val dataHolder: CSCRepository
private val repository: CSCRepository
) : ViewModel() {
val state = dataHolder.data
val state = repository.data.combine(repository.status) { data, status ->
when (status) {
BleManagerStatus.CONNECTING -> CSCState(LoadingState)
BleManagerStatus.OK -> CSCState(DisplayDataState(data))
BleManagerStatus.DISCONNECTED -> CSCState(DisplayDataState(data), false)
}
}.stateIn(viewModelScope, SharingStarted.Lazily, CSCState(LoadingState))
fun onEvent(event: CSCViewEvent) {
when (event) {
is OnSelectedSpeedUnitSelected -> onSelectedSpeedUnit(event)
OnShowEditWheelSizeDialogButtonClick -> onShowDialogEvent()
is OnWheelSizeSelected -> onWheelSizeChanged(event)
OnDisconnectButtonClick -> onDisconnectButtonClick()
OnCloseSelectWheelSizeDialog -> onHideDialogEvent()
}.exhaustive
}
private fun onSelectedSpeedUnit(event: OnSelectedSpeedUnitSelected) {
dataHolder.setSpeedUnit(event.selectedSpeedUnit)
}
private fun onShowDialogEvent() {
dataHolder.setDisplayWheelSizeDialog()
repository.setSpeedUnit(event.selectedSpeedUnit)
}
private fun onWheelSizeChanged(event: OnWheelSizeSelected) {
dataHolder.setWheelSize(event.wheelSize, event.wheelSizeDisplayInfo)
repository.sendNewServiceCommand(SetWheelSizeCommand(event.wheelSize))
}
private fun onDisconnectButtonClick() {
finish()
dataHolder.clear()
repository.sendNewServiceCommand(DisconnectCommand)
repository.clear()
}
private fun onHideDialogEvent() {
dataHolder.setHideWheelSizeDialog()
override fun onCleared() {
super.onCleared()
repository.clear()
}
}

View File

@@ -1,13 +1,11 @@
package no.nordicsemi.dfu
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,9 +1,13 @@
package no.nordicsemi.dfu.data
import android.net.Uri
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 no.nordicsemi.android.service.SelectedBluetoothDeviceHolder
import javax.inject.Inject
import javax.inject.Singleton
@@ -17,6 +21,12 @@ internal class DFURepository @Inject constructor(
private val _data = MutableStateFlow<DFUData>(NoFileSelectedState())
val data: StateFlow<DFUData> = _data.asStateFlow()
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 setZipFile(file: Uri) {
val currentState = _data.value as NoFileSelectedState
_data.value = fileManger.createFile(file)?.let {
@@ -36,7 +46,16 @@ internal class DFURepository @Inject constructor(
_data.value = FileInstallingState()
}
fun sendNewCommand(command: DisconnectCommand) {
_command.tryEmit(command)
}
fun setNewStatus(status: BleManagerStatus) {
_status.value = status
}
fun clear() {
_status.value = BleManagerStatus.CONNECTING
_data.value = NoFileSelectedState()
}
}

View File

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

View File

@@ -28,10 +28,25 @@ import android.app.NotificationManager
import android.content.Context
import android.os.Build
import androidx.annotation.RequiresApi
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import no.nordicsemi.android.dfu.DfuBaseService
import no.nordicsemi.android.service.BleManagerStatus
import no.nordicsemi.android.service.CloseableCoroutineScope
import no.nordicsemi.dfu.R
import no.nordicsemi.dfu.data.DFURepository
import javax.inject.Inject
class DFUService : DfuBaseService() {
@AndroidEntryPoint
internal class DFUService : DfuBaseService() {
private val scope = CloseableCoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
@Inject
lateinit var repository: DFURepository
override fun onCreate() {
super.onCreate()
@@ -39,6 +54,12 @@ class DFUService : DfuBaseService() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
createDfuNotificationChannel(this)
}
repository.command.onEach {
stopSelf()
}.launchIn(scope)
repository.setNewStatus(BleManagerStatus.OK)
}
override fun getNotificationTarget(): Class<out Activity?>? {
@@ -77,4 +98,10 @@ class DFUService : DfuBaseService() {
context.getSystemService(NOTIFICATION_SERVICE) as NotificationManager
notificationManager?.createNotificationChannel(channel)
}
override fun onDestroy() {
repository.setNewStatus(BleManagerStatus.DISCONNECTED)
super.onDestroy()
scope.close()
}
}

View File

@@ -6,7 +6,12 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import no.nordicsemi.android.utils.exhaustive
import no.nordicsemi.dfu.data.*
import no.nordicsemi.dfu.data.DFUData
import no.nordicsemi.dfu.data.FileInstallingState
import no.nordicsemi.dfu.data.FileReadyState
import no.nordicsemi.dfu.data.NoFileSelectedState
import no.nordicsemi.dfu.data.UploadFailureState
import no.nordicsemi.dfu.data.UploadSuccessState
@Composable
internal fun DFUContentView(state: DFUData, onEvent: (DFUViewEvent) -> Unit) {

View File

@@ -9,9 +9,9 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel
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
import no.nordicsemi.dfu.R
import no.nordicsemi.dfu.data.DFUData
import no.nordicsemi.dfu.repository.DFUService
import no.nordicsemi.dfu.viewmodel.DFUViewModel
@@ -19,36 +19,30 @@ import no.nordicsemi.dfu.viewmodel.DFUViewModel
fun DFUScreen(finishAction: () -> Unit) {
val viewModel: DFUViewModel = hiltViewModel()
val state = viewModel.state.collectAsState().value
val isScreenActive = viewModel.isActive.collectAsState().value
val context = LocalContext.current
LaunchedEffect(isScreenActive) {
if (!isScreenActive) {
finishAction()
}
if (context.isServiceRunning(DFUService::class.java.name)) {
val intent = Intent(context, DFUService::class.java)
context.stopService(intent)
}
}
LaunchedEffect("start-service") {
if (!context.isServiceRunning(DFUService::class.java.name)) {
LaunchedEffect(state.isActive) {
if (state.isActive) {
val intent = Intent(context, DFUService::class.java)
context.startService(intent)
} else {
finishAction()
}
}
DFUView(state) { viewModel.onEvent(it) }
DFUView(state.viewState) { viewModel.onEvent(it) }
}
@Composable
private fun DFUView(state: DFUData, onEvent: (DFUViewEvent) -> Unit) {
private fun DFUView(state: DFUViewState, onEvent: (DFUViewEvent) -> Unit) {
Column {
BackIconAppBar(stringResource(id = R.string.dfu_title)) {
onEvent(OnDisconnectButtonClick)
}
DFUContentView(state) { onEvent(it) }
when (state) {
is DisplayDataState -> DFUContentView(state.data) { onEvent(it) }
LoadingState -> DeviceConnectingView()
}.exhaustive
}
}

View File

@@ -0,0 +1,14 @@
package no.nordicsemi.dfu.view
import no.nordicsemi.dfu.data.DFUData
internal data class DFUState(
val viewState: DFUViewState,
val isActive: Boolean = true
)
internal sealed class DFUViewState
internal object LoadingState : DFUViewState()
internal data class DisplayDataState(val data: DFUData) : DFUViewState()

View File

@@ -1,24 +1,28 @@
package no.nordicsemi.dfu.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.service.BleManagerStatus
import no.nordicsemi.android.service.SelectedBluetoothDeviceHolder
import no.nordicsemi.android.theme.viewmodel.CloseableViewModel
import no.nordicsemi.android.utils.exhaustive
import no.nordicsemi.dfu.data.Completed
import no.nordicsemi.dfu.data.DFUManager
import no.nordicsemi.dfu.data.DFUProgressManager
import no.nordicsemi.dfu.data.DFURepository
import no.nordicsemi.dfu.data.DFUServiceStatus
import no.nordicsemi.dfu.data.DisconnectCommand
import no.nordicsemi.dfu.data.Error
import no.nordicsemi.dfu.data.FileInstallingState
import no.nordicsemi.dfu.data.FileReadyState
import no.nordicsemi.dfu.data.NoFileSelectedState
import no.nordicsemi.dfu.data.ZipFile
import no.nordicsemi.dfu.view.DFUState
import no.nordicsemi.dfu.view.DFUViewEvent
import no.nordicsemi.dfu.view.DisplayDataState
import no.nordicsemi.dfu.view.LoadingState
import no.nordicsemi.dfu.view.OnDisconnectButtonClick
import no.nordicsemi.dfu.view.OnInstallButtonClick
import no.nordicsemi.dfu.view.OnPauseButtonClick
@@ -32,13 +36,19 @@ internal class DFUViewModel @Inject constructor(
private val progressManager: DFUProgressManager,
private val deviceHolder: SelectedBluetoothDeviceHolder,
private val dfuManager: DFUManager
) : CloseableViewModel() {
) : ViewModel() {
val state = repository.data.combine(progressManager.status) { state, status ->
(state as? FileInstallingState)
?.run { createInstallingStateWithNewStatus(state, status) }
?: state
}.stateIn(viewModelScope, SharingStarted.Eagerly, NoFileSelectedState())
}.combine(repository.status) { data, status ->
when (status) {
BleManagerStatus.CONNECTING -> DFUState(LoadingState)
BleManagerStatus.OK -> DFUState(DisplayDataState(data))
BleManagerStatus.DISCONNECTED -> DFUState(DisplayDataState(data), false)
}
}.stateIn(viewModelScope, SharingStarted.Lazily, DFUState(LoadingState))
init {
progressManager.registerListener()
@@ -58,9 +68,9 @@ internal class DFUViewModel @Inject constructor(
}
private fun closeScreen() {
repository.sendNewCommand(DisconnectCommand)
repository.clear()
deviceHolder.forgetDevice()
finish()
}
private fun requireFile(): ZipFile {
@@ -82,6 +92,7 @@ internal class DFUViewModel @Inject constructor(
override fun onCleared() {
super.onCleared()
repository.clear()
progressManager.unregisterListener()
}
}

View File

@@ -1,9 +1,8 @@
package no.nordicsemi.dfu
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).
*

View File

@@ -1,13 +1,11 @@
package no.nordicsemi.android.gls
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

@@ -3,6 +3,7 @@ package no.nordicsemi.android.gls.data
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import no.nordicsemi.android.service.BleManagerStatus
import javax.inject.Inject
import javax.inject.Singleton
@@ -12,6 +13,9 @@ internal class GLSRepository @Inject constructor() {
private val _data = MutableStateFlow(GLSData())
val data: StateFlow<GLSData> = _data.asStateFlow()
private val _status = MutableStateFlow(BleManagerStatus.CONNECTING)
val status = _status.asStateFlow()
fun addNewRecord(record: GLSRecord) {
val newRecords = _data.value.records.toMutableList().apply {
add(record)
@@ -40,7 +44,12 @@ internal class GLSRepository @Inject constructor() {
_data.tryEmit(_data.value.copy(batteryLevel = batteryLevel))
}
fun setNewStatus(status: BleManagerStatus) {
_status.value = status
}
fun clear() {
_status.value = BleManagerStatus.CONNECTING
_data.tryEmit(GLSData())
}
}

View File

@@ -35,17 +35,23 @@ import no.nordicsemi.android.ble.common.data.RecordAccessControlPointData
import no.nordicsemi.android.ble.common.profile.RecordAccessControlPointCallback.RACPErrorCode
import no.nordicsemi.android.ble.common.profile.RecordAccessControlPointCallback.RACPOpCode
import no.nordicsemi.android.ble.common.profile.glucose.GlucoseMeasurementCallback.GlucoseStatus
import no.nordicsemi.android.ble.common.profile.glucose.GlucoseMeasurementContextCallback.*
import no.nordicsemi.android.ble.data.Data
import no.nordicsemi.android.gls.data.*
import no.nordicsemi.android.ble.common.profile.glucose.GlucoseMeasurementContextCallback.Carbohydrate
import no.nordicsemi.android.ble.common.profile.glucose.GlucoseMeasurementContextCallback.Health
import no.nordicsemi.android.ble.common.profile.glucose.GlucoseMeasurementContextCallback.Meal
import no.nordicsemi.android.ble.common.profile.glucose.GlucoseMeasurementContextCallback.Medication
import no.nordicsemi.android.ble.common.profile.glucose.GlucoseMeasurementContextCallback.Tester
import no.nordicsemi.android.gls.data.CarbohydrateId
import no.nordicsemi.android.gls.data.ConcentrationUnit
import no.nordicsemi.android.gls.data.GLSRecord
import no.nordicsemi.android.gls.data.GLSRepository
import no.nordicsemi.android.gls.data.HealthStatus
import no.nordicsemi.android.gls.data.MeasurementContext
import no.nordicsemi.android.gls.data.MedicationId
import no.nordicsemi.android.gls.data.MedicationUnit
import no.nordicsemi.android.gls.data.RecordType
import no.nordicsemi.android.gls.data.RequestStatus
import no.nordicsemi.android.gls.data.TestType
import no.nordicsemi.android.gls.data.TypeOfMeal
import no.nordicsemi.android.log.LogContract
import no.nordicsemi.android.service.BatteryManager
import java.util.*
import javax.inject.Inject
@@ -304,14 +310,7 @@ internal class GLSManager @Inject constructor(
writeCharacteristic(
recordAccessControlPointCharacteristic,
RecordAccessControlPointData.reportLastStoredRecord()
)
.with { device: BluetoothDevice, data: Data ->
log(
LogContract.Log.Level.APPLICATION,
"\"" + GLSRecordAccessControlPointParser.parse(data) + "\" sent"
)
}
.enqueue()
).enqueue()
}
/**
@@ -327,14 +326,7 @@ internal class GLSManager @Inject constructor(
writeCharacteristic(
recordAccessControlPointCharacteristic,
RecordAccessControlPointData.reportFirstStoredRecord()
)
.with { device: BluetoothDevice, data: Data ->
log(
LogContract.Log.Level.APPLICATION,
"\"" + GLSRecordAccessControlPointParser.parse(data) + "\" sent"
)
}
.enqueue()
).enqueue()
}
/**
@@ -351,14 +343,7 @@ internal class GLSManager @Inject constructor(
writeCharacteristic(
recordAccessControlPointCharacteristic,
RecordAccessControlPointData.reportNumberOfAllStoredRecords()
)
.with { device: BluetoothDevice, data: Data ->
log(
LogContract.Log.Level.APPLICATION,
"\"" + GLSRecordAccessControlPointParser.parse(data) + "\" sent"
)
}
.enqueue()
).enqueue()
}
/**
@@ -385,14 +370,7 @@ internal class GLSManager @Inject constructor(
writeCharacteristic(
recordAccessControlPointCharacteristic,
RecordAccessControlPointData.reportStoredRecordsGreaterThenOrEqualTo(sequenceNumber)
)
.with { device: BluetoothDevice, data: Data ->
log(
LogContract.Log.Level.APPLICATION,
"\"" + GLSRecordAccessControlPointParser.parse(data) + "\" sent"
)
}
.enqueue()
).enqueue()
// Info:
// Operators OPERATOR_LESS_THEN_OR_EQUAL and OPERATOR_RANGE are not supported by Nordic Semiconductor Glucose Service in SDK 4.4.2.
}
@@ -407,14 +385,7 @@ internal class GLSManager @Inject constructor(
writeCharacteristic(
recordAccessControlPointCharacteristic,
RecordAccessControlPointData.abortOperation()
)
.with { device: BluetoothDevice, data: Data ->
log(
LogContract.Log.Level.APPLICATION,
"\"" + GLSRecordAccessControlPointParser.parse(data) + "\" sent"
)
}
.enqueue()
).enqueue()
}
/**
@@ -429,14 +400,7 @@ internal class GLSManager @Inject constructor(
writeCharacteristic(
recordAccessControlPointCharacteristic,
RecordAccessControlPointData.deleteAllStoredRecords()
)
.with { device: BluetoothDevice, data: Data ->
log(
LogContract.Log.Level.APPLICATION,
"\"" + GLSRecordAccessControlPointParser.parse(data) + "\" sent"
)
}
.enqueue()
).enqueue()
val elements = listOf(1, 2, 3)
val result = elements.all { it > 3 }

View File

@@ -1,137 +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.gls.repository
import no.nordicsemi.android.ble.data.Data
object GLSRecordAccessControlPointParser {
private const val OP_CODE_REPORT_STORED_RECORDS = 1
private const val OP_CODE_DELETE_STORED_RECORDS = 2
private const val OP_CODE_ABORT_OPERATION = 3
private const val OP_CODE_REPORT_NUMBER_OF_RECORDS = 4
private const val OP_CODE_NUMBER_OF_STORED_RECORDS_RESPONSE = 5
private const val OP_CODE_RESPONSE_CODE = 6
private const val OPERATOR_NULL = 0
private const val OPERATOR_ALL_RECORDS = 1
private const val OPERATOR_LESS_THEN_OR_EQUAL = 2
private const val OPERATOR_GREATER_THEN_OR_EQUAL = 3
private const val OPERATOR_WITHING_RANGE = 4
private const val OPERATOR_FIRST_RECORD = 5
private const val OPERATOR_LAST_RECORD = 6
private const val RESPONSE_SUCCESS = 1
private const val RESPONSE_OP_CODE_NOT_SUPPORTED = 2
private const val RESPONSE_INVALID_OPERATOR = 3
private const val RESPONSE_OPERATOR_NOT_SUPPORTED = 4
private const val RESPONSE_INVALID_OPERAND = 5
private const val RESPONSE_NO_RECORDS_FOUND = 6
private const val RESPONSE_ABORT_UNSUCCESSFUL = 7
private const val RESPONSE_PROCEDURE_NOT_COMPLETED = 8
private const val RESPONSE_OPERAND_NOT_SUPPORTED = 9
fun parse(data: Data): String {
val builder = StringBuilder()
val opCode = data.getIntValue(Data.FORMAT_UINT8, 0)!!
val operator = data.getIntValue(Data.FORMAT_UINT8, 1)!!
when (opCode) {
OP_CODE_REPORT_STORED_RECORDS, OP_CODE_DELETE_STORED_RECORDS, OP_CODE_ABORT_OPERATION, OP_CODE_REPORT_NUMBER_OF_RECORDS -> builder.append(
getOpCode(opCode)
).append("\n")
OP_CODE_NUMBER_OF_STORED_RECORDS_RESPONSE -> {
builder.append(getOpCode(opCode)).append(": ")
val value = data.getIntValue(Data.FORMAT_UINT16, 2)!!
builder.append(value).append("\n")
}
OP_CODE_RESPONSE_CODE -> {
builder.append(getOpCode(opCode)).append(" for ")
val targetOpCode = data.getIntValue(Data.FORMAT_UINT8, 2)!!
builder.append(getOpCode(targetOpCode)).append(": ")
val status = data.getIntValue(Data.FORMAT_UINT8, 3)!!
builder.append(getStatus(status)).append("\n")
}
}
when (operator) {
OPERATOR_ALL_RECORDS, OPERATOR_FIRST_RECORD, OPERATOR_LAST_RECORD -> builder.append("Operator: ")
.append(
getOperator(operator)
).append("\n")
OPERATOR_GREATER_THEN_OR_EQUAL, OPERATOR_LESS_THEN_OR_EQUAL -> {
val filter = data.getIntValue(Data.FORMAT_UINT8, 2)!!
val value = data.getIntValue(Data.FORMAT_UINT16, 3)!!
builder.append("Operator: ").append(getOperator(operator)).append(" ").append(value)
.append(" (filter: ").append(filter).append(")\n")
}
OPERATOR_WITHING_RANGE -> {
val filter = data.getIntValue(Data.FORMAT_UINT8, 2)!!
val value1 = data.getIntValue(Data.FORMAT_UINT16, 3)!!
val value2 = data.getIntValue(Data.FORMAT_UINT16, 5)!!
builder.append("Operator: ").append(getOperator(operator)).append(" ")
.append(value1).append("-").append(value2).append(" (filter: ").append(filter)
.append(")\n")
}
}
if (builder.isNotEmpty()) {
builder.setLength(builder.length - 1)
}
return builder.toString()
}
private fun getOpCode(opCode: Int): String {
return when (opCode) {
OP_CODE_REPORT_STORED_RECORDS -> "Report stored records"
OP_CODE_DELETE_STORED_RECORDS -> "Delete stored records"
OP_CODE_ABORT_OPERATION -> "Abort operation"
OP_CODE_REPORT_NUMBER_OF_RECORDS -> "Report number of stored records"
OP_CODE_NUMBER_OF_STORED_RECORDS_RESPONSE -> "Number of stored records response"
OP_CODE_RESPONSE_CODE -> "Response Code"
else -> "Reserved for future use"
}
}
private fun getOperator(operator: Int): String {
return when (operator) {
OPERATOR_NULL -> "Null"
OPERATOR_ALL_RECORDS -> "All records"
OPERATOR_LESS_THEN_OR_EQUAL -> "Less than or equal to"
OPERATOR_GREATER_THEN_OR_EQUAL -> "Greater than or equal to"
OPERATOR_WITHING_RANGE -> "Within range of"
OPERATOR_FIRST_RECORD -> "First record(i.e. oldest record)"
OPERATOR_LAST_RECORD -> "Last record (i.e. most recent record)"
else -> "Reserved for future use"
}
}
private fun getStatus(status: Int): String {
return when (status) {
RESPONSE_SUCCESS -> "Success"
RESPONSE_OP_CODE_NOT_SUPPORTED -> "Operation not supported"
RESPONSE_INVALID_OPERATOR -> "Invalid operator"
RESPONSE_OPERATOR_NOT_SUPPORTED -> "Operator not supported"
RESPONSE_INVALID_OPERAND -> "Invalid operand"
RESPONSE_NO_RECORDS_FOUND -> "No records found"
RESPONSE_ABORT_UNSUCCESSFUL -> "Abort unsuccessful"
RESPONSE_PROCEDURE_NOT_COMPLETED -> "Procedure not completed"
RESPONSE_OPERAND_NOT_SUPPORTED -> "Operand not supported"
else -> "Reserved for future use"
}
}
}

View File

@@ -0,0 +1,14 @@
package no.nordicsemi.android.gls.view
import no.nordicsemi.android.gls.data.GLSData
internal data class GLSState(
val viewState: GLSViewState,
val isActive: Boolean = true
)
internal sealed class GLSViewState
internal object LoadingState : GLSViewState()
internal data class DisplayDataState(val data: GLSData) : GLSViewState()

View File

@@ -1,6 +1,14 @@
package no.nordicsemi.android.gls.view
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons

View File

@@ -1,5 +1,6 @@
package no.nordicsemi.android.gls.view
import android.util.Log
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -7,40 +8,43 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel
import no.nordicsemi.android.gls.R
import no.nordicsemi.android.gls.data.GLSData
import no.nordicsemi.android.gls.viewmodel.DisconnectEvent
import no.nordicsemi.android.gls.viewmodel.GLSScreenViewEvent
import no.nordicsemi.android.gls.viewmodel.GLSViewModel
import no.nordicsemi.android.theme.view.BackIconAppBar
import no.nordicsemi.android.theme.view.DeviceConnectingView
import no.nordicsemi.android.utils.exhaustive
@Composable
fun GLSScreen(finishAction: () -> Unit) {
val viewModel: GLSViewModel = hiltViewModel()
val state = viewModel.state.collectAsState().value
val isScreenActive = viewModel.isActive.collectAsState().value
LaunchedEffect("connect") {
Log.d("AAATESTAAA", "$viewModel") //TODO fix screen rotation
LaunchedEffect(state.isActive) {
if (state.isActive) {
viewModel.connectDevice()
}
LaunchedEffect(isScreenActive) {
if (!isScreenActive) {
} else {
finishAction()
}
}
GLSView(state) {
GLSView(state.viewState) {
viewModel.onEvent(it)
}
}
@Composable
private fun GLSView(state: GLSData, onEvent: (GLSScreenViewEvent) -> Unit) {
private fun GLSView(state: GLSViewState, onEvent: (GLSScreenViewEvent) -> Unit) {
Column {
BackIconAppBar(stringResource(id = R.string.gls_title)) {
onEvent(DisconnectEvent)
}
GLSContentView(state, onEvent)
when (state) {
is DisplayDataState -> GLSContentView(state.data, onEvent)
LoadingState -> DeviceConnectingView()
}.exhaustive
}
}

View File

@@ -1,11 +1,21 @@
package no.nordicsemi.android.gls.viewmodel
import android.bluetooth.BluetoothDevice
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.gls.data.GLSRepository
import no.nordicsemi.android.gls.data.WorkingMode
import no.nordicsemi.android.gls.repository.GLSManager
import no.nordicsemi.android.gls.view.DisplayDataState
import no.nordicsemi.android.gls.view.GLSState
import no.nordicsemi.android.gls.view.LoadingState
import no.nordicsemi.android.service.BleManagerStatus
import no.nordicsemi.android.service.ConnectionObserverAdapter
import no.nordicsemi.android.service.SelectedBluetoothDeviceHolder
import no.nordicsemi.android.theme.viewmodel.CloseableViewModel
import no.nordicsemi.android.utils.exhaustive
import javax.inject.Inject
@@ -13,10 +23,35 @@ import javax.inject.Inject
internal class GLSViewModel @Inject constructor(
private val glsManager: GLSManager,
private val deviceHolder: SelectedBluetoothDeviceHolder,
private val dataHolder: GLSRepository
) : CloseableViewModel() {
private val repository: GLSRepository
) : ViewModel() {
val state = dataHolder.data
val state = repository.data.combine(repository.status) { data, status ->
when (status) {
BleManagerStatus.CONNECTING -> GLSState(LoadingState)
BleManagerStatus.OK -> GLSState(DisplayDataState(data))
BleManagerStatus.DISCONNECTED -> GLSState(DisplayDataState(data), false)
}
}.stateIn(viewModelScope, SharingStarted.Lazily, GLSState(LoadingState))
init {
glsManager.setConnectionObserver(object : ConnectionObserverAdapter() {
override fun onDeviceConnected(device: BluetoothDevice) {
super.onDeviceConnected(device)
repository.setNewStatus(BleManagerStatus.OK)
}
override fun onDeviceFailedToConnect(device: BluetoothDevice, reason: Int) {
super.onDeviceFailedToConnect(device, reason)
repository.setNewStatus(BleManagerStatus.DISCONNECTED)
}
override fun onDeviceDisconnected(device: BluetoothDevice, reason: Int) {
super.onDeviceDisconnected(device, reason)
repository.setNewStatus(BleManagerStatus.DISCONNECTED)
}
})
}
fun onEvent(event: GLSScreenViewEvent) {
when (event) {
@@ -43,8 +78,12 @@ internal class GLSViewModel @Inject constructor(
}
private fun disconnect() {
finish()
deviceHolder.forgetDevice()
dataHolder.clear()
glsManager.disconnect().enqueue()
}
override fun onCleared() {
super.onCleared()
repository.clear()
}
}

View File

@@ -1,9 +1,8 @@
package no.nordicsemi.android.gls
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).
*

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).
*

View File

@@ -1,13 +1,11 @@
package no.nordicsemi.android.hts
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.hts.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 HTSRepository @Inject constructor() {
private val _data = MutableStateFlow(HTSData())
val data: StateFlow<HTSData> = _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 setNewTemperature(temperature: Float) {
_data.tryEmit(_data.value.copy(temperatureValue = temperature))
}
@@ -23,7 +34,16 @@ internal class HTSRepository @Inject constructor() {
_data.tryEmit(_data.value.copy(temperatureUnit = unit))
}
fun sendDisconnectCommand() {
_command.tryEmit(DisconnectCommand)
}
fun setNewStatus(status: BleManagerStatus) {
_status.value = status
}
fun clear() {
_status.value = BleManagerStatus.CONNECTING
_data.tryEmit(HTSData())
}
}

View File

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

View File

@@ -1,53 +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.hts.repository
import no.nordicsemi.android.ble.common.callback.DateTimeDataCallback
import no.nordicsemi.android.ble.data.Data
import java.util.*
internal object HTSDateTimeParser {
/**
* Parses the date and time info.
*
* @param data
* @return time in human readable format
*/
fun parse(data: Data): String {
return parse(data, 0)
}
/**
* Parses the date and time info. This data has 7 bytes
*
* @param data
* @param offset
* offset to start reading the time
* @return time in human readable format
*/
/* package */
@JvmStatic
fun parse(data: Data, offset: Int): String {
val calendar = DateTimeDataCallback.readDateTime(data, offset)
return String.format(Locale.US, "%1\$te %1\$tb %1\$tY, %1\$tH:%1\$tM:%1\$tS", calendar)
}
}

View File

@@ -28,9 +28,7 @@ import android.content.Context
import no.nordicsemi.android.ble.common.callback.ht.TemperatureMeasurementDataCallback
import no.nordicsemi.android.ble.common.profile.ht.TemperatureType
import no.nordicsemi.android.ble.common.profile.ht.TemperatureUnit
import no.nordicsemi.android.ble.data.Data
import no.nordicsemi.android.hts.data.HTSRepository
import no.nordicsemi.android.log.LogContract
import no.nordicsemi.android.service.BatteryManager
import java.util.*
@@ -50,13 +48,6 @@ internal class HTSManager internal constructor(
private var htCharacteristic: BluetoothGattCharacteristic? = null
private val temperatureMeasurementDataCallback = object : TemperatureMeasurementDataCallback() {
override fun onDataReceived(device: BluetoothDevice, data: Data) {
log(
LogContract.Log.Level.APPLICATION,
"\"" + HTSTemperatureMeasurementParser.parse(data) + "\" received"
)
super.onDataReceived(device, data)
}
override fun onTemperatureMeasurementReceived(
device: BluetoothDevice,

View File

@@ -1,6 +1,8 @@
package no.nordicsemi.android.hts.repository
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import no.nordicsemi.android.hts.data.HTSRepository
import no.nordicsemi.android.service.ForegroundBleService
import javax.inject.Inject
@@ -9,7 +11,19 @@ import javax.inject.Inject
internal class HTSService : ForegroundBleService() {
@Inject
lateinit var dataHolder: HTSRepository
lateinit var repository: HTSRepository
override val manager: HTSManager by lazy { HTSManager(this, dataHolder) }
override val manager: HTSManager by lazy { HTSManager(this, repository) }
override fun onCreate() {
super.onCreate()
status.onEach {
repository.setNewStatus(it)
}.launchIn(scope)
repository.command.onEach {
stopSelf()
}.launchIn(scope)
}
}

View File

@@ -1,73 +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.hts.repository
import no.nordicsemi.android.ble.data.Data
import java.util.*
private const val TEMPERATURE_UNIT_FLAG: Byte = 0x01 // 1 bit
private const val TIMESTAMP_FLAG: Byte = 0x02 // 1 bits
private const val TEMPERATURE_TYPE_FLAG: Byte = 0x04 // 1 bit
internal object HTSTemperatureMeasurementParser {
fun parse(data: Data): String {
var offset = 0
val flags = data.getIntValue(Data.FORMAT_UINT8, offset++)!!
/*
* false Temperature is in Celsius degrees
* true Temperature is in Fahrenheit degrees
*/
val fahrenheit = flags and TEMPERATURE_UNIT_FLAG.toInt() > 0
/*
* false No Timestamp in the packet
* true There is a timestamp information
*/
val timestampIncluded = flags and TIMESTAMP_FLAG.toInt() > 0
/*
* false Temperature type is not included
* true Temperature type included in the packet
*/
val temperatureTypeIncluded = flags and TEMPERATURE_TYPE_FLAG.toInt() > 0
val tempValue = data.getFloatValue(Data.FORMAT_FLOAT, offset)!!
offset += 4
var dateTime: String? = null
if (timestampIncluded) {
dateTime = HTSDateTimeParser.parse(data, offset)
offset += 7
}
var type: String? = null
if (temperatureTypeIncluded) {
type = HTSTemperatureTypeParser.parse(data, offset)
// offset++;
}
val builder = StringBuilder()
builder.append(String.format(Locale.US, "%.02f", tempValue))
if (fahrenheit) builder.append("°F") else builder.append("°C")
if (timestampIncluded) builder.append("\nTime: ").append(dateTime)
if (temperatureTypeIncluded) builder.append("\nType: ").append(type)
return builder.toString()
}
}

View File

@@ -1,47 +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.hts.repository
import no.nordicsemi.android.ble.data.Data
internal object HTSTemperatureTypeParser {
fun parse(data: Data): String {
return parse(data, 0)
}
/* package */
@JvmStatic
fun parse(data: Data, offset: Int): String {
return when (data.value!![offset].toInt()) {
1 -> "Armpit"
2 -> "Body (general)"
3 -> "Ear (usually ear lobe)"
4 -> "Finger"
5 -> "Gastro-intestinal Tract"
6 -> "Mouth"
7 -> "Rectum"
8 -> "Toe"
9 -> "Tympanum (ear drum)"
else -> "Unknown"
}
}
}

View File

@@ -1,6 +1,10 @@
package no.nordicsemi.android.hts.view
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable

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.hts.R
import no.nordicsemi.android.hts.data.HTSData
import no.nordicsemi.android.hts.repository.HTSService
import no.nordicsemi.android.hts.viewmodel.HTSViewModel
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 HTSScreen(finishAction: () -> Unit) {
val viewModel: HTSViewModel = 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(HTSService::class.java.name)) {
val intent = Intent(context, HTSService::class.java)
context.stopService(intent)
}
}
LaunchedEffect("start-service") {
if (!context.isServiceRunning(HTSService::class.java.name)) {
LaunchedEffect(state.isActive) {
if (state.isActive) {
val intent = Intent(context, HTSService::class.java)
context.startService(intent)
} else {
finishAction()
}
}
HTSView(state) { viewModel.onEvent(it) }
HTSView(state.viewState) { viewModel.onEvent(it) }
}
@Composable
private fun HTSView(state: HTSData, onEvent: (HTSScreenViewEvent) -> Unit) {
private fun HTSView(state: HTSViewState, onEvent: (HTSScreenViewEvent) -> Unit) {
Column {
BackIconAppBar(stringResource(id = R.string.hts_title)) {
onEvent(DisconnectEvent)
}
HTSContentView(state) { onEvent(it) }
when (state) {
is DisplayDataState -> HTSContentView(state.data) { onEvent(it) }
LoadingState -> DeviceConnectingView()
}.exhaustive
}
}

View File

@@ -0,0 +1,14 @@
package no.nordicsemi.android.hts.view
import no.nordicsemi.android.hts.data.HTSData
internal data class HTSState(
val viewState: HTSViewState,
val isActive: Boolean = true
)
internal sealed class HTSViewState
internal object LoadingState : HTSViewState()
internal data class DisplayDataState(val data: HTSData) : HTSViewState()

View File

@@ -1,20 +1,34 @@
package no.nordicsemi.android.hts.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.hts.data.HTSRepository
import no.nordicsemi.android.hts.view.DisconnectEvent
import no.nordicsemi.android.hts.view.DisplayDataState
import no.nordicsemi.android.hts.view.HTSScreenViewEvent
import no.nordicsemi.android.hts.view.HTSState
import no.nordicsemi.android.hts.view.LoadingState
import no.nordicsemi.android.hts.view.OnTemperatureUnitSelected
import no.nordicsemi.android.theme.viewmodel.CloseableViewModel
import no.nordicsemi.android.service.BleManagerStatus
import no.nordicsemi.android.utils.exhaustive
import javax.inject.Inject
@HiltViewModel
internal class HTSViewModel @Inject constructor(
private val dataHolder: HTSRepository
) : CloseableViewModel() {
private val repository: HTSRepository
) : ViewModel() {
val state = dataHolder.data
val state = repository.data.combine(repository.status) { data, status ->
when (status) {
BleManagerStatus.CONNECTING -> HTSState(LoadingState)
BleManagerStatus.OK -> HTSState(DisplayDataState(data))
BleManagerStatus.DISCONNECTED -> HTSState(DisplayDataState(data), false)
}
}.stateIn(viewModelScope, SharingStarted.Lazily, HTSState(LoadingState))
fun onEvent(event: HTSScreenViewEvent) {
when (event) {
@@ -24,11 +38,16 @@ internal class HTSViewModel @Inject constructor(
}
private fun onDisconnectButtonClick() {
finish()
dataHolder.clear()
repository.sendDisconnectCommand()
repository.clear()
}
private fun onTemperatureUnitSelected(event: OnTemperatureUnitSelected) {
dataHolder.setTemperatureUnit(event.value)
repository.setTemperatureUnit(event.value)
}
override fun onCleared() {
super.onCleared()
repository.clear()
}
}

View File

@@ -1,9 +1,8 @@
package no.nordicsemi.android.hts
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).
*

View File

@@ -1,13 +1,11 @@
package no.nordicsemi.android.prx
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

@@ -5,3 +5,5 @@ internal sealed class PRXCommand
internal object EnableAlarm : PRXCommand()
internal object DisableAlarm : PRXCommand()
internal object Disconnect : PRXCommand()

View File

@@ -5,6 +5,8 @@ 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
@@ -17,6 +19,9 @@ internal class PRXRepository @Inject constructor() {
private val _command = MutableSharedFlow<PRXCommand>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
val command = _command.asSharedFlow()
private val _status = MutableStateFlow(BleManagerStatus.CONNECTING)
val status = _status.asStateFlow()
fun setBatteryLevel(batteryLevel: Int) {
_data.tryEmit(_data.value.copy(batteryLevel = batteryLevel))
}
@@ -34,7 +39,12 @@ internal class PRXRepository @Inject constructor() {
_command.tryEmit(command)
}
fun clear(){
fun setNewStatus(status: BleManagerStatus) {
_status.value = status
}
fun clear() {
_status.value = BleManagerStatus.CONNECTING
_data.tryEmit(PRXData())
}
}

View File

@@ -1,48 +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.prx.service
import android.bluetooth.BluetoothGattCharacteristic
import no.nordicsemi.android.ble.data.Data
internal object PRXAlertLevelParser {
fun parse(characteristic: BluetoothGattCharacteristic?): String {
return parse(Data.from(characteristic!!))
}
/**
* Parses the alert level.
*
* @param data
* @return alert level in human readable format
*/
fun parse(data: Data): String {
val value = data.getIntValue(Data.FORMAT_UINT8, 0)!!
return when (value) {
0 -> "No Alert"
1 -> "Mild Alert"
2 -> "High Alert"
else -> "Reserved value ($value)"
}
}
}

View File

@@ -30,9 +30,7 @@ import android.util.Log
import no.nordicsemi.android.ble.callback.FailCallback
import no.nordicsemi.android.ble.common.callback.alert.AlertLevelDataCallback
import no.nordicsemi.android.ble.common.data.alert.AlertLevelData
import no.nordicsemi.android.ble.data.Data
import no.nordicsemi.android.ble.error.GattError
import no.nordicsemi.android.log.LogContract
import no.nordicsemi.android.prx.data.PRXRepository
import no.nordicsemi.android.service.BatteryManager
import java.util.*
@@ -161,12 +159,6 @@ internal class PRXManager(
if (on) "Setting alarm to HIGH..." else "Disabling alarm..."
)
}
.with { _: BluetoothDevice, data: Data ->
log(
LogContract.Log.Level.APPLICATION,
"\"" + PRXAlertLevelParser.parse(data) + "\" sent"
)
}
.done { device: BluetoothDevice? ->
isAlertEnabled = on
dataHolder.setRemoteAlarmLevel(on)

View File

@@ -1,11 +1,11 @@
package no.nordicsemi.android.prx.service
import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import no.nordicsemi.android.prx.data.AlarmLevel
import no.nordicsemi.android.prx.data.DisableAlarm
import no.nordicsemi.android.prx.data.Disconnect
import no.nordicsemi.android.prx.data.EnableAlarm
import no.nordicsemi.android.prx.data.PRXRepository
import no.nordicsemi.android.service.ForegroundBleService
@@ -16,7 +16,7 @@ import javax.inject.Inject
internal class PRXService : ForegroundBleService() {
@Inject
lateinit var dataHolder: PRXRepository
lateinit var repository: PRXRepository
@Inject
lateinit var alarmHandler: AlarmHandler
@@ -24,7 +24,7 @@ internal class PRXService : ForegroundBleService() {
private var serverManager: ProximityServerManager = ProximityServerManager(this)
override val manager: PRXManager by lazy {
PRXManager(this, dataHolder).apply {
PRXManager(this, repository).apply {
useServer(serverManager)
}
}
@@ -34,14 +34,19 @@ internal class PRXService : ForegroundBleService() {
serverManager.open()
dataHolder.command.onEach {
status.onEach {
repository.setNewStatus(it)
}.launchIn(scope)
repository.command.onEach {
when (it) {
DisableAlarm -> manager.writeImmediateAlert(false)
EnableAlarm -> manager.writeImmediateAlert(true)
Disconnect -> stopSelf()
}.exhaustive
}.launchIn(scope)
dataHolder.data.onEach {
repository.data.onEach {
if (it.localAlarmLevel != AlarmLevel.NONE) {
alarmHandler.playAlarm()
} else {

View File

@@ -8,55 +8,42 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.hilt.navigation.compose.hiltViewModel
import no.nordicsemi.android.prx.R
import no.nordicsemi.android.prx.data.PRXData
import no.nordicsemi.android.prx.service.PRXService
import no.nordicsemi.android.prx.viewmodel.PRXViewModel
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 PRXScreen(finishAction: () -> Unit) {
val viewModel: PRXViewModel = 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(PRXService::class.java.name)) {
val intent = Intent(context, PRXService::class.java)
context.stopService(intent)
}
}
LaunchedEffect("start-service") {
if (!context.isServiceRunning(PRXService::class.java.name)) {
LaunchedEffect(state.isActive) {
if (state.isActive) {
val intent = Intent(context, PRXService::class.java)
context.startService(intent)
} else {
finishAction()
}
}
PRXView(state) { viewModel.onEvent(it) }
PRXView(state.viewState) { viewModel.onEvent(it) }
}
@Composable
private fun PRXView(state: PRXData, onEvent: (PRXScreenViewEvent) -> Unit) {
private fun PRXView(state: PRXViewState, onEvent: (PRXScreenViewEvent) -> Unit) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
BackIconAppBar(stringResource(id = R.string.prx_title)) {
onEvent(DisconnectEvent)
}
ContentView(state) { onEvent(it) }
when (state) {
is DisplayDataState -> ContentView(state.data) { onEvent(it) }
LoadingState -> DeviceConnectingView()
}.exhaustive
}
}
@Preview
@Composable
private fun PRXViewPreview() {
PRXView(PRXData()) { }
}

View File

@@ -0,0 +1,14 @@
package no.nordicsemi.android.prx.view
import no.nordicsemi.android.prx.data.PRXData
internal data class PRXState(
val viewState: PRXViewState,
val isActive: Boolean = true
)
internal sealed class PRXViewState
internal object LoadingState : PRXViewState()
internal data class DisplayDataState(val data: PRXData) : PRXViewState()

View File

@@ -1,34 +1,54 @@
package no.nordicsemi.android.prx.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.prx.data.DisableAlarm
import no.nordicsemi.android.prx.data.Disconnect
import no.nordicsemi.android.prx.data.EnableAlarm
import no.nordicsemi.android.prx.data.PRXRepository
import no.nordicsemi.android.prx.view.DisconnectEvent
import no.nordicsemi.android.prx.view.DisplayDataState
import no.nordicsemi.android.prx.view.LoadingState
import no.nordicsemi.android.prx.view.PRXScreenViewEvent
import no.nordicsemi.android.prx.view.PRXState
import no.nordicsemi.android.prx.view.TurnOffAlert
import no.nordicsemi.android.prx.view.TurnOnAlert
import no.nordicsemi.android.theme.viewmodel.CloseableViewModel
import no.nordicsemi.android.service.BleManagerStatus
import no.nordicsemi.android.utils.exhaustive
import javax.inject.Inject
@HiltViewModel
internal class PRXViewModel @Inject constructor(
private val dataHolder: PRXRepository
) : CloseableViewModel() {
private val repository: PRXRepository
) : ViewModel() {
val state = dataHolder.data
val state = repository.data.combine(repository.status) { data, status ->
when (status) {
BleManagerStatus.CONNECTING -> PRXState(LoadingState)
BleManagerStatus.OK -> PRXState(DisplayDataState(data))
BleManagerStatus.DISCONNECTED -> PRXState(DisplayDataState(data), false)
}
}.stateIn(viewModelScope, SharingStarted.Lazily, PRXState(LoadingState))
fun onEvent(event: PRXScreenViewEvent) {
when (event) {
DisconnectEvent -> onDisconnectButtonClick()
TurnOffAlert -> dataHolder.invokeCommand(DisableAlarm)
TurnOnAlert -> dataHolder.invokeCommand(EnableAlarm)
TurnOffAlert -> repository.invokeCommand(DisableAlarm)
TurnOnAlert -> repository.invokeCommand(EnableAlarm)
}.exhaustive
}
private fun onDisconnectButtonClick() {
finish()
dataHolder.clear()
repository.invokeCommand(Disconnect)
repository.clear()
}
override fun onCleared() {
super.onCleared()
repository.clear()
}
}

View File

@@ -1,9 +1,8 @@
package no.nordicsemi.android.prx
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).
*

View File

@@ -1,13 +1,11 @@
package no.nordicsemi.android.rscs
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.rscs.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
@@ -9,7 +14,13 @@ import javax.inject.Singleton
internal class RSCSRepository @Inject constructor() {
private val _data = MutableStateFlow(RSCSData())
val data: StateFlow<RSCSData> = _data
val data: StateFlow<RSCSData> = _data.asStateFlow()
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 setNewData(
running: Boolean,
@@ -27,11 +38,20 @@ internal class RSCSRepository @Inject constructor() {
))
}
fun setNewStatus(status: BleManagerStatus) {
_status.value = status
}
fun setBatteryLevel(batteryLevel: Int) {
_data.tryEmit(_data.value.copy(batteryLevel = batteryLevel))
}
fun sendDisconnectCommand() {
_command.tryEmit(DisconnectCommand)
}
fun clear() {
_status.value = BleManagerStatus.CONNECTING
_data.tryEmit(RSCSData())
}
}

View File

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

View File

@@ -1,6 +1,8 @@
package no.nordicsemi.android.rscs.service
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import no.nordicsemi.android.rscs.data.RSCSRepository
import no.nordicsemi.android.service.ForegroundBleService
import javax.inject.Inject
@@ -9,7 +11,19 @@ import javax.inject.Inject
internal class RSCSService : ForegroundBleService() {
@Inject
lateinit var dataHolder: RSCSRepository
lateinit var repository: RSCSRepository
override val manager: RSCSManager by lazy { RSCSManager(this, dataHolder) }
override val manager: RSCSManager by lazy { RSCSManager(this, repository) }
override fun onCreate() {
super.onCreate()
status.onEach {
repository.setNewStatus(it)
}.launchIn(scope)
repository.command.onEach {
stopSelf()
}.launchIn(scope)
}
}

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.rscs.R
import no.nordicsemi.android.rscs.data.RSCSData
import no.nordicsemi.android.rscs.service.RSCSService
import no.nordicsemi.android.rscs.viewmodel.RSCSViewModel
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 RSCSScreen(finishAction: () -> Unit) {
val viewModel: RSCSViewModel = hiltViewModel()
val state = viewModel.state.collectAsState().value
val isScreenActive = viewModel.isActive.collectAsState().value
val context = LocalContext.current
LaunchedEffect(isScreenActive) {
if (!isScreenActive) {
finishAction()
}
if (context.isServiceRunning(RSCSService::class.java.name)) {
val intent = Intent(context, RSCSService::class.java)
context.stopService(intent)
}
}
LaunchedEffect("start-service") {
if (!context.isServiceRunning(RSCSService::class.java.name)) {
LaunchedEffect(state.isActive) {
if (state.isActive) {
val intent = Intent(context, RSCSService::class.java)
context.startService(intent)
} else {
finishAction()
}
}
RSCSView(state) { viewModel.onEvent(it) }
RSCSView(state.viewState) { viewModel.onEvent(it) }
}
@Composable
private fun RSCSView(state: RSCSData, onEvent: (RSCScreenViewEvent) -> Unit) {
private fun RSCSView(state: RSCSViewState, onEvent: (RSCScreenViewEvent) -> Unit) {
Column {
BackIconAppBar(stringResource(id = R.string.rscs_title)) {
onEvent(DisconnectEvent)
}
RSCSContentView(state) { onEvent(it) }
when (state) {
is DisplayDataState -> RSCSContentView(state.data) { onEvent(it) }
LoadingState -> DeviceConnectingView()
}.exhaustive
}
}

View File

@@ -0,0 +1,14 @@
package no.nordicsemi.android.rscs.view
import no.nordicsemi.android.rscs.data.RSCSData
internal data class RSCSState(
val viewState: RSCSViewState,
val isActive: Boolean = true
)
internal sealed class RSCSViewState
internal object LoadingState : RSCSViewState()
internal data class DisplayDataState(val data: RSCSData) : RSCSViewState()

View File

@@ -1,19 +1,33 @@
package no.nordicsemi.android.rscs.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.rscs.data.RSCSRepository
import no.nordicsemi.android.rscs.view.DisconnectEvent
import no.nordicsemi.android.rscs.view.DisplayDataState
import no.nordicsemi.android.rscs.view.LoadingState
import no.nordicsemi.android.rscs.view.RSCSState
import no.nordicsemi.android.rscs.view.RSCScreenViewEvent
import no.nordicsemi.android.theme.viewmodel.CloseableViewModel
import no.nordicsemi.android.service.BleManagerStatus
import no.nordicsemi.android.utils.exhaustive
import javax.inject.Inject
@HiltViewModel
internal class RSCSViewModel @Inject constructor(
private val dataHolder: RSCSRepository
) : CloseableViewModel() {
private val repository: RSCSRepository
) : ViewModel() {
val state = dataHolder.data
val state = repository.data.combine(repository.status) { data, status ->
when (status) {
BleManagerStatus.CONNECTING -> RSCSState(LoadingState)
BleManagerStatus.OK -> RSCSState(DisplayDataState(data))
BleManagerStatus.DISCONNECTED -> RSCSState(DisplayDataState(data), false)
}
}.stateIn(viewModelScope, SharingStarted.Lazily, RSCSState(LoadingState))
fun onEvent(event: RSCScreenViewEvent) {
when (event) {
@@ -22,7 +36,12 @@ internal class RSCSViewModel @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.rscs
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).
*

Some files were not shown because too many files have changed in this diff Show More