From b2da2f20eb602b7b68df7efe8d8de77aa3bb01a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sylwester=20Zieli=C5=84ski?= Date: Thu, 30 Sep 2021 13:37:45 +0200 Subject: [PATCH] Add GLS feature --- app/build.gradle | 1 + .../android/nrftoolbox/HomeScreen.kt | 5 +- .../android/nrftoolbox/NavDestination.kt | 1 + .../android/nrftoolbox/NavigationViewModel.kt | 2 +- app/src/main/res/drawable/ic_gls.xml | 12 + app/src/main/res/drawable/ic_hrs.xml | 11 +- app/src/main/res/values/strings.xml | 1 + .../CSCData.kt} | 4 +- .../android/csc/events/CSCServiceEvent.kt | 28 -- .../csc/service/CSCDataReadBroadcast.kt | 4 +- .../android/csc/service/CSCService.kt | 12 +- .../android/csc/view/ConnectedView.kt | 8 +- .../nordicsemi/android/csc/view/CscScreen.kt | 4 +- .../android/csc/view/SensorsReadingView.kt | 6 +- .../android/csc/view/WheelSizeView.kt | 6 +- .../android/csc/viewmodel/CscViewModel.kt | 39 +- feature_gls/build.gradle | 28 ++ .../android/gls/ExampleInstrumentedTest.kt | 24 + feature_gls/src/main/AndroidManifest.xml | 5 + .../no/nordicsemi/android/gls/data/GLSData.kt | 11 + .../nordicsemi/android/gls/data/GLSRecord.kt | 186 ++++++++ .../android/gls/repository/GLSManager.kt | 451 ++++++++++++++++++ .../GLSRecordAccessControlPointParser.kt | 137 ++++++ .../android/gls/view/GLSContentView.kt | 45 ++ .../nordicsemi/android/gls/view/GLSScreen.kt | 28 ++ .../gls/viewmodel/GLSScreenViewEvent.kt | 5 + .../android/gls/viewmodel/GLSViewModel.kt | 20 + feature_gls/src/main/res/values/strings.xml | 4 + .../nordicsemi/android/gls/ExampleUnitTest.kt | 17 + .../HRSAggregatedData.kt => data/HRSData.kt} | 4 +- .../android/hrs/service/HRSDataBroadcast.kt | 4 +- .../android/hrs/service/HRSService.kt | 6 +- .../android/hrs/view/ContentView.kt | 4 +- .../android/hrs/viewmodel/HRSViewModel.kt | 4 +- lib_service/src/main/AndroidManifest.xml | 1 + .../service/SelectedBluetoothDeviceHolder.kt | 9 + settings.gradle | 1 + 37 files changed, 1036 insertions(+), 102 deletions(-) create mode 100644 app/src/main/res/drawable/ic_gls.xml rename feature_csc/src/main/java/no/nordicsemi/android/csc/{viewmodel/CSCViewConnectedState.kt => data/CSCData.kt} (95%) delete mode 100644 feature_csc/src/main/java/no/nordicsemi/android/csc/events/CSCServiceEvent.kt create mode 100644 feature_gls/build.gradle create mode 100644 feature_gls/src/androidTest/java/no/nordicsemi/android/gls/ExampleInstrumentedTest.kt create mode 100644 feature_gls/src/main/AndroidManifest.xml create mode 100644 feature_gls/src/main/java/no/nordicsemi/android/gls/data/GLSData.kt create mode 100644 feature_gls/src/main/java/no/nordicsemi/android/gls/data/GLSRecord.kt create mode 100644 feature_gls/src/main/java/no/nordicsemi/android/gls/repository/GLSManager.kt create mode 100644 feature_gls/src/main/java/no/nordicsemi/android/gls/repository/GLSRecordAccessControlPointParser.kt create mode 100644 feature_gls/src/main/java/no/nordicsemi/android/gls/view/GLSContentView.kt create mode 100644 feature_gls/src/main/java/no/nordicsemi/android/gls/view/GLSScreen.kt create mode 100644 feature_gls/src/main/java/no/nordicsemi/android/gls/viewmodel/GLSScreenViewEvent.kt create mode 100644 feature_gls/src/main/java/no/nordicsemi/android/gls/viewmodel/GLSViewModel.kt create mode 100644 feature_gls/src/main/res/values/strings.xml create mode 100644 feature_gls/src/test/java/no/nordicsemi/android/gls/ExampleUnitTest.kt rename feature_hrs/src/main/java/no/nordicsemi/android/hrs/{events/HRSAggregatedData.kt => data/HRSData.kt} (58%) diff --git a/app/build.gradle b/app/build.gradle index df211256..1f21139f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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") diff --git a/app/src/main/java/no/nordicsemi/android/nrftoolbox/HomeScreen.kt b/app/src/main/java/no/nordicsemi/android/nrftoolbox/HomeScreen.kt index 9f32178b..79456427 100644 --- a/app/src/main/java/no/nordicsemi/android/nrftoolbox/HomeScreen.kt +++ b/app/src/main/java/no/nordicsemi/android/nrftoolbox/HomeScreen.kt @@ -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() @@ -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) } } } diff --git a/app/src/main/java/no/nordicsemi/android/nrftoolbox/NavDestination.kt b/app/src/main/java/no/nordicsemi/android/nrftoolbox/NavDestination.kt index 19b87a19..5820a21f 100644 --- a/app/src/main/java/no/nordicsemi/android/nrftoolbox/NavDestination.kt +++ b/app/src/main/java/no/nordicsemi/android/nrftoolbox/NavDestination.kt @@ -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"), diff --git a/app/src/main/java/no/nordicsemi/android/nrftoolbox/NavigationViewModel.kt b/app/src/main/java/no/nordicsemi/android/nrftoolbox/NavigationViewModel.kt index cc9ce9ff..620659a2 100644 --- a/app/src/main/java/no/nordicsemi/android/nrftoolbox/NavigationViewModel.kt +++ b/app/src/main/java/no/nordicsemi/android/nrftoolbox/NavigationViewModel.kt @@ -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) diff --git a/app/src/main/res/drawable/ic_gls.xml b/app/src/main/res/drawable/ic_gls.xml new file mode 100644 index 00000000..08a24a89 --- /dev/null +++ b/app/src/main/res/drawable/ic_gls.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_hrs.xml b/app/src/main/res/drawable/ic_hrs.xml index e4324138..c383a183 100644 --- a/app/src/main/res/drawable/ic_hrs.xml +++ b/app/src/main/res/drawable/ic_hrs.xml @@ -1,4 +1,9 @@ - - + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7abbce26..f8f8f56c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,4 +1,5 @@ CSC HRS + GLS \ No newline at end of file diff --git a/feature_csc/src/main/java/no/nordicsemi/android/csc/viewmodel/CSCViewConnectedState.kt b/feature_csc/src/main/java/no/nordicsemi/android/csc/data/CSCData.kt similarity index 95% rename from feature_csc/src/main/java/no/nordicsemi/android/csc/viewmodel/CSCViewConnectedState.kt rename to feature_csc/src/main/java/no/nordicsemi/android/csc/data/CSCData.kt index b63c5970..974acd0f 100644 --- a/feature_csc/src/main/java/no/nordicsemi/android/csc/viewmodel/CSCViewConnectedState.kt +++ b/feature_csc/src/main/java/no/nordicsemi/android/csc/data/CSCData.kt @@ -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, diff --git a/feature_csc/src/main/java/no/nordicsemi/android/csc/events/CSCServiceEvent.kt b/feature_csc/src/main/java/no/nordicsemi/android/csc/events/CSCServiceEvent.kt deleted file mode 100644 index 41674a26..00000000 --- a/feature_csc/src/main/java/no/nordicsemi/android/csc/events/CSCServiceEvent.kt +++ /dev/null @@ -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() diff --git a/feature_csc/src/main/java/no/nordicsemi/android/csc/service/CSCDataReadBroadcast.kt b/feature_csc/src/main/java/no/nordicsemi/android/csc/service/CSCDataReadBroadcast.kt index 9a854e5e..d4c1e1ff 100644 --- a/feature_csc/src/main/java/no/nordicsemi/android/csc/service/CSCDataReadBroadcast.kt +++ b/feature_csc/src/main/java/no/nordicsemi/android/csc/service/CSCDataReadBroadcast.kt @@ -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() { +internal class CSCDataReadBroadcast @Inject constructor() : BluetoothDataReadBroadcast() { private val _wheelSize = MutableSharedFlow( replay = 1, diff --git a/feature_csc/src/main/java/no/nordicsemi/android/csc/service/CSCService.kt b/feature_csc/src/main/java/no/nordicsemi/android/csc/service/CSCService.kt index d8b782e5..2857cfcc 100644 --- a/feature_csc/src/main/java/no/nordicsemi/android/csc/service/CSCService.kt +++ b/feature_csc/src/main/java/no/nordicsemi/android/csc/service/CSCService.kt @@ -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(), CSCManagerCallbacks { + private var data = CSCData() + @Inject lateinit var localBroadcast: CSCDataReadBroadcast @@ -42,7 +42,7 @@ internal class CSCService : ForegroundBleService(), 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(), 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)) } } \ No newline at end of file diff --git a/feature_csc/src/main/java/no/nordicsemi/android/csc/view/ConnectedView.kt b/feature_csc/src/main/java/no/nordicsemi/android/csc/view/ConnectedView.kt index f79bead1..ff755728 100644 --- a/feature_csc/src/main/java/no/nordicsemi/android/csc/view/ConnectedView.kt +++ b/feature_csc/src/main/java/no/nordicsemi/android/csc/view/ConnectedView.kt @@ -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()) { } } diff --git a/feature_csc/src/main/java/no/nordicsemi/android/csc/view/CscScreen.kt b/feature_csc/src/main/java/no/nordicsemi/android/csc/view/CscScreen.kt index 12dae03e..e1431aa3 100644 --- a/feature_csc/src/main/java/no/nordicsemi/android/csc/view/CscScreen.kt +++ b/feature_csc/src/main/java/no/nordicsemi/android/csc/view/CscScreen.kt @@ -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)) }) diff --git a/feature_csc/src/main/java/no/nordicsemi/android/csc/view/SensorsReadingView.kt b/feature_csc/src/main/java/no/nordicsemi/android/csc/view/SensorsReadingView.kt index 59e5c568..3abd742f 100644 --- a/feature_csc/src/main/java/no/nordicsemi/android/csc/view/SensorsReadingView.kt +++ b/feature_csc/src/main/java/no/nordicsemi/android/csc/view/SensorsReadingView.kt @@ -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()) } diff --git a/feature_csc/src/main/java/no/nordicsemi/android/csc/view/WheelSizeView.kt b/feature_csc/src/main/java/no/nordicsemi/android/csc/view/WheelSizeView.kt index 76f69dd1..caf7ec7c 100644 --- a/feature_csc/src/main/java/no/nordicsemi/android/csc/view/WheelSizeView.kt +++ b/feature_csc/src/main/java/no/nordicsemi/android/csc/view/WheelSizeView.kt @@ -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()) { } } diff --git a/feature_csc/src/main/java/no/nordicsemi/android/csc/viewmodel/CscViewModel.kt b/feature_csc/src/main/java/no/nordicsemi/android/csc/viewmodel/CscViewModel.kt index 1f5d2910..e6efa2a5 100644 --- a/feature_csc/src/main/java/no/nordicsemi/android/csc/viewmodel/CscViewModel.kt +++ b/feature_csc/src/main/java/no/nordicsemi/android/csc/viewmodel/CscViewModel.kt @@ -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) diff --git a/feature_gls/build.gradle b/feature_gls/build.gradle new file mode 100644 index 00000000..662e3f13 --- /dev/null +++ b/feature_gls/build.gradle @@ -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 +} diff --git a/feature_gls/src/androidTest/java/no/nordicsemi/android/gls/ExampleInstrumentedTest.kt b/feature_gls/src/androidTest/java/no/nordicsemi/android/gls/ExampleInstrumentedTest.kt new file mode 100644 index 00000000..8a2887a6 --- /dev/null +++ b/feature_gls/src/androidTest/java/no/nordicsemi/android/gls/ExampleInstrumentedTest.kt @@ -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) + } +} \ No newline at end of file diff --git a/feature_gls/src/main/AndroidManifest.xml b/feature_gls/src/main/AndroidManifest.xml new file mode 100644 index 00000000..37b4a974 --- /dev/null +++ b/feature_gls/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/feature_gls/src/main/java/no/nordicsemi/android/gls/data/GLSData.kt b/feature_gls/src/main/java/no/nordicsemi/android/gls/data/GLSData.kt new file mode 100644 index 00000000..d5abc761 --- /dev/null +++ b/feature_gls/src/main/java/no/nordicsemi/android/gls/data/GLSData.kt @@ -0,0 +1,11 @@ +package no.nordicsemi.android.gls.data + +internal data class GLSData( + val record: List = emptyList(), + val batteryLevel: Int = 0, + val requestStatus: RequestStatus = RequestStatus.IDLE +) + +internal enum class RequestStatus { + IDLE, PENDING, SUCCESS, ABORTED, FAILED, NOT_SUPPORTED +} diff --git a/feature_gls/src/main/java/no/nordicsemi/android/gls/data/GLSRecord.kt b/feature_gls/src/main/java/no/nordicsemi/android/gls/data/GLSRecord.kt new file mode 100644 index 00000000..452d511e --- /dev/null +++ b/feature_gls/src/main/java/no/nordicsemi/android/gls/data/GLSRecord.kt @@ -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.") + } + } +} diff --git a/feature_gls/src/main/java/no/nordicsemi/android/gls/repository/GLSManager.kt b/feature_gls/src/main/java/no/nordicsemi/android/gls/repository/GLSManager.kt new file mode 100644 index 00000000..2eb43ad7 --- /dev/null +++ b/feature_gls/src/main/java/no/nordicsemi/android/gls/repository/GLSManager.kt @@ -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(context) { + + val data = MutableStateFlow(GLSData()) + private val records = hashMapOf() + + 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(1, 2, 3) + val result = elements.all { it > 3 } + } +} diff --git a/feature_gls/src/main/java/no/nordicsemi/android/gls/repository/GLSRecordAccessControlPointParser.kt b/feature_gls/src/main/java/no/nordicsemi/android/gls/repository/GLSRecordAccessControlPointParser.kt new file mode 100644 index 00000000..41ac8c0c --- /dev/null +++ b/feature_gls/src/main/java/no/nordicsemi/android/gls/repository/GLSRecordAccessControlPointParser.kt @@ -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" + } + } +} diff --git a/feature_gls/src/main/java/no/nordicsemi/android/gls/view/GLSContentView.kt b/feature_gls/src/main/java/no/nordicsemi/android/gls/view/GLSContentView.kt new file mode 100644 index 00000000..16646538 --- /dev/null +++ b/feature_gls/src/main/java/no/nordicsemi/android/gls/view/GLSContentView.kt @@ -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)) + } + } +} + diff --git a/feature_gls/src/main/java/no/nordicsemi/android/gls/view/GLSScreen.kt b/feature_gls/src/main/java/no/nordicsemi/android/gls/view/GLSScreen.kt new file mode 100644 index 00000000..96071a8a --- /dev/null +++ b/feature_gls/src/main/java/no/nordicsemi/android/gls/view/GLSScreen.kt @@ -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) + } +} diff --git a/feature_gls/src/main/java/no/nordicsemi/android/gls/viewmodel/GLSScreenViewEvent.kt b/feature_gls/src/main/java/no/nordicsemi/android/gls/viewmodel/GLSScreenViewEvent.kt new file mode 100644 index 00000000..e1ef521a --- /dev/null +++ b/feature_gls/src/main/java/no/nordicsemi/android/gls/viewmodel/GLSScreenViewEvent.kt @@ -0,0 +1,5 @@ +package no.nordicsemi.android.gls.viewmodel + +sealed class GLSScreenViewEvent + +object DisconnectEvent : GLSScreenViewEvent() diff --git a/feature_gls/src/main/java/no/nordicsemi/android/gls/viewmodel/GLSViewModel.kt b/feature_gls/src/main/java/no/nordicsemi/android/gls/viewmodel/GLSViewModel.kt new file mode 100644 index 00000000..8a566bc0 --- /dev/null +++ b/feature_gls/src/main/java/no/nordicsemi/android/gls/viewmodel/GLSViewModel.kt @@ -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() { + + } +} diff --git a/feature_gls/src/main/res/values/strings.xml b/feature_gls/src/main/res/values/strings.xml new file mode 100644 index 00000000..b1eafe68 --- /dev/null +++ b/feature_gls/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + GLS + diff --git a/feature_gls/src/test/java/no/nordicsemi/android/gls/ExampleUnitTest.kt b/feature_gls/src/test/java/no/nordicsemi/android/gls/ExampleUnitTest.kt new file mode 100644 index 00000000..57961909 --- /dev/null +++ b/feature_gls/src/test/java/no/nordicsemi/android/gls/ExampleUnitTest.kt @@ -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) + } +} \ No newline at end of file diff --git a/feature_hrs/src/main/java/no/nordicsemi/android/hrs/events/HRSAggregatedData.kt b/feature_hrs/src/main/java/no/nordicsemi/android/hrs/data/HRSData.kt similarity index 58% rename from feature_hrs/src/main/java/no/nordicsemi/android/hrs/events/HRSAggregatedData.kt rename to feature_hrs/src/main/java/no/nordicsemi/android/hrs/data/HRSData.kt index 700be485..0335f907 100644 --- a/feature_hrs/src/main/java/no/nordicsemi/android/hrs/events/HRSAggregatedData.kt +++ b/feature_hrs/src/main/java/no/nordicsemi/android/hrs/data/HRSData.kt @@ -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 = emptyList(), val batteryLevel: Int = 0, val sensorLocation: Int = 0 diff --git a/feature_hrs/src/main/java/no/nordicsemi/android/hrs/service/HRSDataBroadcast.kt b/feature_hrs/src/main/java/no/nordicsemi/android/hrs/service/HRSDataBroadcast.kt index 29eddcf5..ac426907 100644 --- a/feature_hrs/src/main/java/no/nordicsemi/android/hrs/service/HRSDataBroadcast.kt +++ b/feature_hrs/src/main/java/no/nordicsemi/android/hrs/service/HRSDataBroadcast.kt @@ -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() +internal class HRSDataBroadcast @Inject constructor() : BluetoothDataReadBroadcast() diff --git a/feature_hrs/src/main/java/no/nordicsemi/android/hrs/service/HRSService.kt b/feature_hrs/src/main/java/no/nordicsemi/android/hrs/service/HRSService.kt index a8d94192..cc07781d 100644 --- a/feature_hrs/src/main/java/no/nordicsemi/android/hrs/service/HRSService.kt +++ b/feature_hrs/src/main/java/no/nordicsemi/android/hrs/service/HRSService.kt @@ -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(), HRSManagerCallbacks { - private var data = HRSAggregatedData() + private var data = HRSData() private val points = mutableListOf() @Inject @@ -46,7 +46,7 @@ internal class HRSService : ForegroundBleService(), HRSManagerCallba sendNewData(data.copy(heartRates = points)) } - private fun sendNewData(newData: HRSAggregatedData) { + private fun sendNewData(newData: HRSData) { data = newData localBroadcast.offer(newData) } diff --git a/feature_hrs/src/main/java/no/nordicsemi/android/hrs/view/ContentView.kt b/feature_hrs/src/main/java/no/nordicsemi/android/hrs/view/ContentView.kt index 9ee0c88c..a4122d9a 100644 --- a/feature_hrs/src/main/java/no/nordicsemi/android/hrs/view/ContentView.kt +++ b/feature_hrs/src/main/java/no/nordicsemi/android/hrs/view/ContentView.kt @@ -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) diff --git a/feature_hrs/src/main/java/no/nordicsemi/android/hrs/viewmodel/HRSViewModel.kt b/feature_hrs/src/main/java/no/nordicsemi/android/hrs/viewmodel/HRSViewModel.kt index cd0a4aee..3437e7cd 100644 --- a/feature_hrs/src/main/java/no/nordicsemi/android/hrs/viewmodel/HRSViewModel.kt +++ b/feature_hrs/src/main/java/no/nordicsemi/android/hrs/viewmodel/HRSViewModel.kt @@ -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, diff --git a/lib_service/src/main/AndroidManifest.xml b/lib_service/src/main/AndroidManifest.xml index ea6c2307..efd228af 100644 --- a/lib_service/src/main/AndroidManifest.xml +++ b/lib_service/src/main/AndroidManifest.xml @@ -3,6 +3,7 @@ package="no.nordicsemi.android.service"> + \ No newline at end of file diff --git a/lib_service/src/main/java/no/nordicsemi/android/service/SelectedBluetoothDeviceHolder.kt b/lib_service/src/main/java/no/nordicsemi/android/service/SelectedBluetoothDeviceHolder.kt index a2e6aeb1..c1cc9747 100644 --- a/lib_service/src/main/java/no/nordicsemi/android/service/SelectedBluetoothDeviceHolder.kt +++ b/lib_service/src/main/java/no/nordicsemi/android/service/SelectedBluetoothDeviceHolder.kt @@ -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 diff --git a/settings.gradle b/settings.gradle index 6d75f447..ec304a49 100644 --- a/settings.gradle +++ b/settings.gradle @@ -62,6 +62,7 @@ rootProject.name = "Android-nRF-Toolbox" include ':app' include ':feature_csc' +include ':feature_gls' include ':feature_hrs' include ':feature_scanner'