Add GLS feature

This commit is contained in:
Sylwester Zieliński
2021-09-30 13:37:45 +02:00
parent 7a171a1402
commit b2da2f20eb
37 changed files with 1036 additions and 102 deletions

View File

@@ -52,6 +52,7 @@ dependencies {
//https://github.com/google/dagger/issues/2123
implementation project(":feature_csc")
implementation project(":feature_hrs")
implementation project(":feature_gls")
implementation project(':feature_scanner')
implementation project(":lib_theme")
implementation project(":lib_utils")

View File

@@ -20,6 +20,7 @@ import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import no.nordicsemi.android.csc.view.CscScreen
import no.nordicsemi.android.gls.view.GLSScreen
import no.nordicsemi.android.hrs.view.HRSScreen
import no.nordicsemi.android.scanner.view.BluetoothNotAvailableScreen
import no.nordicsemi.android.scanner.view.BluetoothNotEnabledScreen
@@ -29,7 +30,7 @@ import no.nordicsemi.android.scanner.view.ScanDeviceScreenResult
import no.nordicsemi.android.utils.exhaustive
@Composable
fun HomeScreen() {
internal fun HomeScreen() {
val navController = rememberNavController()
val viewModel = hiltViewModel<NavigationViewModel>()
@@ -42,6 +43,7 @@ fun HomeScreen() {
composable(NavDestination.HOME.id) { HomeView { viewModel.navigate(it) } }
composable(NavDestination.CSC.id) { CscScreen { viewModel.navigateUp() } }
composable(NavDestination.HRS.id) { HRSScreen { viewModel.navigateUp() } }
composable(NavDestination.GLS.id) { GLSScreen { viewModel.navigateUp() } }
composable(NavDestination.REQUEST_PERMISSION.id) { RequestPermissionScreen(continueAction) }
composable(NavDestination.BLUETOOTH_NOT_AVAILABLE.id) { BluetoothNotAvailableScreen() }
composable(NavDestination.BLUETOOTH_NOT_ENABLED.id) {
@@ -69,6 +71,7 @@ fun HomeView(callback: (NavDestination) -> Unit) {
FeatureButton(R.drawable.ic_csc, R.string.csc_module) { callback(NavDestination.CSC) }
FeatureButton(R.drawable.ic_hrs, R.string.hrs_module) { callback(NavDestination.HRS) }
FeatureButton(R.drawable.ic_gls, R.string.gls_module) { callback(NavDestination.GLS) }
}
}

View File

@@ -4,6 +4,7 @@ enum class NavDestination(val id: String) {
HOME("home-screen"),
CSC("csc-screen"),
HRS("hrs-screen"),
GLS("gls-screen"),
REQUEST_PERMISSION("request-permission"),
BLUETOOTH_NOT_AVAILABLE("bluetooth-not-available"),
BLUETOOTH_NOT_ENABLED("bluetooth-not-enabled"),

View File

@@ -14,7 +14,7 @@ import javax.inject.Inject
class NavigationViewModel @Inject constructor(
private val bleScanner: NordicBleScanner,
private val permissionHelper: PermissionHelper,
private val selectedDevice: no.nordicsemi.android.service.SelectedBluetoothDeviceHolder
private val selectedDevice: SelectedBluetoothDeviceHolder
): ViewModel() {
val state= MutableStateFlow(NavDestination.HOME)

View File

@@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="80dp"
android:height="80dp"
android:viewportWidth="1024"
android:viewportHeight="1024">
<path
android:fillColor="#00B3DC"
android:pathData="M386.1,310.1c0,-21.9 -5.5,-43.7 -15.9,-63c-0.2,-0.4 -0.5,-0.9 -0.7,-1.3c-2.4,-4.3 -5,-8.4 -7.8,-12.4C311,152.8 276,96.7 271.7,89.2c-4.3,-8.4 -12.5,-14.3 -22.1,-15.4c-11,-1.3 -21.7,3.9 -27.5,13.3L133,230.6c-4.8,6.4 -8.9,13.2 -12.5,20.3c-0.1,0.2 -0.2,0.4 -0.3,0.7c-9.1,18.4 -13.6,38.1 -13.6,58.6c0,74.9 62.7,135.9 139.8,135.9S386.1,385 386.1,310.1zM163.5,310.1c0,-11.8 2.7,-23.1 7.9,-33.6c0,0 0,0 0,-0.1c2.2,-4.3 4.8,-8.6 7.8,-12.5c0.5,-0.7 1,-1.4 1.5,-2.2l65.4,-105.3c4,6.3 8.5,13.6 13.8,22.1c19.9,31.8 42.3,67.5 53.9,85.9c0.3,0.5 0.6,1 0.9,1.4c1.8,2.5 3.5,5.2 5,7.9c0,0 0,0 0,0c0,0 0,0.1 0,0.1c6.2,11.3 9.3,23.5 9.3,36.3c0,43.5 -37.1,78.9 -82.8,78.9S163.5,353.6 163.5,310.1z" />
<path
android:fillColor="#00B3DC"
android:pathData="M975.7,103c-50.2,-48.2 -130.2,-46.5 -178.4,3.7L228.8,699.8c-10.9,11.4 -10.5,29.4 0.8,40.3l49.9,47.9c-1.7,1.2 -3.4,2.7 -4.8,4.3l-91.4,102.9c-10.5,11.8 -9.4,29.8 2.4,40.2c5.4,4.8 12.2,7.2 18.9,7.2c7.9,0 15.7,-3.2 21.3,-9.6l91.4,-102.9c1,-1.1 1.8,-2.2 2.6,-3.4l50.7,48.7c5.3,5.1 12.4,7.9 19.7,7.9c0.2,0 0.4,0 0.6,0c7.6,-0.2 14.8,-3.3 20,-8.8l568.4,-593c0,0 0.1,-0.1 0.1,-0.1C1027.6,231.2 1025.9,151.2 975.7,103zM938.3,241.9C938.3,241.9 938.3,241.9 938.3,241.9L389.5,814.5l-99.8,-95.8l548.8,-572.5c26.4,-27.5 70.3,-28.4 97.8,-2C963.8,170.5 964.7,214.3 938.3,241.9z" />
</vector>

View File

@@ -1,4 +1,9 @@
<vector android:height="80dp" android:viewportHeight="1024"
android:viewportWidth="1024" android:width="80dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#00B3DC" android:pathData="M863.6,210.5c-42.2,-42.2 -98.4,-65.5 -158.2,-65.5c-57.2,0 -111.7,21.6 -153.3,60.8c-16.9,16 -31.1,34.2 -42.2,54c-11.1,-19.9 -25.2,-38.1 -42.2,-54c-41.6,-39.2 -96.1,-60.8 -153.3,-60.8c-59.7,0 -115.9,23.3 -158.2,65.5c-42.2,42.2 -65.5,98.4 -65.5,158.2c0,41.7 11.5,82.3 33.4,117.6c0.3,0.5 0.6,1.1 1,1.6C200,607.1 476.7,896 488.4,908.3c5.6,5.8 13.1,8.8 20.6,8.8c0.3,0 0.6,0 0.9,0c0.3,0 0.6,0 0.9,0c7.5,0 15,-2.9 20.6,-8.8c11.8,-12.3 288.5,-301.2 363.3,-420.5c0.3,-0.5 0.7,-1 1,-1.5c21.9,-35.3 33.4,-76 33.4,-117.6C929.1,308.9 905.9,252.7 863.6,210.5zM846.9,456.9C846.8,456.9 846.8,456.9 846.9,456.9c-0.1,0.1 -0.1,0.1 -0.1,0.1c-60.1,96.2 -271.2,321.8 -336.9,391.2c-65.7,-69.4 -276.8,-295 -336.8,-391.2c0,0 0,0 0,0c0,0 0,-0.1 -0.1,-0.1c-16.5,-26.4 -25.2,-56.9 -25.2,-88.2c0,-91.9 74.8,-166.7 166.7,-166.7c87.7,0 160.7,68.5 166.3,155.9c1,15.3 13.9,27.1 29.2,26.7c15.3,0.4 28.2,-11.3 29.2,-26.7c5.6,-87.4 78.6,-155.9 166.3,-155.9c91.9,0 166.7,74.8 166.7,166.7C872.1,399.9 863.4,430.4 846.9,456.9z"/>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="80dp"
android:height="80dp"
android:viewportWidth="1024"
android:viewportHeight="1024">
<path
android:fillColor="#00B3DC"
android:pathData="M863.6,210.5c-42.2,-42.2 -98.4,-65.5 -158.2,-65.5c-57.2,0 -111.7,21.6 -153.3,60.8c-16.9,16 -31.1,34.2 -42.2,54c-11.1,-19.9 -25.2,-38.1 -42.2,-54c-41.6,-39.2 -96.1,-60.8 -153.3,-60.8c-59.7,0 -115.9,23.3 -158.2,65.5c-42.2,42.2 -65.5,98.4 -65.5,158.2c0,41.7 11.5,82.3 33.4,117.6c0.3,0.5 0.6,1.1 1,1.6C200,607.1 476.7,896 488.4,908.3c5.6,5.8 13.1,8.8 20.6,8.8c0.3,0 0.6,0 0.9,0c0.3,0 0.6,0 0.9,0c7.5,0 15,-2.9 20.6,-8.8c11.8,-12.3 288.5,-301.2 363.3,-420.5c0.3,-0.5 0.7,-1 1,-1.5c21.9,-35.3 33.4,-76 33.4,-117.6C929.1,308.9 905.9,252.7 863.6,210.5zM846.9,456.9C846.8,456.9 846.8,456.9 846.9,456.9c-0.1,0.1 -0.1,0.1 -0.1,0.1c-60.1,96.2 -271.2,321.8 -336.9,391.2c-65.7,-69.4 -276.8,-295 -336.8,-391.2c0,0 0,0 0,0c0,0 0,-0.1 -0.1,-0.1c-16.5,-26.4 -25.2,-56.9 -25.2,-88.2c0,-91.9 74.8,-166.7 166.7,-166.7c87.7,0 160.7,68.5 166.3,155.9c1,15.3 13.9,27.1 29.2,26.7c15.3,0.4 28.2,-11.3 29.2,-26.7c5.6,-87.4 78.6,-155.9 166.3,-155.9c91.9,0 166.7,74.8 166.7,166.7C872.1,399.9 863.4,430.4 846.9,456.9z" />
</vector>

View File

@@ -1,4 +1,5 @@
<resources>
<string name="csc_module">CSC</string>
<string name="hrs_module">HRS</string>
<string name="gls_module">GLS</string>
</resources>

View File

@@ -1,10 +1,10 @@
package no.nordicsemi.android.csc.viewmodel
package no.nordicsemi.android.csc.data
import no.nordicsemi.android.csc.view.CSCSettings
import no.nordicsemi.android.csc.view.SpeedUnit
import java.util.*
internal data class CSCViewState(
internal data class CSCData(
val showDialog: Boolean = false,
val scanDevices: Boolean = false,
val selectedSpeedUnit: SpeedUnit = SpeedUnit.M_S,

View File

@@ -1,28 +0,0 @@
package no.nordicsemi.android.csc.events
import android.bluetooth.BluetoothDevice
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
internal sealed class CSCServiceEvent : Parcelable
@Parcelize
internal data class OnDistanceChangedEvent(
val bluetoothDevice: BluetoothDevice,
val speed: Float,
val distance: Float,
val totalDistance: Float
) : CSCServiceEvent()
@Parcelize
internal data class CrankDataChanged(
val bluetoothDevice: BluetoothDevice,
val crankCadence: Int,
val gearRatio: Float
) : CSCServiceEvent()
@Parcelize
internal data class OnBatteryLevelChanged(
val device: BluetoothDevice,
val batteryLevel: Int
) : CSCServiceEvent()

View File

@@ -3,13 +3,13 @@ package no.nordicsemi.android.csc.service
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import no.nordicsemi.android.csc.events.CSCServiceEvent
import no.nordicsemi.android.csc.data.CSCData
import no.nordicsemi.android.service.BluetoothDataReadBroadcast
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
internal class CSCDataReadBroadcast @Inject constructor() : BluetoothDataReadBroadcast<CSCServiceEvent>() {
internal class CSCDataReadBroadcast @Inject constructor() : BluetoothDataReadBroadcast<CSCData>() {
private val _wheelSize = MutableSharedFlow<Int>(
replay = 1,

View File

@@ -5,9 +5,7 @@ import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import no.nordicsemi.android.csc.events.CrankDataChanged
import no.nordicsemi.android.csc.events.OnBatteryLevelChanged
import no.nordicsemi.android.csc.events.OnDistanceChangedEvent
import no.nordicsemi.android.csc.data.CSCData
import no.nordicsemi.android.service.ForegroundBleService
import no.nordicsemi.android.service.LoggableBleManager
import javax.inject.Inject
@@ -15,6 +13,8 @@ import javax.inject.Inject
@AndroidEntryPoint
internal class CSCService : ForegroundBleService<CSCManager>(), CSCManagerCallbacks {
private var data = CSCData()
@Inject
lateinit var localBroadcast: CSCDataReadBroadcast
@@ -42,7 +42,7 @@ internal class CSCService : ForegroundBleService<CSCManager>(), CSCManagerCallba
distance: Float,
speed: Float
) {
localBroadcast.offer(OnDistanceChangedEvent(bluetoothDevice, speed, distance, totalDistance))
localBroadcast.offer(data.copy(speed = speed, distance = distance, totalDistance = totalDistance))
}
override fun onCrankDataChanged(
@@ -50,10 +50,10 @@ internal class CSCService : ForegroundBleService<CSCManager>(), CSCManagerCallba
crankCadence: Float,
gearRatio: Float
) {
localBroadcast.offer(CrankDataChanged(bluetoothDevice, crankCadence.toInt(), gearRatio))
localBroadcast.offer(data.copy(cadence = crankCadence.toInt(), gearRatio = gearRatio))
}
override fun onBatteryLevelChanged(device: BluetoothDevice, batteryLevel: Int) {
localBroadcast.offer(OnBatteryLevelChanged(bluetoothDevice, batteryLevel))
localBroadcast.offer(data.copy(batteryLevel = batteryLevel))
}
}

View File

@@ -17,11 +17,11 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import no.nordicsemi.android.csc.R
import no.nordicsemi.android.csc.viewmodel.CSCViewState
import no.nordicsemi.android.csc.data.CSCData
import no.nordicsemi.android.theme.NordicColors
@Composable
internal fun ContentView(state: CSCViewState, onEvent: (CSCViewEvent) -> Unit) {
internal fun ContentView(state: CSCData, onEvent: (CSCViewEvent) -> Unit) {
if (state.showDialog) {
SelectWheelSizeDialog { onEvent(it) }
}
@@ -48,7 +48,7 @@ internal fun ContentView(state: CSCViewState, onEvent: (CSCViewEvent) -> Unit) {
}
@Composable
private fun SettingsSection(state: CSCViewState, onEvent: (CSCViewEvent) -> Unit) {
private fun SettingsSection(state: CSCData, onEvent: (CSCViewEvent) -> Unit) {
Card(
backgroundColor = NordicColors.NordicGray4.value(),
shape = RoundedCornerShape(10.dp),
@@ -70,5 +70,5 @@ private fun SettingsSection(state: CSCViewState, onEvent: (CSCViewEvent) -> Unit
@Preview
@Composable
private fun ConnectedPreview() {
ContentView(CSCViewState()) { }
ContentView(CSCData()) { }
}

View File

@@ -12,7 +12,7 @@ import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel
import no.nordicsemi.android.csc.R
import no.nordicsemi.android.csc.service.CSCService
import no.nordicsemi.android.csc.viewmodel.CSCViewState
import no.nordicsemi.android.csc.data.CSCData
import no.nordicsemi.android.csc.viewmodel.CscViewModel
import no.nordicsemi.android.utils.isServiceRunning
@@ -43,7 +43,7 @@ fun CscScreen(finishAction: () -> Unit) {
}
@Composable
private fun CSCView(state: CSCViewState, onEvent: (CSCViewEvent) -> Unit) {
private fun CSCView(state: CSCData, onEvent: (CSCViewEvent) -> Unit) {
Column {
TopAppBar(title = { Text(text = stringResource(id = R.string.csc_title)) })

View File

@@ -12,13 +12,13 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import no.nordicsemi.android.csc.R
import no.nordicsemi.android.csc.viewmodel.CSCViewState
import no.nordicsemi.android.csc.data.CSCData
import no.nordicsemi.android.theme.NordicColors
import no.nordicsemi.android.theme.view.BatteryLevelView
import no.nordicsemi.android.theme.view.KeyValueField
@Composable
internal fun SensorsReadingView(state: CSCViewState) {
internal fun SensorsReadingView(state: CSCData) {
Card(
backgroundColor = NordicColors.NordicGray4.value(),
shape = RoundedCornerShape(10.dp),
@@ -48,5 +48,5 @@ internal fun SensorsReadingView(state: CSCViewState) {
@Preview
@Composable
private fun Preview() {
SensorsReadingView(CSCViewState())
SensorsReadingView(CSCData())
}

View File

@@ -12,10 +12,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import no.nordicsemi.android.csc.R
import no.nordicsemi.android.csc.viewmodel.CSCViewState
import no.nordicsemi.android.csc.data.CSCData
@Composable
internal fun WheelSizeView(state: CSCViewState, onEvent: (CSCViewEvent) -> Unit) {
internal fun WheelSizeView(state: CSCData, onEvent: (CSCViewEvent) -> Unit) {
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = state.wheelSize,
@@ -36,5 +36,5 @@ private fun EditIcon(onEvent: (CSCViewEvent) -> Unit) {
@Preview
@Composable
private fun WheelSizeViewPreview() {
WheelSizeView(CSCViewState()) { }
WheelSizeView(CSCData()) { }
}

View File

@@ -8,10 +8,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.withContext
import no.nordicsemi.android.csc.events.CSCServiceEvent
import no.nordicsemi.android.csc.events.CrankDataChanged
import no.nordicsemi.android.csc.events.OnBatteryLevelChanged
import no.nordicsemi.android.csc.events.OnDistanceChangedEvent
import no.nordicsemi.android.csc.data.CSCData
import no.nordicsemi.android.csc.service.CSCDataReadBroadcast
import no.nordicsemi.android.csc.view.CSCViewEvent
import no.nordicsemi.android.csc.view.OnDisconnectButtonClick
@@ -26,44 +23,14 @@ internal class CscViewModel @Inject constructor(
private val localBroadcast: CSCDataReadBroadcast
) : ViewModel() {
val state = MutableStateFlow(CSCViewState())
val state = MutableStateFlow(CSCData())
init {
localBroadcast.events.onEach {
withContext(Dispatchers.Main) { consumeEvent(it) }
withContext(Dispatchers.Main) { state.value = it }
}.launchIn(viewModelScope)
}
private fun consumeEvent(event: CSCServiceEvent) {
val newValue = when (event) {
is CrankDataChanged -> createNewState(event)
is OnBatteryLevelChanged -> createNewState(event)
is OnDistanceChangedEvent -> createNewState(event)
}
state.value = newValue
}
private fun createNewState(event: CrankDataChanged): CSCViewState {
return state.value.copy(
cadence = event.crankCadence,
gearRatio = event.gearRatio
)
}
private fun createNewState(event: OnBatteryLevelChanged): CSCViewState {
return state.value.copy(
batteryLevel = event.batteryLevel
)
}
private fun createNewState(event: OnDistanceChangedEvent): CSCViewState {
return state.value.copy(
speed = event.speed,
distance = event.distance,
totalDistance = event.totalDistance
)
}
fun onEvent(event: CSCViewEvent) {
when (event) {
is OnSelectedSpeedUnitSelected -> onSelectedSpeedUnit(event)

28
feature_gls/build.gradle Normal file
View File

@@ -0,0 +1,28 @@
apply from: rootProject.file("library.gradle")
apply plugin: 'kotlin-parcelize'
dependencies {
implementation project(":lib_service")
implementation project(":lib_theme")
implementation project(":lib_utils")
implementation libs.chart
implementation libs.nordic.ble.common
implementation libs.nordic.log
implementation libs.bundles.compose
implementation libs.androidx.core
implementation libs.material
implementation libs.lifecycle.activity
implementation libs.lifecycle.service
implementation libs.compose.lifecycle
implementation libs.compose.activity
testImplementation libs.test.junit
androidTestImplementation libs.android.test.junit
androidTestImplementation libs.android.test.espresso
androidTestImplementation libs.android.test.compose.ui
debugImplementation libs.android.test.compose.tooling
}

View File

@@ -0,0 +1,24 @@
package no.nordicsemi.android.gls
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("no.nordicsemi.android.gls.test", appContext.packageName)
}
}

View File

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

View File

@@ -0,0 +1,11 @@
package no.nordicsemi.android.gls.data
internal data class GLSData(
val record: List<GLSRecord> = emptyList(),
val batteryLevel: Int = 0,
val requestStatus: RequestStatus = RequestStatus.IDLE
)
internal enum class RequestStatus {
IDLE, PENDING, SUCCESS, ABORTED, FAILED, NOT_SUPPORTED
}

View File

@@ -0,0 +1,186 @@
/*
* 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.data
import java.util.*
internal data class GLSRecord(
/** Record sequence number */
val sequenceNumber: Int = 0,
/** The base time of the measurement */
val time: Calendar? = null,
/** The glucose concentration. 0 if not present */
val glucoseConcentration: Float = 0f,
/** Concentration unit. One of the following: [GLSRecord.UNIT_kgpl], [GLSRecord.UNIT_molpl] */
val unit: ConcentrationUnit = ConcentrationUnit.UNIT_KGPL,
/** The type of the record. 0 if not present */
val type: Int = 0,
/** The sample location. 0 if unknown */
val sampleLocation: Int = 0,
/** Sensor status annunciation flags. 0 if not present */
val status: Int = 0,
var context: MeasurementContext? = null
)
internal data class MeasurementContext(
val carbohydrateId: CarbohydrateId = CarbohydrateId.NOT_PRESENT,
/** Number of kilograms of carbohydrate */
val carbohydrateUnits: Float = 0f,
val meal: TypeOfMeal = TypeOfMeal.NOT_PRESENT,
val tester: TestType = TestType.NOT_PRESENT,
val health: HealthStatus = HealthStatus.NOT_PRESENT,
/** Exercise duration in seconds. 0 if not present */
val exerciseDuration: Int = 0,
/** Exercise intensity in percent. 0 if not present */
val exerciseIntensity: Int = 0,
val medicationId: MedicationId = MedicationId.NOT_PRESENT,
/** Quantity of medication. See [.medicationUnit] for the unit. */
val medicationQuantity: Float = 0f,
/** One of the following: [MeasurementContext.UNIT_kg], [MeasurementContext.UNIT_l]. */
val medicationUnit: MedicationUnit = MedicationUnit.UNIT_KG,
/** HbA1c value. 0 if not present */
val HbA1c: Float = 0f
)
internal enum class ConcentrationUnit(val id: Int) {
UNIT_KGPL(0),
UNIT_MOLPL(1);
companion object {
fun create(value: Int): ConcentrationUnit {
return values().firstOrNull { it.id == value }
?: throw IllegalArgumentException("Cannot find element for provided value.")
}
}
}
internal enum class CarbohydrateId(val id: Int) {
NOT_PRESENT(0),
BREAKFAST(1),
LUNCH(2),
DINNER(3),
SNACK(4),
DRINK(5),
SUPPER(6),
BRUNCH(7);
companion object {
fun create(value: Byte): CarbohydrateId {
return values().firstOrNull { it.id == value.toInt() }
?: throw IllegalArgumentException("Cannot find element for provided value.")
}
}
}
internal enum class TypeOfMeal(val id: Int) {
NOT_PRESENT(0),
PREPRANDIAL(1),
POSTPRANDIAL(2),
FASTING(3),
CASUAL(4),
BEDTIME(5);
companion object {
fun create(value: Byte): TypeOfMeal {
return values().firstOrNull { it.id == value.toInt() }
?: throw IllegalArgumentException("Cannot find element for provided value.")
}
}
}
internal enum class TestType(val id: Int) {
NOT_PRESENT(0),
SELF(1),
HEALTH_CARE_PROFESSIONAL(2),
LAB_TEST(3),
VALUE_NOT_AVAILABLE(15);
companion object {
fun create(value: Byte): TestType {
return values().firstOrNull { it.id == value.toInt() }
?: throw IllegalArgumentException("Cannot find element for provided value.")
}
}
}
internal enum class HealthStatus(val id: Int) {
NOT_PRESENT(0),
MINOR_HEALTH_ISSUES(1),
MAJOR_HEALTH_ISSUES(2),
DURING_MENSES(3),
UNDER_STRESS(4),
NO_HEALTH_ISSUES(5),
VALUE_NOT_AVAILABLE(15);
companion object {
fun create(value: Byte): HealthStatus {
return values().firstOrNull { it.id == value.toInt() }
?: throw IllegalArgumentException("Cannot find element for provided value.")
}
}
}
internal enum class MedicationId(val id: Int) {
NOT_PRESENT(0),
RAPID_ACTING_INSULIN(1),
SHORT_ACTING_INSULIN(2),
INTERMEDIATE_ACTING_INSULIN(3),
LONG_ACTING_INSULIN(4),
PRE_MIXED_INSULIN(5);
companion object {
fun create(value: Byte): MedicationId {
return values().firstOrNull { it.id == value.toInt() }
?: throw IllegalArgumentException("Cannot find element for provided value.")
}
}
}
internal enum class MedicationUnit(val id: Int) {
UNIT_KG(0),
UNIT_L(1);
companion object {
fun create(value: Int): MedicationUnit {
return values().firstOrNull { it.id == value }
?: throw IllegalArgumentException("Cannot find element for provided value.")
}
}
}

View File

@@ -0,0 +1,451 @@
/*
* 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 android.annotation.SuppressLint
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCharacteristic
import android.content.Context
import android.util.Log
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow
import no.nordicsemi.android.ble.common.callback.RecordAccessControlPointDataCallback
import no.nordicsemi.android.ble.common.callback.glucose.GlucoseMeasurementContextDataCallback
import no.nordicsemi.android.ble.common.callback.glucose.GlucoseMeasurementDataCallback
import no.nordicsemi.android.ble.common.data.RecordAccessControlPointData
import no.nordicsemi.android.ble.common.profile.RecordAccessControlPointCallback.RACPErrorCode
import no.nordicsemi.android.ble.common.profile.RecordAccessControlPointCallback.RACPOpCode
import no.nordicsemi.android.ble.common.profile.glucose.GlucoseMeasurementCallback.GlucoseStatus
import no.nordicsemi.android.ble.common.profile.glucose.GlucoseMeasurementContextCallback.Carbohydrate
import no.nordicsemi.android.ble.common.profile.glucose.GlucoseMeasurementContextCallback.Health
import no.nordicsemi.android.ble.common.profile.glucose.GlucoseMeasurementContextCallback.Meal
import no.nordicsemi.android.ble.common.profile.glucose.GlucoseMeasurementContextCallback.Medication
import no.nordicsemi.android.ble.common.profile.glucose.GlucoseMeasurementContextCallback.Tester
import no.nordicsemi.android.ble.data.Data
import no.nordicsemi.android.gls.data.CarbohydrateId
import no.nordicsemi.android.gls.data.ConcentrationUnit
import no.nordicsemi.android.gls.data.GLSData
import no.nordicsemi.android.gls.data.GLSRecord
import no.nordicsemi.android.gls.data.HealthStatus
import no.nordicsemi.android.gls.data.MeasurementContext
import no.nordicsemi.android.gls.data.MedicationId
import no.nordicsemi.android.gls.data.MedicationUnit
import no.nordicsemi.android.gls.data.RequestStatus
import no.nordicsemi.android.gls.data.TestType
import no.nordicsemi.android.gls.data.TypeOfMeal
import no.nordicsemi.android.log.LogContract
import no.nordicsemi.android.service.BatteryManager
import no.nordicsemi.android.service.BatteryManagerCallbacks
import java.util.*
import javax.inject.Inject
import javax.inject.Singleton
/** Glucose service UUID */
private val GLS_SERVICE_UUID = UUID.fromString("00001808-0000-1000-8000-00805f9b34fb")
/** Glucose Measurement characteristic UUID */
private val GM_CHARACTERISTIC = UUID.fromString("00002A18-0000-1000-8000-00805f9b34fb")
/** Glucose Measurement Context characteristic UUID */
private val GM_CONTEXT_CHARACTERISTIC =
UUID.fromString("00002A34-0000-1000-8000-00805f9b34fb")
/** Glucose Feature characteristic UUID */
private val GF_CHARACTERISTIC = UUID.fromString("00002A51-0000-1000-8000-00805f9b34fb")
/** Record Access Control Point characteristic UUID */
private val RACP_CHARACTERISTIC = UUID.fromString("00002A52-0000-1000-8000-00805f9b34fb")
@Singleton
internal class GLSManager @Inject constructor(
@ApplicationContext context: Context
) : BatteryManager<BatteryManagerCallbacks?>(context) {
val data = MutableStateFlow(GLSData())
private val records = hashMapOf<Int, GLSRecord>()
private var glucoseMeasurementCharacteristic: BluetoothGattCharacteristic? = null
private var glucoseMeasurementContextCharacteristic: BluetoothGattCharacteristic? = null
private var recordAccessControlPointCharacteristic: BluetoothGattCharacteristic? = null
override fun getGattCallback(): BatteryManagerGattCallback {
return GlucoseManagerGattCallback()
}
/**
* BluetoothGatt callbacks for connection/disconnection, service discovery,
* receiving notification, etc.
*/
private inner class GlucoseManagerGattCallback : BatteryManagerGattCallback() {
override fun initialize() {
super.initialize()
// The gatt.setCharacteristicNotification(...) method is called in BleManager during
// enabling notifications or indications
// (see BleManager#internalEnableNotifications/Indications).
// However, on Samsung S3 with Android 4.3 it looks like the 2 gatt calls
// (gatt.setCharacteristicNotification(...) and gatt.writeDescriptor(...)) are called
// too quickly, or from a wrong thread, and in result the notification listener is not
// set, causing onCharacteristicChanged(...) callback never being called when a
// notification comes. Enabling them here, like below, solves the problem.
// However... the original approach works for the Battery Level CCCD, which makes it
// even weirder.
/*
gatt.setCharacteristicNotification(glucoseMeasurementCharacteristic, true);
if (glucoseMeasurementContextCharacteristic != null) {
device.setCharacteristicNotification(glucoseMeasurementContextCharacteristic, true);
}
device.setCharacteristicNotification(recordAccessControlPointCharacteristic, true);
*/
setNotificationCallback(glucoseMeasurementCharacteristic)
.with(object : GlucoseMeasurementDataCallback() {
override fun onGlucoseMeasurementReceived(
device: BluetoothDevice, sequenceNumber: Int,
time: Calendar, glucoseConcentration: Float?,
unit: Int?, type: Int?,
sampleLocation: Int?, status: GlucoseStatus?,
contextInformationFollows: Boolean
) {
val record = GLSRecord(
sequenceNumber = sequenceNumber,
time = time,
glucoseConcentration = glucoseConcentration ?: 0f,
unit = unit?.let { ConcentrationUnit.create(it) }
?: ConcentrationUnit.UNIT_KGPL,
type = type ?: 0,
sampleLocation = sampleLocation ?: 0,
status = status?.value ?: 0
)
records[record.sequenceNumber] = record
if (!contextInformationFollows) {
data.tryEmit(data.value.copy(record = records.values.toList()))
}
}
})
setNotificationCallback(glucoseMeasurementContextCharacteristic)
.with(object : GlucoseMeasurementContextDataCallback() {
override fun onGlucoseMeasurementContextReceived(
device: BluetoothDevice, sequenceNumber: Int,
carbohydrate: Carbohydrate?, carbohydrateAmount: Float?,
meal: Meal?, tester: Tester?,
health: Health?, exerciseDuration: Int?,
exerciseIntensity: Int?, medication: Medication?,
medicationAmount: Float?, medicationUnit: Int?,
HbA1c: Float?
) {
val record = records[sequenceNumber] ?: return
val context = MeasurementContext(
carbohydrateId = carbohydrate?.value?.let { CarbohydrateId.create(it) }
?: CarbohydrateId.NOT_PRESENT,
carbohydrateUnits = carbohydrateAmount ?: 0f,
meal = meal?.value?.let { TypeOfMeal.create(it) }
?: TypeOfMeal.NOT_PRESENT,
tester = tester?.value?.let { TestType.create(it) }
?: TestType.NOT_PRESENT,
health = health?.value?.let { HealthStatus.create(it) }
?: HealthStatus.NOT_PRESENT,
exerciseDuration = exerciseDuration ?: 0,
exerciseIntensity = exerciseIntensity ?: 0,
medicationId = medication?.value?.let { MedicationId.create(it) }
?: MedicationId.NOT_PRESENT,
medicationQuantity = medicationAmount ?: 0f,
medicationUnit = medicationUnit?.let { MedicationUnit.create(it) }
?: MedicationUnit.UNIT_KG,
HbA1c = HbA1c ?: 0f
)
record.context = context
data.tryEmit(data.value)
}
})
setIndicationCallback(recordAccessControlPointCharacteristic)
.with(object : RecordAccessControlPointDataCallback() {
@SuppressLint("SwitchIntDef")
override fun onRecordAccessOperationCompleted(
device: BluetoothDevice,
@RACPOpCode requestCode: Int
) {
val status = when (requestCode) {
RACP_OP_CODE_ABORT_OPERATION -> RequestStatus.ABORTED
else -> RequestStatus.SUCCESS
}
data.tryEmit(data.value.copy(requestStatus = status))
}
override fun onRecordAccessOperationCompletedWithNoRecordsFound(
device: BluetoothDevice,
@RACPOpCode requestCode: Int
) {
data.tryEmit(data.value.copy(requestStatus = RequestStatus.SUCCESS))
}
override fun onNumberOfRecordsReceived(
device: BluetoothDevice,
numberOfRecords: Int
) {
//TODO("Probably not needed")
// mCallbacks!!.onNumberOfRecordsRequested(device, numberOfRecords)
if (numberOfRecords > 0) {
if (records.size > 0) {
val sequenceNumber = records.keys.last() + 1
writeCharacteristic(
recordAccessControlPointCharacteristic,
RecordAccessControlPointData.reportStoredRecordsGreaterThenOrEqualTo(
sequenceNumber
)
)
.enqueue()
} else {
writeCharacteristic(
recordAccessControlPointCharacteristic,
RecordAccessControlPointData.reportAllStoredRecords()
)
.enqueue()
}
} else {
data.tryEmit(data.value.copy(requestStatus = RequestStatus.SUCCESS))
}
}
override fun onRecordAccessOperationError(
device: BluetoothDevice,
@RACPOpCode requestCode: Int,
@RACPErrorCode errorCode: Int
) {
log(Log.WARN, "Record Access operation failed (error $errorCode)")
if (errorCode == RACP_ERROR_OP_CODE_NOT_SUPPORTED) {
data.tryEmit(data.value.copy(requestStatus = RequestStatus.NOT_SUPPORTED))
} else {
data.tryEmit(data.value.copy(requestStatus = RequestStatus.FAILED))
}
}
})
enableNotifications(glucoseMeasurementCharacteristic).enqueue()
enableNotifications(glucoseMeasurementContextCharacteristic).enqueue()
enableIndications(recordAccessControlPointCharacteristic)
.fail { device: BluetoothDevice?, status: Int ->
log(
Log.WARN,
"Failed to enabled Record Access Control Point indications (error $status)"
)
}
.enqueue()
}
public override fun isRequiredServiceSupported(gatt: BluetoothGatt): Boolean {
val service = gatt.getService(GLS_SERVICE_UUID)
if (service != null) {
glucoseMeasurementCharacteristic = service.getCharacteristic(GM_CHARACTERISTIC)
glucoseMeasurementContextCharacteristic = service.getCharacteristic(
GM_CONTEXT_CHARACTERISTIC
)
recordAccessControlPointCharacteristic = service.getCharacteristic(
RACP_CHARACTERISTIC
)
}
return glucoseMeasurementCharacteristic != null && recordAccessControlPointCharacteristic != null
}
override fun onServicesInvalidated() {
TODO("Not yet implemented")
}
override fun isOptionalServiceSupported(gatt: BluetoothGatt): Boolean {
super.isOptionalServiceSupported(gatt)
return glucoseMeasurementContextCharacteristic != null
}
override fun onDeviceDisconnected() {
glucoseMeasurementCharacteristic = null
glucoseMeasurementContextCharacteristic = null
recordAccessControlPointCharacteristic = null
}
}
/**
* Clears the records list locally.
*/
fun clear() {
records.clear()
val target = bluetoothDevice
if (target != null) {
data.tryEmit(data.value.copy(requestStatus = RequestStatus.SUCCESS))
}
}
/**
* Sends the request to obtain the last (most recent) record from glucose device. The data will
* be returned to Glucose Measurement characteristic as a notification followed by Record Access
* Control Point indication with status code Success or other in case of error.
*/
fun lastRecord(): Unit {
if (recordAccessControlPointCharacteristic == null) return
val target = bluetoothDevice ?: return
clear()
data.tryEmit(data.value.copy(requestStatus = RequestStatus.PENDING))
writeCharacteristic(
recordAccessControlPointCharacteristic,
RecordAccessControlPointData.reportLastStoredRecord()
)
.with { device: BluetoothDevice, data: Data ->
log(
LogContract.Log.Level.APPLICATION,
"\"" + GLSRecordAccessControlPointParser.parse(data) + "\" sent"
)
}
.enqueue()
}
/**
* Sends the request to obtain the first (oldest) record from glucose device. The data will be
* returned to Glucose Measurement characteristic as a notification followed by Record Access
* Control Point indication with status code Success or other in case of error.
*/
fun requestFirstRecord(): Unit {
if (recordAccessControlPointCharacteristic == null) return
val target = bluetoothDevice ?: return
clear()
data.tryEmit(data.value.copy(requestStatus = RequestStatus.PENDING))
writeCharacteristic(
recordAccessControlPointCharacteristic,
RecordAccessControlPointData.reportFirstStoredRecord()
)
.with { device: BluetoothDevice, data: Data ->
log(
LogContract.Log.Level.APPLICATION,
"\"" + GLSRecordAccessControlPointParser.parse(data) + "\" sent"
)
}
.enqueue()
}
/**
* Sends the request to obtain all records from glucose device. Initially we want to notify user
* about the number of the records so the 'Report Number of Stored Records' is send. The data
* will be returned to Glucose Measurement characteristic as a notification followed by
* Record Access Control Point indication with status code Success or other in case of error.
*/
fun requestAllRecords(): Unit {
if (recordAccessControlPointCharacteristic == null) return
val target = bluetoothDevice ?: return
clear()
data.tryEmit(data.value.copy(requestStatus = RequestStatus.PENDING))
writeCharacteristic(
recordAccessControlPointCharacteristic,
RecordAccessControlPointData.reportNumberOfAllStoredRecords()
)
.with { device: BluetoothDevice, data: Data ->
log(
LogContract.Log.Level.APPLICATION,
"\"" + GLSRecordAccessControlPointParser.parse(data) + "\" sent"
)
}
.enqueue()
}
/**
* Sends the request to obtain from the glucose device all records newer than the newest one
* from local storage. The data will be returned to Glucose Measurement characteristic as
* a notification followed by Record Access Control Point indication with status code Success
* or other in case of error.
*
*
* Refresh button will not download records older than the oldest in the local memory.
* E.g. if you have pressed Last and then Refresh, than it will try to get only newer records.
* However if there are no records, it will download all existing (using [.getAllRecords]).
*/
fun refreshRecords() {
if (recordAccessControlPointCharacteristic == null) return
val target = bluetoothDevice ?: return
if (records.size == 0) {
requestAllRecords()
} else {
data.tryEmit(data.value.copy(requestStatus = RequestStatus.PENDING))
// obtain the last sequence number
val sequenceNumber = records.keys.last() + 1
writeCharacteristic(
recordAccessControlPointCharacteristic,
RecordAccessControlPointData.reportStoredRecordsGreaterThenOrEqualTo(sequenceNumber)
)
.with { device: BluetoothDevice, data: Data ->
log(
LogContract.Log.Level.APPLICATION,
"\"" + GLSRecordAccessControlPointParser.parse(data) + "\" sent"
)
}
.enqueue()
// Info:
// Operators OPERATOR_LESS_THEN_OR_EQUAL and OPERATOR_RANGE are not supported by Nordic Semiconductor Glucose Service in SDK 4.4.2.
}
}
/**
* Sends abort operation signal to the device.
*/
fun abort() {
if (recordAccessControlPointCharacteristic == null) return
val target = bluetoothDevice ?: return
writeCharacteristic(
recordAccessControlPointCharacteristic,
RecordAccessControlPointData.abortOperation()
)
.with { device: BluetoothDevice, data: Data ->
log(
LogContract.Log.Level.APPLICATION,
"\"" + GLSRecordAccessControlPointParser.parse(data) + "\" sent"
)
}
.enqueue()
}
/**
* Sends the request to delete all data from the device. A Record Access Control Point
* indication with status code Success (or other in case of error) will be send.
*/
fun deleteAllRecords() {
if (recordAccessControlPointCharacteristic == null) return
val target = bluetoothDevice ?: return
clear()
data.tryEmit(data.value.copy(requestStatus = RequestStatus.PENDING))
writeCharacteristic(
recordAccessControlPointCharacteristic,
RecordAccessControlPointData.deleteAllStoredRecords()
)
.with { device: BluetoothDevice, data: Data ->
log(
LogContract.Log.Level.APPLICATION,
"\"" + GLSRecordAccessControlPointParser.parse(data) + "\" sent"
)
}
.enqueue()
val elements = listOf<Int>(1, 2, 3)
val result = elements.all { it > 3 }
}
}

View File

@@ -0,0 +1,137 @@
/*
* 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,45 @@
package no.nordicsemi.android.gls.view
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.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import no.nordicsemi.android.gls.R
import no.nordicsemi.android.gls.data.GLSData
import no.nordicsemi.android.gls.viewmodel.DisconnectEvent
import no.nordicsemi.android.gls.viewmodel.GLSScreenViewEvent
import no.nordicsemi.android.theme.view.BatteryLevelView
@Composable
internal fun GLSContentView(state: GLSData, onEvent: (GLSScreenViewEvent) -> Unit) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(16.dp))
BatteryLevelView(state.batteryLevel)
Spacer(modifier = Modifier.height(16.dp))
Button(
colors = ButtonDefaults.buttonColors(backgroundColor = MaterialTheme.colors.secondary),
onClick = { onEvent(DisconnectEvent) }
) {
Text(text = stringResource(id = R.string.disconnect))
}
}
}

View File

@@ -0,0 +1,28 @@
package no.nordicsemi.android.gls.view
import androidx.compose.foundation.layout.Column
import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel
import no.nordicsemi.android.gls.R
import no.nordicsemi.android.gls.data.GLSData
import no.nordicsemi.android.gls.viewmodel.GLSScreenViewEvent
import no.nordicsemi.android.gls.viewmodel.GLSViewModel
@Composable
fun GLSScreen(finishAction: () -> Unit) {
val viewModel: GLSViewModel = hiltViewModel()
val state = viewModel.state.collectAsState().value
}
@Composable
private fun GLSView(state: GLSData, onEvent: (GLSScreenViewEvent) -> Unit) {
Column {
TopAppBar(title = { Text(text = stringResource(id = R.string.gls_title)) })
GLSContentView(state, onEvent)
}
}

View File

@@ -0,0 +1,5 @@
package no.nordicsemi.android.gls.viewmodel
sealed class GLSScreenViewEvent
object DisconnectEvent : GLSScreenViewEvent()

View File

@@ -0,0 +1,20 @@
package no.nordicsemi.android.gls.viewmodel
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import no.nordicsemi.android.gls.repository.GLSManager
import no.nordicsemi.android.service.SelectedBluetoothDeviceHolder
import javax.inject.Inject
@HiltViewModel
internal class GLSViewModel @Inject constructor(
private val glsManager: GLSManager,
private val deviceHolder: SelectedBluetoothDeviceHolder
) : ViewModel() {
val state = glsManager.data
fun bondDevice() {
}
}

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="gls_title">GLS</string>
</resources>

View File

@@ -0,0 +1,17 @@
package no.nordicsemi.android.gls
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}

View File

@@ -1,6 +1,6 @@
package no.nordicsemi.android.hrs.events
package no.nordicsemi.android.hrs.data
internal data class HRSAggregatedData(
internal data class HRSData(
val heartRates: List<Int> = emptyList(),
val batteryLevel: Int = 0,
val sensorLocation: Int = 0

View File

@@ -1,9 +1,9 @@
package no.nordicsemi.android.hrs.service
import no.nordicsemi.android.hrs.events.HRSAggregatedData
import no.nordicsemi.android.hrs.data.HRSData
import no.nordicsemi.android.service.BluetoothDataReadBroadcast
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
internal class HRSDataBroadcast @Inject constructor() : BluetoothDataReadBroadcast<HRSAggregatedData>()
internal class HRSDataBroadcast @Inject constructor() : BluetoothDataReadBroadcast<HRSData>()

View File

@@ -3,7 +3,7 @@ package no.nordicsemi.android.hrs.service
import android.bluetooth.BluetoothDevice
import dagger.hilt.android.AndroidEntryPoint
import no.nordicsemi.android.ble.BleManagerCallbacks
import no.nordicsemi.android.hrs.events.HRSAggregatedData
import no.nordicsemi.android.hrs.data.HRSData
import no.nordicsemi.android.service.ForegroundBleService
import no.nordicsemi.android.service.LoggableBleManager
import javax.inject.Inject
@@ -11,7 +11,7 @@ import javax.inject.Inject
@AndroidEntryPoint
internal class HRSService : ForegroundBleService<HRSManager>(), HRSManagerCallbacks {
private var data = HRSAggregatedData()
private var data = HRSData()
private val points = mutableListOf<Int>()
@Inject
@@ -46,7 +46,7 @@ internal class HRSService : ForegroundBleService<HRSManager>(), HRSManagerCallba
sendNewData(data.copy(heartRates = points))
}
private fun sendNewData(newData: HRSAggregatedData) {
private fun sendNewData(newData: HRSData) {
data = newData
localBroadcast.offer(newData)
}

View File

@@ -71,7 +71,7 @@ internal fun ContentView(state: HRSViewState, onEvent: (HRSScreenViewEvent) -> U
}
@Composable
fun LineChartView(state: HRSViewState) {
internal fun LineChartView(state: HRSViewState) {
AndroidView(
modifier = Modifier
.fillMaxWidth()
@@ -81,7 +81,7 @@ fun LineChartView(state: HRSViewState) {
)
}
fun createLineChartView(context: Context, state: HRSViewState): LineChart {
internal fun createLineChartView(context: Context, state: HRSViewState): LineChart {
return LineChart(context).apply {
setBackgroundColor(Color.WHITE)

View File

@@ -8,7 +8,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.withContext
import no.nordicsemi.android.hrs.events.HRSAggregatedData
import no.nordicsemi.android.hrs.data.HRSData
import no.nordicsemi.android.hrs.service.HRSDataBroadcast
import no.nordicsemi.android.hrs.view.DisconnectEvent
import no.nordicsemi.android.hrs.view.HRSScreenViewEvent
@@ -27,7 +27,7 @@ internal class HRSViewModel @Inject constructor(
}.launchIn(viewModelScope)
}
private fun consumeEvent(event: HRSAggregatedData) {
private fun consumeEvent(event: HRSData) {
state.value = state.value.copy(
points = event.heartRates,
batteryLevel = event.batteryLevel,

View File

@@ -3,6 +3,7 @@
package="no.nordicsemi.android.service">
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
</manifest>

View File

@@ -16,6 +16,15 @@ class SelectedBluetoothDeviceHolder constructor(
return deviceManager.associations.firstOrNull()?.let { bluetoothAdapter?.getRemoteDevice(it) }
}
//TODO: Check if starts automatically
fun bondDevice() {
device?.let {
if (it.bondState == BluetoothDevice.BOND_NONE) {
it.createBond()
}
}
}
fun forgetDevice() {
device?.let {
val deviceManager = context.getSystemService(Context.COMPANION_DEVICE_SERVICE) as CompanionDeviceManager

View File

@@ -62,6 +62,7 @@ rootProject.name = "Android-nRF-Toolbox"
include ':app'
include ':feature_csc'
include ':feature_gls'
include ':feature_hrs'
include ':feature_scanner'