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 package no.nordicsemi.android.nrftoolbox
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4 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.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.junit.Assert.*
/** /**
* Instrumented test, which will execute on an Android device. * 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.app.Activity
import android.os.ParcelUuid import android.os.ParcelUuid
import android.util.Log
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -46,15 +45,15 @@ internal fun HomeScreen() {
navController = navController, navController = navController,
startDestination = NavigationId.HOME.id startDestination = NavigationId.HOME.id
) { ) {
composable(NavigationId.HOME.id) {
HomeView(viewModel)
}
composable(NavigationId.SCANNER.id) { composable(NavigationId.SCANNER.id) {
val profile = viewModel.profile!! val profile = viewModel.profile!!
FindDeviceScreen(ParcelUuid(profile.uuid)) { FindDeviceScreen(ParcelUuid(profile.uuid)) {
viewModel.onScannerFlowResult(it) viewModel.onScannerFlowResult(it)
} }
} }
composable(NavigationId.HOME.id) {
HomeView(viewModel)
}
composable(NavigationId.CSC.id) { composable(NavigationId.CSC.id) {
CSCScreen(navigateUp) CSCScreen(navigateUp)
} }

View File

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

View File

@@ -1,13 +1,11 @@
package no.nordicsemi.android.service package no.nordicsemi.android.service
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4 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.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.junit.Assert.*
/** /**
* Instrumented test, which will execute on an Android device. * Instrumented test, which will execute on an Android device.
* *

View File

@@ -21,6 +21,8 @@ import java.util.*
</T> */ </T> */
abstract class BatteryManager(context: Context) : BleManager(context) { abstract class BatteryManager(context: Context) : BleManager(context) {
private val TAG = "BLE-MANAGER"
private var batteryLevelCharacteristic: BluetoothGattCharacteristic? = null private var batteryLevelCharacteristic: BluetoothGattCharacteristic? = null
private val batteryLevelDataCallback: DataReceivedCallback = 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() { protected abstract inner class BatteryManagerGattCallback : BleManagerGattCallback() {
override fun initialize() { override fun initialize() {
readBatteryLevelCharacteristic() readBatteryLevelCharacteristic()

View File

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

View File

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

View File

@@ -1,19 +1,34 @@
package no.nordicsemi.android.service package no.nordicsemi.android.service
import android.bluetooth.BluetoothDevice import android.bluetooth.BluetoothDevice
import android.util.Log
import no.nordicsemi.android.ble.observer.ConnectionObserver import no.nordicsemi.android.ble.observer.ConnectionObserver
abstract class ConnectionObserverAdapter : 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 package no.nordicsemi.android.service
import org.junit.Assert.assertEquals
import org.junit.Test import org.junit.Test
import org.junit.Assert.*
/** /**
* Example local unit test, which will execute on the development machine (host). * 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.annotation.DrawableRes
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background 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.foundation.shape.CircleShape
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme 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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@@ -19,6 +18,7 @@ import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -62,18 +62,18 @@ fun StringListView(config: StringListDialogConfig) {
) { ) {
config.items.forEachIndexed { i, entry -> config.items.forEachIndexed { i, entry ->
Column(modifier = Modifier.clickable { config.onResult(ItemSelectedResult(i)) }) { Column(
Spacer(modifier = Modifier.height(16.dp)) modifier = Modifier
.clip(RoundedCornerShape(10.dp))
.clickable { config.onResult(ItemSelectedResult(i)) }
.padding(8.dp),
) {
Row { Row {
config.leftIcon?.let { config.leftIcon?.let {
Image( Image(
modifier = Modifier.padding(horizontal = 4.dp), modifier = Modifier.padding(horizontal = 4.dp),
painter = painterResource(it), painter = painterResource(it),
contentDescription = "Content image", contentDescription = "Content image",
// colorFilter = ColorFilter.tint(
// NordicColors.NordicDarkGray.value()
// )
) )
} }
Text( Text(
@@ -83,10 +83,6 @@ fun StringListView(config: StringListDialogConfig) {
.fillMaxWidth() .fillMaxWidth()
) )
} }
if (i != config.items.size - 1) {
Spacer(modifier = Modifier.height(16.dp))
}
} }
} }

View File

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

View File

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

View File

@@ -1,13 +1,11 @@
package no.nordicsemi.android.bps package no.nordicsemi.android.bps
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4 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.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.junit.Assert.*
/** /**
* Instrumented test, which will execute on an Android device. * Instrumented test, which will execute on an Android device.
* *

View File

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

View File

@@ -63,6 +63,7 @@ internal class BPSRepository @Inject constructor() {
} }
fun clear() { fun clear() {
_status.value = BleManagerStatus.CONNECTING
_data.tryEmit(BPSData()) _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.common.profile.bp.BloodPressureTypes
import no.nordicsemi.android.ble.data.Data import no.nordicsemi.android.ble.data.Data
import no.nordicsemi.android.bps.data.BPSRepository import no.nordicsemi.android.bps.data.BPSRepository
import no.nordicsemi.android.log.LogContract
import no.nordicsemi.android.service.BatteryManager import no.nordicsemi.android.service.BatteryManager
import java.util.* import java.util.*
import javax.inject.Inject import javax.inject.Inject

View File

@@ -42,6 +42,11 @@ internal class BPSViewModel @Inject constructor(
repository.setNewStatus(BleManagerStatus.OK) 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) { override fun onDeviceDisconnected(device: BluetoothDevice, reason: Int) {
super.onDeviceDisconnected(device, reason) super.onDeviceDisconnected(device, reason)
repository.setNewStatus(BleManagerStatus.DISCONNECTED) repository.setNewStatus(BleManagerStatus.DISCONNECTED)
@@ -63,7 +68,13 @@ internal class BPSViewModel @Inject constructor(
} }
private fun onDisconnectButtonClick() { private fun onDisconnectButtonClick() {
deviceHolder.forgetDevice()
bpsManager.disconnect().enqueue() 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 package no.nordicsemi.android.bps
import org.junit.Assert.assertEquals
import org.junit.Test import org.junit.Test
import org.junit.Assert.*
/** /**
* Example local unit test, which will execute on the development machine (host). * Example local unit test, which will execute on the development machine (host).
* *

View File

@@ -1,13 +1,11 @@
package no.nordicsemi.android.cgms package no.nordicsemi.android.cgms
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4 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.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.junit.Assert.*
/** /**
* Instrumented test, which will execute on an Android device. * Instrumented test, which will execute on an Android device.
* *

View File

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

View File

@@ -1,6 +1,14 @@
package no.nordicsemi.android.cgms.view 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.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons 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.R
import no.nordicsemi.android.cgms.data.CGMData import no.nordicsemi.android.cgms.data.CGMData
import no.nordicsemi.android.cgms.data.CGMRecord 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.CGMServiceCommand
import no.nordicsemi.android.cgms.data.RequestStatus
import no.nordicsemi.android.material.you.CircularProgressIndicator import no.nordicsemi.android.material.you.CircularProgressIndicator
import no.nordicsemi.android.theme.view.BatteryLevelView import no.nordicsemi.android.theme.view.BatteryLevelView
import no.nordicsemi.android.theme.view.ScreenSection 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 androidx.compose.ui.res.stringResource
import no.nordicsemi.android.cgms.R import no.nordicsemi.android.cgms.R
import no.nordicsemi.android.cgms.data.CGMRecord import no.nordicsemi.android.cgms.data.CGMRecord
import no.nordicsemi.android.cgms.data.CGMServiceCommand
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*

View File

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

View File

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

View File

@@ -1,13 +1,11 @@
package no.nordicsemi.android.csc package no.nordicsemi.android.csc
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4 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.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.junit.Assert.*
/** /**
* Instrumented test, which will execute on an Android device. * Instrumented test, which will execute on an Android device.
* *

View File

@@ -1,6 +1,5 @@
package no.nordicsemi.android.csc.data package no.nordicsemi.android.csc.data
import no.nordicsemi.android.csc.view.CSCSettings
import no.nordicsemi.android.csc.view.SpeedUnit import no.nordicsemi.android.csc.view.SpeedUnit
import no.nordicsemi.android.material.you.RadioButtonItem import no.nordicsemi.android.material.you.RadioButtonItem
import no.nordicsemi.android.material.you.RadioGroupViewEntity 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" private const val DISPLAY_MPH = "mph"
internal data class CSCData( internal data class CSCData(
val showDialog: Boolean = false,
val scanDevices: Boolean = false, val scanDevices: Boolean = false,
val selectedSpeedUnit: SpeedUnit = SpeedUnit.M_S, val selectedSpeedUnit: SpeedUnit = SpeedUnit.M_S,
val speed: Float = 0f, val speed: Float = 0f,
@@ -20,8 +18,7 @@ internal data class CSCData(
val totalDistance: Float = 0f, val totalDistance: Float = 0f,
val gearRatio: Float = 0f, val gearRatio: Float = 0f,
val batteryLevel: Int = 0, val batteryLevel: Int = 0,
val wheelSize: Int = CSCSettings.DefaultWheelSize.VALUE, val wheelSize: WheelSize = WheelSize()
val wheelSizeDisplay: String = CSCSettings.DefaultWheelSize.NAME
) { ) {
private val speedWithUnit = when (selectedSpeedUnit) { 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import no.nordicsemi.android.csc.view.SpeedUnit import no.nordicsemi.android.csc.view.SpeedUnit
import no.nordicsemi.android.service.BleManagerStatus
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -13,37 +15,38 @@ import javax.inject.Singleton
internal class CSCRepository @Inject constructor() { internal class CSCRepository @Inject constructor() {
private val _data = MutableStateFlow(CSCData()) 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) private val _command = MutableSharedFlow<CSCServiceCommand>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_LATEST)
val command = _command.asSharedFlow() val command = _command.asSharedFlow()
fun setWheelSize(wheelSize: Int, wheelSizeDisplay: String) { private val _status = MutableStateFlow(BleManagerStatus.CONNECTING)
_data.tryEmit(_data.value.copy( val status = _status.asStateFlow()
wheelSize = wheelSize,
wheelSizeDisplay = wheelSizeDisplay,
showDialog = false
))
}
fun setSpeedUnit(selectedSpeedUnit: SpeedUnit) { fun setSpeedUnit(selectedSpeedUnit: SpeedUnit) {
_data.tryEmit(_data.value.copy(selectedSpeedUnit = selectedSpeedUnit)) _data.tryEmit(_data.value.copy(selectedSpeedUnit = selectedSpeedUnit))
} }
fun setHideWheelSizeDialog() { fun setNewDistance(
_data.tryEmit(_data.value.copy(showDialog = false)) totalDistance: Float,
distance: Float,
speed: Float,
wheelSize: WheelSize
) {
_data.tryEmit(_data.value.copy(
totalDistance = totalDistance,
distance = distance,
speed = speed,
wheelSize = wheelSize
))
} }
fun setDisplayWheelSizeDialog() { fun setNewCrankCadence(
_data.tryEmit(_data.value.copy(showDialog = true)) crankCadence: Float,
} gearRatio: Float,
wheelSize: WheelSize
fun setNewDistance(totalDistance: Float, distance: Float, speed: Float) { ) {
_data.tryEmit(_data.value.copy(totalDistance = totalDistance, distance = distance, speed = speed)) _data.tryEmit(_data.value.copy(cadence = crankCadence, gearRatio = gearRatio, wheelSize = wheelSize))
}
fun setNewCrankCadence(crankCadence: Float, gearRatio: Float) {
_data.tryEmit(_data.value.copy(cadence = crankCadence, gearRatio = gearRatio))
} }
fun setBatteryLevel(batteryLevel: Int) { fun setBatteryLevel(batteryLevel: Int) {
@@ -54,7 +57,12 @@ internal class CSCRepository @Inject constructor() {
_command.tryEmit(workingMode) _command.tryEmit(workingMode)
} }
fun setNewStatus(status: BleManagerStatus) {
_status.value = status
}
fun clear() { fun clear() {
_status.value = BleManagerStatus.CONNECTING
_data.tryEmit(CSCData()) _data.tryEmit(CSCData())
} }
} }

View File

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

View File

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

View File

@@ -11,21 +11,41 @@ import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import no.nordicsemi.android.csc.R import no.nordicsemi.android.csc.R
import no.nordicsemi.android.csc.data.CSCData 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.material.you.RadioButtonGroup
import no.nordicsemi.android.theme.view.ScreenSection import no.nordicsemi.android.theme.view.ScreenSection
import no.nordicsemi.android.theme.view.SectionTitle 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 @Composable
internal fun CSCContentView(state: CSCData, onEvent: (CSCViewEvent) -> Unit) { internal fun CSCContentView(state: CSCData, onEvent: (CSCViewEvent) -> Unit) {
if (state.showDialog) { val showDialog = rememberSaveable { mutableStateOf(false) }
SelectWheelSizeDialog { onEvent(it) }
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())) { Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
@@ -33,7 +53,7 @@ internal fun CSCContentView(state: CSCData, onEvent: (CSCViewEvent) -> Unit) {
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(16.dp) modifier = Modifier.padding(16.dp)
) { ) {
SettingsSection(state, onEvent) SettingsSection(state, onEvent) { showDialog.value = true }
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
@@ -51,7 +71,7 @@ internal fun CSCContentView(state: CSCData, onEvent: (CSCViewEvent) -> Unit) {
} }
@Composable @Composable
private fun SettingsSection(state: CSCData, onEvent: (CSCViewEvent) -> Unit) { private fun SettingsSection(state: CSCData, onEvent: (CSCViewEvent) -> Unit, onWheelButtonClick: () -> Unit) {
ScreenSection { ScreenSection {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
@@ -60,7 +80,7 @@ private fun SettingsSection(state: CSCData, onEvent: (CSCViewEvent) -> Unit) {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
WheelSizeView(state, onEvent) WheelSizeView(state, onWheelButtonClick)
Spacer(modifier = Modifier.height(16.dp)) 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.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import no.nordicsemi.android.csc.R 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.repository.CSCService
import no.nordicsemi.android.csc.viewmodel.CSCViewModel import no.nordicsemi.android.csc.viewmodel.CSCViewModel
import no.nordicsemi.android.theme.view.BackIconAppBar 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 @Composable
fun CSCScreen(finishAction: () -> Unit) { fun CSCScreen(finishAction: () -> Unit) {
val viewModel: CSCViewModel = hiltViewModel() val viewModel: CSCViewModel = hiltViewModel()
val state = viewModel.state.collectAsState().value val state = viewModel.state.collectAsState().value
val isScreenActive = viewModel.isActive.collectAsState().value
val context = LocalContext.current val context = LocalContext.current
LaunchedEffect(isScreenActive) { LaunchedEffect(state.isActive) {
if (!isScreenActive) { if (state.isActive) {
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)) {
val intent = Intent(context, CSCService::class.java) val intent = Intent(context, CSCService::class.java)
context.startService(intent) context.startService(intent)
} else {
finishAction()
} }
} }
CSCView(state) { viewModel.onEvent(it) } CSCView(state.viewState) { viewModel.onEvent(it) }
} }
@Composable @Composable
private fun CSCView(state: CSCData, onEvent: (CSCViewEvent) -> Unit) { private fun CSCView(state: CSCViewState, onEvent: (CSCViewEvent) -> Unit) {
Column { Column {
BackIconAppBar(stringResource(id = R.string.csc_title)) { BackIconAppBar(stringResource(id = R.string.csc_title)) {
onEvent(OnDisconnectButtonClick) 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 package no.nordicsemi.android.csc.view
import no.nordicsemi.android.csc.data.WheelSize
internal sealed class CSCViewEvent internal sealed class CSCViewEvent
internal object OnShowEditWheelSizeDialogButtonClick : CSCViewEvent() internal data class OnWheelSizeSelected(val wheelSize: WheelSize) : CSCViewEvent()
internal data class OnWheelSizeSelected(val wheelSize: Int, val wheelSizeDisplayInfo: String) : CSCViewEvent()
internal object OnCloseSelectWheelSizeDialog : CSCViewEvent()
internal data class OnSelectedSpeedUnitSelected(val selectedSpeedUnit: SpeedUnit) : 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.res.stringResource
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import no.nordicsemi.android.csc.R import no.nordicsemi.android.csc.R
import no.nordicsemi.android.csc.data.WheelSize
import no.nordicsemi.android.material.you.NordicTheme import no.nordicsemi.android.material.you.NordicTheme
import no.nordicsemi.android.theme.view.dialog.FlowCanceled import no.nordicsemi.android.theme.view.dialog.FlowCanceled
import no.nordicsemi.android.theme.view.dialog.ItemSelectedResult 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 import no.nordicsemi.android.utils.exhaustive
@Composable @Composable
internal fun SelectWheelSizeDialog(onEvent: (CSCViewEvent) -> Unit) { internal fun SelectWheelSizeDialog(onEvent: (StringListDialogResult) -> Unit) {
val wheelEntries = stringArrayResource(R.array.wheel_entries) val wheelEntries = stringArrayResource(R.array.wheel_entries)
val wheelValues = stringArrayResource(R.array.wheel_values) val wheelValues = stringArrayResource(R.array.wheel_values)
StringListDialog(createConfig(wheelEntries) { StringListDialog(createConfig(wheelEntries) {
when (it) { onEvent(it)
FlowCanceled -> onEvent(OnCloseSelectWheelSizeDialog)
is ItemSelectedResult ->
onEvent(OnWheelSizeSelected(wheelValues[it.index].toInt(), wheelEntries[it.index]))
}.exhaustive
}) })
} }

View File

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

View File

@@ -1,52 +1,61 @@
package no.nordicsemi.android.csc.viewmodel package no.nordicsemi.android.csc.viewmodel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel 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.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.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.OnDisconnectButtonClick
import no.nordicsemi.android.csc.view.OnSelectedSpeedUnitSelected 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.csc.view.OnWheelSizeSelected
import no.nordicsemi.android.service.BleManagerStatus
import no.nordicsemi.android.utils.exhaustive import no.nordicsemi.android.utils.exhaustive
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
internal class CSCViewModel @Inject constructor( internal class CSCViewModel @Inject constructor(
private val dataHolder: CSCRepository private val repository: CSCRepository
) : ViewModel() { ) : 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) { fun onEvent(event: CSCViewEvent) {
when (event) { when (event) {
is OnSelectedSpeedUnitSelected -> onSelectedSpeedUnit(event) is OnSelectedSpeedUnitSelected -> onSelectedSpeedUnit(event)
OnShowEditWheelSizeDialogButtonClick -> onShowDialogEvent()
is OnWheelSizeSelected -> onWheelSizeChanged(event) is OnWheelSizeSelected -> onWheelSizeChanged(event)
OnDisconnectButtonClick -> onDisconnectButtonClick() OnDisconnectButtonClick -> onDisconnectButtonClick()
OnCloseSelectWheelSizeDialog -> onHideDialogEvent()
}.exhaustive }.exhaustive
} }
private fun onSelectedSpeedUnit(event: OnSelectedSpeedUnitSelected) { private fun onSelectedSpeedUnit(event: OnSelectedSpeedUnitSelected) {
dataHolder.setSpeedUnit(event.selectedSpeedUnit) repository.setSpeedUnit(event.selectedSpeedUnit)
}
private fun onShowDialogEvent() {
dataHolder.setDisplayWheelSizeDialog()
} }
private fun onWheelSizeChanged(event: OnWheelSizeSelected) { private fun onWheelSizeChanged(event: OnWheelSizeSelected) {
dataHolder.setWheelSize(event.wheelSize, event.wheelSizeDisplayInfo) repository.sendNewServiceCommand(SetWheelSizeCommand(event.wheelSize))
} }
private fun onDisconnectButtonClick() { private fun onDisconnectButtonClick() {
finish() repository.sendNewServiceCommand(DisconnectCommand)
dataHolder.clear() repository.clear()
} }
private fun onHideDialogEvent() { override fun onCleared() {
dataHolder.setHideWheelSizeDialog() super.onCleared()
repository.clear()
} }
} }

View File

@@ -1,13 +1,11 @@
package no.nordicsemi.dfu package no.nordicsemi.dfu
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4 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.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.junit.Assert.*
/** /**
* Instrumented test, which will execute on an Android device. * Instrumented test, which will execute on an Android device.
* *

View File

@@ -1,9 +1,13 @@
package no.nordicsemi.dfu.data package no.nordicsemi.dfu.data
import android.net.Uri import android.net.Uri
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import no.nordicsemi.android.service.BleManagerStatus
import no.nordicsemi.android.service.SelectedBluetoothDeviceHolder import no.nordicsemi.android.service.SelectedBluetoothDeviceHolder
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -17,6 +21,12 @@ internal class DFURepository @Inject constructor(
private val _data = MutableStateFlow<DFUData>(NoFileSelectedState()) private val _data = MutableStateFlow<DFUData>(NoFileSelectedState())
val data: StateFlow<DFUData> = _data.asStateFlow() 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) { fun setZipFile(file: Uri) {
val currentState = _data.value as NoFileSelectedState val currentState = _data.value as NoFileSelectedState
_data.value = fileManger.createFile(file)?.let { _data.value = fileManger.createFile(file)?.let {
@@ -36,7 +46,16 @@ internal class DFURepository @Inject constructor(
_data.value = FileInstallingState() _data.value = FileInstallingState()
} }
fun sendNewCommand(command: DisconnectCommand) {
_command.tryEmit(command)
}
fun setNewStatus(status: BleManagerStatus) {
_status.value = status
}
fun clear() { fun clear() {
_status.value = BleManagerStatus.CONNECTING
_data.value = NoFileSelectedState() _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.content.Context
import android.os.Build import android.os.Build
import androidx.annotation.RequiresApi 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.dfu.DfuBaseService
import no.nordicsemi.android.service.BleManagerStatus
import no.nordicsemi.android.service.CloseableCoroutineScope
import no.nordicsemi.dfu.R 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() { override fun onCreate() {
super.onCreate() super.onCreate()
@@ -39,6 +54,12 @@ class DFUService : DfuBaseService() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
createDfuNotificationChannel(this) createDfuNotificationChannel(this)
} }
repository.command.onEach {
stopSelf()
}.launchIn(scope)
repository.setNewStatus(BleManagerStatus.OK)
} }
override fun getNotificationTarget(): Class<out Activity?>? { override fun getNotificationTarget(): Class<out Activity?>? {
@@ -77,4 +98,10 @@ class DFUService : DfuBaseService() {
context.getSystemService(NOTIFICATION_SERVICE) as NotificationManager context.getSystemService(NOTIFICATION_SERVICE) as NotificationManager
notificationManager?.createNotificationChannel(channel) 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.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import no.nordicsemi.android.utils.exhaustive 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 @Composable
internal fun DFUContentView(state: DFUData, onEvent: (DFUViewEvent) -> Unit) { 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.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import no.nordicsemi.android.theme.view.BackIconAppBar 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.R
import no.nordicsemi.dfu.data.DFUData
import no.nordicsemi.dfu.repository.DFUService import no.nordicsemi.dfu.repository.DFUService
import no.nordicsemi.dfu.viewmodel.DFUViewModel import no.nordicsemi.dfu.viewmodel.DFUViewModel
@@ -19,36 +19,30 @@ import no.nordicsemi.dfu.viewmodel.DFUViewModel
fun DFUScreen(finishAction: () -> Unit) { fun DFUScreen(finishAction: () -> Unit) {
val viewModel: DFUViewModel = hiltViewModel() val viewModel: DFUViewModel = hiltViewModel()
val state = viewModel.state.collectAsState().value val state = viewModel.state.collectAsState().value
val isScreenActive = viewModel.isActive.collectAsState().value
val context = LocalContext.current val context = LocalContext.current
LaunchedEffect(isScreenActive) { LaunchedEffect(state.isActive) {
if (!isScreenActive) { if (state.isActive) {
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)) {
val intent = Intent(context, DFUService::class.java) val intent = Intent(context, DFUService::class.java)
context.startService(intent) context.startService(intent)
} else {
finishAction()
} }
} }
DFUView(state) { viewModel.onEvent(it) } DFUView(state.viewState) { viewModel.onEvent(it) }
} }
@Composable @Composable
private fun DFUView(state: DFUData, onEvent: (DFUViewEvent) -> Unit) { private fun DFUView(state: DFUViewState, onEvent: (DFUViewEvent) -> Unit) {
Column { Column {
BackIconAppBar(stringResource(id = R.string.dfu_title)) { BackIconAppBar(stringResource(id = R.string.dfu_title)) {
onEvent(OnDisconnectButtonClick) 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 package no.nordicsemi.dfu.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import no.nordicsemi.android.service.BleManagerStatus
import no.nordicsemi.android.service.SelectedBluetoothDeviceHolder import no.nordicsemi.android.service.SelectedBluetoothDeviceHolder
import no.nordicsemi.android.theme.viewmodel.CloseableViewModel
import no.nordicsemi.android.utils.exhaustive import no.nordicsemi.android.utils.exhaustive
import no.nordicsemi.dfu.data.Completed import no.nordicsemi.dfu.data.Completed
import no.nordicsemi.dfu.data.DFUManager import no.nordicsemi.dfu.data.DFUManager
import no.nordicsemi.dfu.data.DFUProgressManager import no.nordicsemi.dfu.data.DFUProgressManager
import no.nordicsemi.dfu.data.DFURepository import no.nordicsemi.dfu.data.DFURepository
import no.nordicsemi.dfu.data.DFUServiceStatus import no.nordicsemi.dfu.data.DFUServiceStatus
import no.nordicsemi.dfu.data.DisconnectCommand
import no.nordicsemi.dfu.data.Error import no.nordicsemi.dfu.data.Error
import no.nordicsemi.dfu.data.FileInstallingState import no.nordicsemi.dfu.data.FileInstallingState
import no.nordicsemi.dfu.data.FileReadyState import no.nordicsemi.dfu.data.FileReadyState
import no.nordicsemi.dfu.data.NoFileSelectedState
import no.nordicsemi.dfu.data.ZipFile import no.nordicsemi.dfu.data.ZipFile
import no.nordicsemi.dfu.view.DFUState
import no.nordicsemi.dfu.view.DFUViewEvent 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.OnDisconnectButtonClick
import no.nordicsemi.dfu.view.OnInstallButtonClick import no.nordicsemi.dfu.view.OnInstallButtonClick
import no.nordicsemi.dfu.view.OnPauseButtonClick import no.nordicsemi.dfu.view.OnPauseButtonClick
@@ -32,13 +36,19 @@ internal class DFUViewModel @Inject constructor(
private val progressManager: DFUProgressManager, private val progressManager: DFUProgressManager,
private val deviceHolder: SelectedBluetoothDeviceHolder, private val deviceHolder: SelectedBluetoothDeviceHolder,
private val dfuManager: DFUManager private val dfuManager: DFUManager
) : CloseableViewModel() { ) : ViewModel() {
val state = repository.data.combine(progressManager.status) { state, status -> val state = repository.data.combine(progressManager.status) { state, status ->
(state as? FileInstallingState) (state as? FileInstallingState)
?.run { createInstallingStateWithNewStatus(state, status) } ?.run { createInstallingStateWithNewStatus(state, status) }
?: state ?: 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 { init {
progressManager.registerListener() progressManager.registerListener()
@@ -58,9 +68,9 @@ internal class DFUViewModel @Inject constructor(
} }
private fun closeScreen() { private fun closeScreen() {
repository.sendNewCommand(DisconnectCommand)
repository.clear() repository.clear()
deviceHolder.forgetDevice() deviceHolder.forgetDevice()
finish()
} }
private fun requireFile(): ZipFile { private fun requireFile(): ZipFile {
@@ -82,6 +92,7 @@ internal class DFUViewModel @Inject constructor(
override fun onCleared() { override fun onCleared() {
super.onCleared() super.onCleared()
repository.clear()
progressManager.unregisterListener() progressManager.unregisterListener()
} }
} }

View File

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

View File

@@ -1,13 +1,11 @@
package no.nordicsemi.android.gls package no.nordicsemi.android.gls
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4 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.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.junit.Assert.*
/** /**
* Instrumented test, which will execute on an Android device. * 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import no.nordicsemi.android.service.BleManagerStatus
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -12,6 +13,9 @@ internal class GLSRepository @Inject constructor() {
private val _data = MutableStateFlow(GLSData()) private val _data = MutableStateFlow(GLSData())
val data: StateFlow<GLSData> = _data.asStateFlow() val data: StateFlow<GLSData> = _data.asStateFlow()
private val _status = MutableStateFlow(BleManagerStatus.CONNECTING)
val status = _status.asStateFlow()
fun addNewRecord(record: GLSRecord) { fun addNewRecord(record: GLSRecord) {
val newRecords = _data.value.records.toMutableList().apply { val newRecords = _data.value.records.toMutableList().apply {
add(record) add(record)
@@ -40,7 +44,12 @@ internal class GLSRepository @Inject constructor() {
_data.tryEmit(_data.value.copy(batteryLevel = batteryLevel)) _data.tryEmit(_data.value.copy(batteryLevel = batteryLevel))
} }
fun setNewStatus(status: BleManagerStatus) {
_status.value = status
}
fun clear() { fun clear() {
_status.value = BleManagerStatus.CONNECTING
_data.tryEmit(GLSData()) _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.RACPErrorCode
import no.nordicsemi.android.ble.common.profile.RecordAccessControlPointCallback.RACPOpCode 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.GlucoseMeasurementCallback.GlucoseStatus
import no.nordicsemi.android.ble.common.profile.glucose.GlucoseMeasurementContextCallback.* import no.nordicsemi.android.ble.common.profile.glucose.GlucoseMeasurementContextCallback.Carbohydrate
import no.nordicsemi.android.ble.data.Data import no.nordicsemi.android.ble.common.profile.glucose.GlucoseMeasurementContextCallback.Health
import no.nordicsemi.android.gls.data.* 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.CarbohydrateId
import no.nordicsemi.android.gls.data.ConcentrationUnit 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.HealthStatus
import no.nordicsemi.android.gls.data.MeasurementContext
import no.nordicsemi.android.gls.data.MedicationId import no.nordicsemi.android.gls.data.MedicationId
import no.nordicsemi.android.gls.data.MedicationUnit 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.TestType
import no.nordicsemi.android.gls.data.TypeOfMeal import no.nordicsemi.android.gls.data.TypeOfMeal
import no.nordicsemi.android.log.LogContract
import no.nordicsemi.android.service.BatteryManager import no.nordicsemi.android.service.BatteryManager
import java.util.* import java.util.*
import javax.inject.Inject import javax.inject.Inject
@@ -304,14 +310,7 @@ internal class GLSManager @Inject constructor(
writeCharacteristic( writeCharacteristic(
recordAccessControlPointCharacteristic, recordAccessControlPointCharacteristic,
RecordAccessControlPointData.reportLastStoredRecord() RecordAccessControlPointData.reportLastStoredRecord()
) ).enqueue()
.with { device: BluetoothDevice, data: Data ->
log(
LogContract.Log.Level.APPLICATION,
"\"" + GLSRecordAccessControlPointParser.parse(data) + "\" sent"
)
}
.enqueue()
} }
/** /**
@@ -327,14 +326,7 @@ internal class GLSManager @Inject constructor(
writeCharacteristic( writeCharacteristic(
recordAccessControlPointCharacteristic, recordAccessControlPointCharacteristic,
RecordAccessControlPointData.reportFirstStoredRecord() RecordAccessControlPointData.reportFirstStoredRecord()
) ).enqueue()
.with { device: BluetoothDevice, data: Data ->
log(
LogContract.Log.Level.APPLICATION,
"\"" + GLSRecordAccessControlPointParser.parse(data) + "\" sent"
)
}
.enqueue()
} }
/** /**
@@ -351,14 +343,7 @@ internal class GLSManager @Inject constructor(
writeCharacteristic( writeCharacteristic(
recordAccessControlPointCharacteristic, recordAccessControlPointCharacteristic,
RecordAccessControlPointData.reportNumberOfAllStoredRecords() RecordAccessControlPointData.reportNumberOfAllStoredRecords()
) ).enqueue()
.with { device: BluetoothDevice, data: Data ->
log(
LogContract.Log.Level.APPLICATION,
"\"" + GLSRecordAccessControlPointParser.parse(data) + "\" sent"
)
}
.enqueue()
} }
/** /**
@@ -385,14 +370,7 @@ internal class GLSManager @Inject constructor(
writeCharacteristic( writeCharacteristic(
recordAccessControlPointCharacteristic, recordAccessControlPointCharacteristic,
RecordAccessControlPointData.reportStoredRecordsGreaterThenOrEqualTo(sequenceNumber) RecordAccessControlPointData.reportStoredRecordsGreaterThenOrEqualTo(sequenceNumber)
) ).enqueue()
.with { device: BluetoothDevice, data: Data ->
log(
LogContract.Log.Level.APPLICATION,
"\"" + GLSRecordAccessControlPointParser.parse(data) + "\" sent"
)
}
.enqueue()
// Info: // Info:
// Operators OPERATOR_LESS_THEN_OR_EQUAL and OPERATOR_RANGE are not supported by Nordic Semiconductor Glucose Service in SDK 4.4.2. // 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( writeCharacteristic(
recordAccessControlPointCharacteristic, recordAccessControlPointCharacteristic,
RecordAccessControlPointData.abortOperation() RecordAccessControlPointData.abortOperation()
) ).enqueue()
.with { device: BluetoothDevice, data: Data ->
log(
LogContract.Log.Level.APPLICATION,
"\"" + GLSRecordAccessControlPointParser.parse(data) + "\" sent"
)
}
.enqueue()
} }
/** /**
@@ -429,14 +400,7 @@ internal class GLSManager @Inject constructor(
writeCharacteristic( writeCharacteristic(
recordAccessControlPointCharacteristic, recordAccessControlPointCharacteristic,
RecordAccessControlPointData.deleteAllStoredRecords() RecordAccessControlPointData.deleteAllStoredRecords()
) ).enqueue()
.with { device: BluetoothDevice, data: Data ->
log(
LogContract.Log.Level.APPLICATION,
"\"" + GLSRecordAccessControlPointParser.parse(data) + "\" sent"
)
}
.enqueue()
val elements = listOf(1, 2, 3) val elements = listOf(1, 2, 3)
val result = elements.all { it > 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 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.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons

View File

@@ -1,5 +1,6 @@
package no.nordicsemi.android.gls.view package no.nordicsemi.android.gls.view
import android.util.Log
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -7,40 +8,43 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import no.nordicsemi.android.gls.R 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.DisconnectEvent
import no.nordicsemi.android.gls.viewmodel.GLSScreenViewEvent import no.nordicsemi.android.gls.viewmodel.GLSScreenViewEvent
import no.nordicsemi.android.gls.viewmodel.GLSViewModel import no.nordicsemi.android.gls.viewmodel.GLSViewModel
import no.nordicsemi.android.theme.view.BackIconAppBar import no.nordicsemi.android.theme.view.BackIconAppBar
import no.nordicsemi.android.theme.view.DeviceConnectingView
import no.nordicsemi.android.utils.exhaustive
@Composable @Composable
fun GLSScreen(finishAction: () -> Unit) { fun GLSScreen(finishAction: () -> Unit) {
val viewModel: GLSViewModel = hiltViewModel() val viewModel: GLSViewModel = hiltViewModel()
val state = viewModel.state.collectAsState().value 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() viewModel.connectDevice()
} } else {
LaunchedEffect(isScreenActive) {
if (!isScreenActive) {
finishAction() finishAction()
} }
} }
GLSView(state) { GLSView(state.viewState) {
viewModel.onEvent(it) viewModel.onEvent(it)
} }
} }
@Composable @Composable
private fun GLSView(state: GLSData, onEvent: (GLSScreenViewEvent) -> Unit) { private fun GLSView(state: GLSViewState, onEvent: (GLSScreenViewEvent) -> Unit) {
Column { Column {
BackIconAppBar(stringResource(id = R.string.gls_title)) { BackIconAppBar(stringResource(id = R.string.gls_title)) {
onEvent(DisconnectEvent) 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 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 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.GLSRepository
import no.nordicsemi.android.gls.data.WorkingMode import no.nordicsemi.android.gls.data.WorkingMode
import no.nordicsemi.android.gls.repository.GLSManager 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.service.SelectedBluetoothDeviceHolder
import no.nordicsemi.android.theme.viewmodel.CloseableViewModel
import no.nordicsemi.android.utils.exhaustive import no.nordicsemi.android.utils.exhaustive
import javax.inject.Inject import javax.inject.Inject
@@ -13,10 +23,35 @@ import javax.inject.Inject
internal class GLSViewModel @Inject constructor( internal class GLSViewModel @Inject constructor(
private val glsManager: GLSManager, private val glsManager: GLSManager,
private val deviceHolder: SelectedBluetoothDeviceHolder, private val deviceHolder: SelectedBluetoothDeviceHolder,
private val dataHolder: GLSRepository private val repository: GLSRepository
) : CloseableViewModel() { ) : 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) { fun onEvent(event: GLSScreenViewEvent) {
when (event) { when (event) {
@@ -43,8 +78,12 @@ internal class GLSViewModel @Inject constructor(
} }
private fun disconnect() { private fun disconnect() {
finish()
deviceHolder.forgetDevice() 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 package no.nordicsemi.android.gls
import org.junit.Assert.assertEquals
import org.junit.Test import org.junit.Test
import org.junit.Assert.*
/** /**
* Example local unit test, which will execute on the development machine (host). * Example local unit test, which will execute on the development machine (host).
* *

View File

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

View File

@@ -1,7 +1,12 @@
package no.nordicsemi.android.hrs.data 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow 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.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -11,6 +16,12 @@ internal class HRSRepository @Inject constructor() {
private val _data = MutableStateFlow(HRSData()) private val _data = MutableStateFlow(HRSData())
val data: StateFlow<HRSData> = _data 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) { fun addNewHeartRate(heartRate: Int) {
val result = _data.value.heartRates.toMutableList().apply { val result = _data.value.heartRates.toMutableList().apply {
add(heartRate) add(heartRate)
@@ -26,7 +37,16 @@ internal class HRSRepository @Inject constructor() {
_data.tryEmit(_data.value.copy(batteryLevel = batteryLevel)) _data.tryEmit(_data.value.copy(batteryLevel = batteryLevel))
} }
fun sendDisconnectCommand() {
_command.tryEmit(DisconnectCommand)
}
fun setNewStatus(status: BleManagerStatus) {
_status.value = status
}
fun clear() { fun clear() {
_status.value = BleManagerStatus.CONNECTING
_data.tryEmit(HRSData()) _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.BodySensorLocationDataCallback
import no.nordicsemi.android.ble.common.callback.hr.HeartRateMeasurementDataCallback import no.nordicsemi.android.ble.common.callback.hr.HeartRateMeasurementDataCallback
import no.nordicsemi.android.ble.common.profile.hr.BodySensorLocation 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.hrs.data.HRSRepository
import no.nordicsemi.android.log.LogContract
import no.nordicsemi.android.service.BatteryManager import no.nordicsemi.android.service.BatteryManager
import java.util.* import java.util.*
@@ -53,11 +51,6 @@ internal class HRSManager(context: Context, private val dataHolder: HRSRepositor
private val bodySensorLocationDataCallback = object : BodySensorLocationDataCallback() { 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( override fun onBodySensorLocationReceived(
device: BluetoothDevice, device: BluetoothDevice,
@BodySensorLocation sensorLocation: Int @BodySensorLocation sensorLocation: Int
@@ -68,11 +61,6 @@ internal class HRSManager(context: Context, private val dataHolder: HRSRepositor
private val heartRateMeasurementDataCallback = object : HeartRateMeasurementDataCallback() { 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( override fun onHeartRateMeasurementReceived(
device: BluetoothDevice, device: BluetoothDevice,
@IntRange(from = 0) heartRate: Int, @IntRange(from = 0) heartRate: Int,

View File

@@ -1,6 +1,8 @@
package no.nordicsemi.android.hrs.service package no.nordicsemi.android.hrs.service
import dagger.hilt.android.AndroidEntryPoint 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.hrs.data.HRSRepository
import no.nordicsemi.android.service.ForegroundBleService import no.nordicsemi.android.service.ForegroundBleService
import javax.inject.Inject import javax.inject.Inject
@@ -9,7 +11,19 @@ import javax.inject.Inject
internal class HRSService : ForegroundBleService() { internal class HRSService : ForegroundBleService() {
@Inject @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.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import no.nordicsemi.android.hrs.R 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.service.HRSService
import no.nordicsemi.android.hrs.viewmodel.HRSViewModel import no.nordicsemi.android.hrs.viewmodel.HRSViewModel
import no.nordicsemi.android.theme.view.BackIconAppBar 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 @Composable
fun HRSScreen(finishAction: () -> Unit) { fun HRSScreen(finishAction: () -> Unit) {
val viewModel: HRSViewModel = hiltViewModel() val viewModel: HRSViewModel = hiltViewModel()
val state = viewModel.state.collectAsState().value val state = viewModel.state.collectAsState().value
val isActive = viewModel.isActive.collectAsState().value
val context = LocalContext.current val context = LocalContext.current
LaunchedEffect(isActive) { LaunchedEffect(state.isActive) {
if (!isActive) { if (state.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)) {
val intent = Intent(context, HRSService::class.java) val intent = Intent(context, HRSService::class.java)
context.startService(intent) context.startService(intent)
} else {
finishAction()
} }
} }
HRSView(state) { viewModel.onEvent(it) } HRSView(state.viewState) { viewModel.onEvent(it) }
} }
@Composable @Composable
private fun HRSView(state: HRSData, onEvent: (HRSScreenViewEvent) -> Unit) { private fun HRSView(state: HRSViewState, onEvent: (HRSScreenViewEvent) -> Unit) {
Column { Column {
BackIconAppBar(stringResource(id = R.string.hrs_title)) { BackIconAppBar(stringResource(id = R.string.hrs_title)) {
onEvent(DisconnectEvent) 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 package no.nordicsemi.android.hrs.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel 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.data.HRSRepository
import no.nordicsemi.android.hrs.view.DisconnectEvent 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.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 import javax.inject.Inject
@HiltViewModel @HiltViewModel
internal class HRSViewModel @Inject constructor( internal class HRSViewModel @Inject constructor(
private val dataHolder: HRSRepository private val repository: HRSRepository
) : CloseableViewModel() { ) : 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) { fun onEvent(event: HRSScreenViewEvent) {
(event as? DisconnectEvent)?.let { (event as? DisconnectEvent)?.let {
@@ -21,7 +35,12 @@ internal class HRSViewModel @Inject constructor(
} }
private fun onDisconnectButtonClick() { private fun onDisconnectButtonClick() {
finish() repository.sendDisconnectCommand()
dataHolder.clear() repository.clear()
}
override fun onCleared() {
super.onCleared()
repository.clear()
} }
} }

View File

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

View File

@@ -1,13 +1,11 @@
package no.nordicsemi.android.hts package no.nordicsemi.android.hts
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4 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.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.junit.Assert.*
/** /**
* Instrumented test, which will execute on an Android device. * Instrumented test, which will execute on an Android device.
* *

View File

@@ -1,7 +1,12 @@
package no.nordicsemi.android.hts.data 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow 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.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -11,6 +16,12 @@ internal class HTSRepository @Inject constructor() {
private val _data = MutableStateFlow(HTSData()) private val _data = MutableStateFlow(HTSData())
val data: StateFlow<HTSData> = _data 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) { fun setNewTemperature(temperature: Float) {
_data.tryEmit(_data.value.copy(temperatureValue = temperature)) _data.tryEmit(_data.value.copy(temperatureValue = temperature))
} }
@@ -23,7 +34,16 @@ internal class HTSRepository @Inject constructor() {
_data.tryEmit(_data.value.copy(temperatureUnit = unit)) _data.tryEmit(_data.value.copy(temperatureUnit = unit))
} }
fun sendDisconnectCommand() {
_command.tryEmit(DisconnectCommand)
}
fun setNewStatus(status: BleManagerStatus) {
_status.value = status
}
fun clear() { fun clear() {
_status.value = BleManagerStatus.CONNECTING
_data.tryEmit(HTSData()) _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.callback.ht.TemperatureMeasurementDataCallback
import no.nordicsemi.android.ble.common.profile.ht.TemperatureType import no.nordicsemi.android.ble.common.profile.ht.TemperatureType
import no.nordicsemi.android.ble.common.profile.ht.TemperatureUnit 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.hts.data.HTSRepository
import no.nordicsemi.android.log.LogContract
import no.nordicsemi.android.service.BatteryManager import no.nordicsemi.android.service.BatteryManager
import java.util.* import java.util.*
@@ -50,13 +48,6 @@ internal class HTSManager internal constructor(
private var htCharacteristic: BluetoothGattCharacteristic? = null private var htCharacteristic: BluetoothGattCharacteristic? = null
private val temperatureMeasurementDataCallback = object : TemperatureMeasurementDataCallback() { 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( override fun onTemperatureMeasurementReceived(
device: BluetoothDevice, device: BluetoothDevice,

View File

@@ -1,6 +1,8 @@
package no.nordicsemi.android.hts.repository package no.nordicsemi.android.hts.repository
import dagger.hilt.android.AndroidEntryPoint 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.hts.data.HTSRepository
import no.nordicsemi.android.service.ForegroundBleService import no.nordicsemi.android.service.ForegroundBleService
import javax.inject.Inject import javax.inject.Inject
@@ -9,7 +11,19 @@ import javax.inject.Inject
internal class HTSService : ForegroundBleService() { internal class HTSService : ForegroundBleService() {
@Inject @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 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.Button
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable 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.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import no.nordicsemi.android.hts.R 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.repository.HTSService
import no.nordicsemi.android.hts.viewmodel.HTSViewModel import no.nordicsemi.android.hts.viewmodel.HTSViewModel
import no.nordicsemi.android.theme.view.BackIconAppBar 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 @Composable
fun HTSScreen(finishAction: () -> Unit) { fun HTSScreen(finishAction: () -> Unit) {
val viewModel: HTSViewModel = hiltViewModel() val viewModel: HTSViewModel = hiltViewModel()
val state = viewModel.state.collectAsState().value val state = viewModel.state.collectAsState().value
val isActive = viewModel.isActive.collectAsState().value
val context = LocalContext.current val context = LocalContext.current
LaunchedEffect(isActive) { LaunchedEffect(state.isActive) {
if (!isActive) { if (state.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)) {
val intent = Intent(context, HTSService::class.java) val intent = Intent(context, HTSService::class.java)
context.startService(intent) context.startService(intent)
} else {
finishAction()
} }
} }
HTSView(state) { viewModel.onEvent(it) } HTSView(state.viewState) { viewModel.onEvent(it) }
} }
@Composable @Composable
private fun HTSView(state: HTSData, onEvent: (HTSScreenViewEvent) -> Unit) { private fun HTSView(state: HTSViewState, onEvent: (HTSScreenViewEvent) -> Unit) {
Column { Column {
BackIconAppBar(stringResource(id = R.string.hts_title)) { BackIconAppBar(stringResource(id = R.string.hts_title)) {
onEvent(DisconnectEvent) 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 package no.nordicsemi.android.hts.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel 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.data.HTSRepository
import no.nordicsemi.android.hts.view.DisconnectEvent 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.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.hts.view.OnTemperatureUnitSelected
import no.nordicsemi.android.theme.viewmodel.CloseableViewModel import no.nordicsemi.android.service.BleManagerStatus
import no.nordicsemi.android.utils.exhaustive import no.nordicsemi.android.utils.exhaustive
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
internal class HTSViewModel @Inject constructor( internal class HTSViewModel @Inject constructor(
private val dataHolder: HTSRepository private val repository: HTSRepository
) : CloseableViewModel() { ) : 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) { fun onEvent(event: HTSScreenViewEvent) {
when (event) { when (event) {
@@ -24,11 +38,16 @@ internal class HTSViewModel @Inject constructor(
} }
private fun onDisconnectButtonClick() { private fun onDisconnectButtonClick() {
finish() repository.sendDisconnectCommand()
dataHolder.clear() repository.clear()
} }
private fun onTemperatureUnitSelected(event: OnTemperatureUnitSelected) { 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 package no.nordicsemi.android.hts
import org.junit.Assert.assertEquals
import org.junit.Test import org.junit.Test
import org.junit.Assert.*
/** /**
* Example local unit test, which will execute on the development machine (host). * Example local unit test, which will execute on the development machine (host).
* *

View File

@@ -1,13 +1,11 @@
package no.nordicsemi.android.prx package no.nordicsemi.android.prx
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4 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.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.junit.Assert.*
/** /**
* Instrumented test, which will execute on an Android device. * 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 EnableAlarm : PRXCommand()
internal object DisableAlarm : 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import no.nordicsemi.android.service.BleManagerStatus
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -17,6 +19,9 @@ internal class PRXRepository @Inject constructor() {
private val _command = MutableSharedFlow<PRXCommand>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) private val _command = MutableSharedFlow<PRXCommand>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
val command = _command.asSharedFlow() val command = _command.asSharedFlow()
private val _status = MutableStateFlow(BleManagerStatus.CONNECTING)
val status = _status.asStateFlow()
fun setBatteryLevel(batteryLevel: Int) { fun setBatteryLevel(batteryLevel: Int) {
_data.tryEmit(_data.value.copy(batteryLevel = batteryLevel)) _data.tryEmit(_data.value.copy(batteryLevel = batteryLevel))
} }
@@ -34,7 +39,12 @@ internal class PRXRepository @Inject constructor() {
_command.tryEmit(command) _command.tryEmit(command)
} }
fun clear(){ fun setNewStatus(status: BleManagerStatus) {
_status.value = status
}
fun clear() {
_status.value = BleManagerStatus.CONNECTING
_data.tryEmit(PRXData()) _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.callback.FailCallback
import no.nordicsemi.android.ble.common.callback.alert.AlertLevelDataCallback import no.nordicsemi.android.ble.common.callback.alert.AlertLevelDataCallback
import no.nordicsemi.android.ble.common.data.alert.AlertLevelData 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.ble.error.GattError
import no.nordicsemi.android.log.LogContract
import no.nordicsemi.android.prx.data.PRXRepository import no.nordicsemi.android.prx.data.PRXRepository
import no.nordicsemi.android.service.BatteryManager import no.nordicsemi.android.service.BatteryManager
import java.util.* import java.util.*
@@ -161,12 +159,6 @@ internal class PRXManager(
if (on) "Setting alarm to HIGH..." else "Disabling alarm..." 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? -> .done { device: BluetoothDevice? ->
isAlertEnabled = on isAlertEnabled = on
dataHolder.setRemoteAlarmLevel(on) dataHolder.setRemoteAlarmLevel(on)

View File

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

View File

@@ -8,55 +8,42 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import no.nordicsemi.android.prx.R 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.service.PRXService
import no.nordicsemi.android.prx.viewmodel.PRXViewModel import no.nordicsemi.android.prx.viewmodel.PRXViewModel
import no.nordicsemi.android.theme.view.BackIconAppBar 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 @Composable
fun PRXScreen(finishAction: () -> Unit) { fun PRXScreen(finishAction: () -> Unit) {
val viewModel: PRXViewModel = hiltViewModel() val viewModel: PRXViewModel = hiltViewModel()
val state = viewModel.state.collectAsState().value val state = viewModel.state.collectAsState().value
val isActive = viewModel.isActive.collectAsState().value
val context = LocalContext.current val context = LocalContext.current
LaunchedEffect(isActive) { LaunchedEffect(state.isActive) {
if (!isActive) { if (state.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)) {
val intent = Intent(context, PRXService::class.java) val intent = Intent(context, PRXService::class.java)
context.startService(intent) context.startService(intent)
} else {
finishAction()
} }
} }
PRXView(state) { viewModel.onEvent(it) } PRXView(state.viewState) { viewModel.onEvent(it) }
} }
@Composable @Composable
private fun PRXView(state: PRXData, onEvent: (PRXScreenViewEvent) -> Unit) { private fun PRXView(state: PRXViewState, onEvent: (PRXScreenViewEvent) -> Unit) {
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
BackIconAppBar(stringResource(id = R.string.prx_title)) { BackIconAppBar(stringResource(id = R.string.prx_title)) {
onEvent(DisconnectEvent) 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 package no.nordicsemi.android.prx.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel 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.DisableAlarm
import no.nordicsemi.android.prx.data.Disconnect
import no.nordicsemi.android.prx.data.EnableAlarm import no.nordicsemi.android.prx.data.EnableAlarm
import no.nordicsemi.android.prx.data.PRXRepository import no.nordicsemi.android.prx.data.PRXRepository
import no.nordicsemi.android.prx.view.DisconnectEvent 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.PRXScreenViewEvent
import no.nordicsemi.android.prx.view.PRXState
import no.nordicsemi.android.prx.view.TurnOffAlert import no.nordicsemi.android.prx.view.TurnOffAlert
import no.nordicsemi.android.prx.view.TurnOnAlert 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 no.nordicsemi.android.utils.exhaustive
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
internal class PRXViewModel @Inject constructor( internal class PRXViewModel @Inject constructor(
private val dataHolder: PRXRepository private val repository: PRXRepository
) : CloseableViewModel() { ) : 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) { fun onEvent(event: PRXScreenViewEvent) {
when (event) { when (event) {
DisconnectEvent -> onDisconnectButtonClick() DisconnectEvent -> onDisconnectButtonClick()
TurnOffAlert -> dataHolder.invokeCommand(DisableAlarm) TurnOffAlert -> repository.invokeCommand(DisableAlarm)
TurnOnAlert -> dataHolder.invokeCommand(EnableAlarm) TurnOnAlert -> repository.invokeCommand(EnableAlarm)
}.exhaustive }.exhaustive
} }
private fun onDisconnectButtonClick() { private fun onDisconnectButtonClick() {
finish() repository.invokeCommand(Disconnect)
dataHolder.clear() repository.clear()
}
override fun onCleared() {
super.onCleared()
repository.clear()
} }
} }

View File

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

View File

@@ -1,13 +1,11 @@
package no.nordicsemi.android.rscs package no.nordicsemi.android.rscs
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4 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.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.junit.Assert.*
/** /**
* Instrumented test, which will execute on an Android device. * Instrumented test, which will execute on an Android device.
* *

View File

@@ -1,7 +1,12 @@
package no.nordicsemi.android.rscs.data 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow 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.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -9,7 +14,13 @@ import javax.inject.Singleton
internal class RSCSRepository @Inject constructor() { internal class RSCSRepository @Inject constructor() {
private val _data = MutableStateFlow(RSCSData()) 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( fun setNewData(
running: Boolean, running: Boolean,
@@ -27,11 +38,20 @@ internal class RSCSRepository @Inject constructor() {
)) ))
} }
fun setNewStatus(status: BleManagerStatus) {
_status.value = status
}
fun setBatteryLevel(batteryLevel: Int) { fun setBatteryLevel(batteryLevel: Int) {
_data.tryEmit(_data.value.copy(batteryLevel = batteryLevel)) _data.tryEmit(_data.value.copy(batteryLevel = batteryLevel))
} }
fun sendDisconnectCommand() {
_command.tryEmit(DisconnectCommand)
}
fun clear() { fun clear() {
_status.value = BleManagerStatus.CONNECTING
_data.tryEmit(RSCSData()) _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 package no.nordicsemi.android.rscs.service
import dagger.hilt.android.AndroidEntryPoint 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.rscs.data.RSCSRepository
import no.nordicsemi.android.service.ForegroundBleService import no.nordicsemi.android.service.ForegroundBleService
import javax.inject.Inject import javax.inject.Inject
@@ -9,7 +11,19 @@ import javax.inject.Inject
internal class RSCSService : ForegroundBleService() { internal class RSCSService : ForegroundBleService() {
@Inject @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.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import no.nordicsemi.android.rscs.R 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.service.RSCSService
import no.nordicsemi.android.rscs.viewmodel.RSCSViewModel import no.nordicsemi.android.rscs.viewmodel.RSCSViewModel
import no.nordicsemi.android.theme.view.BackIconAppBar 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 @Composable
fun RSCSScreen(finishAction: () -> Unit) { fun RSCSScreen(finishAction: () -> Unit) {
val viewModel: RSCSViewModel = hiltViewModel() val viewModel: RSCSViewModel = hiltViewModel()
val state = viewModel.state.collectAsState().value val state = viewModel.state.collectAsState().value
val isScreenActive = viewModel.isActive.collectAsState().value
val context = LocalContext.current val context = LocalContext.current
LaunchedEffect(isScreenActive) { LaunchedEffect(state.isActive) {
if (!isScreenActive) { if (state.isActive) {
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)) {
val intent = Intent(context, RSCSService::class.java) val intent = Intent(context, RSCSService::class.java)
context.startService(intent) context.startService(intent)
} else {
finishAction()
} }
} }
RSCSView(state) { viewModel.onEvent(it) } RSCSView(state.viewState) { viewModel.onEvent(it) }
} }
@Composable @Composable
private fun RSCSView(state: RSCSData, onEvent: (RSCScreenViewEvent) -> Unit) { private fun RSCSView(state: RSCSViewState, onEvent: (RSCScreenViewEvent) -> Unit) {
Column { Column {
BackIconAppBar(stringResource(id = R.string.rscs_title)) { BackIconAppBar(stringResource(id = R.string.rscs_title)) {
onEvent(DisconnectEvent) 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 package no.nordicsemi.android.rscs.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel 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.data.RSCSRepository
import no.nordicsemi.android.rscs.view.DisconnectEvent 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.rscs.view.RSCScreenViewEvent
import no.nordicsemi.android.theme.viewmodel.CloseableViewModel import no.nordicsemi.android.service.BleManagerStatus
import no.nordicsemi.android.utils.exhaustive import no.nordicsemi.android.utils.exhaustive
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
internal class RSCSViewModel @Inject constructor( internal class RSCSViewModel @Inject constructor(
private val dataHolder: RSCSRepository private val repository: RSCSRepository
) : CloseableViewModel() { ) : 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) { fun onEvent(event: RSCScreenViewEvent) {
when (event) { when (event) {
@@ -22,7 +36,12 @@ internal class RSCSViewModel @Inject constructor(
} }
private fun onDisconnectButtonClick() { private fun onDisconnectButtonClick() {
finish() repository.sendDisconnectCommand()
dataHolder.clear() repository.clear()
}
override fun onCleared() {
super.onCleared()
repository.clear()
} }
} }

View File

@@ -1,9 +1,8 @@
package no.nordicsemi.android.rscs package no.nordicsemi.android.rscs
import org.junit.Assert.assertEquals
import org.junit.Test import org.junit.Test
import org.junit.Assert.*
/** /**
* Example local unit test, which will execute on the development machine (host). * 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