Merge branch 'develop'

This commit is contained in:
Sylwester Zieliński
2023-08-06 12:00:59 +02:00
165 changed files with 3955 additions and 4601 deletions

View File

@@ -11,6 +11,10 @@ jobs:
with:
ref: main
fetch-depth: 0
- uses: actions/setup-java@v3
with:
distribution: 'corretto'
java-version: '17'
- shell: bash
run: |
git config user.email mag@nordicsemi.no
@@ -29,6 +33,10 @@ jobs:
with:
ref: main
fetch-depth: 0
- uses: actions/setup-java@v3
with:
distribution: 'corretto'
java-version: '17'
- shell: bash
env:
KEYSTORE_ALIAS: ${{ secrets.KEYSTORE_ALIAS }}

View File

@@ -8,6 +8,10 @@ jobs:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- uses: actions/setup-java@v3
with:
distribution: 'corretto'
java-version: '17'
- shell: bash
env:
KEYSTORE_ALIAS: ${{ secrets.KEYSTORE_ALIAS }}

View File

@@ -8,6 +8,10 @@ jobs:
- uses: actions/checkout@v2
with:
fetch-depth: 0
- uses: actions/setup-java@v3
with:
distribution: 'corretto'
java-version: '17'
- shell: bash
env:
CONTACT_EMAIL: ${{ secrets.CONTACT_EMAIL }}

View File

@@ -65,9 +65,9 @@ dependencies {
implementation(libs.nordic.core)
implementation(libs.nordic.theme)
implementation(libs.nordic.navigation)
implementation(libs.nordic.uiscanner)
implementation(libs.nordic.blek.uiscanner)
implementation(libs.nordic.uilogger)
implementation(libs.nordic.permission)
implementation(libs.nordic.permissions.ble)
implementation(libs.nordic.analytics)
implementation(libs.androidx.lifecycle.runtime.compose)
@@ -81,4 +81,6 @@ dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.activity.compose)
implementation(libs.nordic.blek.client)
}

View File

@@ -1,16 +1,18 @@
# Simple XML
-keep public class org.simpleframework.** { *; }
-keep class org.simpleframework.xml.** { *; }
-keep class org.simpleframework.xml.core.** { *; }
-keep class org.simpleframework.xml.util.** { *; }
-dontwarn javax.xml.**
-keep class no.nordicsemi.android.log.** { *; }
-keep public class org.simpleframework.**{ *; }
-keep class org.simpleframework.xml.**{ *; }
-keep class org.simpleframework.xml.core.**{ *; }
-keep class org.simpleframework.xml.util.**{ *; }
-keepattributes ElementList, Root, InnerClasses, LineNumberTable
-keepattributes Signature
-keepattributes *Annotation*
-keepclasseswithmembers class * {
@org.simpleframework.xml.* <fields>;
# Ignore our XML Serialization classes
-keep public class your.annotated.pojo.models.*{
public protected private *;
}
# Crashlytics

View File

@@ -29,24 +29,31 @@
* EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package no.nordicsemi.android.prx.data
package no.nordicsemi.android.nrftoolbox
internal data class PRXData(
val batteryLevel: Int? = null,
val localAlarmLevel: AlarmLevel = AlarmLevel.NONE,
val isRemoteAlarm: Boolean = false,
val linkLossAlarmLevel: AlarmLevel = AlarmLevel.HIGH
)
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
import no.nordicsemi.android.analytics.AppAnalytics
import no.nordicsemi.android.analytics.AppOpenEvent
import no.nordicsemi.android.gls.GlsServer
import no.nordicsemi.android.uart.UartServer
import javax.inject.Inject
internal enum class AlarmLevel(val value: Int) {
NONE(0x00),
MEDIUM(0x01),
HIGH(0x02);
@HiltAndroidApp
class NrfToolboxApplication : Application() {
companion object {
fun create(value: Int): AlarmLevel {
return AlarmLevel.values().firstOrNull { it.value == value }
?: throw IllegalArgumentException("Cannot find AlarmLevel for provided value: $value")
}
@Inject
lateinit var analytics: AppAnalytics
@Inject
lateinit var glsServer: GlsServer
@Inject
lateinit var uartServer: UartServer
override fun onCreate() {
super.onCreate()
analytics.logEvent(AppOpenEvent)
}
}

View File

@@ -30,7 +30,8 @@
~ EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:tools="http://schemas.android.com/tools"
xmlns:android="http://schemas.android.com/apk/res/android">
<queries>
<package android:name="no.nordicsemi.android.dfu" />
@@ -44,18 +45,19 @@
<application
android:name=".NrfToolboxApplication"
android:allowBackup="true"
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/NordicTheme">
android:theme="@style/NordicTheme"
android:dataExtractionRules="@xml/data_extraction_rules"
tools:targetApi="s">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:launchMode="singleTask"
android:windowSoftInputMode="stateVisible|adjustResize"
android:windowSoftInputMode="adjustResize"
android:theme="@style/NordicTheme.SplashScreen">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

View File

@@ -0,0 +1,16 @@
package no.nordicsemi.android.nrftoolbox
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
@Module
@InstallIn(SingletonComponent::class)
class ApplicationScopeModule {
@Provides
fun applicationScope() = CoroutineScope(SupervisorJob())
}

View File

@@ -44,6 +44,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Text
@@ -61,6 +62,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import no.nordicsemi.android.nrftoolbox.R
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FeatureButton(
@DrawableRes iconId: Int,
@@ -70,7 +72,7 @@ fun FeatureButton(
@StringRes description: Int? = null,
onClick: () -> Unit
) {
OutlinedCard(modifier = Modifier.clickable { onClick() }) {
OutlinedCard(onClick = onClick) {
Row(
modifier = Modifier.padding(16.dp).fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,

View File

@@ -43,7 +43,7 @@ import kotlinx.coroutines.flow.onEach
import no.nordicsemi.android.analytics.AppAnalytics
import no.nordicsemi.android.analytics.ProfileOpenEvent
import no.nordicsemi.android.cgms.repository.CGMRepository
import no.nordicsemi.android.common.logger.NordicLogger
import no.nordicsemi.android.common.logger.LoggerLauncher
import no.nordicsemi.android.common.navigation.DestinationId
import no.nordicsemi.android.common.navigation.Navigator
import no.nordicsemi.android.csc.repository.CSCRepository
@@ -114,7 +114,7 @@ class HomeViewModel @Inject constructor(
}
fun openLogger() {
NordicLogger.launch(context, logger = null)
LoggerLauncher.launch(context)
}
fun logEvent(event: ProfileOpenEvent) {

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<exclude domain="root" />
<exclude domain="file" />
<exclude domain="database" />
<exclude domain="sharedpref" />
<exclude domain="external" />
</cloud-backup>
<device-transfer>
<exclude domain="root" />
<exclude domain="file" />
<exclude domain="database" />
<exclude domain="sharedpref" />
<exclude domain="external" />
</device-transfer>
</data-extraction-rules>

View File

@@ -33,7 +33,8 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.jvm) apply false
alias(libs.plugins.kotlin.kapt) apply true
alias(libs.plugins.kotlin.kapt) apply false
alias(libs.plugins.ksp) apply false
alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.kotlin.parcelize) apply false
alias(libs.plugins.hilt) apply false
@@ -45,6 +46,6 @@ plugins {
alias(libs.plugins.nordic.library.compose) apply false
alias(libs.plugins.nordic.hilt) apply false
alias(libs.plugins.nordic.feature) apply false
id("com.google.gms.google-services") version "4.3.15" apply false
id("com.google.firebase.crashlytics") version "2.9.2" apply false
alias(libs.plugins.google.services) apply false
alias(libs.plugins.firebase.crashlytics) apply false
}

View File

@@ -37,7 +37,7 @@
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
@@ -46,7 +46,7 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# Android operating system, and which are packaged with your app"s APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Automatically convert third-party libraries to use AndroidX
android.enableJetifier=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
kotlin.code.style=official
android.nonTransitiveRClass=false

View File

@@ -31,7 +31,7 @@
#Mon Feb 14 14:46:55 CET 2022
distributionBase=GRADLE_USER_HOME
distributionUrl=https\://services.gradle.org/distributions/gradle-7.6-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.2.1-bin.zip
distributionPath=wrapper/dists
zipStorePath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME

View File

@@ -38,9 +38,9 @@ android {
}
dependencies {
implementation(platform("com.google.firebase:firebase-bom:29.2.1"))
implementation("com.google.firebase:firebase-analytics")
implementation("com.google.firebase:firebase-crashlytics")
implementation(platform(libs.firebase.bom))
implementation(libs.firebase.analytics)
implementation(libs.firebase.crashlytics)
implementation(libs.nordic.analytics)
}

View File

@@ -38,8 +38,10 @@ android {
}
dependencies {
implementation(libs.nordic.uiscanner)
implementation(libs.nordic.navigation)
implementation(libs.nordic.blek.uiscanner)
implementation(libs.nordic.blek.scanner)
implementation(libs.androidx.compose.material.iconsExtended)
implementation(libs.androidx.core.ktx)

View File

@@ -1,22 +0,0 @@
package no.nordicsemi.android.toolbox.scanner
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.*
import org.junit.Test
import org.junit.runner.RunWith
/**
* 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.toolbox.scanner.test", appContext.packageName)
}
}

View File

@@ -5,12 +5,12 @@ import androidx.hilt.navigation.compose.hiltViewModel
import no.nordicsemi.android.common.navigation.createDestination
import no.nordicsemi.android.common.navigation.defineDestination
import no.nordicsemi.android.common.navigation.viewmodel.SimpleNavigationViewModel
import no.nordicsemi.android.common.ui.scanner.DeviceSelected
import no.nordicsemi.android.common.ui.scanner.ScannerScreen
import no.nordicsemi.android.common.ui.scanner.ScanningCancelled
import no.nordicsemi.android.common.ui.scanner.model.DiscoveredBluetoothDevice
import no.nordicsemi.android.kotlin.ble.core.ServerDevice
import no.nordicsemi.android.kotlin.ble.ui.scanner.DeviceSelected
import no.nordicsemi.android.kotlin.ble.ui.scanner.ScannerScreen
import no.nordicsemi.android.kotlin.ble.ui.scanner.ScanningCancelled
val ScannerDestinationId = createDestination<ParcelUuid, DiscoveredBluetoothDevice>("uiscanner-destination")
val ScannerDestinationId = createDestination<ParcelUuid, ServerDevice>("uiscanner-destination")
val ScannerDestination = defineDestination(ScannerDestinationId) {
val navigationViewModel = hiltViewModel<SimpleNavigationViewModel>()
@@ -21,7 +21,7 @@ val ScannerDestination = defineDestination(ScannerDestinationId) {
uuid = arg,
onResult = {
when (it) {
is DeviceSelected -> navigationViewModel.navigateUpWithResult(ScannerDestinationId, it.device)
is DeviceSelected -> navigationViewModel.navigateUpWithResult(ScannerDestinationId, it.scanResults.device)
ScanningCancelled -> navigationViewModel.navigateUp()
}
}

View File

@@ -1,16 +0,0 @@
package no.nordicsemi.android.toolbox.scanner
import org.junit.Assert.*
import org.junit.Test
/**
* 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

@@ -40,9 +40,12 @@ android {
dependencies {
implementation(project(":lib_ui"))
implementation(libs.nordic.ble.common)
implementation(libs.nordic.ble.ktx)
implementation(libs.nordic.uiscanner)
implementation(libs.nordic.blek.uiscanner)
implementation(libs.nordic.blek.core)
implementation(libs.androidx.lifecycle.service)
implementation(libs.androidx.localbroadcastmanager)

View File

@@ -1,67 +0,0 @@
/*
* Copyright (c) 2022, 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.service
import android.annotation.SuppressLint
import android.bluetooth.BluetoothDevice
sealed interface BleManagerResult<T> {
fun isRunning(): Boolean {
return this is SuccessResult
}
fun hasBeenDisconnected(): Boolean {
return this is LinkLossResult || this is DisconnectedResult || this is MissingServiceResult
}
fun hasBeenDisconnectedWithoutLinkLoss(): Boolean {
return this is DisconnectedResult || this is MissingServiceResult
}
}
sealed class DeviceHolder(val device: BluetoothDevice) {
@SuppressLint("MissingPermission")
fun deviceName(): String = device.name ?: device.address
}
class IdleResult<T> : BleManagerResult<T>
class ConnectingResult<T>(device: BluetoothDevice) : DeviceHolder(device), BleManagerResult<T>
class ConnectedResult<T>(device: BluetoothDevice) : DeviceHolder(device), BleManagerResult<T>
class SuccessResult<T>(device: BluetoothDevice, val data: T) : DeviceHolder(device), BleManagerResult<T>
class LinkLossResult<T>(device: BluetoothDevice, val data: T?) : DeviceHolder(device), BleManagerResult<T>
class DisconnectedResult<T>(device: BluetoothDevice) : DeviceHolder(device), BleManagerResult<T>
class UnknownErrorResult<T>(device: BluetoothDevice) : DeviceHolder(device), BleManagerResult<T>
class MissingServiceResult<T>(device: BluetoothDevice) : DeviceHolder(device), BleManagerResult<T>

View File

@@ -1,45 +0,0 @@
/*
* Copyright (c) 2022, 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.service
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.cancelChildren
import java.io.Closeable
import kotlin.coroutines.CoroutineContext
class CloseableCoroutineScope(context: CoroutineContext) : Closeable, CoroutineScope {
override val coroutineContext: CoroutineContext = context
override fun close() {
coroutineContext.cancelChildren()
}
}

View File

@@ -1,93 +0,0 @@
/*
* Copyright (c) 2022, 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.service
import android.bluetooth.BluetoothDevice
import android.util.Log
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import no.nordicsemi.android.ble.observer.ConnectionObserver
class ConnectionObserverAdapter<T> : ConnectionObserver {
private val TAG = "BLE-CONNECTION"
private val _status = MutableStateFlow<BleManagerResult<T>>(IdleResult())
val status = _status.asStateFlow()
private var lastValue: T? = null
private fun getData(): T? {
return (_status.value as? SuccessResult)?.data
}
override fun onDeviceConnecting(device: BluetoothDevice) {
Log.d(TAG, "onDeviceConnecting()")
_status.value = ConnectingResult(device)
}
override fun onDeviceConnected(device: BluetoothDevice) {
Log.d(TAG, "onDeviceConnected()")
_status.value = ConnectedResult(device)
}
override fun onDeviceFailedToConnect(device: BluetoothDevice, reason: Int) {
Log.d(TAG, "onDeviceFailedToConnect(), reason: $reason")
_status.value = MissingServiceResult(device)
}
override fun onDeviceReady(device: BluetoothDevice) {
Log.d(TAG, "onDeviceReady()")
_status.value = SuccessResult(device, lastValue!!)
}
override fun onDeviceDisconnecting(device: BluetoothDevice) {
Log.d(TAG, "onDeviceDisconnecting()")
}
override fun onDeviceDisconnected(device: BluetoothDevice, reason: Int) {
Log.d(TAG, "onDeviceDisconnected(), reason: $reason")
_status.value = when (reason) {
ConnectionObserver.REASON_NOT_SUPPORTED -> MissingServiceResult(device)
ConnectionObserver.REASON_LINK_LOSS -> LinkLossResult(device, getData())
ConnectionObserver.REASON_SUCCESS -> DisconnectedResult(device)
else -> UnknownErrorResult(device)
}
}
fun setValue(value: T) {
lastValue = value
(_status.value as? SuccessResult)?.let {
_status.value = SuccessResult(it.device, value)
}
}
}

View File

@@ -0,0 +1,3 @@
package no.nordicsemi.android.service
class DisconnectAndStopEvent

View File

@@ -0,0 +1,3 @@
package no.nordicsemi.android.service
class OpenLoggerEvent

View File

@@ -31,41 +31,11 @@
package no.nordicsemi.android.service
import android.bluetooth.BluetoothDevice
import android.content.Context
import android.content.Intent
import dagger.hilt.android.qualifiers.ApplicationContext
import no.nordicsemi.android.common.ui.scanner.model.DiscoveredBluetoothDevice
import javax.inject.Inject
import no.nordicsemi.android.kotlin.ble.core.ServerDevice
const val DEVICE_DATA = "device-data"
class ServiceManager @Inject constructor(
@ApplicationContext
private val context: Context
) {
interface ServiceManager {
fun <T> startService(service: Class<T>, device: DiscoveredBluetoothDevice) {
val intent = Intent(context, service).apply {
putExtra(DEVICE_DATA, device)
}
context.startService(intent)
}
fun <T> startService(service: Class<T>, device: BluetoothDevice) {
val intent = Intent(context, service).apply {
putExtra(DEVICE_DATA, device)
}
context.startService(intent)
}
fun <T> startService(service: Class<T>) {
val intent = Intent(context, service)
context.startService(intent)
}
fun <T> stopService(service: Class<T>) {
val intent = Intent(context, service)
context.stopService(intent)
}
fun <T> startService(service: Class<T>, device: ServerDevice)
}

View File

@@ -0,0 +1,21 @@
package no.nordicsemi.android.service
import android.content.Context
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
class ServiceManagerHiltModule {
@Provides
fun createServiceManager(
@ApplicationContext
context: Context,
): ServiceManager {
return ServiceManagerImpl(context)
}
}

View File

@@ -0,0 +1,20 @@
package no.nordicsemi.android.service
import android.content.Context
import android.content.Intent
import dagger.hilt.android.qualifiers.ApplicationContext
import no.nordicsemi.android.kotlin.ble.core.ServerDevice
import javax.inject.Inject
class ServiceManagerImpl @Inject constructor(
@ApplicationContext
private val context: Context
): ServiceManager {
override fun <T> startService(service: Class<T>, device: ServerDevice) {
val intent = Intent(context, service).apply {
putExtra(DEVICE_DATA, device)
}
context.startService(intent)
}
}

View File

@@ -35,6 +35,10 @@ plugins {
android {
namespace = "no.nordicsemi.android.ui"
testOptions {
unitTests.isIncludeAndroidResources = true
}
}
dependencies {
@@ -46,4 +50,6 @@ dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.activity.compose)
implementation(libs.nordic.blek.client)
implementation(libs.nordic.logger)
}

View File

@@ -0,0 +1,9 @@
package no.nordicsemi.android.ui.view
import android.content.Context
import no.nordicsemi.android.common.logger.BleLoggerAndLauncher
interface NordicLoggerFactory {
fun createNordicLogger(context: Context, profile: String?, key: String, name: String?): BleLoggerAndLauncher
}

View File

@@ -0,0 +1,28 @@
package no.nordicsemi.android.ui.view
import android.content.Context
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import no.nordicsemi.android.common.logger.BleLoggerAndLauncher
import no.nordicsemi.android.common.logger.DefaultBleLogger
@Module
@InstallIn(SingletonComponent::class)
class NordicLoggerFactoryHiltModule {
@Provides
fun createLogger(): NordicLoggerFactory {
return object : NordicLoggerFactory {
override fun createNordicLogger(
context: Context,
profile: String?,
key: String,
name: String?,
): BleLoggerAndLauncher {
return DefaultBleLogger.create(context, profile, key, name)
}
}
}
}

View File

@@ -31,6 +31,7 @@
package no.nordicsemi.android.ui.view
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
@@ -43,6 +44,8 @@ import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import no.nordicsemi.android.kotlin.ble.core.data.GattConnectionState
import no.nordicsemi.android.kotlin.ble.core.data.GattConnectionStateWithStatus
import no.nordicsemi.android.ui.R
@OptIn(ExperimentalMaterial3Api::class)
@@ -168,3 +171,24 @@ fun LoggerIconAppBar(text: String, onClick: () -> Unit, onDisconnectClick: () ->
}
)
}
@Composable
fun ProfileAppBar(
deviceName: String?,
connectionState: GattConnectionStateWithStatus?,
@StringRes
title: Int,
navigateUp: () -> Unit,
disconnect: () -> Unit,
openLogger: () -> Unit
) {
if (deviceName?.isNotBlank() == true) {
if (connectionState?.state == GattConnectionState.STATE_DISCONNECTING || connectionState?.state == GattConnectionState.STATE_DISCONNECTED) {
LoggerBackIconAppBar(deviceName, openLogger)
} else {
LoggerIconAppBar(deviceName, navigateUp, disconnect, openLogger)
}
} else {
BackIconAppBar(stringResource(id = title), navigateUp)
}
}

View File

@@ -40,6 +40,6 @@ android {
dependencies {
implementation(libs.nordic.navigation)
implementation(libs.nordic.uiscanner)
implementation(libs.nordic.blek.uiscanner)
implementation(libs.kotlinx.coroutines.core)
}

View File

@@ -36,7 +36,7 @@ import android.content.Context
import android.util.Log
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
val String.Companion.EMPTY
@@ -53,6 +53,6 @@ private val exceptionHandler = CoroutineExceptionHandler { _, t ->
}
fun CoroutineScope.launchWithCatch(block: suspend CoroutineScope.() -> Unit) =
launch(Job() + exceptionHandler) {
launch(SupervisorJob() + exceptionHandler) {
block()
}

View File

@@ -45,13 +45,16 @@ dependencies {
implementation(project(":lib_ui"))
implementation(project(":lib_utils"))
implementation(libs.nordic.blek.client)
implementation(libs.nordic.blek.profile)
implementation(libs.nordic.ble.common)
implementation(libs.nordic.ble.ktx)
implementation(libs.nordic.navigation)
implementation(libs.nordic.theme)
implementation(libs.nordic.uilogger)
implementation(libs.nordic.uiscanner)
implementation(libs.nordic.blek.uiscanner)
implementation(libs.androidx.compose.material.iconsExtended)
implementation(libs.androidx.hilt.navigation.compose)

View File

@@ -1,48 +0,0 @@
/*
* Copyright (c) 2022, 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.bps.data
import no.nordicsemi.android.ble.common.profile.bp.BloodPressureTypes
import java.util.*
data class BPSData(
val batteryLevel: Int? = null,
val cuffPressure: Float = 0f,
val unit: Int = 0,
val pulseRate: Float? = null,
val userID: Int? = null,
val status: BloodPressureTypes.BPMStatus? = null,
val calendar: Calendar? = null,
val systolic: Float = 0f,
val diastolic: Float = 0f,
val meanArterialPressure: Float = 0f,
)

View File

@@ -1,133 +0,0 @@
/*
* Copyright (c) 2022, 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.bps.data
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.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import no.nordicsemi.android.ble.BleManager
import no.nordicsemi.android.ble.common.callback.battery.BatteryLevelResponse
import no.nordicsemi.android.ble.common.callback.bps.BloodPressureMeasurementResponse
import no.nordicsemi.android.ble.common.callback.bps.IntermediateCuffPressureResponse
import no.nordicsemi.android.ble.ktx.asValidResponseFlow
import no.nordicsemi.android.common.logger.NordicLogger
import no.nordicsemi.android.service.ConnectionObserverAdapter
import java.util.*
val BPS_SERVICE_UUID: UUID = UUID.fromString("00001810-0000-1000-8000-00805f9b34fb")
private val BPM_CHARACTERISTIC_UUID = UUID.fromString("00002A35-0000-1000-8000-00805f9b34fb")
private val ICP_CHARACTERISTIC_UUID = UUID.fromString("00002A36-0000-1000-8000-00805f9b34fb")
private val BATTERY_SERVICE_UUID = UUID.fromString("0000180F-0000-1000-8000-00805f9b34fb")
private val BATTERY_LEVEL_CHARACTERISTIC_UUID = UUID.fromString("00002A19-0000-1000-8000-00805f9b34fb")
internal class BPSManager(
@ApplicationContext context: Context,
private val scope: CoroutineScope,
private val logger: NordicLogger
) : BleManager(context) {
private var batteryLevelCharacteristic: BluetoothGattCharacteristic? = null
private var bpmCharacteristic: BluetoothGattCharacteristic? = null
private var icpCharacteristic: BluetoothGattCharacteristic? = null
private val data = MutableStateFlow(BPSData())
val dataHolder = ConnectionObserverAdapter<BPSData>()
init {
connectionObserver = dataHolder
data.onEach {
dataHolder.setValue(it)
}.launchIn(scope)
}
override fun log(priority: Int, message: String) {
logger.log(priority, message)
}
override fun getMinLogPriority(): Int {
return Log.VERBOSE
}
override fun getGattCallback(): BleManagerGattCallback {
return BloodPressureManagerGattCallback()
}
private inner class BloodPressureManagerGattCallback : BleManagerGattCallback() {
@OptIn(ExperimentalCoroutinesApi::class)
override fun initialize() {
super.initialize()
setNotificationCallback(icpCharacteristic).asValidResponseFlow<IntermediateCuffPressureResponse>()
.onEach { data.tryEmit(data.value.copyWithNewResponse(it)) }
.launchIn(scope)
setIndicationCallback(bpmCharacteristic).asValidResponseFlow<BloodPressureMeasurementResponse>()
.onEach { data.tryEmit(data.value.copyWithNewResponse(it)) }
.launchIn(scope)
setNotificationCallback(batteryLevelCharacteristic).asValidResponseFlow<BatteryLevelResponse>()
.onEach {
data.value = data.value.copy(batteryLevel = it.batteryLevel)
}.launchIn(scope)
enableNotifications(icpCharacteristic).enqueue()
enableIndications(bpmCharacteristic).enqueue()
enableNotifications(batteryLevelCharacteristic).enqueue()
}
override fun isRequiredServiceSupported(gatt: BluetoothGatt): Boolean {
gatt.getService(BPS_SERVICE_UUID)?.run {
bpmCharacteristic = getCharacteristic(BPM_CHARACTERISTIC_UUID)
icpCharacteristic = getCharacteristic(ICP_CHARACTERISTIC_UUID)
}
gatt.getService(BATTERY_SERVICE_UUID)?.run {
batteryLevelCharacteristic = getCharacteristic(BATTERY_LEVEL_CHARACTERISTIC_UUID)
}
return bpmCharacteristic != null
}
override fun onServicesInvalidated() {
icpCharacteristic = null
bpmCharacteristic = null
batteryLevelCharacteristic = null
}
}
}

View File

@@ -0,0 +1,12 @@
package no.nordicsemi.android.bps.data
import no.nordicsemi.android.kotlin.ble.core.data.GattConnectionStateWithStatus
import no.nordicsemi.android.kotlin.ble.profile.bps.data.BloodPressureMeasurementData
import no.nordicsemi.android.kotlin.ble.profile.bps.data.IntermediateCuffPressureData
data class BPSServiceData (
val bloodPressureMeasurement: BloodPressureMeasurementData? = null,
val intermediateCuffPressure: IntermediateCuffPressureData? = null,
val batteryLevel: Int? = null,
val connectionState: GattConnectionStateWithStatus? = null
)

View File

@@ -1,62 +0,0 @@
/*
* Copyright (c) 2022, 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.bps.data
import no.nordicsemi.android.ble.common.callback.bps.BloodPressureMeasurementResponse
import no.nordicsemi.android.ble.common.callback.bps.IntermediateCuffPressureResponse
internal fun BPSData.copyWithNewResponse(response: IntermediateCuffPressureResponse): BPSData {
return with (response) {
copy(
cuffPressure = cuffPressure,
unit = unit,
pulseRate = pulseRate,
userID = userID,
status = status,
calendar = timestamp
)
}
}
internal fun BPSData.copyWithNewResponse(response: BloodPressureMeasurementResponse): BPSData {
return with (response) {
copy(
systolic = systolic,
diastolic = diastolic,
meanArterialPressure = meanArterialPressure,
unit = unit,
pulseRate = pulseRate,
userID = userID,
status = status,
)
}
}

View File

@@ -1,98 +0,0 @@
/*
* Copyright (c) 2022, 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.bps.repository
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.scopes.ViewModelScoped
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import no.nordicsemi.android.ble.ktx.suspend
import no.nordicsemi.android.bps.data.BPSData
import no.nordicsemi.android.bps.data.BPSManager
import no.nordicsemi.android.common.logger.NordicLogger
import no.nordicsemi.android.common.logger.NordicLoggerFactory
import no.nordicsemi.android.common.ui.scanner.model.DiscoveredBluetoothDevice
import no.nordicsemi.android.service.BleManagerResult
import no.nordicsemi.android.ui.view.StringConst
import javax.inject.Inject
@ViewModelScoped
internal class BPSRepository @Inject constructor(
@ApplicationContext
private val context: Context,
private val loggerFactory: NordicLoggerFactory,
private val stringConst: StringConst
) {
private var logger: NordicLogger? = null
fun downloadData(scope: CoroutineScope, device: DiscoveredBluetoothDevice): Flow<BleManagerResult<BPSData>> = callbackFlow {
val createdLogger = loggerFactory.create(stringConst.APP_NAME, "BPS", device.address).also {
logger = it
}
val manager = BPSManager(context, scope, createdLogger)
manager.dataHolder.status.onEach {
trySend(it)
}.launchIn(scope)
scope.launch {
manager.start(device)
}
awaitClose {
manager.disconnect().enqueue()
logger = null
}
}
private suspend fun BPSManager.start(device: DiscoveredBluetoothDevice) {
try {
connect(device.device)
.useAutoConnect(false)
.retry(3, 100)
.suspend()
} catch (e: Exception) {
e.printStackTrace()
}
}
fun openLogger() {
NordicLogger.launch(context, logger)
}
}

View File

@@ -34,7 +34,6 @@ package no.nordicsemi.android.bps.view
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -43,10 +42,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import no.nordicsemi.android.bps.R
import no.nordicsemi.android.bps.data.BPSData
import no.nordicsemi.android.bps.data.BPSServiceData
@Composable
internal fun BPSContentView(state: BPSData, onEvent: (BPSViewEvent) -> Unit) {
internal fun BPSContentView(state: BPSServiceData, onEvent: (BPSViewEvent) -> Unit) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {

View File

@@ -1,57 +0,0 @@
/*
* Copyright (c) 2022, 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.bps.view
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import no.nordicsemi.android.bps.R
import no.nordicsemi.android.bps.data.BPSData
@Composable
fun BPSData.displaySystolic(): String {
return stringResource(id = R.string.bps_blood_pressure, systolic)
}
@Composable
fun BPSData.displayDiastolic(): String {
return stringResource(id = R.string.bps_blood_pressure, diastolic)
}
@Composable
fun BPSData.displayMeanArterialPressure(): String {
return stringResource(id = R.string.bps_blood_pressure, meanArterialPressure)
}
@Composable
fun BPSData.displayHeartRate(): String? {
return pulseRate?.toString()
}

View File

@@ -38,77 +38,52 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import no.nordicsemi.android.bps.R
import no.nordicsemi.android.bps.viewmodel.BPSViewModel
import no.nordicsemi.android.common.ui.scanner.view.DeviceConnectingView
import no.nordicsemi.android.common.ui.scanner.view.DeviceDisconnectedView
import no.nordicsemi.android.common.ui.scanner.view.Reason
import no.nordicsemi.android.service.ConnectedResult
import no.nordicsemi.android.service.ConnectingResult
import no.nordicsemi.android.service.DeviceHolder
import no.nordicsemi.android.service.DisconnectedResult
import no.nordicsemi.android.service.IdleResult
import no.nordicsemi.android.service.LinkLossResult
import no.nordicsemi.android.service.MissingServiceResult
import no.nordicsemi.android.service.SuccessResult
import no.nordicsemi.android.service.UnknownErrorResult
import no.nordicsemi.android.ui.view.BackIconAppBar
import no.nordicsemi.android.ui.view.LoggerIconAppBar
import no.nordicsemi.android.kotlin.ble.core.data.GattConnectionState
import no.nordicsemi.android.kotlin.ble.ui.scanner.view.DeviceConnectingView
import no.nordicsemi.android.kotlin.ble.ui.scanner.view.DeviceDisconnectedView
import no.nordicsemi.android.ui.view.NavigateUpButton
import no.nordicsemi.android.ui.view.ProfileAppBar
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BPSScreen() {
val viewModel: BPSViewModel = hiltViewModel()
val state by viewModel.state.collectAsStateWithLifecycle()
val state = viewModel.state.collectAsState().value
val navigateUp = { viewModel.onEvent(DisconnectEvent) }
Scaffold(
topBar = { AppBar(state = state, navigateUp = navigateUp, viewModel = viewModel) }
topBar = {
ProfileAppBar(
deviceName = state.deviceName,
connectionState = state.result.connectionState,
title = R.string.bps_title,
navigateUp = navigateUp,
disconnect = { viewModel.onEvent(DisconnectEvent) },
openLogger = { viewModel.onEvent(OpenLoggerEvent) }
)
}
) {
Column(
modifier = Modifier
.padding(it)
.padding(16.dp)
.verticalScroll(rememberScrollState())
.padding(16.dp)
) {
when (val bpsState = state) {
NoDeviceState -> DeviceConnectingView()
is WorkingState -> when (bpsState.result) {
is IdleResult,
is ConnectingResult -> DeviceConnectingView { NavigateUpButton(navigateUp) }
is ConnectedResult -> DeviceConnectingView { NavigateUpButton(navigateUp) }
is DisconnectedResult -> DeviceDisconnectedView(Reason.USER) { NavigateUpButton(navigateUp) }
is LinkLossResult -> DeviceDisconnectedView(Reason.LINK_LOSS) { NavigateUpButton(navigateUp) }
is MissingServiceResult -> DeviceDisconnectedView(Reason.MISSING_SERVICE) { NavigateUpButton(navigateUp) }
is UnknownErrorResult -> DeviceDisconnectedView(Reason.UNKNOWN) { NavigateUpButton(navigateUp) }
is SuccessResult -> BPSContentView(bpsState.result.data) { viewModel.onEvent(it) }
when (state.result.connectionState?.state) {
null,
GattConnectionState.STATE_CONNECTING -> DeviceConnectingView { NavigateUpButton(navigateUp) }
GattConnectionState.STATE_DISCONNECTED,
GattConnectionState.STATE_DISCONNECTING -> DeviceDisconnectedView(state.disconnectStatus) {
NavigateUpButton(navigateUp)
}
GattConnectionState.STATE_CONNECTED -> BPSContentView(state.result) { viewModel.onEvent(it) }
}
}
}
}
@Composable
private fun AppBar(state: BPSViewState, navigateUp: () -> Unit, viewModel: BPSViewModel) {
val toolbarName = (state as? WorkingState)?.let {
(it.result as? DeviceHolder)?.deviceName()
}
if (toolbarName == null) {
BackIconAppBar(stringResource(id = R.string.bps_title), navigateUp)
} else {
LoggerIconAppBar(toolbarName, {
viewModel.onEvent(DisconnectEvent)
}, { viewModel.onEvent(DisconnectEvent) }) {
viewModel.onEvent(OpenLoggerEvent)
}
}
}

View File

@@ -34,34 +34,45 @@ package no.nordicsemi.android.bps.view
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import no.nordicsemi.android.bps.R
import no.nordicsemi.android.bps.data.BPSData
import no.nordicsemi.android.bps.data.BPSServiceData
import no.nordicsemi.android.kotlin.ble.profile.bps.data.BloodPressureMeasurementData
import no.nordicsemi.android.kotlin.ble.profile.bps.data.IntermediateCuffPressureData
import no.nordicsemi.android.ui.view.BatteryLevelView
import no.nordicsemi.android.ui.view.KeyValueField
import no.nordicsemi.android.ui.view.ScreenSection
import no.nordicsemi.android.ui.view.SectionTitle
@Composable
internal fun BPSSensorsReadingView(state: BPSData) {
internal fun BPSSensorsReadingView(state: BPSServiceData) {
ScreenSection {
Column {
SectionTitle(resId = R.drawable.ic_records, title = stringResource(id = R.string.bps_records))
Spacer(modifier = Modifier.height(16.dp))
KeyValueField(stringResource(id = R.string.bps_systolic), state.displaySystolic())
Spacer(modifier = Modifier.height(4.dp))
KeyValueField(stringResource(id = R.string.bps_diastolic), state.displayDiastolic())
Spacer(modifier = Modifier.height(4.dp))
KeyValueField(stringResource(id = R.string.bps_mean), state.displayMeanArterialPressure())
state.displayHeartRate()?.let {
state.bloodPressureMeasurement?.let {
Spacer(modifier = Modifier.height(16.dp))
BloodPressureView(it)
}
state.intermediateCuffPressure?.displayHeartRate()?.let {
Spacer(modifier = Modifier.height(4.dp))
KeyValueField(stringResource(id = R.string.bps_pulse), it)
}
if (state.intermediateCuffPressure == null && state.bloodPressureMeasurement == null) {
Spacer(modifier = Modifier.height(16.dp))
Text(
stringResource(id = R.string.no_data_info),
style = MaterialTheme.typography.bodyMedium
)
}
}
}
@@ -72,8 +83,37 @@ internal fun BPSSensorsReadingView(state: BPSData) {
}
}
@Composable
private fun BloodPressureView(state: BloodPressureMeasurementData) {
KeyValueField(stringResource(id = R.string.bps_systolic), state.displaySystolic())
Spacer(modifier = Modifier.height(4.dp))
KeyValueField(stringResource(id = R.string.bps_diastolic), state.displayDiastolic())
Spacer(modifier = Modifier.height(4.dp))
KeyValueField(stringResource(id = R.string.bps_mean), state.displayMeanArterialPressure())
}
@Composable
fun BloodPressureMeasurementData.displaySystolic(): String {
return stringResource(id = R.string.bps_blood_pressure, systolic)
}
@Composable
fun BloodPressureMeasurementData.displayDiastolic(): String {
return stringResource(id = R.string.bps_blood_pressure, diastolic)
}
@Composable
fun BloodPressureMeasurementData.displayMeanArterialPressure(): String {
return stringResource(id = R.string.bps_blood_pressure, meanArterialPressure)
}
@Composable
fun IntermediateCuffPressureData.displayHeartRate(): String? {
return pulseRate?.toString()
}
@Preview
@Composable
private fun Preview() {
BPSSensorsReadingView(BPSData())
BPSSensorsReadingView(BPSServiceData())
}

View File

@@ -31,13 +31,18 @@
package no.nordicsemi.android.bps.view
import no.nordicsemi.android.bps.data.BPSData
import no.nordicsemi.android.service.BleManagerResult
import no.nordicsemi.android.bps.data.BPSServiceData
import no.nordicsemi.android.kotlin.ble.core.data.BleGattConnectionStatus
internal sealed class BPSViewState
internal data class BPSViewState(
val result: BPSServiceData = BPSServiceData(),
val deviceName: String? = null,
val missingServices: Boolean = false
) {
internal data class WorkingState(
val result: BleManagerResult<BPSData>
) : BPSViewState()
internal object NoDeviceState : BPSViewState()
val disconnectStatus = if (missingServices) {
BleGattConnectionStatus.NOT_SUPPORTED
} else {
result.connectionState?.status ?: BleGattConnectionStatus.UNKNOWN
}
}

View File

@@ -31,42 +31,70 @@
package no.nordicsemi.android.bps.viewmodel
import android.annotation.SuppressLint
import android.content.Context
import android.os.ParcelUuid
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import no.nordicsemi.android.analytics.AppAnalytics
import no.nordicsemi.android.analytics.Profile
import no.nordicsemi.android.analytics.ProfileConnectedEvent
import no.nordicsemi.android.bps.data.BPS_SERVICE_UUID
import no.nordicsemi.android.bps.repository.BPSRepository
import no.nordicsemi.android.bps.view.BPSViewEvent
import no.nordicsemi.android.bps.view.BPSViewState
import no.nordicsemi.android.bps.view.DisconnectEvent
import no.nordicsemi.android.bps.view.NoDeviceState
import no.nordicsemi.android.bps.view.OpenLoggerEvent
import no.nordicsemi.android.bps.view.WorkingState
import no.nordicsemi.android.common.logger.BleLoggerAndLauncher
import no.nordicsemi.android.common.logger.DefaultBleLogger
import no.nordicsemi.android.common.navigation.NavigationResult
import no.nordicsemi.android.common.navigation.Navigator
import no.nordicsemi.android.common.ui.scanner.model.DiscoveredBluetoothDevice
import no.nordicsemi.android.service.ConnectedResult
import no.nordicsemi.android.kotlin.ble.client.main.callback.ClientBleGatt
import no.nordicsemi.android.kotlin.ble.client.main.service.ClientBleGattServices
import no.nordicsemi.android.kotlin.ble.core.ServerDevice
import no.nordicsemi.android.kotlin.ble.core.data.GattConnectionState
import no.nordicsemi.android.kotlin.ble.core.data.GattConnectionStateWithStatus
import no.nordicsemi.android.kotlin.ble.profile.battery.BatteryLevelParser
import no.nordicsemi.android.kotlin.ble.profile.bps.BloodPressureMeasurementParser
import no.nordicsemi.android.kotlin.ble.profile.bps.IntermediateCuffPressureParser
import no.nordicsemi.android.kotlin.ble.profile.bps.data.BloodPressureMeasurementData
import no.nordicsemi.android.kotlin.ble.profile.bps.data.IntermediateCuffPressureData
import no.nordicsemi.android.toolbox.scanner.ScannerDestinationId
import no.nordicsemi.android.ui.view.StringConst
import java.util.UUID
import javax.inject.Inject
val BPS_SERVICE_UUID: UUID = UUID.fromString("00001810-0000-1000-8000-00805f9b34fb")
private val BPM_CHARACTERISTIC_UUID = UUID.fromString("00002A35-0000-1000-8000-00805f9b34fb")
private val ICP_CHARACTERISTIC_UUID = UUID.fromString("00002A36-0000-1000-8000-00805f9b34fb")
private val BATTERY_SERVICE_UUID = UUID.fromString("0000180F-0000-1000-8000-00805f9b34fb")
private val BATTERY_LEVEL_CHARACTERISTIC_UUID = UUID.fromString("00002A19-0000-1000-8000-00805f9b34fb")
@SuppressLint("MissingPermission", "StaticFieldLeak")
@HiltViewModel
internal class BPSViewModel @Inject constructor(
private val repository: BPSRepository,
@ApplicationContext
private val context: Context,
private val navigationManager: Navigator,
private val analytics: AppAnalytics
private val analytics: AppAnalytics,
private val stringConst: StringConst
) : ViewModel() {
private val _state = MutableStateFlow<BPSViewState>(NoDeviceState)
private val _state = MutableStateFlow(BPSViewState())
val state = _state.asStateFlow()
private lateinit var client: ClientBleGatt
private lateinit var logger: BleLoggerAndLauncher
init {
navigationManager.navigateTo(ScannerDestinationId, ParcelUuid(BPS_SERVICE_UUID))
@@ -75,27 +103,104 @@ internal class BPSViewModel @Inject constructor(
.launchIn(viewModelScope)
}
private fun handleArgs(result: NavigationResult<DiscoveredBluetoothDevice>) {
private fun handleArgs(result: NavigationResult<ServerDevice>) {
when (result) {
is NavigationResult.Cancelled -> navigationManager.navigateUp()
is NavigationResult.Success -> connectDevice(result.value)
is NavigationResult.Success -> startGattClient(result.value)
}
}
fun onEvent(event: BPSViewEvent) {
when (event) {
DisconnectEvent -> navigationManager.navigateUp()
OpenLoggerEvent -> repository.openLogger()
DisconnectEvent -> onDisconnectEvent()
OpenLoggerEvent -> logger.launch()
}
}
private fun connectDevice(device: DiscoveredBluetoothDevice) {
repository.downloadData(viewModelScope, device).onEach {
_state.value = WorkingState(it)
private fun onDisconnectEvent() {
client.disconnect()
navigationManager.navigateUp()
}
(it as? ConnectedResult)?.let {
analytics.logEvent(ProfileConnectedEvent(Profile.BPS))
}
}.launchIn(viewModelScope)
private fun startGattClient(device: ServerDevice) = viewModelScope.launch {
_state.value = _state.value.copy(deviceName = device.name)
logger = DefaultBleLogger.create(context, stringConst.APP_NAME, "BPS", device.address)
client = ClientBleGatt.connect(context, device, logger = logger)
client.connectionStateWithStatus
.filterNotNull()
.onEach { onDataUpdate(it) }
.onEach { logAnalytics(it.state) }
.launchIn(viewModelScope)
if (!client.isConnected) {
return@launch
}
try {
val services = client.discoverServices()
configureGatt(services)
} catch (e: Exception) {
onMissingServices()
}
}
private suspend fun configureGatt(services: ClientBleGattServices) {
val bpsService = services.findService(BPS_SERVICE_UUID)!!
val bpmCharacteristic = bpsService.findCharacteristic(BPM_CHARACTERISTIC_UUID)!!
val icpCharacteristic = bpsService.findCharacteristic(ICP_CHARACTERISTIC_UUID)
val batteryService = services.findService(BATTERY_SERVICE_UUID)!!
val batteryLevelCharacteristic = batteryService.findCharacteristic(BATTERY_LEVEL_CHARACTERISTIC_UUID)!!
batteryLevelCharacteristic.getNotifications()
.mapNotNull { BatteryLevelParser.parse(it) }
.onEach { onDataUpdate(it) }
.catch { it.printStackTrace() }
.launchIn(viewModelScope)
bpmCharacteristic.getNotifications()
.mapNotNull { BloodPressureMeasurementParser.parse(it) }
.onEach { onDataUpdate(it) }
.catch { it.printStackTrace() }
.launchIn(viewModelScope)
icpCharacteristic?.getNotifications()
?.mapNotNull { IntermediateCuffPressureParser.parse(it) }
?.onEach { onDataUpdate(it) }
?.catch { it.printStackTrace() }
?.launchIn(viewModelScope)
}
private fun onMissingServices() {
_state.value = _state.value.copy(missingServices = true)
client.disconnect()
}
private fun onDataUpdate(connectionState: GattConnectionStateWithStatus) {
val newResult = _state.value.result.copy(connectionState = connectionState)
_state.value = _state.value.copy(result = newResult)
}
private fun onDataUpdate(batteryLevel: Int) {
val newResult = _state.value.result.copy(batteryLevel = batteryLevel)
_state.value = _state.value.copy(result = newResult)
}
private fun onDataUpdate(data: BloodPressureMeasurementData) {
val newResult = _state.value.result.copy(bloodPressureMeasurement = data)
_state.value = _state.value.copy(result = newResult)
}
private fun onDataUpdate(data: IntermediateCuffPressureData) {
val newResult = _state.value.result.copy(intermediateCuffPressure = data)
_state.value = _state.value.copy(result = newResult)
}
private fun logAnalytics(connectionState: GattConnectionState) {
if (connectionState == GattConnectionState.STATE_CONNECTED) {
analytics.logEvent(ProfileConnectedEvent(Profile.BPS))
}
}
}

View File

@@ -35,6 +35,8 @@
<string name="bps_records">Data</string>
<string name="no_data_info">No data available. If you are using nRF DK\'s press button 1 to see the result.</string>
<string name="bps_systolic">Systolic</string>
<string name="bps_diastolic">Diastolic</string>
<string name="bps_mean">Mean AP</string>

View File

@@ -45,13 +45,17 @@ dependencies {
implementation(project(":lib_ui"))
implementation(project(":lib_utils"))
implementation(libs.nordic.blek.client)
implementation(libs.nordic.blek.profile)
implementation(libs.nordic.ble.common)
implementation(libs.nordic.ble.ktx)
implementation(libs.nordic.uilogger)
implementation(libs.nordic.theme)
implementation(libs.nordic.uiscanner)
implementation(libs.nordic.blek.uiscanner)
implementation(libs.nordic.navigation)
implementation(libs.nordic.core)
implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.androidx.compose.material.iconsExtended)

View File

@@ -1,38 +0,0 @@
/*
* Copyright (c) 2022, 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.cgms.data
internal data class CGMData(
val records: List<CGMRecord> = emptyList(),
val batteryLevel: Int? = null,
val requestStatus: RequestStatus = RequestStatus.IDLE
)

View File

@@ -1,323 +0,0 @@
/*
* Copyright (c) 2022, 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.cgms.data
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCharacteristic
import android.content.Context
import android.util.Log
import android.util.SparseArray
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import no.nordicsemi.android.ble.BleManager
import no.nordicsemi.android.ble.common.callback.RecordAccessControlPointResponse
import no.nordicsemi.android.ble.common.callback.battery.BatteryLevelResponse
import no.nordicsemi.android.ble.common.callback.cgm.CGMFeatureResponse
import no.nordicsemi.android.ble.common.callback.cgm.CGMSpecificOpsControlPointResponse
import no.nordicsemi.android.ble.common.callback.cgm.CGMStatusResponse
import no.nordicsemi.android.ble.common.callback.cgm.ContinuousGlucoseMeasurementResponse
import no.nordicsemi.android.ble.common.data.RecordAccessControlPointData
import no.nordicsemi.android.ble.common.data.cgm.CGMSpecificOpsControlPointData
import no.nordicsemi.android.ble.common.profile.RecordAccessControlPointCallback
import no.nordicsemi.android.ble.common.profile.cgm.CGMSpecificOpsControlPointCallback
import no.nordicsemi.android.ble.ktx.asValidResponseFlow
import no.nordicsemi.android.ble.ktx.suspend
import no.nordicsemi.android.ble.ktx.suspendForValidResponse
import no.nordicsemi.android.common.logger.NordicLogger
import no.nordicsemi.android.service.ConnectionObserverAdapter
import no.nordicsemi.android.utils.launchWithCatch
import java.util.*
val CGMS_SERVICE_UUID: UUID = UUID.fromString("0000181F-0000-1000-8000-00805f9b34fb")
private val CGM_STATUS_UUID = UUID.fromString("00002AA9-0000-1000-8000-00805f9b34fb")
private val CGM_FEATURE_UUID = UUID.fromString("00002AA8-0000-1000-8000-00805f9b34fb")
private val CGM_MEASUREMENT_UUID = UUID.fromString("00002AA7-0000-1000-8000-00805f9b34fb")
private val CGM_OPS_CONTROL_POINT_UUID = UUID.fromString("00002AAC-0000-1000-8000-00805f9b34fb")
private val RACP_UUID = UUID.fromString("00002A52-0000-1000-8000-00805f9b34fb")
private val BATTERY_SERVICE_UUID = UUID.fromString("0000180F-0000-1000-8000-00805f9b34fb")
private val BATTERY_LEVEL_CHARACTERISTIC_UUID = UUID.fromString("00002A19-0000-1000-8000-00805f9b34fb")
internal class CGMManager(
context: Context,
private val scope: CoroutineScope,
private val logger: NordicLogger
) : BleManager(context) {
private var cgmStatusCharacteristic: BluetoothGattCharacteristic? = null
private var cgmFeatureCharacteristic: BluetoothGattCharacteristic? = null
private var cgmMeasurementCharacteristic: BluetoothGattCharacteristic? = null
private var cgmSpecificOpsControlPointCharacteristic: BluetoothGattCharacteristic? = null
private var recordAccessControlPointCharacteristic: BluetoothGattCharacteristic? = null
private val records: SparseArray<CGMRecord> = SparseArray<CGMRecord>()
private var batteryLevelCharacteristic: BluetoothGattCharacteristic? = null
private var secured = false
private var recordAccessRequestInProgress = false
private var sessionStartTime: Long = 0
private val data = MutableStateFlow(CGMData())
val dataHolder = ConnectionObserverAdapter<CGMData>()
init {
connectionObserver = dataHolder
data.onEach {
dataHolder.setValue(it)
}.launchIn(scope)
}
override fun getGattCallback(): BleManagerGattCallback {
return CGMManagerGattCallback()
}
override fun log(priority: Int, message: String) {
logger.log(priority, message)
}
override fun getMinLogPriority(): Int {
return Log.VERBOSE
}
private inner class CGMManagerGattCallback : BleManagerGattCallback() {
override fun initialize() {
super.initialize()
setNotificationCallback(cgmMeasurementCharacteristic).asValidResponseFlow<ContinuousGlucoseMeasurementResponse>()
.onEach {
if (sessionStartTime == 0L && !recordAccessRequestInProgress) {
val timeOffset = it.items.minOf { it.timeOffset }
sessionStartTime = System.currentTimeMillis() - timeOffset * 60000L
}
it.items.map {
val timestamp = sessionStartTime + it.timeOffset * 60000L
val item = CGMRecord(it.timeOffset, it.glucoseConcentration, timestamp)
records.put(item.sequenceNumber, item)
}
data.value = data.value.copy(records = records.toList())
}.launchIn(scope)
setIndicationCallback(cgmSpecificOpsControlPointCharacteristic).asValidResponseFlow<CGMSpecificOpsControlPointResponse>()
.onEach {
if (it.isOperationCompleted) {
when (it.requestCode) {
CGMSpecificOpsControlPointCallback.CGM_OP_CODE_START_SESSION -> sessionStartTime =
System.currentTimeMillis()
CGMSpecificOpsControlPointCallback.CGM_OP_CODE_STOP_SESSION -> sessionStartTime =
0
}
} else {
when (it.requestCode) {
CGMSpecificOpsControlPointCallback.CGM_OP_CODE_START_SESSION ->
if (it.errorCode == CGMSpecificOpsControlPointCallback.CGM_ERROR_PROCEDURE_NOT_COMPLETED) {
sessionStartTime = 0
}
CGMSpecificOpsControlPointCallback.CGM_OP_CODE_STOP_SESSION -> sessionStartTime =
0
}
}
}.launchIn(scope)
setIndicationCallback(recordAccessControlPointCharacteristic).asValidResponseFlow<RecordAccessControlPointResponse>()
.onEach {
if (it.isOperationCompleted && it.wereRecordsFound() && it.numberOfRecords > 0) {
onRecordsReceived(it)
} else if (it.isOperationCompleted && !it.wereRecordsFound()) {
onNoRecordsFound()
} else if (it.isOperationCompleted && it.wereRecordsFound()) {
onOperationCompleted(it)
} else if (it.errorCode > 0) {
onError(it)
}
}.launchIn(scope)
setNotificationCallback(batteryLevelCharacteristic).asValidResponseFlow<BatteryLevelResponse>()
.onEach {
data.value = data.value.copy(batteryLevel = it.batteryLevel)
}.launchIn(scope)
enableNotifications(cgmMeasurementCharacteristic).enqueue()
enableIndications(cgmSpecificOpsControlPointCharacteristic).enqueue()
enableIndications(recordAccessControlPointCharacteristic).enqueue()
enableNotifications(batteryLevelCharacteristic).enqueue()
scope.launchWithCatch {
val cgmResponse = readCharacteristic(cgmFeatureCharacteristic).suspendForValidResponse<CGMFeatureResponse>()
this@CGMManager.secured = cgmResponse.features.e2eCrcSupported
}
scope.launchWithCatch {
val response = readCharacteristic(cgmStatusCharacteristic).suspendForValidResponse<CGMStatusResponse>()
if (response.status?.sessionStopped == false) {
sessionStartTime = System.currentTimeMillis() - response.timeOffset * 60000L
}
}
scope.launchWithCatch {
if (sessionStartTime == 0L) {
writeCharacteristic(
cgmSpecificOpsControlPointCharacteristic,
CGMSpecificOpsControlPointData.startSession(secured),
BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
).suspend()
}
}
}
private suspend fun onRecordsReceived(response: RecordAccessControlPointResponse) {
if (response.numberOfRecords > 0) {
if (records.size() > 0) {
val sequenceNumber = records.keyAt(records.size() - 1) + 1
writeCharacteristic(
recordAccessControlPointCharacteristic,
RecordAccessControlPointData.reportStoredRecordsGreaterThenOrEqualTo(
sequenceNumber
),
BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
).suspend()
} else {
writeCharacteristic(
recordAccessControlPointCharacteristic,
RecordAccessControlPointData.reportAllStoredRecords(),
BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
).suspend()
}
} else {
recordAccessRequestInProgress = false
data.value = data.value.copy(requestStatus = RequestStatus.SUCCESS)
}
}
private fun onNoRecordsFound() {
recordAccessRequestInProgress = false
data.value = data.value.copy(requestStatus = RequestStatus.SUCCESS)
}
private fun onOperationCompleted(response: RecordAccessControlPointResponse) {
when (response.requestCode) {
RecordAccessControlPointCallback.RACP_OP_CODE_ABORT_OPERATION ->
data.value = data.value.copy(requestStatus = RequestStatus.ABORTED)
else -> {
recordAccessRequestInProgress = false
data.value = data.value.copy(requestStatus = RequestStatus.SUCCESS)
}
}
}
private fun onError(response: RecordAccessControlPointResponse) {
if (response.errorCode == RecordAccessControlPointCallback.RACP_ERROR_OP_CODE_NOT_SUPPORTED) {
data.value = data.value.copy(requestStatus = RequestStatus.NOT_SUPPORTED)
} else {
data.value = data.value.copy(requestStatus = RequestStatus.FAILED)
}
}
override fun isRequiredServiceSupported(gatt: BluetoothGatt): Boolean {
gatt.getService(CGMS_SERVICE_UUID)?.run {
cgmStatusCharacteristic = getCharacteristic(CGM_STATUS_UUID)
cgmFeatureCharacteristic = getCharacteristic(CGM_FEATURE_UUID)
cgmMeasurementCharacteristic = getCharacteristic(CGM_MEASUREMENT_UUID)
cgmSpecificOpsControlPointCharacteristic = getCharacteristic(CGM_OPS_CONTROL_POINT_UUID)
recordAccessControlPointCharacteristic = getCharacteristic(RACP_UUID)
}
gatt.getService(BATTERY_SERVICE_UUID)?.run {
batteryLevelCharacteristic = getCharacteristic(BATTERY_LEVEL_CHARACTERISTIC_UUID)
}
return cgmMeasurementCharacteristic != null
&& cgmSpecificOpsControlPointCharacteristic != null
&& recordAccessControlPointCharacteristic != null
&& cgmStatusCharacteristic != null
&& cgmFeatureCharacteristic != null
}
override fun onServicesInvalidated() {
cgmStatusCharacteristic = null
cgmFeatureCharacteristic = null
cgmMeasurementCharacteristic = null
cgmSpecificOpsControlPointCharacteristic = null
recordAccessControlPointCharacteristic = null
batteryLevelCharacteristic = null
}
}
private fun clear() {
records.clear()
}
fun requestLastRecord() {
if (recordAccessControlPointCharacteristic == null) return
clear()
data.value = data.value.copy(requestStatus = RequestStatus.PENDING)
recordAccessRequestInProgress = true
scope.launchWithCatch {
writeCharacteristic(
recordAccessControlPointCharacteristic,
RecordAccessControlPointData.reportLastStoredRecord(),
BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
).suspend()
}
}
fun requestFirstRecord() {
if (recordAccessControlPointCharacteristic == null) return
clear()
data.value = data.value.copy(requestStatus = RequestStatus.PENDING)
recordAccessRequestInProgress = true
scope.launchWithCatch {
writeCharacteristic(
recordAccessControlPointCharacteristic,
RecordAccessControlPointData.reportFirstStoredRecord(),
BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
).suspend()
}
}
fun requestAllRecords() {
if (recordAccessControlPointCharacteristic == null) return
clear()
data.value = data.value.copy(requestStatus = RequestStatus.PENDING)
recordAccessRequestInProgress = true
scope.launchWithCatch {
writeCharacteristic(
recordAccessControlPointCharacteristic,
RecordAccessControlPointData.reportNumberOfAllStoredRecords(),
BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
).suspend()
}
}
}

View File

@@ -1,42 +0,0 @@
/*
* Copyright (c) 2022, 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.cgms.data
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
internal data class CGMRecord(
var sequenceNumber: Int,
var glucoseConcentration: Float,
var timestamp: Long
) : Parcelable

View File

@@ -0,0 +1,28 @@
package no.nordicsemi.android.cgms.data
import no.nordicsemi.android.kotlin.ble.core.data.BleGattConnectionStatus
import no.nordicsemi.android.kotlin.ble.core.data.GattConnectionStateWithStatus
import no.nordicsemi.android.kotlin.ble.profile.cgm.data.CGMRecord
import no.nordicsemi.android.kotlin.ble.profile.gls.data.RequestStatus
internal data class CGMServiceData(
val records: List<CGMRecordWithSequenceNumber> = emptyList(),
val batteryLevel: Int? = null,
val connectionState: GattConnectionStateWithStatus? = null,
val requestStatus: RequestStatus = RequestStatus.IDLE,
val deviceName: String? = null,
val missingServices: Boolean = false
) {
val disconnectStatus = if (missingServices) {
BleGattConnectionStatus.NOT_SUPPORTED
} else {
connectionState?.status ?: BleGattConnectionStatus.UNKNOWN
}
}
data class CGMRecordWithSequenceNumber(
val sequenceNumber: Int,
val record: CGMRecord,
val timestamp: Long
)

View File

@@ -1,43 +0,0 @@
/*
* Copyright (c) 2022, 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.cgms.data
import android.util.SparseArray
import androidx.core.util.keyIterator
internal fun SparseArray<CGMRecord>.toList(): List<CGMRecord> {
val list = mutableListOf<CGMRecord>()
this.keyIterator().forEach {
list.add(get(it))
}
return list.sortedBy { it.sequenceNumber }.toList()
}

View File

@@ -1,36 +0,0 @@
/*
* Copyright (c) 2022, 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.cgms.data
internal enum class RequestStatus {
IDLE, PENDING, SUCCESS, ABORTED, FAILED, NOT_SUPPORTED
}

View File

@@ -33,21 +33,21 @@ package no.nordicsemi.android.cgms.repository
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import no.nordicsemi.android.ble.ktx.suspend
import no.nordicsemi.android.cgms.data.CGMData
import no.nordicsemi.android.cgms.data.CGMManager
import no.nordicsemi.android.common.logger.NordicLogger
import no.nordicsemi.android.common.logger.NordicLoggerFactory
import no.nordicsemi.android.common.ui.scanner.model.DiscoveredBluetoothDevice
import no.nordicsemi.android.service.BleManagerResult
import no.nordicsemi.android.service.IdleResult
import no.nordicsemi.android.cgms.data.CGMRecordWithSequenceNumber
import no.nordicsemi.android.cgms.data.CGMServiceCommand
import no.nordicsemi.android.cgms.data.CGMServiceData
import no.nordicsemi.android.common.core.simpleSharedFlow
import no.nordicsemi.android.common.logger.BleLoggerAndLauncher
import no.nordicsemi.android.common.logger.DefaultBleLogger
import no.nordicsemi.android.kotlin.ble.core.ServerDevice
import no.nordicsemi.android.kotlin.ble.core.data.GattConnectionState
import no.nordicsemi.android.kotlin.ble.core.data.GattConnectionStateWithStatus
import no.nordicsemi.android.kotlin.ble.profile.gls.data.RequestStatus
import no.nordicsemi.android.service.DisconnectAndStopEvent
import no.nordicsemi.android.service.ServiceManager
import no.nordicsemi.android.ui.view.StringConst
import javax.inject.Inject
@@ -58,68 +58,89 @@ class CGMRepository @Inject constructor(
@ApplicationContext
private val context: Context,
private val serviceManager: ServiceManager,
private val loggerFactory: NordicLoggerFactory,
private val stringConst: StringConst
) {
private var manager: CGMManager? = null
private var logger: NordicLogger? = null
private var logger: BleLoggerAndLauncher? = null
private val _data = MutableStateFlow<BleManagerResult<CGMData>>(IdleResult())
private val _data = MutableStateFlow(CGMServiceData())
internal val data = _data.asStateFlow()
val isRunning = data.map { it.isRunning() }
val hasBeenDisconnected = data.map { it.hasBeenDisconnected() }
private val _stopEvent = simpleSharedFlow<DisconnectAndStopEvent>()
internal val stopEvent = _stopEvent.asSharedFlow()
fun launch(device: DiscoveredBluetoothDevice) {
private val _command = simpleSharedFlow<CGMServiceCommand>()
internal val command = _command.asSharedFlow()
val isRunning = data.map { it.connectionState?.state == GattConnectionState.STATE_CONNECTED }
val hasRecords = data.value.records.isNotEmpty()
val highestSequenceNumber = data.value.records.maxOfOrNull { it.sequenceNumber } ?: -1
private var isOnScreen = false
private var isServiceRunning = false
fun setOnScreen(isOnScreen: Boolean) {
this.isOnScreen = isOnScreen
if (shouldClean()) clean()
}
fun setServiceRunning(serviceRunning: Boolean) {
this.isServiceRunning = serviceRunning
if (shouldClean()) clean()
}
private fun shouldClean() = !isOnScreen && !isServiceRunning
fun launch(device: ServerDevice) {
logger = DefaultBleLogger.create(context, stringConst.APP_NAME, "CGM", device.address)
_data.value = _data.value.copy(deviceName = device.name)
serviceManager.startService(CGMService::class.java, device)
}
fun start(device: DiscoveredBluetoothDevice, scope: CoroutineScope) {
val createdLogger = loggerFactory.create(stringConst.APP_NAME, "CGMS", device.address).also {
logger = it
}
val manager = CGMManager(context, scope, createdLogger)
this.manager = manager
manager.dataHolder.status.onEach {
_data.value = it
}.launchIn(scope)
scope.launch {
manager.start(device)
}
fun onDataReceived(data: List<CGMRecordWithSequenceNumber>) {
_data.value = _data.value.copy(records = _data.value.records + data)
}
private suspend fun CGMManager.start(device: DiscoveredBluetoothDevice) {
try {
connect(device.device)
.useAutoConnect(false)
.retry(3, 100)
.suspend()
} catch (e: Exception) {
e.printStackTrace()
}
internal fun onCommand(command: CGMServiceCommand) {
_command.tryEmit(command)
}
fun requestAllRecords() {
manager?.requestAllRecords()
fun onConnectionStateChanged(connectionState: GattConnectionStateWithStatus?) {
_data.value = _data.value.copy(connectionState = connectionState)
}
fun requestLastRecord() {
manager?.requestLastRecord()
fun onBatteryLevelChanged(batteryLevel: Int) {
_data.value = _data.value.copy(batteryLevel = batteryLevel)
}
fun requestFirstRecord() {
manager?.requestFirstRecord()
fun onNewRequestStatus(requestStatus: RequestStatus) {
_data.value = _data.value.copy(requestStatus = requestStatus)
}
fun onMissingServices() {
_data.value = _data.value.copy(missingServices = true)
_stopEvent.tryEmit(DisconnectAndStopEvent())
}
fun openLogger() {
NordicLogger.launch(context, logger)
logger?.launch()
}
fun release() {
manager?.disconnect()?.enqueue()
fun log(priority: Int, message: String) {
logger?.log(priority, message)
}
fun clear() {
_data.value = _data.value.copy(records = emptyList())
}
fun disconnect() {
_stopEvent.tryEmit(DisconnectAndStopEvent())
}
private fun clean() {
logger = null
manager = null
_data.value = CGMServiceData()
}
}

View File

@@ -31,33 +31,304 @@
package no.nordicsemi.android.cgms.repository
import android.annotation.SuppressLint
import android.content.Intent
import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.onEach
import no.nordicsemi.android.common.ui.scanner.model.DiscoveredBluetoothDevice
import kotlinx.coroutines.launch
import no.nordicsemi.android.cgms.data.CGMRecordWithSequenceNumber
import no.nordicsemi.android.cgms.data.CGMServiceCommand
import no.nordicsemi.android.kotlin.ble.client.main.callback.ClientBleGatt
import no.nordicsemi.android.kotlin.ble.client.main.errors.GattOperationException
import no.nordicsemi.android.kotlin.ble.client.main.service.ClientBleGattCharacteristic
import no.nordicsemi.android.kotlin.ble.client.main.service.ClientBleGattServices
import no.nordicsemi.android.kotlin.ble.core.ServerDevice
import no.nordicsemi.android.kotlin.ble.core.data.GattConnectionState
import no.nordicsemi.android.kotlin.ble.core.data.GattConnectionStateWithStatus
import no.nordicsemi.android.kotlin.ble.profile.battery.BatteryLevelParser
import no.nordicsemi.android.kotlin.ble.profile.cgm.CGMFeatureParser
import no.nordicsemi.android.kotlin.ble.profile.cgm.CGMMeasurementParser
import no.nordicsemi.android.kotlin.ble.profile.cgm.CGMSpecificOpsControlPointParser
import no.nordicsemi.android.kotlin.ble.profile.cgm.CGMStatusParser
import no.nordicsemi.android.kotlin.ble.profile.cgm.data.CGMErrorCode
import no.nordicsemi.android.kotlin.ble.profile.cgm.data.CGMOpCode
import no.nordicsemi.android.kotlin.ble.profile.cgm.data.CGMSpecificOpsControlPointData
import no.nordicsemi.android.kotlin.ble.profile.gls.CGMSpecificOpsControlPointDataParser
import no.nordicsemi.android.kotlin.ble.profile.gls.RecordAccessControlPointInputParser
import no.nordicsemi.android.kotlin.ble.profile.gls.RecordAccessControlPointParser
import no.nordicsemi.android.kotlin.ble.profile.gls.data.NumberOfRecordsData
import no.nordicsemi.android.kotlin.ble.profile.gls.data.RecordAccessControlPointData
import no.nordicsemi.android.kotlin.ble.profile.gls.data.RequestStatus
import no.nordicsemi.android.kotlin.ble.profile.gls.data.ResponseData
import no.nordicsemi.android.kotlin.ble.profile.racp.RACPOpCode
import no.nordicsemi.android.kotlin.ble.profile.racp.RACPResponseCode
import no.nordicsemi.android.service.DEVICE_DATA
import no.nordicsemi.android.service.NotificationService
import no.nordicsemi.android.utils.launchWithCatch
import java.util.*
import javax.inject.Inject
val CGMS_SERVICE_UUID: UUID = UUID.fromString("0000181F-0000-1000-8000-00805f9b34fb")
private val CGM_STATUS_UUID = UUID.fromString("00002AA9-0000-1000-8000-00805f9b34fb")
private val CGM_FEATURE_UUID = UUID.fromString("00002AA8-0000-1000-8000-00805f9b34fb")
private val CGM_MEASUREMENT_UUID = UUID.fromString("00002AA7-0000-1000-8000-00805f9b34fb")
private val CGM_OPS_CONTROL_POINT_UUID = UUID.fromString("00002AAC-0000-1000-8000-00805f9b34fb")
private val RACP_UUID = UUID.fromString("00002A52-0000-1000-8000-00805f9b34fb")
private val BATTERY_SERVICE_UUID = UUID.fromString("0000180F-0000-1000-8000-00805f9b34fb")
private val BATTERY_LEVEL_CHARACTERISTIC_UUID = UUID.fromString("00002A19-0000-1000-8000-00805f9b34fb")
@SuppressLint("MissingPermission")
@AndroidEntryPoint
internal class CGMService : NotificationService() {
@Inject
lateinit var repository: CGMRepository
private lateinit var client: ClientBleGatt
private var secured = false
private var recordAccessRequestInProgress = false
private var sessionStartTime: Long = 0
private lateinit var recordAccessControlPointCharacteristic: ClientBleGattCharacteristic
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId)
val device = intent!!.getParcelableExtra<DiscoveredBluetoothDevice>(DEVICE_DATA)!!
repository.setServiceRunning(true)
repository.start(device, lifecycleScope)
val device = intent!!.getParcelableExtra<ServerDevice>(DEVICE_DATA)!!
repository.hasBeenDisconnected.onEach {
if (it) stopSelf()
}.launchIn(lifecycleScope)
startGattClient(device)
repository.stopEvent
.onEach { disconnect() }
.launchIn(lifecycleScope)
repository.command
.onEach { onCommand(it) }
.launchIn(lifecycleScope)
return START_REDELIVER_INTENT
}
private fun onCommand(command: CGMServiceCommand) = lifecycleScope.launch{
when (command) {
CGMServiceCommand.REQUEST_ALL_RECORDS -> requestAllRecords()
CGMServiceCommand.REQUEST_LAST_RECORD -> requestLastRecord()
CGMServiceCommand.REQUEST_FIRST_RECORD -> requestFirstRecord()
CGMServiceCommand.DISCONNECT -> client.disconnect()
}
}
private fun startGattClient(device: ServerDevice) = lifecycleScope.launch {
client = ClientBleGatt.connect(this@CGMService, device, logger = { p, s -> repository.log(p, s) })
client.connectionStateWithStatus
.onEach { repository.onConnectionStateChanged(it) }
.filterNotNull()
.onEach { stopIfDisconnected(it) }
.launchIn(lifecycleScope)
if (!client.isConnected) {
return@launch
}
try {
val services = client.discoverServices()
configureGatt(services)
} catch (e: Exception) {
repository.onMissingServices()
}
}
private suspend fun configureGatt(services: ClientBleGattServices) {
val cgmService = services.findService(CGMS_SERVICE_UUID)!!
val statusCharacteristic = cgmService.findCharacteristic(CGM_STATUS_UUID)!!
val featureCharacteristic = cgmService.findCharacteristic(CGM_FEATURE_UUID)!!
val measurementCharacteristic = cgmService.findCharacteristic(CGM_MEASUREMENT_UUID)!!
val opsControlPointCharacteristic = cgmService.findCharacteristic(CGM_OPS_CONTROL_POINT_UUID)!!
recordAccessControlPointCharacteristic = cgmService.findCharacteristic(RACP_UUID)!!
val batteryService = services.findService(BATTERY_SERVICE_UUID)!!
val batteryLevelCharacteristic = batteryService.findCharacteristic(BATTERY_LEVEL_CHARACTERISTIC_UUID)!!
batteryLevelCharacteristic.getNotifications()
.mapNotNull { BatteryLevelParser.parse(it) }
.onEach { repository.onBatteryLevelChanged(it) }
.catch { it.printStackTrace() }
.launchIn(lifecycleScope)
measurementCharacteristic.getNotifications()
.mapNotNull { CGMMeasurementParser.parse(it) }
.onEach {
if (sessionStartTime == 0L && !recordAccessRequestInProgress) {
val timeOffset = it.minOf { it.timeOffset }
sessionStartTime = System.currentTimeMillis() - timeOffset * 60000L
}
val result = it.map {
val timestamp = sessionStartTime + it.timeOffset * 60000L
CGMRecordWithSequenceNumber(it.timeOffset, it, timestamp)
}
repository.onDataReceived(result)
}
.catch { it.printStackTrace() }
.launchIn(lifecycleScope)
opsControlPointCharacteristic.getNotifications()
.mapNotNull { CGMSpecificOpsControlPointParser.parse(it) }
.onEach {
if (it.isOperationCompleted) {
sessionStartTime = if (it.requestCode == CGMOpCode.CGM_OP_CODE_START_SESSION) {
System.currentTimeMillis()
} else {
0
}
} else {
if (it.requestCode == CGMOpCode.CGM_OP_CODE_START_SESSION && it.errorCode == CGMErrorCode.CGM_ERROR_PROCEDURE_NOT_COMPLETED) {
sessionStartTime = 0
} else if (it.requestCode == CGMOpCode.CGM_OP_CODE_STOP_SESSION) {
sessionStartTime = 0
}
}
}
.catch { it.printStackTrace() }
.launchIn(lifecycleScope)
recordAccessControlPointCharacteristic.getNotifications()
.mapNotNull { RecordAccessControlPointParser.parse(it) }
.onEach { onAccessControlPointDataReceived(it) }
.catch { it.printStackTrace() }
.launchIn(lifecycleScope)
lifecycleScope.launchWithCatch {
val featuresEnvelope = featureCharacteristic.read().let { CGMFeatureParser.parse(it) }!!
secured = featuresEnvelope.features.e2eCrcSupported
}
lifecycleScope.launchWithCatch {
val statusEnvelope = statusCharacteristic.read().let { CGMStatusParser.parse(it) }!!
if (!statusEnvelope.status.sessionStopped) {
sessionStartTime = System.currentTimeMillis() - statusEnvelope.timeOffset * 60000L
}
}
if (sessionStartTime == 0L) {
opsControlPointCharacteristic.write(CGMSpecificOpsControlPointDataParser.startSession(secured))
}
}
private fun onAccessControlPointDataReceived(data: RecordAccessControlPointData) = lifecycleScope.launch {
when (data) {
is NumberOfRecordsData -> onNumberOfRecordsReceived(data.numberOfRecords)
is ResponseData -> when (data.responseCode) {
RACPResponseCode.RACP_RESPONSE_SUCCESS -> onRecordAccessOperationCompleted(data.requestCode)
RACPResponseCode.RACP_ERROR_NO_RECORDS_FOUND -> onRecordAccessOperationCompletedWithNoRecordsFound()
RACPResponseCode.RACP_ERROR_OP_CODE_NOT_SUPPORTED,
RACPResponseCode.RACP_ERROR_INVALID_OPERATOR,
RACPResponseCode.RACP_ERROR_OPERATOR_NOT_SUPPORTED,
RACPResponseCode.RACP_ERROR_INVALID_OPERAND,
RACPResponseCode.RACP_ERROR_ABORT_UNSUCCESSFUL,
RACPResponseCode.RACP_ERROR_PROCEDURE_NOT_COMPLETED,
RACPResponseCode.RACP_ERROR_OPERAND_NOT_SUPPORTED -> onRecordAccessOperationError(data.responseCode)
}
}
}
private fun onRecordAccessOperationCompleted(requestCode: RACPOpCode) {
val status = when (requestCode) {
RACPOpCode.RACP_OP_CODE_ABORT_OPERATION -> RequestStatus.ABORTED
else -> RequestStatus.SUCCESS
}
repository.onNewRequestStatus(status)
}
private fun onRecordAccessOperationCompletedWithNoRecordsFound() {
repository.onNewRequestStatus(RequestStatus.SUCCESS)
}
private suspend fun onNumberOfRecordsReceived(numberOfRecords: Int) {
if (numberOfRecords > 0) {
if (repository.hasRecords) {
recordAccessControlPointCharacteristic.write(
RecordAccessControlPointInputParser.reportStoredRecordsGreaterThenOrEqualTo(repository.highestSequenceNumber)
)
} else {
recordAccessControlPointCharacteristic.write(
RecordAccessControlPointInputParser.reportAllStoredRecords()
)
}
}
repository.onNewRequestStatus(RequestStatus.SUCCESS)
}
private fun onRecordAccessOperationError(response: RACPResponseCode) {
if (response == RACPResponseCode.RACP_ERROR_OP_CODE_NOT_SUPPORTED) {
repository.onNewRequestStatus(RequestStatus.NOT_SUPPORTED)
} else {
repository.onNewRequestStatus(RequestStatus.FAILED)
}
}
private fun clear() {
repository.clear()
}
private suspend fun requestLastRecord() {
clear()
repository.onNewRequestStatus(RequestStatus.PENDING)
try {
recordAccessControlPointCharacteristic.write(RecordAccessControlPointInputParser.reportLastStoredRecord())
} catch (e: GattOperationException) {
e.printStackTrace()
repository.onNewRequestStatus(RequestStatus.FAILED)
}
}
private suspend fun requestFirstRecord() {
clear()
repository.onNewRequestStatus(RequestStatus.PENDING)
try {
recordAccessControlPointCharacteristic.write(RecordAccessControlPointInputParser.reportFirstStoredRecord())
} catch (e: GattOperationException) {
e.printStackTrace()
repository.onNewRequestStatus(RequestStatus.FAILED)
}
}
private suspend fun requestAllRecords() {
clear()
repository.onNewRequestStatus(RequestStatus.PENDING)
try {
recordAccessControlPointCharacteristic.write(RecordAccessControlPointInputParser.reportNumberOfAllStoredRecords())
} catch (e: GattOperationException) {
e.printStackTrace()
repository.onNewRequestStatus(RequestStatus.FAILED)
}
}
private fun stopIfDisconnected(connectionState: GattConnectionStateWithStatus) {
if (connectionState.state == GattConnectionState.STATE_DISCONNECTED) {
stopSelf()
}
}
private fun disconnect() {
client.disconnect()
}
override fun onDestroy() {
super.onDestroy()
repository.setServiceRunning(false)
}
}

View File

@@ -38,7 +38,6 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Search
@@ -53,16 +52,16 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import no.nordicsemi.android.cgms.R
import no.nordicsemi.android.cgms.data.CGMData
import no.nordicsemi.android.cgms.data.CGMRecord
import no.nordicsemi.android.cgms.data.CGMRecordWithSequenceNumber
import no.nordicsemi.android.cgms.data.CGMServiceCommand
import no.nordicsemi.android.cgms.data.RequestStatus
import no.nordicsemi.android.cgms.data.CGMServiceData
import no.nordicsemi.android.kotlin.ble.profile.gls.data.RequestStatus
import no.nordicsemi.android.ui.view.BatteryLevelView
import no.nordicsemi.android.ui.view.ScreenSection
import no.nordicsemi.android.ui.view.SectionTitle
@Composable
internal fun CGMContentView(state: CGMData, onEvent: (CGMViewEvent) -> Unit) {
internal fun CGMContentView(state: CGMServiceData, onEvent: (CGMViewEvent) -> Unit) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally
@@ -92,7 +91,7 @@ internal fun CGMContentView(state: CGMData, onEvent: (CGMViewEvent) -> Unit) {
}
@Composable
private fun SettingsView(state: CGMData, onEvent: (CGMViewEvent) -> Unit) {
private fun SettingsView(state: CGMServiceData, onEvent: (CGMViewEvent) -> Unit) {
ScreenSection {
SectionTitle(icon = Icons.Default.Settings, title = "Request items")
@@ -120,7 +119,7 @@ private fun SettingsView(state: CGMData, onEvent: (CGMViewEvent) -> Unit) {
}
@Composable
private fun RecordsView(state: CGMData) {
private fun RecordsView(state: CGMServiceData) {
ScreenSection {
if (state.records.isEmpty()) {
RecordsViewWithoutData()
@@ -132,7 +131,7 @@ private fun RecordsView(state: CGMData) {
}
@Composable
private fun RecordsViewWithData(state: CGMData) {
private fun RecordsViewWithData(state: CGMServiceData) {
Column(modifier = Modifier.fillMaxWidth()) {
SectionTitle(resId = R.drawable.ic_records, title = "Records")
@@ -149,7 +148,7 @@ private fun RecordsViewWithData(state: CGMData) {
}
@Composable
private fun RecordItem(record: CGMRecord) {
private fun RecordItem(record: CGMRecordWithSequenceNumber) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(
modifier = Modifier

View File

@@ -34,16 +34,17 @@ package no.nordicsemi.android.cgms.view
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import no.nordicsemi.android.cgms.R
import no.nordicsemi.android.cgms.data.CGMRecord
import no.nordicsemi.android.cgms.data.CGMRecordWithSequenceNumber
import java.text.SimpleDateFormat
import java.util.*
import java.util.Date
import java.util.Locale
internal fun CGMRecord.formattedTime(): String {
internal fun CGMRecordWithSequenceNumber.formattedTime(): String {
val timeFormat = SimpleDateFormat("dd.MM.yyyy HH:mm", Locale.US)
return timeFormat.format(Date(timestamp))
}
@Composable
internal fun CGMRecord.glucoseConcentration(): String {
return stringResource(id = R.string.cgms_value_unit, glucoseConcentration)
internal fun CGMRecordWithSequenceNumber.glucoseConcentration(): String {
return stringResource(id = R.string.cgms_value_unit, record.glucoseConcentration)
}

View File

@@ -38,75 +38,54 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import no.nordicsemi.android.cgms.R
import no.nordicsemi.android.cgms.viewmodel.CGMViewModel
import no.nordicsemi.android.common.ui.scanner.view.DeviceConnectingView
import no.nordicsemi.android.common.ui.scanner.view.DeviceDisconnectedView
import no.nordicsemi.android.common.ui.scanner.view.Reason
import no.nordicsemi.android.service.ConnectedResult
import no.nordicsemi.android.service.ConnectingResult
import no.nordicsemi.android.service.DeviceHolder
import no.nordicsemi.android.service.DisconnectedResult
import no.nordicsemi.android.service.IdleResult
import no.nordicsemi.android.service.LinkLossResult
import no.nordicsemi.android.service.MissingServiceResult
import no.nordicsemi.android.service.SuccessResult
import no.nordicsemi.android.service.UnknownErrorResult
import no.nordicsemi.android.ui.view.BackIconAppBar
import no.nordicsemi.android.ui.view.LoggerIconAppBar
import no.nordicsemi.android.kotlin.ble.core.data.GattConnectionState
import no.nordicsemi.android.kotlin.ble.ui.scanner.view.DeviceConnectingView
import no.nordicsemi.android.kotlin.ble.ui.scanner.view.DeviceDisconnectedView
import no.nordicsemi.android.ui.view.NavigateUpButton
import no.nordicsemi.android.ui.view.ProfileAppBar
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CGMScreen() {
val viewModel: CGMViewModel = hiltViewModel()
val state by viewModel.state.collectAsStateWithLifecycle()
val state = viewModel.state.collectAsState().value
val navigateUp = { viewModel.onEvent(NavigateUp) }
Scaffold(
topBar = { AppBar(state = state, navigateUp = navigateUp, viewModel = viewModel) }
topBar = {
ProfileAppBar(
deviceName = state.deviceName,
connectionState = state.connectionState,
title = R.string.cgms_title,
navigateUp = navigateUp,
disconnect = { viewModel.onEvent(DisconnectEvent) },
openLogger = { viewModel.onEvent(OpenLoggerEvent) }
)
}
) {
Column(
modifier = Modifier
.padding(it)
.padding(16.dp)
.verticalScroll(rememberScrollState())
.padding(16.dp)
) {
when (val cgmState = state) {
NoDeviceState -> DeviceConnectingView()
is WorkingState -> when (cgmState.result) {
is IdleResult,
is ConnectingResult -> DeviceConnectingView { NavigateUpButton(navigateUp) }
is ConnectedResult -> DeviceConnectingView { NavigateUpButton(navigateUp) }
is DisconnectedResult -> DeviceDisconnectedView(Reason.USER) { NavigateUpButton(navigateUp) }
is LinkLossResult -> DeviceDisconnectedView(Reason.LINK_LOSS) { NavigateUpButton(navigateUp) }
is MissingServiceResult -> DeviceDisconnectedView(Reason.MISSING_SERVICE) { NavigateUpButton(navigateUp) }
is UnknownErrorResult -> DeviceDisconnectedView(Reason.UNKNOWN) { NavigateUpButton(navigateUp) }
is SuccessResult -> CGMContentView(cgmState.result.data) { viewModel.onEvent(it) }
when (state.connectionState?.state) {
null,
GattConnectionState.STATE_CONNECTING -> DeviceConnectingView { NavigateUpButton(navigateUp) }
GattConnectionState.STATE_DISCONNECTED,
GattConnectionState.STATE_DISCONNECTING -> DeviceDisconnectedView(state.disconnectStatus) {
NavigateUpButton(navigateUp)
}
GattConnectionState.STATE_CONNECTED -> CGMContentView(state) { viewModel.onEvent(it) }
}
}
}
}
@Composable
private fun AppBar(state: CGMViewState, navigateUp: () -> Unit, viewModel: CGMViewModel) {
val toolbarName = (state as? WorkingState)?.let {
(it.result as? DeviceHolder)?.deviceName()
}
if (toolbarName == null) {
BackIconAppBar(stringResource(id = R.string.cgms_title), navigateUp)
} else {
LoggerIconAppBar(toolbarName, navigateUp, { viewModel.onEvent(DisconnectEvent) }) {
viewModel.onEvent(OpenLoggerEvent)
}
}
}

View File

@@ -1,40 +0,0 @@
/*
* Copyright (c) 2022, 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.cgms.view
import no.nordicsemi.android.cgms.data.CGMData
import no.nordicsemi.android.service.BleManagerResult
internal sealed class CGMViewState
internal data class WorkingState(val result: BleManagerResult<CGMData>) : CGMViewState()
internal object NoDeviceState : CGMViewState()

View File

@@ -35,8 +35,6 @@ import android.os.ParcelUuid
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@@ -44,21 +42,18 @@ import kotlinx.coroutines.launch
import no.nordicsemi.android.analytics.AppAnalytics
import no.nordicsemi.android.analytics.Profile
import no.nordicsemi.android.analytics.ProfileConnectedEvent
import no.nordicsemi.android.cgms.data.CGMS_SERVICE_UUID
import no.nordicsemi.android.cgms.data.CGMServiceCommand
import no.nordicsemi.android.cgms.repository.CGMRepository
import no.nordicsemi.android.cgms.repository.CGMS_SERVICE_UUID
import no.nordicsemi.android.cgms.view.CGMViewEvent
import no.nordicsemi.android.cgms.view.CGMViewState
import no.nordicsemi.android.cgms.view.DisconnectEvent
import no.nordicsemi.android.cgms.view.NavigateUp
import no.nordicsemi.android.cgms.view.NoDeviceState
import no.nordicsemi.android.cgms.view.OnWorkingModeSelected
import no.nordicsemi.android.cgms.view.OpenLoggerEvent
import no.nordicsemi.android.cgms.view.WorkingState
import no.nordicsemi.android.common.navigation.NavigationResult
import no.nordicsemi.android.common.navigation.Navigator
import no.nordicsemi.android.common.ui.scanner.model.DiscoveredBluetoothDevice
import no.nordicsemi.android.service.ConnectedResult
import no.nordicsemi.android.kotlin.ble.core.ServerDevice
import no.nordicsemi.android.kotlin.ble.core.data.GattConnectionState
import no.nordicsemi.android.toolbox.scanner.ScannerDestinationId
import javax.inject.Inject
@@ -69,10 +64,11 @@ internal class CGMViewModel @Inject constructor(
private val analytics: AppAnalytics
) : ViewModel() {
private val _state = MutableStateFlow<CGMViewState>(NoDeviceState)
val state = _state.asStateFlow()
val state = repository.data
init {
repository.setOnScreen(true)
viewModelScope.launch {
if (repository.isRunning.firstOrNull() == false) {
requestBluetoothDevice()
@@ -80,9 +76,7 @@ internal class CGMViewModel @Inject constructor(
}
repository.data.onEach {
_state.value = WorkingState(it)
(it as? ConnectedResult)?.let {
if (it.connectionState?.state == GattConnectionState.STATE_CONNECTED) {
analytics.logEvent(ProfileConnectedEvent(Profile.CGMS))
}
}.launchIn(viewModelScope)
@@ -105,24 +99,28 @@ internal class CGMViewModel @Inject constructor(
.launchIn(viewModelScope)
}
private fun handleResult(result: NavigationResult<DiscoveredBluetoothDevice>) {
private fun handleResult(result: NavigationResult<ServerDevice>) {
when (result) {
is NavigationResult.Cancelled -> navigationManager.navigateUp()
is NavigationResult.Success -> repository.launch(result.value)
is NavigationResult.Success -> onDeviceSelected(result.value)
}
}
private fun onDeviceSelected(device: ServerDevice) {
repository.launch(device)
}
private fun onCommandReceived(workingMode: CGMServiceCommand) {
when (workingMode) {
CGMServiceCommand.REQUEST_ALL_RECORDS -> repository.requestAllRecords()
CGMServiceCommand.REQUEST_LAST_RECORD -> repository.requestLastRecord()
CGMServiceCommand.REQUEST_FIRST_RECORD -> repository.requestFirstRecord()
CGMServiceCommand.DISCONNECT -> disconnect()
}
repository.onCommand(workingMode)
}
private fun disconnect() {
repository.release()
repository.disconnect()
navigationManager.navigateUp()
}
override fun onCleared() {
super.onCleared()
repository.setOnScreen(false)
}
}

View File

@@ -45,13 +45,17 @@ dependencies {
implementation(project(":lib_ui"))
implementation(project(":lib_utils"))
implementation(libs.nordic.blek.client)
implementation(libs.nordic.blek.profile)
implementation(libs.nordic.ble.common)
implementation(libs.nordic.ble.ktx)
implementation(libs.nordic.uilogger)
implementation(libs.nordic.theme)
implementation(libs.nordic.navigation)
implementation(libs.nordic.uiscanner)
implementation(libs.nordic.blek.uiscanner)
implementation(libs.nordic.core)
implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.androidx.compose.material.iconsExtended)

View File

@@ -1,43 +0,0 @@
/*
* Copyright (c) 2022, 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.csc.data
internal data class CSCData(
val scanDevices: Boolean = false,
val speed: Float = 0f,
val cadence: Float = 0f,
val distance: Float = 0f,
val totalDistance: Float = 0f,
val gearRatio: Float = 0f,
val batteryLevel: Int? = null,
val wheelSize: WheelSize = WheelSize()
)

View File

@@ -1,143 +0,0 @@
/*
* Copyright (c) 2022, 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.csc.data
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCharacteristic
import android.content.Context
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import no.nordicsemi.android.ble.BleManager
import no.nordicsemi.android.ble.common.callback.battery.BatteryLevelResponse
import no.nordicsemi.android.ble.common.callback.csc.CyclingSpeedAndCadenceMeasurementResponse
import no.nordicsemi.android.ble.ktx.asValidResponseFlow
import no.nordicsemi.android.common.logger.NordicLogger
import no.nordicsemi.android.service.ConnectionObserverAdapter
import java.util.*
val CSC_SERVICE_UUID: UUID = UUID.fromString("00001816-0000-1000-8000-00805f9b34fb")
private val CSC_MEASUREMENT_CHARACTERISTIC_UUID = UUID.fromString("00002A5B-0000-1000-8000-00805f9b34fb")
private val BATTERY_SERVICE_UUID = UUID.fromString("0000180F-0000-1000-8000-00805f9b34fb")
private val BATTERY_LEVEL_CHARACTERISTIC_UUID = UUID.fromString("00002A19-0000-1000-8000-00805f9b34fb")
internal class CSCManager(
context: Context,
private val scope: CoroutineScope,
private val logger: NordicLogger
) : BleManager(context) {
private var batteryLevelCharacteristic: BluetoothGattCharacteristic? = null
private var cscMeasurementCharacteristic: BluetoothGattCharacteristic? = null
private var wheelSize: WheelSize = WheelSize()
private var previousResponse: CyclingSpeedAndCadenceMeasurementResponse? = null
private val data = MutableStateFlow(CSCData())
val dataHolder = ConnectionObserverAdapter<CSCData>()
init {
connectionObserver = dataHolder
data.onEach {
dataHolder.setValue(it)
}.launchIn(scope)
}
override fun log(priority: Int, message: String) {
logger.log(priority, message)
}
override fun getMinLogPriority(): Int {
return Log.VERBOSE
}
override fun getGattCallback(): BleManagerGattCallback {
return CSCManagerGattCallback()
}
fun setWheelSize(value: WheelSize) {
wheelSize = value
}
private inner class CSCManagerGattCallback : BleManagerGattCallback() {
override fun initialize() {
super.initialize()
setNotificationCallback(cscMeasurementCharacteristic).asValidResponseFlow<CyclingSpeedAndCadenceMeasurementResponse>()
.onEach {
previousResponse?.let { previousResponse ->
val wheelCircumference = wheelSize.value.toFloat()
val totalDistance = it.getTotalDistance(wheelSize.value.toFloat())
val distance = it.getDistance(wheelCircumference, previousResponse)
val speed = it.getSpeed(wheelCircumference, previousResponse)
val crankCadence = it.getCrankCadence(previousResponse)
val gearRatio = it.getGearRatio(previousResponse)
data.tryEmit(data.value.copy(
totalDistance = totalDistance,
distance = distance,
speed = speed,
wheelSize = wheelSize,
cadence = crankCadence,
gearRatio = gearRatio,
))
}
previousResponse = it
}.launchIn(scope)
enableNotifications(cscMeasurementCharacteristic).enqueue()
setNotificationCallback(batteryLevelCharacteristic).asValidResponseFlow<BatteryLevelResponse>().onEach {
data.value = data.value.copy(batteryLevel = it.batteryLevel)
}.launchIn(scope)
enableNotifications(batteryLevelCharacteristic).enqueue()
}
public override fun isRequiredServiceSupported(gatt: BluetoothGatt): Boolean {
gatt.getService(CSC_SERVICE_UUID)?.run {
cscMeasurementCharacteristic = getCharacteristic(CSC_MEASUREMENT_CHARACTERISTIC_UUID)
}
gatt.getService(BATTERY_SERVICE_UUID)?.run {
batteryLevelCharacteristic = getCharacteristic(BATTERY_LEVEL_CHARACTERISTIC_UUID)
}
return cscMeasurementCharacteristic != null
}
override fun onServicesInvalidated() {
cscMeasurementCharacteristic = null
batteryLevelCharacteristic = null
}
}
}

View File

@@ -0,0 +1,21 @@
package no.nordicsemi.android.csc.data
import no.nordicsemi.android.kotlin.ble.core.data.BleGattConnectionStatus
import no.nordicsemi.android.kotlin.ble.core.data.GattConnectionStateWithStatus
import no.nordicsemi.android.kotlin.ble.profile.csc.data.CSCData
internal data class CSCServiceData(
val data: CSCData = CSCData(),
val batteryLevel: Int? = null,
val connectionState: GattConnectionStateWithStatus? = null,
val speedUnit: SpeedUnit = SpeedUnit.M_S,
val deviceName: String? = null,
val missingServices: Boolean = false
) {
val disconnectStatus = if (missingServices) {
BleGattConnectionStatus.NOT_SUPPORTED
} else {
connectionState?.status ?: BleGattConnectionStatus.UNKNOWN
}
}

View File

@@ -29,7 +29,7 @@
* EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package no.nordicsemi.android.csc.view
package no.nordicsemi.android.csc.data
internal enum class SpeedUnit(val displayName: String) {
M_S("m/s"),

View File

@@ -1,39 +0,0 @@
/*
* Copyright (c) 2022, 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.csc.data
import no.nordicsemi.android.csc.view.CSCSettings
data class WheelSize(
val value: Int = CSCSettings.DefaultWheelSize.VALUE,
val name: String = CSCSettings.DefaultWheelSize.NAME
)

View File

@@ -33,22 +33,22 @@ package no.nordicsemi.android.csc.repository
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import no.nordicsemi.android.ble.ktx.suspend
import no.nordicsemi.android.common.logger.NordicLogger
import no.nordicsemi.android.common.logger.NordicLoggerFactory
import no.nordicsemi.android.common.ui.scanner.model.DiscoveredBluetoothDevice
import no.nordicsemi.android.csc.data.CSCData
import no.nordicsemi.android.csc.data.CSCManager
import no.nordicsemi.android.csc.data.WheelSize
import no.nordicsemi.android.service.BleManagerResult
import no.nordicsemi.android.service.IdleResult
import no.nordicsemi.android.common.core.simpleSharedFlow
import no.nordicsemi.android.common.logger.BleLoggerAndLauncher
import no.nordicsemi.android.common.logger.DefaultBleLogger
import no.nordicsemi.android.csc.data.CSCServiceData
import no.nordicsemi.android.csc.data.SpeedUnit
import no.nordicsemi.android.kotlin.ble.core.ServerDevice
import no.nordicsemi.android.kotlin.ble.core.data.GattConnectionState
import no.nordicsemi.android.kotlin.ble.core.data.GattConnectionStateWithStatus
import no.nordicsemi.android.kotlin.ble.profile.csc.data.CSCData
import no.nordicsemi.android.kotlin.ble.profile.csc.data.WheelSize
import no.nordicsemi.android.kotlin.ble.profile.csc.data.WheelSizes
import no.nordicsemi.android.service.DisconnectAndStopEvent
import no.nordicsemi.android.service.ServiceManager
import no.nordicsemi.android.ui.view.StringConst
import javax.inject.Inject
@@ -59,60 +59,83 @@ class CSCRepository @Inject constructor(
@ApplicationContext
private val context: Context,
private val serviceManager: ServiceManager,
private val loggerFactory: NordicLoggerFactory,
private val stringConst: StringConst
) {
private var manager: CSCManager? = null
private var logger: NordicLogger? = null
private var logger: BleLoggerAndLauncher? = null
private val _data = MutableStateFlow<BleManagerResult<CSCData>>(IdleResult())
private val _wheelSize = MutableStateFlow(WheelSizes.default)
internal val wheelSize = _wheelSize.asStateFlow()
private val _data = MutableStateFlow(CSCServiceData())
internal val data = _data.asStateFlow()
val isRunning = data.map { it.isRunning() }
val hasBeenDisconnected = data.map { it.hasBeenDisconnected() }
private val _stopEvent = simpleSharedFlow<DisconnectAndStopEvent>()
internal val stopEvent = _stopEvent.asSharedFlow()
fun launch(device: DiscoveredBluetoothDevice) {
val isRunning = data.map { it.connectionState?.state == GattConnectionState.STATE_CONNECTED }
private var isOnScreen = false
private var isServiceRunning = false
fun setOnScreen(isOnScreen: Boolean) {
this.isOnScreen = isOnScreen
if (shouldClean()) clean()
}
fun setServiceRunning(serviceRunning: Boolean) {
this.isServiceRunning = serviceRunning
if (shouldClean()) clean()
}
private fun shouldClean() = !isOnScreen && !isServiceRunning
fun launch(device: ServerDevice) {
logger = DefaultBleLogger.create(context, stringConst.APP_NAME, "CSC", device.address)
_data.value = _data.value.copy(deviceName = device.name)
serviceManager.startService(CSCService::class.java, device)
}
fun start(device: DiscoveredBluetoothDevice, scope: CoroutineScope) {
val createdLogger = loggerFactory.create(stringConst.APP_NAME, "CSC", device.address).also {
logger = it
}
val manager = CSCManager(context, scope, createdLogger)
this.manager = manager
manager.dataHolder.status.onEach {
_data.value = it
}.launchIn(scope)
scope.launch {
manager.start(device)
}
internal fun setSpeedUnit(speedUnit: SpeedUnit) {
_data.value = _data.value.copy(speedUnit = speedUnit)
}
fun setWheelSize(wheelSize: WheelSize) {
manager?.setWheelSize(wheelSize)
_wheelSize.value = wheelSize
}
private suspend fun CSCManager.start(device: DiscoveredBluetoothDevice) {
try {
connect(device.device)
.useAutoConnect(false)
.retry(3, 100)
.suspend()
} catch (e: Exception) {
e.printStackTrace()
}
fun onConnectionStateChanged(connectionState: GattConnectionStateWithStatus?) {
_data.value = _data.value.copy(connectionState = connectionState)
}
fun onBatteryLevelChanged(batteryLevel: Int) {
_data.value = _data.value.copy(batteryLevel = batteryLevel)
}
fun onCSCDataChanged(cscData: CSCData) {
_data.value = _data.value.copy(data = cscData)
}
fun onMissingServices() {
_data.value = _data.value.copy(missingServices = true)
_stopEvent.tryEmit(DisconnectAndStopEvent())
}
fun openLogger() {
NordicLogger.launch(context, logger)
logger?.launch()
}
fun release() {
manager?.disconnect()?.enqueue()
fun log(priority: Int, message: String) {
logger?.log(priority, message)
}
fun disconnect() {
_stopEvent.tryEmit(DisconnectAndStopEvent())
}
private fun clean() {
logger = null
manager = null
_data.value = CSCServiceData()
}
}

View File

@@ -31,33 +31,113 @@
package no.nordicsemi.android.csc.repository
import android.annotation.SuppressLint
import android.content.Intent
import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.onEach
import no.nordicsemi.android.common.ui.scanner.model.DiscoveredBluetoothDevice
import kotlinx.coroutines.launch
import no.nordicsemi.android.kotlin.ble.client.main.callback.ClientBleGatt
import no.nordicsemi.android.kotlin.ble.client.main.service.ClientBleGattServices
import no.nordicsemi.android.kotlin.ble.core.ServerDevice
import no.nordicsemi.android.kotlin.ble.core.data.GattConnectionState
import no.nordicsemi.android.kotlin.ble.core.data.GattConnectionStateWithStatus
import no.nordicsemi.android.kotlin.ble.profile.battery.BatteryLevelParser
import no.nordicsemi.android.kotlin.ble.profile.csc.CSCDataParser
import no.nordicsemi.android.service.DEVICE_DATA
import no.nordicsemi.android.service.NotificationService
import java.util.*
import javax.inject.Inject
val CSC_SERVICE_UUID: UUID = UUID.fromString("00001816-0000-1000-8000-00805f9b34fb")
private val CSC_MEASUREMENT_CHARACTERISTIC_UUID = UUID.fromString("00002A5B-0000-1000-8000-00805f9b34fb")
private val BATTERY_SERVICE_UUID = UUID.fromString("0000180F-0000-1000-8000-00805f9b34fb")
private val BATTERY_LEVEL_CHARACTERISTIC_UUID = UUID.fromString("00002A19-0000-1000-8000-00805f9b34fb")
@SuppressLint("MissingPermission")
@AndroidEntryPoint
internal class CSCService : NotificationService() {
@Inject
lateinit var repository: CSCRepository
private lateinit var client: ClientBleGatt
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId)
val device = intent!!.getParcelableExtra<DiscoveredBluetoothDevice>(DEVICE_DATA)!!
repository.setServiceRunning(true)
repository.start(device, lifecycleScope)
val device = intent!!.getParcelableExtra<ServerDevice>(DEVICE_DATA)!!
repository.hasBeenDisconnected.onEach {
if (it) stopSelf()
}.launchIn(lifecycleScope)
startGattClient(device)
repository.stopEvent
.onEach { disconnect() }
.launchIn(lifecycleScope)
return START_REDELIVER_INTENT
}
private fun startGattClient(device: ServerDevice) = lifecycleScope.launch {
client = ClientBleGatt.connect(this@CSCService, device, logger = { p, s -> repository.log(p, s) })
client.connectionStateWithStatus
.onEach { repository.onConnectionStateChanged(it) }
.filterNotNull()
.onEach { stopIfDisconnected(it) }
.launchIn(lifecycleScope)
if (!client.isConnected) {
return@launch
}
try {
val services = client.discoverServices()
configureGatt(services)
} catch (e: Exception) {
repository.onMissingServices()
}
}
private suspend fun configureGatt(services: ClientBleGattServices) {
val cscService = services.findService(CSC_SERVICE_UUID)!!
val cscMeasurementCharacteristic = cscService.findCharacteristic(CSC_MEASUREMENT_CHARACTERISTIC_UUID)!!
val batteryService = services.findService(BATTERY_SERVICE_UUID)!!
val batteryLevelCharacteristic = batteryService.findCharacteristic(BATTERY_LEVEL_CHARACTERISTIC_UUID)!!
batteryLevelCharacteristic.getNotifications()
.mapNotNull { BatteryLevelParser.parse(it) }
.onEach { repository.onBatteryLevelChanged(it) }
.catch { it.printStackTrace() }
.launchIn(lifecycleScope)
val cscDataParser = CSCDataParser()
cscMeasurementCharacteristic.getNotifications()
.mapNotNull { cscDataParser.parse(it, repository.wheelSize.value) }
.onEach { repository.onCSCDataChanged(it) }
.catch { it.printStackTrace() }
.launchIn(lifecycleScope)
}
private fun stopIfDisconnected(connectionState: GattConnectionStateWithStatus) {
if (connectionState.state == GattConnectionState.STATE_DISCONNECTED) {
stopSelf()
}
}
private fun disconnect() {
client.disconnect()
}
override fun onDestroy() {
super.onDestroy()
repository.setServiceRunning(false)
}
}

View File

@@ -34,7 +34,6 @@ package no.nordicsemi.android.csc.view
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Button
@@ -50,15 +49,17 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import no.nordicsemi.android.common.theme.view.RadioButtonGroup
import no.nordicsemi.android.csc.R
import no.nordicsemi.android.csc.data.CSCData
import no.nordicsemi.android.csc.data.WheelSize
import no.nordicsemi.android.csc.data.CSCServiceData
import no.nordicsemi.android.csc.data.SpeedUnit
import no.nordicsemi.android.kotlin.ble.profile.csc.data.CSCData
import no.nordicsemi.android.kotlin.ble.profile.csc.data.WheelSize
import no.nordicsemi.android.ui.view.ScreenSection
import no.nordicsemi.android.ui.view.SectionTitle
import no.nordicsemi.android.ui.view.dialog.FlowCanceled
import no.nordicsemi.android.ui.view.dialog.ItemSelectedResult
@Composable
internal fun CSCContentView(state: CSCData, speedUnit: SpeedUnit, onEvent: (CSCViewEvent) -> Unit) {
internal fun CSCContentView(state: CSCServiceData, onEvent: (CSCViewEvent) -> Unit) {
val showDialog = rememberSaveable { mutableStateOf(false) }
if (showDialog.value) {
@@ -69,8 +70,7 @@ internal fun CSCContentView(state: CSCData, speedUnit: SpeedUnit, onEvent: (CSCV
when (it) {
FlowCanceled -> showDialog.value = false
is ItemSelectedResult -> {
onEvent(OnWheelSizeSelected(WheelSize(wheelValues[it.index].toInt(),
wheelEntries[it.index])))
onEvent(OnWheelSizeSelected(WheelSize(wheelValues[it.index].toInt(), wheelEntries[it.index])))
showDialog.value = false
}
}
@@ -80,11 +80,11 @@ internal fun CSCContentView(state: CSCData, speedUnit: SpeedUnit, onEvent: (CSCV
Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
SettingsSection(state, speedUnit, onEvent) { showDialog.value = true }
SettingsSection(state.data, state.speedUnit, onEvent) { showDialog.value = true }
Spacer(modifier = Modifier.height(16.dp))
SensorsReadingView(state = state, speedUnit)
SensorsReadingView(state = state, state.speedUnit)
Spacer(modifier = Modifier.height(16.dp))
@@ -126,5 +126,5 @@ private fun SettingsSection(
@Preview
@Composable
private fun ConnectedPreview() {
CSCContentView(CSCData(), SpeedUnit.KM_H) { }
CSCContentView(CSCServiceData()) { }
}

View File

@@ -33,8 +33,9 @@ package no.nordicsemi.android.csc.view
import no.nordicsemi.android.common.theme.view.RadioButtonItem
import no.nordicsemi.android.common.theme.view.RadioGroupViewEntity
import no.nordicsemi.android.csc.data.CSCData
import java.util.*
import no.nordicsemi.android.csc.data.SpeedUnit
import no.nordicsemi.android.kotlin.ble.profile.csc.data.CSCData
import java.util.Locale
private const val DISPLAY_M_S = "m/s"
private const val DISPLAY_KM_H = "km/h"

View File

@@ -38,80 +38,52 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import no.nordicsemi.android.common.ui.scanner.view.DeviceConnectingView
import no.nordicsemi.android.common.ui.scanner.view.DeviceDisconnectedView
import no.nordicsemi.android.common.ui.scanner.view.Reason
import no.nordicsemi.android.csc.R
import no.nordicsemi.android.csc.viewmodel.CSCViewModel
import no.nordicsemi.android.service.ConnectedResult
import no.nordicsemi.android.service.ConnectingResult
import no.nordicsemi.android.service.DeviceHolder
import no.nordicsemi.android.service.DisconnectedResult
import no.nordicsemi.android.service.IdleResult
import no.nordicsemi.android.service.LinkLossResult
import no.nordicsemi.android.service.MissingServiceResult
import no.nordicsemi.android.service.SuccessResult
import no.nordicsemi.android.service.UnknownErrorResult
import no.nordicsemi.android.ui.view.BackIconAppBar
import no.nordicsemi.android.ui.view.LoggerIconAppBar
import no.nordicsemi.android.kotlin.ble.core.data.GattConnectionState
import no.nordicsemi.android.kotlin.ble.ui.scanner.view.DeviceConnectingView
import no.nordicsemi.android.kotlin.ble.ui.scanner.view.DeviceDisconnectedView
import no.nordicsemi.android.ui.view.NavigateUpButton
import no.nordicsemi.android.ui.view.ProfileAppBar
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CSCScreen() {
val viewModel: CSCViewModel = hiltViewModel()
val state by viewModel.state.collectAsStateWithLifecycle()
val state = viewModel.state.collectAsState().value
val navigateUp = { viewModel.onEvent(NavigateUp) }
Scaffold(
topBar = { AppBar(state, navigateUp, viewModel) }
topBar = {
ProfileAppBar(
deviceName = state.deviceName,
connectionState = state.connectionState,
title = R.string.csc_title,
navigateUp = navigateUp,
disconnect = { viewModel.onEvent(OnDisconnectButtonClick) },
openLogger = { viewModel.onEvent(OpenLogger) }
)
}
) {
Column(
modifier = Modifier
.padding(it)
.padding(16.dp)
.verticalScroll(rememberScrollState())
.padding(16.dp)
) {
when (val cscState = state.cscManagerState) {
NoDeviceState -> DeviceConnectingView()
is WorkingState -> when (cscState.result) {
is IdleResult,
is ConnectingResult -> DeviceConnectingView { NavigateUpButton(navigateUp) }
is ConnectedResult -> DeviceConnectingView { NavigateUpButton(navigateUp) }
is DisconnectedResult -> DeviceDisconnectedView(Reason.USER) { NavigateUpButton(navigateUp) }
is LinkLossResult -> DeviceDisconnectedView(Reason.LINK_LOSS) { NavigateUpButton(navigateUp) }
is MissingServiceResult -> DeviceDisconnectedView(Reason.MISSING_SERVICE) {
NavigateUpButton(navigateUp)
}
is UnknownErrorResult -> DeviceDisconnectedView(Reason.UNKNOWN) { NavigateUpButton(navigateUp) }
is SuccessResult -> CSCContentView(
cscState.result.data,
state.speedUnit
) { viewModel.onEvent(it) }
when (state.connectionState?.state) {
null,
GattConnectionState.STATE_CONNECTING -> DeviceConnectingView { NavigateUpButton(navigateUp) }
GattConnectionState.STATE_DISCONNECTED,
GattConnectionState.STATE_DISCONNECTING -> DeviceDisconnectedView(state.disconnectStatus) {
NavigateUpButton(navigateUp)
}
GattConnectionState.STATE_CONNECTED -> CSCContentView(state) { viewModel.onEvent(it) }
}
}
}
}
@Composable
private fun AppBar(state: CSCViewState, navigateUp: () -> Unit, viewModel: CSCViewModel) {
val toolbarName = (state.cscManagerState as? WorkingState)?.let {
(it.result as? DeviceHolder)?.deviceName()
}
if (toolbarName == null) {
BackIconAppBar(stringResource(id = R.string.csc_title), navigateUp)
} else {
LoggerIconAppBar(toolbarName, navigateUp, { viewModel.onEvent(OnDisconnectButtonClick) }) {
viewModel.onEvent(OpenLogger)
}
}
}

View File

@@ -1,40 +0,0 @@
/*
* Copyright (c) 2022, 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.csc.view
internal object CSCSettings {
object DefaultWheelSize {
const val NAME = "60-622"
const val VALUE = 2340
}
}

View File

@@ -1,46 +0,0 @@
/*
* Copyright (c) 2022, 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.csc.view
import no.nordicsemi.android.csc.data.CSCData
import no.nordicsemi.android.service.BleManagerResult
internal data class CSCViewState(
val speedUnit: SpeedUnit = SpeedUnit.M_S,
val cscManagerState: CSCMangerState = NoDeviceState
)
internal sealed class CSCMangerState
internal data class WorkingState(val result: BleManagerResult<CSCData>) : CSCMangerState()
internal object NoDeviceState : CSCMangerState()

View File

@@ -31,7 +31,8 @@
package no.nordicsemi.android.csc.view
import no.nordicsemi.android.csc.data.WheelSize
import no.nordicsemi.android.csc.data.SpeedUnit
import no.nordicsemi.android.kotlin.ble.profile.csc.data.WheelSize
internal sealed class CSCViewEvent

View File

@@ -40,32 +40,34 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import no.nordicsemi.android.csc.R
import no.nordicsemi.android.csc.data.CSCData
import no.nordicsemi.android.csc.data.CSCServiceData
import no.nordicsemi.android.csc.data.SpeedUnit
import no.nordicsemi.android.ui.view.BatteryLevelView
import no.nordicsemi.android.ui.view.KeyValueField
import no.nordicsemi.android.ui.view.ScreenSection
import no.nordicsemi.android.ui.view.SectionTitle
@Composable
internal fun SensorsReadingView(state: CSCData, speedUnit: SpeedUnit) {
internal fun SensorsReadingView(state: CSCServiceData, speedUnit: SpeedUnit) {
val csc = state.data
ScreenSection {
SectionTitle(resId = R.drawable.ic_records, title = "Records")
Spacer(modifier = Modifier.height(16.dp))
Column {
KeyValueField(stringResource(id = R.string.csc_field_speed), state.displaySpeed(speedUnit))
KeyValueField(stringResource(id = R.string.csc_field_speed), csc.displaySpeed(speedUnit))
Spacer(modifier = Modifier.height(4.dp))
KeyValueField(stringResource(id = R.string.csc_field_cadence), state.displayCadence())
KeyValueField(stringResource(id = R.string.csc_field_cadence), csc.displayCadence())
Spacer(modifier = Modifier.height(4.dp))
KeyValueField(stringResource(id = R.string.csc_field_distance), state.displayDistance(speedUnit))
KeyValueField(stringResource(id = R.string.csc_field_distance), csc.displayDistance(speedUnit))
Spacer(modifier = Modifier.height(4.dp))
KeyValueField(
stringResource(id = R.string.csc_field_total_distance),
state.displayTotalDistance(speedUnit)
csc.displayTotalDistance(speedUnit)
)
Spacer(modifier = Modifier.height(4.dp))
KeyValueField(stringResource(id = R.string.csc_field_gear_ratio), state.displayGearRatio())
KeyValueField(stringResource(id = R.string.csc_field_gear_ratio), csc.displayGearRatio())
}
}
@@ -79,5 +81,5 @@ internal fun SensorsReadingView(state: CSCData, speedUnit: SpeedUnit) {
@Preview
@Composable
private fun Preview() {
SensorsReadingView(CSCData(), SpeedUnit.KM_H)
SensorsReadingView(CSCServiceData(), SpeedUnit.KM_H)
}

View File

@@ -47,7 +47,7 @@ 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.data.CSCData
import no.nordicsemi.android.kotlin.ble.profile.csc.data.CSCData
@Composable
internal fun WheelSizeView(state: CSCData, onClick: () -> Unit) {

View File

@@ -35,8 +35,6 @@ import android.os.ParcelUuid
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@@ -46,19 +44,17 @@ import no.nordicsemi.android.analytics.Profile
import no.nordicsemi.android.analytics.ProfileConnectedEvent
import no.nordicsemi.android.common.navigation.NavigationResult
import no.nordicsemi.android.common.navigation.Navigator
import no.nordicsemi.android.common.ui.scanner.model.DiscoveredBluetoothDevice
import no.nordicsemi.android.csc.data.CSC_SERVICE_UUID
import no.nordicsemi.android.csc.data.SpeedUnit
import no.nordicsemi.android.csc.repository.CSCRepository
import no.nordicsemi.android.csc.repository.CSC_SERVICE_UUID
import no.nordicsemi.android.csc.view.CSCViewEvent
import no.nordicsemi.android.csc.view.CSCViewState
import no.nordicsemi.android.csc.view.NavigateUp
import no.nordicsemi.android.csc.view.OnDisconnectButtonClick
import no.nordicsemi.android.csc.view.OnSelectedSpeedUnitSelected
import no.nordicsemi.android.csc.view.OnWheelSizeSelected
import no.nordicsemi.android.csc.view.OpenLogger
import no.nordicsemi.android.csc.view.SpeedUnit
import no.nordicsemi.android.csc.view.WorkingState
import no.nordicsemi.android.service.ConnectedResult
import no.nordicsemi.android.kotlin.ble.core.ServerDevice
import no.nordicsemi.android.kotlin.ble.core.data.GattConnectionState
import no.nordicsemi.android.toolbox.scanner.ScannerDestinationId
import javax.inject.Inject
@@ -69,10 +65,11 @@ internal class CSCViewModel @Inject constructor(
private val analytics: AppAnalytics
) : ViewModel() {
private val _state = MutableStateFlow(CSCViewState())
val state = _state.asStateFlow()
val state = repository.data
init {
repository.setOnScreen(true)
viewModelScope.launch {
if (repository.isRunning.firstOrNull() == false) {
requestBluetoothDevice()
@@ -80,9 +77,7 @@ internal class CSCViewModel @Inject constructor(
}
repository.data.onEach {
_state.value = _state.value.copy(cscManagerState = WorkingState(it))
(it as? ConnectedResult)?.let {
if (it.connectionState?.state == GattConnectionState.STATE_CONNECTED) {
analytics.logEvent(ProfileConnectedEvent(Profile.CSC))
}
}.launchIn(viewModelScope)
@@ -96,13 +91,17 @@ internal class CSCViewModel @Inject constructor(
.launchIn(viewModelScope)
}
private fun handleResult(result: NavigationResult<DiscoveredBluetoothDevice>) {
private fun handleResult(result: NavigationResult<ServerDevice>) {
when (result) {
is NavigationResult.Cancelled -> navigationManager.navigateUp()
is NavigationResult.Success -> repository.launch(result.value)
is NavigationResult.Success -> onDeviceSelected(result.value)
}
}
private fun onDeviceSelected(device: ServerDevice) {
repository.launch(device)
}
fun onEvent(event: CSCViewEvent) {
when (event) {
is OnSelectedSpeedUnitSelected -> setSpeedUnit(event.selectedSpeedUnit)
@@ -114,11 +113,16 @@ internal class CSCViewModel @Inject constructor(
}
private fun setSpeedUnit(speedUnit: SpeedUnit) {
_state.value = _state.value.copy(speedUnit = speedUnit)
repository.setSpeedUnit(speedUnit)
}
private fun disconnect() {
repository.release()
repository.disconnect()
navigationManager.navigateUp()
}
override fun onCleared() {
super.onCleared()
repository.setOnScreen(false)
}
}

View File

@@ -45,12 +45,17 @@ dependencies {
implementation(project(":lib_ui"))
implementation(project(":lib_utils"))
implementation(libs.nordic.blek.client)
implementation(libs.nordic.blek.profile)
implementation(libs.nordic.blek.server)
implementation(libs.nordic.blek.advertiser)
implementation(libs.chart)
implementation(libs.nordic.ble.common)
implementation(libs.nordic.ble.ktx)
implementation(libs.nordic.theme)
implementation(libs.nordic.uiscanner)
implementation(libs.nordic.blek.uiscanner)
implementation(libs.nordic.navigation)
implementation(libs.nordic.uilogger)
@@ -60,4 +65,12 @@ dependencies {
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.lifecycle.service)
testImplementation(libs.junit4)
testImplementation(libs.test.mockk)
testImplementation(libs.androidx.test.ext)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.test.slf4j.simple)
testImplementation(libs.test.robolectric)
testImplementation(libs.kotlin.junit)
}

View File

@@ -0,0 +1,190 @@
package no.nordicsemi.android.gls
import android.annotation.SuppressLint
import android.content.Context
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import no.nordicsemi.android.common.core.DataByteArray
import no.nordicsemi.android.gls.main.viewmodel.BATTERY_LEVEL_CHARACTERISTIC_UUID
import no.nordicsemi.android.gls.main.viewmodel.BATTERY_SERVICE_UUID
import no.nordicsemi.android.gls.main.viewmodel.GLS_SERVICE_UUID
import no.nordicsemi.android.gls.main.viewmodel.GLUCOSE_MEASUREMENT_CHARACTERISTIC
import no.nordicsemi.android.gls.main.viewmodel.GLUCOSE_MEASUREMENT_CONTEXT_CHARACTERISTIC
import no.nordicsemi.android.gls.main.viewmodel.RACP_CHARACTERISTIC
import no.nordicsemi.android.kotlin.ble.advertiser.BleAdvertiser
import no.nordicsemi.android.kotlin.ble.core.MockServerDevice
import no.nordicsemi.android.kotlin.ble.core.advertiser.BleAdvertisingConfig
import no.nordicsemi.android.kotlin.ble.core.data.BleGattPermission
import no.nordicsemi.android.kotlin.ble.core.data.BleGattProperty
import no.nordicsemi.android.kotlin.ble.profile.gls.RecordAccessControlPointInputParser
import no.nordicsemi.android.kotlin.ble.server.main.ServerBleGatt
import no.nordicsemi.android.kotlin.ble.server.main.service.ServerBleGattCharacteristic
import no.nordicsemi.android.kotlin.ble.server.main.service.ServerBleGattCharacteristicConfig
import no.nordicsemi.android.kotlin.ble.server.main.service.ServerBleGattServiceConfig
import no.nordicsemi.android.kotlin.ble.server.main.service.ServerBleGattServiceType
import no.nordicsemi.android.kotlin.ble.server.main.service.ServerBluetoothGattConnection
import javax.inject.Inject
import javax.inject.Singleton
private const val STANDARD_DELAY = 1000L
@SuppressLint("MissingPermission")
@Singleton
class GlsServer @Inject constructor(
private val scope: CoroutineScope
) {
lateinit var server: ServerBleGatt
lateinit var glsCharacteristic: ServerBleGattCharacteristic
lateinit var glsContextCharacteristic: ServerBleGattCharacteristic
lateinit var racpCharacteristic: ServerBleGattCharacteristic
lateinit var batteryLevelCharacteristic: ServerBleGattCharacteristic
private var lastRequest = DataByteArray()
val YOUNGEST_RECORD = DataByteArray.from(0x07, 0x00, 0x00, 0xDC.toByte(), 0x07, 0x01, 0x01, 0x0C, 0x1E, 0x05, 0x00, 0x00, 0x26, 0xD2.toByte(), 0x11)
val OLDEST_RECORD = DataByteArray.from(0x07, 0x04, 0x00, 0xDC.toByte(), 0x07, 0x01, 0x01, 0x0C, 0x1E, 0x11, 0x00, 0x00, 0x82.toByte(), 0xD2.toByte(), 0x11)
val records = listOf(
YOUNGEST_RECORD,
DataByteArray.from(0x07, 0x01, 0x00, 0xDC.toByte(), 0x07, 0x01, 0x01, 0x0C, 0x1E, 0x08, 0x00, 0x00, 0x3D, 0xD2.toByte(), 0x11),
DataByteArray.from(0x07, 0x02, 0x00, 0xDC.toByte(), 0x07, 0x01, 0x01, 0x0C, 0x1E, 0x0B, 0x00, 0x00, 0x54, 0xD2.toByte(), 0x11),
DataByteArray.from(0x07, 0x03, 0x00, 0xDC.toByte(), 0x07, 0x01, 0x01, 0x0C, 0x1E, 0x0E, 0x00, 0x00, 0x6B, 0xD2.toByte(), 0x11),
OLDEST_RECORD
)
val racp = DataByteArray.from(0x06, 0x00, 0x01, 0x01)
fun start(
context: Context,
device: MockServerDevice = MockServerDevice(
name = "GLS Server",
address = "55:44:33:22:11"
),
) = scope.launch {
val gmCharacteristic = ServerBleGattCharacteristicConfig(
GLUCOSE_MEASUREMENT_CHARACTERISTIC,
listOf(BleGattProperty.PROPERTY_NOTIFY),
listOf()
)
val gmContextCharacteristic = ServerBleGattCharacteristicConfig(
GLUCOSE_MEASUREMENT_CONTEXT_CHARACTERISTIC,
listOf(BleGattProperty.PROPERTY_NOTIFY),
listOf()
)
val racpCharacteristic = ServerBleGattCharacteristicConfig(
RACP_CHARACTERISTIC,
listOf(BleGattProperty.PROPERTY_INDICATE, BleGattProperty.PROPERTY_WRITE),
listOf(BleGattPermission.PERMISSION_WRITE)
)
val serviceConfig = ServerBleGattServiceConfig(
GLS_SERVICE_UUID,
ServerBleGattServiceType.SERVICE_TYPE_PRIMARY,
listOf(gmCharacteristic, gmContextCharacteristic, racpCharacteristic)
)
val batteryLevelCharacteristic = ServerBleGattCharacteristicConfig(
BATTERY_LEVEL_CHARACTERISTIC_UUID,
listOf(BleGattProperty.PROPERTY_READ, BleGattProperty.PROPERTY_NOTIFY),
listOf(BleGattPermission.PERMISSION_READ)
)
val batteryService = ServerBleGattServiceConfig(
BATTERY_SERVICE_UUID,
ServerBleGattServiceType.SERVICE_TYPE_PRIMARY,
listOf(batteryLevelCharacteristic)
)
server = ServerBleGatt.create(
context = context,
config = arrayOf(serviceConfig, batteryService),
mock = device
)
val advertiser = BleAdvertiser.create(context)
advertiser.advertise(config = BleAdvertisingConfig(), mock = device).launchIn(scope)
launch {
server.connections
.mapNotNull { it.values.firstOrNull() }
.collect { setUpConnection(it) }
}
}
internal fun stopServer() {
server.stopServer()
}
private fun setUpConnection(connection: ServerBluetoothGattConnection) {
val glsService = connection.services.findService(GLS_SERVICE_UUID)!!
glsCharacteristic = glsService.findCharacteristic(GLUCOSE_MEASUREMENT_CHARACTERISTIC)!!
glsContextCharacteristic = glsService.findCharacteristic(GLUCOSE_MEASUREMENT_CONTEXT_CHARACTERISTIC)!!
racpCharacteristic = glsService.findCharacteristic(RACP_CHARACTERISTIC)!!
val batteryService = connection.services.findService(BATTERY_SERVICE_UUID)!!
batteryLevelCharacteristic = batteryService.findCharacteristic(BATTERY_LEVEL_CHARACTERISTIC_UUID)!!
startGlsService(connection)
// startBatteryService(connection)
}
private fun startGlsService(connection: ServerBluetoothGattConnection) {
racpCharacteristic.value
.onEach { lastRequest = it }
.launchIn(scope)
}
internal fun continueWithResponse() {
sendResponse(lastRequest)
}
private fun sendResponse(request: DataByteArray) {
if (request == RecordAccessControlPointInputParser.reportNumberOfAllStoredRecords()) {
sendAll(glsCharacteristic)
racpCharacteristic.setValue(racp)
} else if (request == RecordAccessControlPointInputParser.reportLastStoredRecord()) {
sendLast(glsCharacteristic)
racpCharacteristic.setValue(racp)
} else if (request == RecordAccessControlPointInputParser.reportFirstStoredRecord()) {
sendFirst(glsCharacteristic)
racpCharacteristic.setValue(racp)
}
}
private fun sendFirst(characteristics: ServerBleGattCharacteristic) {
characteristics.setValue(records.first())
}
private fun sendLast(characteristics: ServerBleGattCharacteristic) {
characteristics.setValue(records.last())
}
private fun sendAll(characteristics: ServerBleGattCharacteristic) = scope.launch {
records.forEach {
characteristics.setValue(it)
delay(100)
}
}
private fun startBatteryService(connection: ServerBluetoothGattConnection) {
scope.launch {
repeat(100) {
batteryLevelCharacteristic.setValue(DataByteArray.from(0x61))
delay(STANDARD_DELAY)
batteryLevelCharacteristic.setValue(DataByteArray.from(0x60))
delay(STANDARD_DELAY)
batteryLevelCharacteristic.setValue(DataByteArray.from(0x5F))
delay(STANDARD_DELAY)
}
}
}
}

View File

@@ -33,9 +33,10 @@ package no.nordicsemi.android.gls
import no.nordicsemi.android.common.navigation.createDestination
import no.nordicsemi.android.common.navigation.defineDestination
import no.nordicsemi.android.gls.data.GLSRecord
import no.nordicsemi.android.gls.details.view.GLSDetailsScreen
import no.nordicsemi.android.kotlin.ble.profile.gls.data.GLSMeasurementContext
import no.nordicsemi.android.kotlin.ble.profile.gls.data.GLSRecord
internal val GlsDetailsDestinationId = createDestination<GLSRecord, Unit>("gls-details-screen")
internal val GlsDetailsDestinationId = createDestination<Pair<GLSRecord, GLSMeasurementContext?>, Unit>("gls-details-screen")
val GLSDestination = defineDestination(GlsDetailsDestinationId) { GLSDetailsScreen() }

View File

@@ -1,74 +0,0 @@
/*
* Copyright (c) 2022, 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 no.nordicsemi.android.ble.common.callback.glucose.GlucoseMeasurementContextResponse
import no.nordicsemi.android.ble.common.callback.glucose.GlucoseMeasurementResponse
internal fun GlucoseMeasurementResponse.toRecord(): GLSRecord {
return this.let {
GLSRecord(
sequenceNumber = it.sequenceNumber,
time = it.time,
glucoseConcentration = it.glucoseConcentration ?: 0f,
unit = it.unit?.let { ConcentrationUnit.create(it) }
?: ConcentrationUnit.UNIT_KGPL,
type = RecordType.createOrNull(it.type),
sampleLocation = SampleLocation.createOrNull(it.sampleLocation),
status = it.status
)
}
}
internal fun GlucoseMeasurementContextResponse.toMeasurementContext(): MeasurementContext {
return this.let {
MeasurementContext(
sequenceNumber = it.sequenceNumber,
carbohydrate = it.carbohydrate,
carbohydrateAmount = it.carbohydrateAmount ?: 0f,
meal = it.meal,
tester = it.tester,
health = it.health,
exerciseDuration = it.exerciseDuration ?: 0,
exerciseIntensity = it.exerciseIntensity ?: 0,
medication = it.medication,
medicationQuantity = it.medicationAmount ?: 0f,
medicationUnit = it.medicationUnit?.let { MedicationUnit.create(it) }
?: MedicationUnit.UNIT_KG,
HbA1c = it.hbA1c ?: 0f
)
}
}
internal fun GLSRecord.copyWithNewContext(response: GlucoseMeasurementContextResponse): GLSRecord {
return copy(context = context)
}

View File

@@ -1,251 +0,0 @@
/*
* Copyright (c) 2022, 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 android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCharacteristic
import android.content.Context
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import no.nordicsemi.android.ble.BleManager
import no.nordicsemi.android.ble.common.callback.RecordAccessControlPointDataCallback
import no.nordicsemi.android.ble.common.callback.RecordAccessControlPointResponse
import no.nordicsemi.android.ble.common.callback.battery.BatteryLevelResponse
import no.nordicsemi.android.ble.common.callback.glucose.GlucoseMeasurementContextResponse
import no.nordicsemi.android.ble.common.callback.glucose.GlucoseMeasurementResponse
import no.nordicsemi.android.ble.common.data.RecordAccessControlPointData
import no.nordicsemi.android.ble.ktx.asValidResponseFlow
import no.nordicsemi.android.ble.ktx.suspend
import no.nordicsemi.android.common.logger.NordicLogger
import no.nordicsemi.android.service.ConnectionObserverAdapter
import no.nordicsemi.android.utils.launchWithCatch
import java.util.*
val GLS_SERVICE_UUID: UUID = UUID.fromString("00001808-0000-1000-8000-00805f9b34fb")
private val GM_CHARACTERISTIC = UUID.fromString("00002A18-0000-1000-8000-00805f9b34fb")
private val GM_CONTEXT_CHARACTERISTIC = UUID.fromString("00002A34-0000-1000-8000-00805f9b34fb")
private val GF_CHARACTERISTIC = UUID.fromString("00002A51-0000-1000-8000-00805f9b34fb")
private val RACP_CHARACTERISTIC = UUID.fromString("00002A52-0000-1000-8000-00805f9b34fb")
private val BATTERY_SERVICE_UUID = UUID.fromString("0000180F-0000-1000-8000-00805f9b34fb")
private val BATTERY_LEVEL_CHARACTERISTIC_UUID =
UUID.fromString("00002A19-0000-1000-8000-00805f9b34fb")
internal class GLSManager(
context: Context,
private val scope: CoroutineScope,
private val logger: NordicLogger
) : BleManager(context) {
private var batteryLevelCharacteristic: BluetoothGattCharacteristic? = null
private var glucoseMeasurementCharacteristic: BluetoothGattCharacteristic? = null
private var glucoseMeasurementContextCharacteristic: BluetoothGattCharacteristic? = null
private var recordAccessControlPointCharacteristic: BluetoothGattCharacteristic? = null
private val data = MutableStateFlow(GLSData())
val dataHolder = ConnectionObserverAdapter<GLSData>()
init {
connectionObserver = dataHolder
data.onEach {
dataHolder.setValue(it)
}.launchIn(scope)
}
override fun log(priority: Int, message: String) {
logger.log(priority, message)
}
override fun getMinLogPriority(): Int {
return Log.VERBOSE
}
override fun getGattCallback(): BleManagerGattCallback {
return GlucoseManagerGattCallback()
}
private inner class GlucoseManagerGattCallback : BleManagerGattCallback() {
override fun initialize() {
super.initialize()
setNotificationCallback(glucoseMeasurementCharacteristic).asValidResponseFlow<GlucoseMeasurementResponse>()
.onEach { data.tryEmit(data.value.copy(records = data.value.records + it.toRecord())) }
.launchIn(scope)
setNotificationCallback(glucoseMeasurementContextCharacteristic).asValidResponseFlow<GlucoseMeasurementContextResponse>()
.onEach {
val context = it.toMeasurementContext()
data.value.records.find { context.sequenceNumber == it.sequenceNumber }?.let {
it.context = context
}
data.tryEmit(data.value)
}.launchIn(scope)
setIndicationCallback(recordAccessControlPointCharacteristic).asValidResponseFlow<RecordAccessControlPointResponse>()
.onEach {
if (it.isOperationCompleted && it.wereRecordsFound() && it.numberOfRecords > 0) {
onNumberOfRecordsReceived(it)
} else if (it.isOperationCompleted && it.wereRecordsFound() && it.numberOfRecords == 0) {
onRecordAccessOperationCompletedWithNoRecordsFound(it)
} else if (it.isOperationCompleted && it.wereRecordsFound()) {
onRecordAccessOperationCompleted(it)
} else if (it.errorCode > 0) {
onRecordAccessOperationError(it)
}
}.launchIn(scope)
setNotificationCallback(batteryLevelCharacteristic).asValidResponseFlow<BatteryLevelResponse>()
.onEach {
data.value = data.value.copy(batteryLevel = it.batteryLevel)
}.launchIn(scope)
enableNotifications(glucoseMeasurementCharacteristic).enqueue()
enableNotifications(glucoseMeasurementContextCharacteristic).enqueue()
enableIndications(recordAccessControlPointCharacteristic).enqueue()
enableNotifications(batteryLevelCharacteristic).enqueue()
}
private fun onRecordAccessOperationCompleted(response: RecordAccessControlPointResponse) {
val status = when (response.requestCode) {
RecordAccessControlPointDataCallback.RACP_OP_CODE_ABORT_OPERATION -> RequestStatus.ABORTED
else -> RequestStatus.SUCCESS
}
data.tryEmit(data.value.copy(requestStatus = status))
}
private fun onRecordAccessOperationCompletedWithNoRecordsFound(response: RecordAccessControlPointResponse) {
data.tryEmit(data.value.copy(requestStatus = RequestStatus.SUCCESS))
}
private suspend fun onNumberOfRecordsReceived(response: RecordAccessControlPointResponse) {
if (response.numberOfRecords > 0) {
if (data.value.records.isNotEmpty()) {
val sequenceNumber = data.value.records
.last().sequenceNumber + 1
writeCharacteristic(
recordAccessControlPointCharacteristic,
RecordAccessControlPointData.reportStoredRecordsGreaterThenOrEqualTo(
sequenceNumber
),
BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
).suspend()
} else {
writeCharacteristic(
recordAccessControlPointCharacteristic,
RecordAccessControlPointData.reportAllStoredRecords(),
BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
).suspend()
}
}
data.tryEmit(data.value.copy(requestStatus = RequestStatus.SUCCESS))
}
private fun onRecordAccessOperationError(response: RecordAccessControlPointResponse) {
log(Log.WARN, "Record Access operation failed (error ${response.errorCode})")
if (response.errorCode == RecordAccessControlPointDataCallback.RACP_ERROR_OP_CODE_NOT_SUPPORTED) {
data.tryEmit(data.value.copy(requestStatus = RequestStatus.NOT_SUPPORTED))
} else {
data.tryEmit(data.value.copy(requestStatus = RequestStatus.FAILED))
}
}
public override fun isRequiredServiceSupported(gatt: BluetoothGatt): Boolean {
gatt.getService(GLS_SERVICE_UUID)?.run {
glucoseMeasurementCharacteristic = getCharacteristic(GM_CHARACTERISTIC)
glucoseMeasurementContextCharacteristic = getCharacteristic(GM_CONTEXT_CHARACTERISTIC)
recordAccessControlPointCharacteristic = getCharacteristic(RACP_CHARACTERISTIC)
}
gatt.getService(BATTERY_SERVICE_UUID)?.run {
batteryLevelCharacteristic = getCharacteristic(BATTERY_LEVEL_CHARACTERISTIC_UUID)
}
return glucoseMeasurementCharacteristic != null && recordAccessControlPointCharacteristic != null
}
override fun onServicesInvalidated() {
glucoseMeasurementCharacteristic = null
glucoseMeasurementContextCharacteristic = null
recordAccessControlPointCharacteristic = null
}
}
private fun clear() {
data.tryEmit(data.value.copy(records = emptyList()))
val target = bluetoothDevice
if (target != null) {
data.tryEmit(data.value.copy(requestStatus = RequestStatus.SUCCESS))
}
}
fun requestLastRecord() {
if (recordAccessControlPointCharacteristic == null) return
val target = bluetoothDevice ?: return
clear()
data.tryEmit(data.value.copy(requestStatus = RequestStatus.PENDING))
scope.launchWithCatch {
writeCharacteristic(
recordAccessControlPointCharacteristic,
RecordAccessControlPointData.reportLastStoredRecord(),
BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
).suspend()
}
}
fun requestFirstRecord() {
if (recordAccessControlPointCharacteristic == null) return
clear()
data.tryEmit(data.value.copy(requestStatus = RequestStatus.PENDING))
scope.launchWithCatch {
writeCharacteristic(
recordAccessControlPointCharacteristic,
RecordAccessControlPointData.reportFirstStoredRecord(),
BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
).suspend()
}
}
fun requestAllRecords() {
if (recordAccessControlPointCharacteristic == null) return
clear()
data.tryEmit(data.value.copy(requestStatus = RequestStatus.PENDING))
scope.launchWithCatch {
writeCharacteristic(
recordAccessControlPointCharacteristic,
RecordAccessControlPointData.reportNumberOfAllStoredRecords(),
BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
).suspend()
}
}
}

View File

@@ -1,127 +0,0 @@
/*
* Copyright (c) 2022, 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 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 java.util.*
internal data class GLSRecord(
val sequenceNumber: Int = 0,
val time: Calendar? = null,
val glucoseConcentration: Float = 0f,
val unit: ConcentrationUnit = ConcentrationUnit.UNIT_KGPL,
val type: RecordType? = null,
val status: GlucoseStatus? = null,
val sampleLocation: SampleLocation? = null,
var context: MeasurementContext? = null
)
internal enum class RecordType(val id: Int) {
CAPILLARY_WHOLE_BLOOD(1),
CAPILLARY_PLASMA(2),
VENOUS_WHOLE_BLOOD(3),
VENOUS_PLASMA(4),
ARTERIAL_WHOLE_BLOOD(5),
ARTERIAL_PLASMA(6),
UNDETERMINED_WHOLE_BLOOD(7),
UNDETERMINED_PLASMA(8),
INTERSTITIAL_FLUID(9),
CONTROL_SOLUTION(10);
companion object {
fun create(value: Int): RecordType {
return values().firstOrNull { it.id == value.toInt() }
?: throw IllegalArgumentException("Cannot find element for provided value.")
}
fun createOrNull(value: Int?): RecordType? {
return values().firstOrNull { it.id == value }
}
}
}
internal data class MeasurementContext(
val sequenceNumber: Int = 0,
val carbohydrate: Carbohydrate? = null,
val carbohydrateAmount: Float = 0f,
val meal: Meal? = null,
val tester: Tester? = null,
val health: Health? = null,
val exerciseDuration: Int = 0,
val exerciseIntensity: Int = 0,
val medication: Medication?,
val medicationQuantity: Float = 0f,
val medicationUnit: MedicationUnit = MedicationUnit.UNIT_KG,
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 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.")
}
}
}
internal enum class SampleLocation(val id: Int) {
FINGER(1),
AST(2),
EARLOBE(3),
CONTROL_SOLUTION(4),
NOT_AVAILABLE(15);
companion object {
fun createOrNull(value: Int?): SampleLocation? {
return values().firstOrNull { it.id == value }
}
}
}

View File

@@ -31,8 +31,14 @@
package no.nordicsemi.android.gls.data
internal data class GLSData(
val records: List<GLSRecord> = emptyList(),
import no.nordicsemi.android.kotlin.ble.core.data.GattConnectionStateWithStatus
import no.nordicsemi.android.kotlin.ble.profile.gls.data.GLSMeasurementContext
import no.nordicsemi.android.kotlin.ble.profile.gls.data.GLSRecord
import no.nordicsemi.android.kotlin.ble.profile.gls.data.RequestStatus
internal data class GLSServiceData(
val records: Map<GLSRecord, GLSMeasurementContext?> = mapOf(),
val batteryLevel: Int? = null,
val connectionState: GattConnectionStateWithStatus? = null,
val requestStatus: RequestStatus = RequestStatus.IDLE
)

View File

@@ -1,36 +0,0 @@
/*
* Copyright (c) 2022, 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
internal enum class RequestStatus {
IDLE, PENDING, SUCCESS, ABORTED, FAILED, NOT_SUPPORTED
}

View File

@@ -49,12 +49,13 @@ 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.GLSRecord
import no.nordicsemi.android.gls.main.view.toDisplayString
import no.nordicsemi.android.kotlin.ble.profile.gls.data.GLSMeasurementContext
import no.nordicsemi.android.kotlin.ble.profile.gls.data.GLSRecord
import no.nordicsemi.android.ui.view.ScreenSection
@Composable
internal fun GLSDetailsContentView(record: GLSRecord) {
internal fun GLSDetailsContentView(record: GLSRecord, context: GLSMeasurementContext?) {
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
Column(modifier = Modifier.padding(16.dp)) {
ScreenSection {
@@ -86,24 +87,28 @@ internal fun GLSDetailsContentView(record: GLSRecord) {
Spacer(modifier = Modifier.size(4.dp))
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Bottom
) {
Text(
text = stringResource(id = R.string.gls_details_glucose_condensation_title),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.outline
)
Text(
text = stringResource(
id = R.string.gls_details_glucose_condensation_field,
record.glucoseConcentration,
record.unit.toDisplayString()
),
style = MaterialTheme.typography.titleLarge
)
record.glucoseConcentration?.let { glucoseConcentration ->
record.unit?.let { unit ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Bottom
) {
Text(
text = stringResource(id = R.string.gls_details_glucose_condensation_title),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.outline
)
Text(
text = stringResource(
id = R.string.gls_details_glucose_condensation_field,
glucoseConcentration,
unit.toDisplayString()
),
style = MaterialTheme.typography.titleLarge
)
}
}
}
record.status?.let {
@@ -172,7 +177,7 @@ internal fun GLSDetailsContentView(record: GLSRecord) {
Spacer(modifier = Modifier.size(4.dp))
}
record.context?.let {
context?.let {
Divider(
color = MaterialTheme.colorScheme.secondary,
thickness = 1.dp,
@@ -209,33 +214,42 @@ internal fun GLSDetailsContentView(record: GLSRecord) {
)
Spacer(modifier = Modifier.size(4.dp))
}
Field(
stringResource(id = R.string.gls_context_exercise_title),
stringResource(
id = R.string.gls_context_exercise_field,
it.exerciseDuration,
it.exerciseIntensity
it.exerciseDuration?.let { exerciseDuration ->
it.exerciseIntensity?.let { exerciseIntensity ->
Field(
stringResource(id = R.string.gls_context_exercise_title),
stringResource(
id = R.string.gls_context_exercise_field,
exerciseDuration,
exerciseIntensity
)
)
}
}
it.medicationUnit?.let { medicationUnit ->
Spacer(modifier = Modifier.size(4.dp))
val medicationField = String.format(
stringResource(id = R.string.gls_context_medication_field),
it.medicationQuantity,
medicationUnit.toDisplayString(),
it.medication?.toDisplayString()
)
)
Spacer(modifier = Modifier.size(4.dp))
Field(
stringResource(id = R.string.gls_context_medication_title),
medicationField
)
}
val medicationField = String.format(
stringResource(id = R.string.gls_context_medication_field),
it.medicationQuantity,
it.medicationUnit.toDisplayString(),
it.medication?.toDisplayString()
)
Field(
stringResource(id = R.string.gls_context_medication_title),
medicationField
)
it.HbA1c?.let { hbA1c ->
Spacer(modifier = Modifier.size(4.dp))
Field(
stringResource(id = R.string.gls_context_hba1c_title),
stringResource(id = R.string.gls_context_hba1c_field, hbA1c)
)
}
Spacer(modifier = Modifier.size(4.dp))
Field(
stringResource(id = R.string.gls_context_hba1c_title),
stringResource(id = R.string.gls_context_hba1c_field, it.HbA1c)
)
Spacer(modifier = Modifier.size(4.dp))
} ?: Field(
stringResource(id = R.string.gls_context_title),
stringResource(id = R.string.gls_unavailable)

View File

@@ -33,15 +33,15 @@ package no.nordicsemi.android.gls.details.view
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import no.nordicsemi.android.ble.common.profile.glucose.GlucoseMeasurementContextCallback.Carbohydrate
import no.nordicsemi.android.ble.common.profile.glucose.GlucoseMeasurementContextCallback.Health
import no.nordicsemi.android.ble.common.profile.glucose.GlucoseMeasurementContextCallback.Meal
import no.nordicsemi.android.ble.common.profile.glucose.GlucoseMeasurementContextCallback.Medication
import no.nordicsemi.android.ble.common.profile.glucose.GlucoseMeasurementContextCallback.Tester
import no.nordicsemi.android.gls.R
import no.nordicsemi.android.gls.data.ConcentrationUnit
import no.nordicsemi.android.gls.data.MedicationUnit
import no.nordicsemi.android.gls.data.SampleLocation
import no.nordicsemi.android.kotlin.ble.profile.gls.data.Carbohydrate
import no.nordicsemi.android.kotlin.ble.profile.gls.data.ConcentrationUnit
import no.nordicsemi.android.kotlin.ble.profile.gls.data.Health
import no.nordicsemi.android.kotlin.ble.profile.gls.data.Meal
import no.nordicsemi.android.kotlin.ble.profile.gls.data.Medication
import no.nordicsemi.android.kotlin.ble.profile.gls.data.MedicationUnit
import no.nordicsemi.android.kotlin.ble.profile.gls.data.SampleLocation
import no.nordicsemi.android.kotlin.ble.profile.gls.data.Tester
@Composable
internal fun SampleLocation.toDisplayString(): String {
@@ -65,8 +65,8 @@ internal fun ConcentrationUnit.toDisplayString(): String {
@Composable
internal fun MedicationUnit.toDisplayString(): String {
return when (this) {
MedicationUnit.UNIT_KG -> stringResource(id = R.string.gls_sample_location_kg)
MedicationUnit.UNIT_L -> stringResource(id = R.string.gls_sample_location_l)
MedicationUnit.UNIT_MG -> stringResource(id = R.string.gls_sample_location_kg)
MedicationUnit.UNIT_ML -> stringResource(id = R.string.gls_sample_location_l)
}
}

View File

@@ -51,6 +51,6 @@ internal fun GLSDetailsScreen() {
viewModel.navigateBack()
}
GLSDetailsContentView(record)
GLSDetailsContentView(record.first, record.second)
}
}

View File

@@ -57,17 +57,17 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import no.nordicsemi.android.gls.R
import no.nordicsemi.android.gls.data.GLSData
import no.nordicsemi.android.gls.data.GLSRecord
import no.nordicsemi.android.gls.data.RequestStatus
import no.nordicsemi.android.gls.data.GLSServiceData
import no.nordicsemi.android.gls.data.WorkingMode
import no.nordicsemi.android.gls.main.viewmodel.GLSViewModel
import no.nordicsemi.android.kotlin.ble.profile.gls.data.GLSRecord
import no.nordicsemi.android.kotlin.ble.profile.gls.data.RequestStatus
import no.nordicsemi.android.ui.view.BatteryLevelView
import no.nordicsemi.android.ui.view.ScreenSection
import no.nordicsemi.android.ui.view.SectionTitle
@Composable
internal fun GLSContentView(state: GLSData, onEvent: (GLSScreenViewEvent) -> Unit) {
internal fun GLSContentView(state: GLSServiceData, onEvent: (GLSScreenViewEvent) -> Unit) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally
@@ -97,7 +97,7 @@ internal fun GLSContentView(state: GLSData, onEvent: (GLSScreenViewEvent) -> Uni
}
@Composable
private fun SettingsView(state: GLSData, onEvent: (GLSScreenViewEvent) -> Unit) {
private fun SettingsView(state: GLSServiceData, onEvent: (GLSScreenViewEvent) -> Unit) {
ScreenSection {
SectionTitle(icon = Icons.Default.Settings, title = "Request items")
@@ -121,7 +121,7 @@ private fun SettingsView(state: GLSData, onEvent: (GLSScreenViewEvent) -> Unit)
}
@Composable
private fun RecordsView(state: GLSData) {
private fun RecordsView(state: GLSServiceData) {
ScreenSection {
if (state.records.isEmpty()) {
RecordsViewWithoutData()
@@ -133,13 +133,13 @@ private fun RecordsView(state: GLSData) {
}
@Composable
private fun RecordsViewWithData(state: GLSData) {
private fun RecordsViewWithData(state: GLSServiceData) {
Column(modifier = Modifier.fillMaxWidth()) {
SectionTitle(resId = R.drawable.ic_records, title = "Records")
Spacer(modifier = Modifier.height(16.dp))
state.records.forEachIndexed { i, it ->
state.records.keys.forEachIndexed { i, it ->
RecordItem(it)
if (i < state.records.size - 1) {
@@ -184,13 +184,12 @@ private fun RecordItem(record: GLSRecord) {
style = MaterialTheme.typography.bodySmall
)
Text(
text = glucoseConcentrationDisplayValue(
record.glucoseConcentration,
record.unit
),
style = MaterialTheme.typography.labelLarge,
)
record.glucoseConcentration?.let { glucoseConcentration -> record.unit?.let { unit ->
Text(
text = glucoseConcentrationDisplayValue(glucoseConcentration, unit),
style = MaterialTheme.typography.labelLarge,
)
} }
}
}
}

View File

@@ -34,9 +34,9 @@ package no.nordicsemi.android.gls.main.view
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import no.nordicsemi.android.gls.R
import no.nordicsemi.android.gls.data.ConcentrationUnit
import no.nordicsemi.android.gls.data.RecordType
import no.nordicsemi.android.gls.data.WorkingMode
import no.nordicsemi.android.kotlin.ble.profile.gls.data.ConcentrationUnit
import no.nordicsemi.android.kotlin.ble.profile.gls.data.RecordType
@Composable
internal fun RecordType?.toDisplayString(): String {

View File

@@ -38,79 +38,52 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import no.nordicsemi.android.common.ui.scanner.view.DeviceConnectingView
import no.nordicsemi.android.common.ui.scanner.view.DeviceDisconnectedView
import no.nordicsemi.android.common.ui.scanner.view.Reason
import no.nordicsemi.android.gls.R
import no.nordicsemi.android.gls.main.viewmodel.GLSViewModel
import no.nordicsemi.android.service.ConnectedResult
import no.nordicsemi.android.service.ConnectingResult
import no.nordicsemi.android.service.DeviceHolder
import no.nordicsemi.android.service.DisconnectedResult
import no.nordicsemi.android.service.IdleResult
import no.nordicsemi.android.service.LinkLossResult
import no.nordicsemi.android.service.MissingServiceResult
import no.nordicsemi.android.service.SuccessResult
import no.nordicsemi.android.service.UnknownErrorResult
import no.nordicsemi.android.ui.view.BackIconAppBar
import no.nordicsemi.android.ui.view.LoggerIconAppBar
import no.nordicsemi.android.kotlin.ble.core.data.GattConnectionState
import no.nordicsemi.android.kotlin.ble.ui.scanner.view.DeviceConnectingView
import no.nordicsemi.android.kotlin.ble.ui.scanner.view.DeviceDisconnectedView
import no.nordicsemi.android.ui.view.NavigateUpButton
import no.nordicsemi.android.ui.view.ProfileAppBar
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun GLSScreen() {
val viewModel: GLSViewModel = hiltViewModel()
val state by viewModel.state.collectAsStateWithLifecycle()
val state = viewModel.state.collectAsState().value
val navigateUp = { viewModel.onEvent(DisconnectEvent) }
Scaffold(
topBar = { AppBar(state, navigateUp, viewModel) }
topBar = {
ProfileAppBar(
deviceName = state.deviceName,
connectionState = state.glsServiceData.connectionState,
title = R.string.gls_title,
navigateUp = navigateUp,
disconnect = { viewModel.onEvent(DisconnectEvent) },
openLogger = { viewModel.onEvent(OpenLoggerEvent) }
)
}
) {
Column(
modifier = Modifier
.padding(it)
.padding(16.dp)
.verticalScroll(rememberScrollState())
.padding(16.dp)
) {
when (val glsState = state) {
NoDeviceState -> DeviceConnectingView()
is WorkingState -> when (glsState.result) {
is IdleResult,
is ConnectingResult -> DeviceConnectingView { NavigateUpButton(navigateUp) }
is ConnectedResult -> DeviceConnectingView { NavigateUpButton(navigateUp) }
is DisconnectedResult -> DeviceDisconnectedView(Reason.USER) { NavigateUpButton(navigateUp) }
is LinkLossResult -> DeviceDisconnectedView(Reason.LINK_LOSS) { NavigateUpButton(navigateUp) }
is MissingServiceResult -> DeviceDisconnectedView(Reason.MISSING_SERVICE) {
NavigateUpButton(navigateUp)
}
is UnknownErrorResult -> DeviceDisconnectedView(Reason.UNKNOWN) { NavigateUpButton(navigateUp) }
is SuccessResult -> GLSContentView(glsState.result.data) { viewModel.onEvent(it) }
when (state.glsServiceData.connectionState?.state) {
null,
GattConnectionState.STATE_CONNECTING -> DeviceConnectingView { NavigateUpButton(navigateUp) }
GattConnectionState.STATE_DISCONNECTED,
GattConnectionState.STATE_DISCONNECTING -> DeviceDisconnectedView(state.disconnectStatus) {
NavigateUpButton(navigateUp)
}
GattConnectionState.STATE_CONNECTED -> GLSContentView(state.glsServiceData) { viewModel.onEvent(it) }
}
}
}
}
@Composable
private fun AppBar(state: GLSViewState, navigateUp: () -> Unit, viewModel: GLSViewModel) {
val toolbarName = (state as? WorkingState)?.let {
(it.result as? DeviceHolder)?.deviceName()
}
if (toolbarName == null) {
BackIconAppBar(stringResource(id = R.string.gls_title), navigateUp)
} else {
LoggerIconAppBar(toolbarName, {
viewModel.onEvent(DisconnectEvent)
}, { viewModel.onEvent(DisconnectEvent) }) {
viewModel.onEvent(OpenLoggerEvent)
}
}
}

View File

@@ -31,8 +31,8 @@
package no.nordicsemi.android.gls.main.view
import no.nordicsemi.android.gls.data.GLSRecord
import no.nordicsemi.android.gls.data.WorkingMode
import no.nordicsemi.android.kotlin.ble.profile.gls.data.GLSRecord
internal sealed class GLSScreenViewEvent

View File

@@ -1,40 +0,0 @@
/*
* Copyright (c) 2022, 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.main.view
import no.nordicsemi.android.gls.data.GLSData
import no.nordicsemi.android.service.BleManagerResult
internal sealed class GLSViewState
internal data class WorkingState(val result: BleManagerResult<GLSData>) : GLSViewState()
internal object NoDeviceState : GLSViewState()

View File

@@ -0,0 +1,84 @@
/*
* Copyright (c) 2022, 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.main.view
import no.nordicsemi.android.gls.data.GLSServiceData
import no.nordicsemi.android.kotlin.ble.core.data.BleGattConnectionStatus
import no.nordicsemi.android.kotlin.ble.core.data.GattConnectionStateWithStatus
import no.nordicsemi.android.kotlin.ble.profile.gls.data.GLSMeasurementContext
import no.nordicsemi.android.kotlin.ble.profile.gls.data.GLSRecord
import no.nordicsemi.android.kotlin.ble.profile.gls.data.RequestStatus
internal data class GLSViewState(
val glsServiceData: GLSServiceData = GLSServiceData(),
val deviceName: String? = null,
val missingServices: Boolean = false
) {
val disconnectStatus = if (missingServices) {
BleGattConnectionStatus.NOT_SUPPORTED
} else {
glsServiceData.connectionState?.status ?: BleGattConnectionStatus.UNKNOWN
}
fun copyWithNewConnectionState(connectionState: GattConnectionStateWithStatus): GLSViewState {
return copy(glsServiceData = glsServiceData.copy(connectionState = connectionState))
}
fun copyAndClear(): GLSViewState {
return copy(glsServiceData = glsServiceData.copy(records = mapOf(), requestStatus = RequestStatus.IDLE))
}
fun copyWithNewRequestStatus(requestStatus: RequestStatus): GLSViewState {
return copy(glsServiceData = glsServiceData.copy(requestStatus = requestStatus))
}
fun copyWithNewBatteryLevel(batteryLevel: Int): GLSViewState {
return copy(glsServiceData = glsServiceData.copy(batteryLevel = batteryLevel))
}
//todo optimise
fun copyWithNewRecord(record: GLSRecord): GLSViewState {
val records = glsServiceData.records.toMutableMap()
records[record] = null
return copy(glsServiceData = glsServiceData.copy(records = records.toMap()))
}
//todo optimise
fun copyWithNewContext(context: GLSMeasurementContext): GLSViewState {
val records = glsServiceData.records.toMutableMap()
return records.keys.firstOrNull { it.sequenceNumber == context.sequenceNumber }?.let {
records[it] = context
copy(glsServiceData = glsServiceData.copy(records = records.toMap()))
} ?: this
}
}

View File

@@ -31,46 +31,93 @@
package no.nordicsemi.android.gls.main.viewmodel
import android.annotation.SuppressLint
import android.content.Context
import android.os.ParcelUuid
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import no.nordicsemi.android.analytics.AppAnalytics
import no.nordicsemi.android.analytics.Profile
import no.nordicsemi.android.analytics.ProfileConnectedEvent
import no.nordicsemi.android.common.logger.BleLoggerAndLauncher
import no.nordicsemi.android.common.navigation.NavigationResult
import no.nordicsemi.android.common.navigation.Navigator
import no.nordicsemi.android.common.ui.scanner.model.DiscoveredBluetoothDevice
import no.nordicsemi.android.gls.GlsDetailsDestinationId
import no.nordicsemi.android.gls.data.GLS_SERVICE_UUID
import no.nordicsemi.android.gls.data.WorkingMode
import no.nordicsemi.android.gls.main.view.DisconnectEvent
import no.nordicsemi.android.gls.main.view.GLSScreenViewEvent
import no.nordicsemi.android.gls.main.view.GLSViewState
import no.nordicsemi.android.gls.main.view.NoDeviceState
import no.nordicsemi.android.gls.main.view.OnGLSRecordClick
import no.nordicsemi.android.gls.main.view.OnWorkingModeSelected
import no.nordicsemi.android.gls.main.view.OpenLoggerEvent
import no.nordicsemi.android.gls.main.view.WorkingState
import no.nordicsemi.android.gls.repository.GLSRepository
import no.nordicsemi.android.service.ConnectedResult
import no.nordicsemi.android.kotlin.ble.client.main.callback.ClientBleGatt
import no.nordicsemi.android.kotlin.ble.client.main.errors.GattOperationException
import no.nordicsemi.android.kotlin.ble.client.main.service.ClientBleGattCharacteristic
import no.nordicsemi.android.kotlin.ble.client.main.service.ClientBleGattServices
import no.nordicsemi.android.kotlin.ble.core.ServerDevice
import no.nordicsemi.android.kotlin.ble.core.data.GattConnectionState
import no.nordicsemi.android.kotlin.ble.core.data.GattConnectionStateWithStatus
import no.nordicsemi.android.kotlin.ble.profile.battery.BatteryLevelParser
import no.nordicsemi.android.kotlin.ble.profile.gls.GlucoseMeasurementContextParser
import no.nordicsemi.android.kotlin.ble.profile.gls.GlucoseMeasurementParser
import no.nordicsemi.android.kotlin.ble.profile.gls.RecordAccessControlPointInputParser
import no.nordicsemi.android.kotlin.ble.profile.gls.RecordAccessControlPointParser
import no.nordicsemi.android.kotlin.ble.profile.gls.data.GLSRecord
import no.nordicsemi.android.kotlin.ble.profile.gls.data.NumberOfRecordsData
import no.nordicsemi.android.kotlin.ble.profile.gls.data.RecordAccessControlPointData
import no.nordicsemi.android.kotlin.ble.profile.gls.data.RequestStatus
import no.nordicsemi.android.kotlin.ble.profile.gls.data.ResponseData
import no.nordicsemi.android.kotlin.ble.profile.racp.RACPOpCode
import no.nordicsemi.android.kotlin.ble.profile.racp.RACPResponseCode
import no.nordicsemi.android.toolbox.scanner.ScannerDestinationId
import no.nordicsemi.android.ui.view.NordicLoggerFactory
import no.nordicsemi.android.ui.view.StringConst
import java.util.*
import javax.inject.Inject
val GLS_SERVICE_UUID: UUID = UUID.fromString("00001808-0000-1000-8000-00805f9b34fb")
val GLUCOSE_MEASUREMENT_CHARACTERISTIC = UUID.fromString("00002A18-0000-1000-8000-00805f9b34fb")
val GLUCOSE_MEASUREMENT_CONTEXT_CHARACTERISTIC = UUID.fromString("00002A34-0000-1000-8000-00805f9b34fb")
val GLUCOSE_FEATURE_CHARACTERISTIC = UUID.fromString("00002A51-0000-1000-8000-00805f9b34fb")
val RACP_CHARACTERISTIC = UUID.fromString("00002A52-0000-1000-8000-00805f9b34fb")
val BATTERY_SERVICE_UUID = UUID.fromString("0000180F-0000-1000-8000-00805f9b34fb")
val BATTERY_LEVEL_CHARACTERISTIC_UUID = UUID.fromString("00002A19-0000-1000-8000-00805f9b34fb")
@SuppressLint("MissingPermission")
@HiltViewModel
internal class GLSViewModel @Inject constructor(
private val repository: GLSRepository,
@ApplicationContext
private val context: Context,
private val navigationManager: Navigator,
private val analytics: AppAnalytics
private val analytics: AppAnalytics,
private val stringConst: StringConst,
private val loggerFactory: NordicLoggerFactory
) : ViewModel() {
private val _state = MutableStateFlow<GLSViewState>(NoDeviceState)
internal lateinit var client: ClientBleGatt
private lateinit var logger: BleLoggerAndLauncher
private lateinit var glucoseMeasurementCharacteristic: ClientBleGattCharacteristic
private lateinit var recordAccessControlPointCharacteristic: ClientBleGattCharacteristic
private val _state = MutableStateFlow(GLSViewState())
val state = _state.asStateFlow()
private val highestSequenceNumber
get() = state.value.glsServiceData.records.keys.maxByOrNull { it.sequenceNumber }?.sequenceNumber ?: -1
init {
navigationManager.navigateTo(ScannerDestinationId, ParcelUuid(GLS_SERVICE_UUID))
@@ -79,30 +126,204 @@ internal class GLSViewModel @Inject constructor(
.launchIn(viewModelScope)
}
private fun handleResult(result: NavigationResult<DiscoveredBluetoothDevice>) {
internal fun handleResult(result: NavigationResult<ServerDevice>) {
when (result) {
is NavigationResult.Cancelled -> navigationManager.navigateUp()
is NavigationResult.Success -> connectDevice(result.value)
is NavigationResult.Success -> onDeviceSelected(result.value)
}
}
fun onEvent(event: GLSScreenViewEvent) {
when (event) {
OpenLoggerEvent -> repository.openLogger()
DisconnectEvent -> navigationManager.navigateUp()
is OnWorkingModeSelected -> repository.requestMode(event.workingMode)
is OnGLSRecordClick -> navigationManager.navigateTo(GlsDetailsDestinationId, event.record)
DisconnectEvent -> navigationManager.navigateUp()
OpenLoggerEvent -> logger.launch()
is OnWorkingModeSelected -> onEvent(event)
is OnGLSRecordClick -> navigateToDetails(event.record)
DisconnectEvent -> onDisconnectEvent()
}
}
private fun connectDevice(device: DiscoveredBluetoothDevice) {
repository.downloadData(viewModelScope, device).onEach {
_state.value = WorkingState(it)
private fun onDisconnectEvent() {
client.disconnect()
navigationManager.navigateUp()
}
(it as? ConnectedResult)?.let {
analytics.logEvent(ProfileConnectedEvent(Profile.GLS))
private fun navigateToDetails(record: GLSRecord) {
val context = state.value.glsServiceData.records[record]
navigationManager.navigateTo(GlsDetailsDestinationId, record to context)
}
private fun onDeviceSelected(device: ServerDevice) {
startGattClient(device)
}
private fun onEvent(event: OnWorkingModeSelected) = viewModelScope.launch {
when (event.workingMode) {
WorkingMode.ALL -> requestAllRecords()
WorkingMode.LAST -> requestLastRecord()
WorkingMode.FIRST -> requestFirstRecord()
}
}
private fun startGattClient(device: ServerDevice) = viewModelScope.launch {
_state.value = _state.value.copy(deviceName = device.name)
logger = loggerFactory.createNordicLogger(context, stringConst.APP_NAME, "GLS", device.address)
client = ClientBleGatt.connect(context, device, logger = logger)
client.waitForBonding()
client.connectionStateWithStatus
.filterNotNull()
.onEach { _state.value = _state.value.copyWithNewConnectionState(it) }
.onEach { logAnalytics(it) }
.launchIn(viewModelScope)
if (!client.isConnected) {
return@launch
}
try {
val services = client.discoverServices()
configureGatt(services)
} catch (e: Exception) {
onMissingServices()
}
}
private fun onMissingServices() {
_state.value = state.value.copy(missingServices = true)
client.disconnect()
}
internal fun logAnalytics(connectionState: GattConnectionStateWithStatus) {
if (connectionState.state == GattConnectionState.STATE_CONNECTED) {
analytics.logEvent(ProfileConnectedEvent(Profile.GLS))
}
}
private suspend fun configureGatt(services: ClientBleGattServices) {
val glsService = services.findService(GLS_SERVICE_UUID)!!
glucoseMeasurementCharacteristic = glsService.findCharacteristic(GLUCOSE_MEASUREMENT_CHARACTERISTIC)!!
recordAccessControlPointCharacteristic = glsService.findCharacteristic(RACP_CHARACTERISTIC)!!
val batteryService = services.findService(BATTERY_SERVICE_UUID)!!
val batteryLevelCharacteristic = batteryService.findCharacteristic(BATTERY_LEVEL_CHARACTERISTIC_UUID)!!
batteryLevelCharacteristic.getNotifications()
.mapNotNull { BatteryLevelParser.parse(it) }
.onEach { _state.value = _state.value.copyWithNewBatteryLevel(it) }
.catch { it.printStackTrace() }
.launchIn(viewModelScope)
glucoseMeasurementCharacteristic.getNotifications()
.mapNotNull { GlucoseMeasurementParser.parse(it) }
.onEach { _state.value = _state.value.copyWithNewRecord(it) }
.catch { it.printStackTrace() }
.launchIn(viewModelScope)
glsService.findCharacteristic(GLUCOSE_MEASUREMENT_CONTEXT_CHARACTERISTIC)?.getNotifications()
?.mapNotNull { GlucoseMeasurementContextParser.parse(it) }
?.onEach { _state.value = _state.value.copyWithNewContext(it) }
?.catch { it.printStackTrace() }
?.launchIn(viewModelScope)
recordAccessControlPointCharacteristic.getNotifications()
.mapNotNull { RecordAccessControlPointParser.parse(it) }
.onEach { onAccessControlPointDataReceived(it) }
.catch { it.printStackTrace() }
.launchIn(viewModelScope)
}
private fun onAccessControlPointDataReceived(data: RecordAccessControlPointData) = viewModelScope.launch {
when (data) {
is NumberOfRecordsData -> onNumberOfRecordsReceived(data.numberOfRecords)
is ResponseData -> when (data.responseCode) {
RACPResponseCode.RACP_RESPONSE_SUCCESS -> onRecordAccessOperationCompleted(data.requestCode)
RACPResponseCode.RACP_ERROR_NO_RECORDS_FOUND -> onRecordAccessOperationCompletedWithNoRecordsFound()
RACPResponseCode.RACP_ERROR_OP_CODE_NOT_SUPPORTED,
RACPResponseCode.RACP_ERROR_INVALID_OPERATOR,
RACPResponseCode.RACP_ERROR_OPERATOR_NOT_SUPPORTED,
RACPResponseCode.RACP_ERROR_INVALID_OPERAND,
RACPResponseCode.RACP_ERROR_ABORT_UNSUCCESSFUL,
RACPResponseCode.RACP_ERROR_PROCEDURE_NOT_COMPLETED,
RACPResponseCode.RACP_ERROR_OPERAND_NOT_SUPPORTED -> onRecordAccessOperationError(data.responseCode)
}
}.launchIn(viewModelScope)
}
}
private fun onRecordAccessOperationCompleted(requestCode: RACPOpCode) {
val status = when (requestCode) {
RACPOpCode.RACP_OP_CODE_ABORT_OPERATION -> RequestStatus.ABORTED
else -> RequestStatus.SUCCESS
}
_state.value = _state.value.copyWithNewRequestStatus(status)
}
private fun onRecordAccessOperationCompletedWithNoRecordsFound() {
_state.value = _state.value.copyWithNewRequestStatus(RequestStatus.SUCCESS)
}
private suspend fun onNumberOfRecordsReceived(numberOfRecords: Int) {
if (numberOfRecords > 0) {
try {
if (state.value.glsServiceData.records.isNotEmpty()) {
recordAccessControlPointCharacteristic.write(
RecordAccessControlPointInputParser.reportStoredRecordsGreaterThenOrEqualTo(highestSequenceNumber)
)
} else {
recordAccessControlPointCharacteristic.write(
RecordAccessControlPointInputParser.reportAllStoredRecords()
)
}
} catch (e: GattOperationException) {
e.printStackTrace()
}
}
_state.value = _state.value.copyWithNewRequestStatus(RequestStatus.SUCCESS)
}
private fun onRecordAccessOperationError(response: RACPResponseCode) {
if (response == RACPResponseCode.RACP_ERROR_OP_CODE_NOT_SUPPORTED) {
_state.value = _state.value.copyWithNewRequestStatus(RequestStatus.NOT_SUPPORTED)
} else {
_state.value = _state.value.copyWithNewRequestStatus(RequestStatus.FAILED)
}
}
private fun clear() {
_state.value = _state.value.copyAndClear()
}
private suspend fun requestLastRecord() {
clear()
_state.value = _state.value.copyWithNewRequestStatus(RequestStatus.PENDING)
try {
recordAccessControlPointCharacteristic.write(RecordAccessControlPointInputParser.reportLastStoredRecord())
} catch (e: Exception) {
e.printStackTrace()
_state.value = _state.value.copyWithNewRequestStatus(RequestStatus.FAILED)
}
}
private suspend fun requestFirstRecord() {
clear()
_state.value = _state.value.copyWithNewRequestStatus(RequestStatus.PENDING)
try {
recordAccessControlPointCharacteristic.write(RecordAccessControlPointInputParser.reportFirstStoredRecord())
} catch (e: Exception) {
e.printStackTrace()
_state.value = _state.value.copyWithNewRequestStatus(RequestStatus.FAILED)
}
}
private suspend fun requestAllRecords() {
clear()
_state.value = _state.value.copyWithNewRequestStatus(RequestStatus.PENDING)
try {
recordAccessControlPointCharacteristic.write(RecordAccessControlPointInputParser.reportNumberOfAllStoredRecords())
} catch (e: Exception) {
e.printStackTrace()
_state.value = _state.value.copyWithNewRequestStatus(RequestStatus.FAILED)
}
}
}

View File

@@ -1,112 +0,0 @@
/*
* Copyright (c) 2022, 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.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.scopes.ViewModelScoped
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import no.nordicsemi.android.ble.ktx.suspend
import no.nordicsemi.android.common.logger.NordicLogger
import no.nordicsemi.android.common.logger.NordicLoggerFactory
import no.nordicsemi.android.common.ui.scanner.model.DiscoveredBluetoothDevice
import no.nordicsemi.android.gls.data.GLSData
import no.nordicsemi.android.gls.data.GLSManager
import no.nordicsemi.android.gls.data.WorkingMode
import no.nordicsemi.android.service.BleManagerResult
import no.nordicsemi.android.ui.view.StringConst
import javax.inject.Inject
@ViewModelScoped
internal class GLSRepository @Inject constructor(
@ApplicationContext
private val context: Context,
private val loggerFactory: NordicLoggerFactory,
private val stringConst: StringConst
) {
private var manager: GLSManager? = null
private var logger: NordicLogger? = null
fun downloadData(scope: CoroutineScope, device: DiscoveredBluetoothDevice): Flow<BleManagerResult<GLSData>> = callbackFlow {
val createdLogger = loggerFactory.create(stringConst.APP_NAME, "GLS", device.address).also {
logger = it
}
val managerInstance = manager ?: GLSManager(context, scope, createdLogger)
manager = managerInstance
managerInstance.dataHolder.status.onEach {
send(it)
}.launchIn(scope)
scope.launch {
managerInstance.start(device)
}
awaitClose {
launch {
manager?.disconnect()?.suspend()
logger = null
manager = null
}
}
}
private suspend fun GLSManager.start(device: DiscoveredBluetoothDevice) {
try {
connect(device.device)
.useAutoConnect(false)
.retry(3, 100)
.suspend()
} catch (e: Exception) {
e.printStackTrace()
}
}
fun openLogger() {
NordicLogger.launch(context, logger)
}
fun requestMode(workingMode: WorkingMode) {
when (workingMode) {
WorkingMode.ALL -> manager?.requestAllRecords()
WorkingMode.LAST -> manager?.requestLastRecord()
WorkingMode.FIRST -> manager?.requestFirstRecord()
}
}
}

View File

@@ -0,0 +1,191 @@
package no.nordicsemi.android.gls
import android.content.Context
import io.mockk.every
import io.mockk.impl.annotations.MockK
import io.mockk.impl.annotations.RelaxedMockK
import io.mockk.junit4.MockKRule
import io.mockk.justRun
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.mockkStatic
import io.mockk.spyk
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import no.nordicsemi.android.analytics.AppAnalytics
import no.nordicsemi.android.common.logger.NordicBlekLogger
import no.nordicsemi.android.common.navigation.NavigationResult
import no.nordicsemi.android.common.navigation.Navigator
import no.nordicsemi.android.gls.data.WorkingMode
import no.nordicsemi.android.gls.main.view.OnWorkingModeSelected
import no.nordicsemi.android.gls.main.viewmodel.GLSViewModel
import no.nordicsemi.android.kotlin.ble.client.main.ClientScope
import no.nordicsemi.android.kotlin.ble.core.MockServerDevice
import no.nordicsemi.android.kotlin.ble.core.data.BleGattConnectionStatus
import no.nordicsemi.android.kotlin.ble.core.data.GattConnectionState
import no.nordicsemi.android.kotlin.ble.core.data.GattConnectionStateWithStatus
import no.nordicsemi.android.kotlin.ble.profile.gls.GlucoseMeasurementParser
import no.nordicsemi.android.kotlin.ble.profile.gls.data.RequestStatus
import no.nordicsemi.android.kotlin.ble.server.main.ServerScope
import no.nordicsemi.android.ui.view.NordicLoggerFactory
import no.nordicsemi.android.ui.view.StringConst
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import kotlin.test.assertContentEquals
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
internal class GLSViewModelTest {
@get:Rule
val mockkRule = MockKRule(this)
@RelaxedMockK
lateinit var navigator: Navigator
@RelaxedMockK
lateinit var analytics: AppAnalytics
@MockK
lateinit var stringConst: StringConst
@RelaxedMockK
lateinit var context: Context
@RelaxedMockK
lateinit var logger: NordicBlekLogger
lateinit var viewModel: GLSViewModel
lateinit var glsServer: GlsServer
private val device = MockServerDevice(
name = "GLS Server",
address = "55:44:33:22:11"
)
@Before
fun setUp() {
Dispatchers.setMain(UnconfinedTestDispatcher())
}
@After
fun release() {
Dispatchers.resetMain()
}
@Before
fun before() {
runBlocking {
mockkStatic("no.nordicsemi.android.kotlin.ble.client.main.ClientScopeKt")
every { ClientScope } returns CoroutineScope(UnconfinedTestDispatcher())
mockkStatic("no.nordicsemi.android.kotlin.ble.server.main.ServerScopeKt")
every { ServerScope } returns CoroutineScope(UnconfinedTestDispatcher())
every { stringConst.APP_NAME } returns "Test"
viewModel = spyk(GLSViewModel(context, navigator, analytics, stringConst, object :
NordicLoggerFactory {
override fun createNordicLogger(
context: Context,
profile: String?,
key: String,
name: String?,
): NordicBlekLogger {
return logger
}
}))
justRun { viewModel.logAnalytics(any()) }
glsServer = GlsServer(CoroutineScope(UnconfinedTestDispatcher()))
glsServer.start(spyk(), device)
}
}
@Before
fun prepareLogger() {
mockkObject(NordicBlekLogger.Companion)
every { NordicBlekLogger.create(any(), any(), any(), any()) } returns mockk()
}
@Test
fun `when connection fails return disconnected`() = runTest {
val disconnectedState = GattConnectionStateWithStatus(
GattConnectionState.STATE_DISCONNECTED,
BleGattConnectionStatus.SUCCESS
)
viewModel.handleResult(NavigationResult.Success(device))
glsServer.stopServer()
advanceUntilIdle()
assertEquals(disconnectedState, viewModel.state.value.glsServiceData.connectionState)
}
@Test
fun `when request first record then change status and get 1 record`() = runTest {
viewModel.handleResult(NavigationResult.Success(device))
advanceUntilIdle() //Needed because of delay() in waitForBonding()
assertEquals(RequestStatus.IDLE, viewModel.state.value.glsServiceData.requestStatus)
viewModel.onEvent(OnWorkingModeSelected(WorkingMode.FIRST))
assertEquals(RequestStatus.PENDING, viewModel.state.value.glsServiceData.requestStatus)
glsServer.continueWithResponse() //continue server breakpoint
assertEquals(RequestStatus.SUCCESS, viewModel.state.value.glsServiceData.requestStatus)
assertEquals(1, viewModel.state.value.glsServiceData.records.size)
val parsedResponse = GlucoseMeasurementParser.parse(glsServer.YOUNGEST_RECORD)
assertEquals(parsedResponse, viewModel.state.value.glsServiceData.records.keys.first())
}
@Test
fun `when request last record then change status and get 1 record`() = runTest {
viewModel.handleResult(NavigationResult.Success(device))
advanceUntilIdle() //Needed because of delay() in waitForBonding()
assertEquals(RequestStatus.IDLE, viewModel.state.value.glsServiceData.requestStatus)
viewModel.onEvent(OnWorkingModeSelected(WorkingMode.LAST))
assertEquals(RequestStatus.PENDING, viewModel.state.value.glsServiceData.requestStatus)
glsServer.continueWithResponse() //continue server breakpoint
assertEquals(RequestStatus.SUCCESS, viewModel.state.value.glsServiceData.requestStatus)
assertEquals(1, viewModel.state.value.glsServiceData.records.size)
val parsedResponse = GlucoseMeasurementParser.parse(glsServer.OLDEST_RECORD)
assertEquals(parsedResponse, viewModel.state.value.glsServiceData.records.keys.first())
}
@Test
fun `when request all record then change status and get 5 records`() = runTest {
viewModel.handleResult(NavigationResult.Success(device))
advanceUntilIdle() //Needed because of delay() in waitForBonding()
assertEquals(RequestStatus.IDLE, viewModel.state.value.glsServiceData.requestStatus)
viewModel.onEvent(OnWorkingModeSelected(WorkingMode.ALL))
assertEquals(RequestStatus.PENDING, viewModel.state.value.glsServiceData.requestStatus)
glsServer.continueWithResponse() //continue server breakpoint
advanceUntilIdle() //We have to use because of delay() in sendAll()
assertEquals(RequestStatus.SUCCESS, viewModel.state.value.glsServiceData.requestStatus)
assertEquals(5, viewModel.state.value.glsServiceData.records.size)
val expectedRecords = glsServer.records.map { GlucoseMeasurementParser.parse(it) }
assertContentEquals(expectedRecords, viewModel.state.value.glsServiceData.records.keys)
}
}

View File

@@ -45,14 +45,18 @@ dependencies {
implementation(project(":lib_ui"))
implementation(project(":lib_utils"))
implementation(libs.nordic.blek.client)
implementation(libs.nordic.blek.profile)
implementation(libs.chart)
implementation(libs.nordic.theme)
implementation(libs.nordic.ble.common)
implementation(libs.nordic.ble.ktx)
implementation(libs.nordic.navigation)
implementation(libs.nordic.uiscanner)
implementation(libs.nordic.blek.uiscanner)
implementation(libs.nordic.uilogger)
implementation(libs.nordic.core)
implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.androidx.compose.material.iconsExtended)

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