mirror of
https://github.com/aljazceru/Android-nRF-Toolbox.git
synced 2026-02-15 11:44:22 +01:00
Merge branch 'develop'
This commit is contained in:
8
.github/workflows/deploy-all.yml
vendored
8
.github/workflows/deploy-all.yml
vendored
@@ -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 }}
|
||||
|
||||
4
.github/workflows/deploy-to-play-store.yml
vendored
4
.github/workflows/deploy-to-play-store.yml
vendored
@@ -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 }}
|
||||
|
||||
4
.github/workflows/generate-readme.yml
vendored
4
.github/workflows/generate-readme.yml
vendored
@@ -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 }}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
18
app/proguard-rules.pro
vendored
18
app/proguard-rules.pro
vendored
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
22
app/src/main/res/xml/data_extraction_rules.xml
Normal file
22
app/src/main/res/xml/data_extraction_rules.xml
Normal 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>
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package no.nordicsemi.android.service
|
||||
|
||||
class DisconnectAndStopEvent
|
||||
@@ -0,0 +1,3 @@
|
||||
package no.nordicsemi.android.service
|
||||
|
||||
class OpenLoggerEvent
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,6 @@ android {
|
||||
|
||||
dependencies {
|
||||
implementation(libs.nordic.navigation)
|
||||
implementation(libs.nordic.uiscanner)
|
||||
implementation(libs.nordic.blek.uiscanner)
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
@@ -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
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()) { }
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,6 @@ internal fun GLSDetailsScreen() {
|
||||
viewModel.navigateBack()
|
||||
}
|
||||
|
||||
GLSDetailsContentView(record)
|
||||
GLSDetailsContentView(record.first, record.second)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
} }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user