diff --git a/.github/workflows/deploy-all.yml b/.github/workflows/deploy-all.yml index a07bfec4..ac5f3e56 100644 --- a/.github/workflows/deploy-all.yml +++ b/.github/workflows/deploy-all.yml @@ -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 }} diff --git a/.github/workflows/deploy-to-play-store.yml b/.github/workflows/deploy-to-play-store.yml index 37208f65..b0856553 100644 --- a/.github/workflows/deploy-to-play-store.yml +++ b/.github/workflows/deploy-to-play-store.yml @@ -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 }} diff --git a/.github/workflows/generate-readme.yml b/.github/workflows/generate-readme.yml index feba80f7..92b7c41c 100644 --- a/.github/workflows/generate-readme.yml +++ b/.github/workflows/generate-readme.yml @@ -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 }} diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0c3d502b..23f9f20f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) } diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 40fe7e95..6779d7fc 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -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.* ; +# Ignore our XML Serialization classes +-keep public class your.annotated.pojo.models.*{ + public protected private *; } # Crashlytics diff --git a/profile_prx/src/main/java/no/nordicsemi/android/prx/data/PRXData.kt b/app/src/debug/java/no/nordicsemi/android/nrftoolbox/NrfToolboxApplication.kt similarity index 70% rename from profile_prx/src/main/java/no/nordicsemi/android/prx/data/PRXData.kt rename to app/src/debug/java/no/nordicsemi/android/nrftoolbox/NrfToolboxApplication.kt index 1488297d..2d8a9f81 100644 --- a/profile_prx/src/main/java/no/nordicsemi/android/prx/data/PRXData.kt +++ b/app/src/debug/java/no/nordicsemi/android/nrftoolbox/NrfToolboxApplication.kt @@ -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) } } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f51aa1b0..05f78497 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -30,7 +30,8 @@ ~ EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. --> - + @@ -44,18 +45,19 @@ + android:theme="@style/NordicTheme" + android:dataExtractionRules="@xml/data_extraction_rules" + tools:targetApi="s"> diff --git a/app/src/main/java/no/nordicsemi/android/nrftoolbox/ApplicationScopeModule.kt b/app/src/main/java/no/nordicsemi/android/nrftoolbox/ApplicationScopeModule.kt new file mode 100644 index 00000000..0e1e58c8 --- /dev/null +++ b/app/src/main/java/no/nordicsemi/android/nrftoolbox/ApplicationScopeModule.kt @@ -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()) +} diff --git a/app/src/main/java/no/nordicsemi/android/nrftoolbox/view/FeatureButton.kt b/app/src/main/java/no/nordicsemi/android/nrftoolbox/view/FeatureButton.kt index 3708b895..8d280648 100644 --- a/app/src/main/java/no/nordicsemi/android/nrftoolbox/view/FeatureButton.kt +++ b/app/src/main/java/no/nordicsemi/android/nrftoolbox/view/FeatureButton.kt @@ -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, diff --git a/app/src/main/java/no/nordicsemi/android/nrftoolbox/viewmodel/HomeViewModel.kt b/app/src/main/java/no/nordicsemi/android/nrftoolbox/viewmodel/HomeViewModel.kt index c156e5c0..7928ab0f 100644 --- a/app/src/main/java/no/nordicsemi/android/nrftoolbox/viewmodel/HomeViewModel.kt +++ b/app/src/main/java/no/nordicsemi/android/nrftoolbox/viewmodel/HomeViewModel.kt @@ -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) { diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 00000000..be242ad7 --- /dev/null +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/no/nordicsemi/android/nrftoolbox/NrfToolboxApplication.kt b/app/src/release/java/no/nordicsemi/android/nrftoolbox/NrfToolboxApplication.kt similarity index 100% rename from app/src/main/java/no/nordicsemi/android/nrftoolbox/NrfToolboxApplication.kt rename to app/src/release/java/no/nordicsemi/android/nrftoolbox/NrfToolboxApplication.kt diff --git a/build.gradle.kts b/build.gradle.kts index 403f8311..b8e2230a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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 } diff --git a/gradle.properties b/gradle.properties index 7b00938f..cb91515f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -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 \ No newline at end of file +kotlin.code.style=official + +android.nonTransitiveRClass=false diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index b49ef06f..dfd5018b 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/lib_analytics/build.gradle.kts b/lib_analytics/build.gradle.kts index 5f5d090a..7e2986c8 100644 --- a/lib_analytics/build.gradle.kts +++ b/lib_analytics/build.gradle.kts @@ -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) } diff --git a/lib_scanner/build.gradle.kts b/lib_scanner/build.gradle.kts index 44b2e683..51a09721 100644 --- a/lib_scanner/build.gradle.kts +++ b/lib_scanner/build.gradle.kts @@ -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) diff --git a/lib_scanner/src/androidTest/java/no/nordicsemi/android/toolbox/scanner/ExampleInstrumentedTest.kt b/lib_scanner/src/androidTest/java/no/nordicsemi/android/toolbox/scanner/ExampleInstrumentedTest.kt deleted file mode 100644 index 9f3317ee..00000000 --- a/lib_scanner/src/androidTest/java/no/nordicsemi/android/toolbox/scanner/ExampleInstrumentedTest.kt +++ /dev/null @@ -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) - } -} \ No newline at end of file diff --git a/lib_scanner/src/main/java/no/nordicsemi/android/toolbox/scanner/ScannerDestination.kt b/lib_scanner/src/main/java/no/nordicsemi/android/toolbox/scanner/ScannerDestination.kt index da5cf239..9c44d07c 100644 --- a/lib_scanner/src/main/java/no/nordicsemi/android/toolbox/scanner/ScannerDestination.kt +++ b/lib_scanner/src/main/java/no/nordicsemi/android/toolbox/scanner/ScannerDestination.kt @@ -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("uiscanner-destination") +val ScannerDestinationId = createDestination("uiscanner-destination") val ScannerDestination = defineDestination(ScannerDestinationId) { val navigationViewModel = hiltViewModel() @@ -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() } } diff --git a/lib_scanner/src/test/java/no/nordicsemi/android/toolbox/scanner/ExampleUnitTest.kt b/lib_scanner/src/test/java/no/nordicsemi/android/toolbox/scanner/ExampleUnitTest.kt deleted file mode 100644 index f3df530f..00000000 --- a/lib_scanner/src/test/java/no/nordicsemi/android/toolbox/scanner/ExampleUnitTest.kt +++ /dev/null @@ -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) - } -} \ No newline at end of file diff --git a/lib_service/build.gradle.kts b/lib_service/build.gradle.kts index a58d8cce..c8c164b1 100644 --- a/lib_service/build.gradle.kts +++ b/lib_service/build.gradle.kts @@ -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) diff --git a/lib_service/src/main/java/no/nordicsemi/android/service/BleManagerStatus.kt b/lib_service/src/main/java/no/nordicsemi/android/service/BleManagerStatus.kt deleted file mode 100644 index c533b04d..00000000 --- a/lib_service/src/main/java/no/nordicsemi/android/service/BleManagerStatus.kt +++ /dev/null @@ -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 { - - 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 : BleManagerResult -class ConnectingResult(device: BluetoothDevice) : DeviceHolder(device), BleManagerResult -class ConnectedResult(device: BluetoothDevice) : DeviceHolder(device), BleManagerResult -class SuccessResult(device: BluetoothDevice, val data: T) : DeviceHolder(device), BleManagerResult - -class LinkLossResult(device: BluetoothDevice, val data: T?) : DeviceHolder(device), BleManagerResult -class DisconnectedResult(device: BluetoothDevice) : DeviceHolder(device), BleManagerResult -class UnknownErrorResult(device: BluetoothDevice) : DeviceHolder(device), BleManagerResult -class MissingServiceResult(device: BluetoothDevice) : DeviceHolder(device), BleManagerResult diff --git a/lib_service/src/main/java/no/nordicsemi/android/service/CloseableCoroutineScope.kt b/lib_service/src/main/java/no/nordicsemi/android/service/CloseableCoroutineScope.kt deleted file mode 100644 index da30f631..00000000 --- a/lib_service/src/main/java/no/nordicsemi/android/service/CloseableCoroutineScope.kt +++ /dev/null @@ -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() - } -} diff --git a/lib_service/src/main/java/no/nordicsemi/android/service/ConnectionObserverAdapter.kt b/lib_service/src/main/java/no/nordicsemi/android/service/ConnectionObserverAdapter.kt deleted file mode 100644 index 87c6de7c..00000000 --- a/lib_service/src/main/java/no/nordicsemi/android/service/ConnectionObserverAdapter.kt +++ /dev/null @@ -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 : ConnectionObserver { - - private val TAG = "BLE-CONNECTION" - - private val _status = MutableStateFlow>(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) - } - } -} diff --git a/lib_service/src/main/java/no/nordicsemi/android/service/DisconnectAndStopEvent.kt b/lib_service/src/main/java/no/nordicsemi/android/service/DisconnectAndStopEvent.kt new file mode 100644 index 00000000..31bdb0ee --- /dev/null +++ b/lib_service/src/main/java/no/nordicsemi/android/service/DisconnectAndStopEvent.kt @@ -0,0 +1,3 @@ +package no.nordicsemi.android.service + +class DisconnectAndStopEvent diff --git a/lib_service/src/main/java/no/nordicsemi/android/service/OpenLoggerEvent.kt b/lib_service/src/main/java/no/nordicsemi/android/service/OpenLoggerEvent.kt new file mode 100644 index 00000000..52d8ed38 --- /dev/null +++ b/lib_service/src/main/java/no/nordicsemi/android/service/OpenLoggerEvent.kt @@ -0,0 +1,3 @@ +package no.nordicsemi.android.service + +class OpenLoggerEvent diff --git a/lib_service/src/main/java/no/nordicsemi/android/service/ServiceManager.kt b/lib_service/src/main/java/no/nordicsemi/android/service/ServiceManager.kt index f2753e6f..c9ae50fc 100644 --- a/lib_service/src/main/java/no/nordicsemi/android/service/ServiceManager.kt +++ b/lib_service/src/main/java/no/nordicsemi/android/service/ServiceManager.kt @@ -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 startService(service: Class, device: DiscoveredBluetoothDevice) { - val intent = Intent(context, service).apply { - putExtra(DEVICE_DATA, device) - } - context.startService(intent) - } - - fun startService(service: Class, device: BluetoothDevice) { - val intent = Intent(context, service).apply { - putExtra(DEVICE_DATA, device) - } - context.startService(intent) - } - - fun startService(service: Class) { - val intent = Intent(context, service) - context.startService(intent) - } - - fun stopService(service: Class) { - val intent = Intent(context, service) - context.stopService(intent) - } + fun startService(service: Class, device: ServerDevice) } diff --git a/lib_service/src/main/java/no/nordicsemi/android/service/ServiceManagerHiltModule.kt b/lib_service/src/main/java/no/nordicsemi/android/service/ServiceManagerHiltModule.kt new file mode 100644 index 00000000..b9b0eba9 --- /dev/null +++ b/lib_service/src/main/java/no/nordicsemi/android/service/ServiceManagerHiltModule.kt @@ -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) + } +} diff --git a/lib_service/src/main/java/no/nordicsemi/android/service/ServiceManagerImpl.kt b/lib_service/src/main/java/no/nordicsemi/android/service/ServiceManagerImpl.kt new file mode 100644 index 00000000..1872a84d --- /dev/null +++ b/lib_service/src/main/java/no/nordicsemi/android/service/ServiceManagerImpl.kt @@ -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 startService(service: Class, device: ServerDevice) { + val intent = Intent(context, service).apply { + putExtra(DEVICE_DATA, device) + } + context.startService(intent) + } +} diff --git a/lib_ui/build.gradle.kts b/lib_ui/build.gradle.kts index 86970f79..3d577f0f 100644 --- a/lib_ui/build.gradle.kts +++ b/lib_ui/build.gradle.kts @@ -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) } diff --git a/lib_ui/src/main/java/no/nordicsemi/android/ui/view/NordicLoggerFactory.kt b/lib_ui/src/main/java/no/nordicsemi/android/ui/view/NordicLoggerFactory.kt new file mode 100644 index 00000000..3e668674 --- /dev/null +++ b/lib_ui/src/main/java/no/nordicsemi/android/ui/view/NordicLoggerFactory.kt @@ -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 +} diff --git a/lib_ui/src/main/java/no/nordicsemi/android/ui/view/NordicLoggerFactoryHiltModule.kt b/lib_ui/src/main/java/no/nordicsemi/android/ui/view/NordicLoggerFactoryHiltModule.kt new file mode 100644 index 00000000..ce8bc4a7 --- /dev/null +++ b/lib_ui/src/main/java/no/nordicsemi/android/ui/view/NordicLoggerFactoryHiltModule.kt @@ -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) + } + } + } +} diff --git a/lib_ui/src/main/java/no/nordicsemi/android/ui/view/TopAppBar.kt b/lib_ui/src/main/java/no/nordicsemi/android/ui/view/TopAppBar.kt index b89a039e..95b3974c 100644 --- a/lib_ui/src/main/java/no/nordicsemi/android/ui/view/TopAppBar.kt +++ b/lib_ui/src/main/java/no/nordicsemi/android/ui/view/TopAppBar.kt @@ -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) + } +} diff --git a/lib_utils/build.gradle.kts b/lib_utils/build.gradle.kts index 9657da5e..4be89537 100644 --- a/lib_utils/build.gradle.kts +++ b/lib_utils/build.gradle.kts @@ -40,6 +40,6 @@ android { dependencies { implementation(libs.nordic.navigation) - implementation(libs.nordic.uiscanner) + implementation(libs.nordic.blek.uiscanner) implementation(libs.kotlinx.coroutines.core) } diff --git a/lib_utils/src/main/java/no/nordicsemi/android/utils/Ext.kt b/lib_utils/src/main/java/no/nordicsemi/android/utils/Ext.kt index 07901c4a..d5bac361 100644 --- a/lib_utils/src/main/java/no/nordicsemi/android/utils/Ext.kt +++ b/lib_utils/src/main/java/no/nordicsemi/android/utils/Ext.kt @@ -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() } diff --git a/profile_bps/build.gradle.kts b/profile_bps/build.gradle.kts index c0d4a0da..5a8f75da 100644 --- a/profile_bps/build.gradle.kts +++ b/profile_bps/build.gradle.kts @@ -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) diff --git a/profile_bps/src/main/java/no/nordicsemi/android/bps/data/BPSData.kt b/profile_bps/src/main/java/no/nordicsemi/android/bps/data/BPSData.kt deleted file mode 100644 index a8a8ebb0..00000000 --- a/profile_bps/src/main/java/no/nordicsemi/android/bps/data/BPSData.kt +++ /dev/null @@ -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, -) diff --git a/profile_bps/src/main/java/no/nordicsemi/android/bps/data/BPSManager.kt b/profile_bps/src/main/java/no/nordicsemi/android/bps/data/BPSManager.kt deleted file mode 100644 index 93b3a8ca..00000000 --- a/profile_bps/src/main/java/no/nordicsemi/android/bps/data/BPSManager.kt +++ /dev/null @@ -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() - - 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() - .onEach { data.tryEmit(data.value.copyWithNewResponse(it)) } - .launchIn(scope) - - setIndicationCallback(bpmCharacteristic).asValidResponseFlow() - .onEach { data.tryEmit(data.value.copyWithNewResponse(it)) } - .launchIn(scope) - - setNotificationCallback(batteryLevelCharacteristic).asValidResponseFlow() - .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 - } - } -} diff --git a/profile_bps/src/main/java/no/nordicsemi/android/bps/data/BPSServiceData.kt b/profile_bps/src/main/java/no/nordicsemi/android/bps/data/BPSServiceData.kt new file mode 100644 index 00000000..4c254197 --- /dev/null +++ b/profile_bps/src/main/java/no/nordicsemi/android/bps/data/BPSServiceData.kt @@ -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 +) \ No newline at end of file diff --git a/profile_bps/src/main/java/no/nordicsemi/android/bps/data/DataMapper.kt b/profile_bps/src/main/java/no/nordicsemi/android/bps/data/DataMapper.kt deleted file mode 100644 index 5efd28b0..00000000 --- a/profile_bps/src/main/java/no/nordicsemi/android/bps/data/DataMapper.kt +++ /dev/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, - ) - } -} diff --git a/profile_bps/src/main/java/no/nordicsemi/android/bps/repository/BPSRepository.kt b/profile_bps/src/main/java/no/nordicsemi/android/bps/repository/BPSRepository.kt deleted file mode 100644 index 527a4db8..00000000 --- a/profile_bps/src/main/java/no/nordicsemi/android/bps/repository/BPSRepository.kt +++ /dev/null @@ -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> = 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) - } -} diff --git a/profile_bps/src/main/java/no/nordicsemi/android/bps/view/BPSContentView.kt b/profile_bps/src/main/java/no/nordicsemi/android/bps/view/BPSContentView.kt index baf2d1c2..33bc2f87 100644 --- a/profile_bps/src/main/java/no/nordicsemi/android/bps/view/BPSContentView.kt +++ b/profile_bps/src/main/java/no/nordicsemi/android/bps/view/BPSContentView.kt @@ -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 ) { diff --git a/profile_bps/src/main/java/no/nordicsemi/android/bps/view/BPSMapper.kt b/profile_bps/src/main/java/no/nordicsemi/android/bps/view/BPSMapper.kt deleted file mode 100644 index c4cecf58..00000000 --- a/profile_bps/src/main/java/no/nordicsemi/android/bps/view/BPSMapper.kt +++ /dev/null @@ -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() -} \ No newline at end of file diff --git a/profile_bps/src/main/java/no/nordicsemi/android/bps/view/BPSScreen.kt b/profile_bps/src/main/java/no/nordicsemi/android/bps/view/BPSScreen.kt index 4779355f..60a469f9 100644 --- a/profile_bps/src/main/java/no/nordicsemi/android/bps/view/BPSScreen.kt +++ b/profile_bps/src/main/java/no/nordicsemi/android/bps/view/BPSScreen.kt @@ -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) - } - } -} diff --git a/profile_bps/src/main/java/no/nordicsemi/android/bps/view/BPSSensorsReadingView.kt b/profile_bps/src/main/java/no/nordicsemi/android/bps/view/BPSSensorsReadingView.kt index e8789407..1e649b72 100644 --- a/profile_bps/src/main/java/no/nordicsemi/android/bps/view/BPSSensorsReadingView.kt +++ b/profile_bps/src/main/java/no/nordicsemi/android/bps/view/BPSSensorsReadingView.kt @@ -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()) } diff --git a/profile_bps/src/main/java/no/nordicsemi/android/bps/view/BPSViewState.kt b/profile_bps/src/main/java/no/nordicsemi/android/bps/view/BPSViewState.kt index 8b51b307..4b9d617b 100644 --- a/profile_bps/src/main/java/no/nordicsemi/android/bps/view/BPSViewState.kt +++ b/profile_bps/src/main/java/no/nordicsemi/android/bps/view/BPSViewState.kt @@ -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 -) : BPSViewState() - -internal object NoDeviceState : BPSViewState() + val disconnectStatus = if (missingServices) { + BleGattConnectionStatus.NOT_SUPPORTED + } else { + result.connectionState?.status ?: BleGattConnectionStatus.UNKNOWN + } +} diff --git a/profile_bps/src/main/java/no/nordicsemi/android/bps/viewmodel/BPSViewModel.kt b/profile_bps/src/main/java/no/nordicsemi/android/bps/viewmodel/BPSViewModel.kt index 3f79cd58..5e51edcd 100644 --- a/profile_bps/src/main/java/no/nordicsemi/android/bps/viewmodel/BPSViewModel.kt +++ b/profile_bps/src/main/java/no/nordicsemi/android/bps/viewmodel/BPSViewModel.kt @@ -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(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) { + private fun handleArgs(result: NavigationResult) { 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)) + } } } diff --git a/profile_bps/src/main/res/values/strings.xml b/profile_bps/src/main/res/values/strings.xml index e465ae40..524087c9 100644 --- a/profile_bps/src/main/res/values/strings.xml +++ b/profile_bps/src/main/res/values/strings.xml @@ -35,6 +35,8 @@ Data + No data available. If you are using nRF DK\'s press button 1 to see the result. + Systolic Diastolic Mean AP diff --git a/profile_cgms/build.gradle.kts b/profile_cgms/build.gradle.kts index 4c107eff..b7f82f0e 100644 --- a/profile_cgms/build.gradle.kts +++ b/profile_cgms/build.gradle.kts @@ -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) diff --git a/profile_cgms/src/main/java/no/nordicsemi/android/cgms/data/CGMData.kt b/profile_cgms/src/main/java/no/nordicsemi/android/cgms/data/CGMData.kt deleted file mode 100644 index a0764930..00000000 --- a/profile_cgms/src/main/java/no/nordicsemi/android/cgms/data/CGMData.kt +++ /dev/null @@ -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 = emptyList(), - val batteryLevel: Int? = null, - val requestStatus: RequestStatus = RequestStatus.IDLE -) diff --git a/profile_cgms/src/main/java/no/nordicsemi/android/cgms/data/CGMManager.kt b/profile_cgms/src/main/java/no/nordicsemi/android/cgms/data/CGMManager.kt deleted file mode 100644 index 55c1189e..00000000 --- a/profile_cgms/src/main/java/no/nordicsemi/android/cgms/data/CGMManager.kt +++ /dev/null @@ -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 = SparseArray() - 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() - - 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() - .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() - .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() - .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() - .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() - this@CGMManager.secured = cgmResponse.features.e2eCrcSupported - } - - scope.launchWithCatch { - val response = readCharacteristic(cgmStatusCharacteristic).suspendForValidResponse() - 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() - } - } -} diff --git a/profile_cgms/src/main/java/no/nordicsemi/android/cgms/data/CGMRecord.kt b/profile_cgms/src/main/java/no/nordicsemi/android/cgms/data/CGMRecord.kt deleted file mode 100644 index 3bcef7e0..00000000 --- a/profile_cgms/src/main/java/no/nordicsemi/android/cgms/data/CGMRecord.kt +++ /dev/null @@ -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 diff --git a/profile_cgms/src/main/java/no/nordicsemi/android/cgms/data/CGMServiceData.kt b/profile_cgms/src/main/java/no/nordicsemi/android/cgms/data/CGMServiceData.kt new file mode 100644 index 00000000..7c7133f1 --- /dev/null +++ b/profile_cgms/src/main/java/no/nordicsemi/android/cgms/data/CGMServiceData.kt @@ -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 = 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 +) diff --git a/profile_cgms/src/main/java/no/nordicsemi/android/cgms/data/Ext.kt b/profile_cgms/src/main/java/no/nordicsemi/android/cgms/data/Ext.kt deleted file mode 100644 index 80861747..00000000 --- a/profile_cgms/src/main/java/no/nordicsemi/android/cgms/data/Ext.kt +++ /dev/null @@ -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.toList(): List { - val list = mutableListOf() - this.keyIterator().forEach { - list.add(get(it)) - } - return list.sortedBy { it.sequenceNumber }.toList() -} diff --git a/profile_cgms/src/main/java/no/nordicsemi/android/cgms/data/RequestStatus.kt b/profile_cgms/src/main/java/no/nordicsemi/android/cgms/data/RequestStatus.kt deleted file mode 100644 index 6ded6128..00000000 --- a/profile_cgms/src/main/java/no/nordicsemi/android/cgms/data/RequestStatus.kt +++ /dev/null @@ -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 -} diff --git a/profile_cgms/src/main/java/no/nordicsemi/android/cgms/repository/CGMRepository.kt b/profile_cgms/src/main/java/no/nordicsemi/android/cgms/repository/CGMRepository.kt index 43b179eb..7ef38ebf 100644 --- a/profile_cgms/src/main/java/no/nordicsemi/android/cgms/repository/CGMRepository.kt +++ b/profile_cgms/src/main/java/no/nordicsemi/android/cgms/repository/CGMRepository.kt @@ -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>(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() + internal val stopEvent = _stopEvent.asSharedFlow() - fun launch(device: DiscoveredBluetoothDevice) { + private val _command = simpleSharedFlow() + 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) { + _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() } } diff --git a/profile_cgms/src/main/java/no/nordicsemi/android/cgms/repository/CGMService.kt b/profile_cgms/src/main/java/no/nordicsemi/android/cgms/repository/CGMService.kt index 853516d3..5cb4fac5 100644 --- a/profile_cgms/src/main/java/no/nordicsemi/android/cgms/repository/CGMService.kt +++ b/profile_cgms/src/main/java/no/nordicsemi/android/cgms/repository/CGMService.kt @@ -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(DEVICE_DATA)!! + repository.setServiceRunning(true) - repository.start(device, lifecycleScope) + val device = intent!!.getParcelableExtra(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) + } } diff --git a/profile_cgms/src/main/java/no/nordicsemi/android/cgms/view/CGMContentView.kt b/profile_cgms/src/main/java/no/nordicsemi/android/cgms/view/CGMContentView.kt index 7e949d51..b2449b7f 100644 --- a/profile_cgms/src/main/java/no/nordicsemi/android/cgms/view/CGMContentView.kt +++ b/profile_cgms/src/main/java/no/nordicsemi/android/cgms/view/CGMContentView.kt @@ -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 diff --git a/profile_cgms/src/main/java/no/nordicsemi/android/cgms/view/CGMMapper.kt b/profile_cgms/src/main/java/no/nordicsemi/android/cgms/view/CGMMapper.kt index 94dbf67a..aab7d8d7 100644 --- a/profile_cgms/src/main/java/no/nordicsemi/android/cgms/view/CGMMapper.kt +++ b/profile_cgms/src/main/java/no/nordicsemi/android/cgms/view/CGMMapper.kt @@ -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) } diff --git a/profile_cgms/src/main/java/no/nordicsemi/android/cgms/view/CGMScreen.kt b/profile_cgms/src/main/java/no/nordicsemi/android/cgms/view/CGMScreen.kt index bbaddaab..23d2dd45 100644 --- a/profile_cgms/src/main/java/no/nordicsemi/android/cgms/view/CGMScreen.kt +++ b/profile_cgms/src/main/java/no/nordicsemi/android/cgms/view/CGMScreen.kt @@ -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) - } - } -} diff --git a/profile_cgms/src/main/java/no/nordicsemi/android/cgms/view/CGMViewState.kt b/profile_cgms/src/main/java/no/nordicsemi/android/cgms/view/CGMViewState.kt deleted file mode 100644 index b53cc4f6..00000000 --- a/profile_cgms/src/main/java/no/nordicsemi/android/cgms/view/CGMViewState.kt +++ /dev/null @@ -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) : CGMViewState() -internal object NoDeviceState : CGMViewState() diff --git a/profile_cgms/src/main/java/no/nordicsemi/android/cgms/viewmodel/CGMViewModel.kt b/profile_cgms/src/main/java/no/nordicsemi/android/cgms/viewmodel/CGMViewModel.kt index 6cb09a35..67c09256 100644 --- a/profile_cgms/src/main/java/no/nordicsemi/android/cgms/viewmodel/CGMViewModel.kt +++ b/profile_cgms/src/main/java/no/nordicsemi/android/cgms/viewmodel/CGMViewModel.kt @@ -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(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) { + private fun handleResult(result: NavigationResult) { 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) + } } diff --git a/profile_csc/build.gradle.kts b/profile_csc/build.gradle.kts index 3418458c..1d66f68c 100644 --- a/profile_csc/build.gradle.kts +++ b/profile_csc/build.gradle.kts @@ -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) diff --git a/profile_csc/src/main/java/no/nordicsemi/android/csc/data/CSCData.kt b/profile_csc/src/main/java/no/nordicsemi/android/csc/data/CSCData.kt deleted file mode 100644 index e9e54649..00000000 --- a/profile_csc/src/main/java/no/nordicsemi/android/csc/data/CSCData.kt +++ /dev/null @@ -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() -) diff --git a/profile_csc/src/main/java/no/nordicsemi/android/csc/data/CSCManager.kt b/profile_csc/src/main/java/no/nordicsemi/android/csc/data/CSCManager.kt deleted file mode 100644 index 5e44daf9..00000000 --- a/profile_csc/src/main/java/no/nordicsemi/android/csc/data/CSCManager.kt +++ /dev/null @@ -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() - - 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() - .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().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 - } - } -} diff --git a/profile_csc/src/main/java/no/nordicsemi/android/csc/data/CSCServiceData.kt b/profile_csc/src/main/java/no/nordicsemi/android/csc/data/CSCServiceData.kt new file mode 100644 index 00000000..1bb81d26 --- /dev/null +++ b/profile_csc/src/main/java/no/nordicsemi/android/csc/data/CSCServiceData.kt @@ -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 + } +} diff --git a/profile_csc/src/main/java/no/nordicsemi/android/csc/view/SpeedUnit.kt b/profile_csc/src/main/java/no/nordicsemi/android/csc/data/SpeedUnit.kt similarity index 97% rename from profile_csc/src/main/java/no/nordicsemi/android/csc/view/SpeedUnit.kt rename to profile_csc/src/main/java/no/nordicsemi/android/csc/data/SpeedUnit.kt index fe083614..0ebea419 100644 --- a/profile_csc/src/main/java/no/nordicsemi/android/csc/view/SpeedUnit.kt +++ b/profile_csc/src/main/java/no/nordicsemi/android/csc/data/SpeedUnit.kt @@ -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"), diff --git a/profile_csc/src/main/java/no/nordicsemi/android/csc/data/WheelSize.kt b/profile_csc/src/main/java/no/nordicsemi/android/csc/data/WheelSize.kt deleted file mode 100644 index bfeb2cfc..00000000 --- a/profile_csc/src/main/java/no/nordicsemi/android/csc/data/WheelSize.kt +++ /dev/null @@ -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 -) diff --git a/profile_csc/src/main/java/no/nordicsemi/android/csc/repository/CSCRepository.kt b/profile_csc/src/main/java/no/nordicsemi/android/csc/repository/CSCRepository.kt index 3d7bd006..2964f664 100644 --- a/profile_csc/src/main/java/no/nordicsemi/android/csc/repository/CSCRepository.kt +++ b/profile_csc/src/main/java/no/nordicsemi/android/csc/repository/CSCRepository.kt @@ -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>(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() + 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() } } diff --git a/profile_csc/src/main/java/no/nordicsemi/android/csc/repository/CSCService.kt b/profile_csc/src/main/java/no/nordicsemi/android/csc/repository/CSCService.kt index 7b9acf43..aa750bf5 100644 --- a/profile_csc/src/main/java/no/nordicsemi/android/csc/repository/CSCService.kt +++ b/profile_csc/src/main/java/no/nordicsemi/android/csc/repository/CSCService.kt @@ -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(DEVICE_DATA)!! + repository.setServiceRunning(true) - repository.start(device, lifecycleScope) + val device = intent!!.getParcelableExtra(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) + } } diff --git a/profile_csc/src/main/java/no/nordicsemi/android/csc/view/CSCContentView.kt b/profile_csc/src/main/java/no/nordicsemi/android/csc/view/CSCContentView.kt index 1bc80f35..2140926a 100644 --- a/profile_csc/src/main/java/no/nordicsemi/android/csc/view/CSCContentView.kt +++ b/profile_csc/src/main/java/no/nordicsemi/android/csc/view/CSCContentView.kt @@ -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()) { } } diff --git a/profile_csc/src/main/java/no/nordicsemi/android/csc/view/CSCMappers.kt b/profile_csc/src/main/java/no/nordicsemi/android/csc/view/CSCMappers.kt index 43589f94..e87358f2 100644 --- a/profile_csc/src/main/java/no/nordicsemi/android/csc/view/CSCMappers.kt +++ b/profile_csc/src/main/java/no/nordicsemi/android/csc/view/CSCMappers.kt @@ -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" diff --git a/profile_csc/src/main/java/no/nordicsemi/android/csc/view/CSCScreen.kt b/profile_csc/src/main/java/no/nordicsemi/android/csc/view/CSCScreen.kt index de62a528..6accb0f6 100644 --- a/profile_csc/src/main/java/no/nordicsemi/android/csc/view/CSCScreen.kt +++ b/profile_csc/src/main/java/no/nordicsemi/android/csc/view/CSCScreen.kt @@ -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) - } - } -} diff --git a/profile_csc/src/main/java/no/nordicsemi/android/csc/view/CSCSettings.kt b/profile_csc/src/main/java/no/nordicsemi/android/csc/view/CSCSettings.kt deleted file mode 100644 index 9b9a362c..00000000 --- a/profile_csc/src/main/java/no/nordicsemi/android/csc/view/CSCSettings.kt +++ /dev/null @@ -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 - } -} \ No newline at end of file diff --git a/profile_csc/src/main/java/no/nordicsemi/android/csc/view/CSCState.kt b/profile_csc/src/main/java/no/nordicsemi/android/csc/view/CSCState.kt deleted file mode 100644 index d6f2de13..00000000 --- a/profile_csc/src/main/java/no/nordicsemi/android/csc/view/CSCState.kt +++ /dev/null @@ -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) : CSCMangerState() - -internal object NoDeviceState : CSCMangerState() diff --git a/profile_csc/src/main/java/no/nordicsemi/android/csc/view/CSCViewEvent.kt b/profile_csc/src/main/java/no/nordicsemi/android/csc/view/CSCViewEvent.kt index 6cc033b7..d48b4e7e 100644 --- a/profile_csc/src/main/java/no/nordicsemi/android/csc/view/CSCViewEvent.kt +++ b/profile_csc/src/main/java/no/nordicsemi/android/csc/view/CSCViewEvent.kt @@ -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 diff --git a/profile_csc/src/main/java/no/nordicsemi/android/csc/view/SensorsReadingView.kt b/profile_csc/src/main/java/no/nordicsemi/android/csc/view/SensorsReadingView.kt index 123af1c8..3bbba565 100644 --- a/profile_csc/src/main/java/no/nordicsemi/android/csc/view/SensorsReadingView.kt +++ b/profile_csc/src/main/java/no/nordicsemi/android/csc/view/SensorsReadingView.kt @@ -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) } diff --git a/profile_csc/src/main/java/no/nordicsemi/android/csc/view/WheelSizeView.kt b/profile_csc/src/main/java/no/nordicsemi/android/csc/view/WheelSizeView.kt index dd94c9a3..a6b1d1ec 100644 --- a/profile_csc/src/main/java/no/nordicsemi/android/csc/view/WheelSizeView.kt +++ b/profile_csc/src/main/java/no/nordicsemi/android/csc/view/WheelSizeView.kt @@ -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) { diff --git a/profile_csc/src/main/java/no/nordicsemi/android/csc/viewmodel/CSCViewModel.kt b/profile_csc/src/main/java/no/nordicsemi/android/csc/viewmodel/CSCViewModel.kt index 8f5d333b..dc447ac7 100644 --- a/profile_csc/src/main/java/no/nordicsemi/android/csc/viewmodel/CSCViewModel.kt +++ b/profile_csc/src/main/java/no/nordicsemi/android/csc/viewmodel/CSCViewModel.kt @@ -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) { + private fun handleResult(result: NavigationResult) { 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) + } } diff --git a/profile_gls/build.gradle.kts b/profile_gls/build.gradle.kts index 5a2d4e70..8c7f71e8 100644 --- a/profile_gls/build.gradle.kts +++ b/profile_gls/build.gradle.kts @@ -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) } diff --git a/profile_gls/src/debug/java/no/nordicsemi/android/gls/GlsServer.kt b/profile_gls/src/debug/java/no/nordicsemi/android/gls/GlsServer.kt new file mode 100644 index 00000000..c1d5490c --- /dev/null +++ b/profile_gls/src/debug/java/no/nordicsemi/android/gls/GlsServer.kt @@ -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) + } + } + } +} \ No newline at end of file diff --git a/profile_gls/src/main/java/no/nordicsemi/android/gls/GLSDestination.kt b/profile_gls/src/main/java/no/nordicsemi/android/gls/GLSDestination.kt index 00e0ef92..db69b290 100644 --- a/profile_gls/src/main/java/no/nordicsemi/android/gls/GLSDestination.kt +++ b/profile_gls/src/main/java/no/nordicsemi/android/gls/GLSDestination.kt @@ -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("gls-details-screen") +internal val GlsDetailsDestinationId = createDestination, Unit>("gls-details-screen") val GLSDestination = defineDestination(GlsDetailsDestinationId) { GLSDetailsScreen() } diff --git a/profile_gls/src/main/java/no/nordicsemi/android/gls/data/DataMapper.kt b/profile_gls/src/main/java/no/nordicsemi/android/gls/data/DataMapper.kt deleted file mode 100644 index 46657dea..00000000 --- a/profile_gls/src/main/java/no/nordicsemi/android/gls/data/DataMapper.kt +++ /dev/null @@ -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) -} diff --git a/profile_gls/src/main/java/no/nordicsemi/android/gls/data/GLSManager.kt b/profile_gls/src/main/java/no/nordicsemi/android/gls/data/GLSManager.kt deleted file mode 100644 index ba51274a..00000000 --- a/profile_gls/src/main/java/no/nordicsemi/android/gls/data/GLSManager.kt +++ /dev/null @@ -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() - - 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() - .onEach { data.tryEmit(data.value.copy(records = data.value.records + it.toRecord())) } - .launchIn(scope) - - setNotificationCallback(glucoseMeasurementContextCharacteristic).asValidResponseFlow() - .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() - .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() - .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() - } - } -} diff --git a/profile_gls/src/main/java/no/nordicsemi/android/gls/data/GLSRecord.kt b/profile_gls/src/main/java/no/nordicsemi/android/gls/data/GLSRecord.kt deleted file mode 100644 index cc4ec51c..00000000 --- a/profile_gls/src/main/java/no/nordicsemi/android/gls/data/GLSRecord.kt +++ /dev/null @@ -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 } - } - } -} diff --git a/profile_gls/src/main/java/no/nordicsemi/android/gls/data/GLSData.kt b/profile_gls/src/main/java/no/nordicsemi/android/gls/data/GLSServiceData.kt similarity index 78% rename from profile_gls/src/main/java/no/nordicsemi/android/gls/data/GLSData.kt rename to profile_gls/src/main/java/no/nordicsemi/android/gls/data/GLSServiceData.kt index 7f973f1d..8a75c921 100644 --- a/profile_gls/src/main/java/no/nordicsemi/android/gls/data/GLSData.kt +++ b/profile_gls/src/main/java/no/nordicsemi/android/gls/data/GLSServiceData.kt @@ -31,8 +31,14 @@ package no.nordicsemi.android.gls.data -internal data class GLSData( - val records: List = 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 = mapOf(), val batteryLevel: Int? = null, + val connectionState: GattConnectionStateWithStatus? = null, val requestStatus: RequestStatus = RequestStatus.IDLE ) diff --git a/profile_gls/src/main/java/no/nordicsemi/android/gls/data/RequestStatus.kt b/profile_gls/src/main/java/no/nordicsemi/android/gls/data/RequestStatus.kt deleted file mode 100644 index f369da4a..00000000 --- a/profile_gls/src/main/java/no/nordicsemi/android/gls/data/RequestStatus.kt +++ /dev/null @@ -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 -} diff --git a/profile_gls/src/main/java/no/nordicsemi/android/gls/details/view/GLSDetailsContentView.kt b/profile_gls/src/main/java/no/nordicsemi/android/gls/details/view/GLSDetailsContentView.kt index fccc7909..6112db26 100644 --- a/profile_gls/src/main/java/no/nordicsemi/android/gls/details/view/GLSDetailsContentView.kt +++ b/profile_gls/src/main/java/no/nordicsemi/android/gls/details/view/GLSDetailsContentView.kt @@ -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) diff --git a/profile_gls/src/main/java/no/nordicsemi/android/gls/details/view/GLSDetailsMappers.kt b/profile_gls/src/main/java/no/nordicsemi/android/gls/details/view/GLSDetailsMappers.kt index 9d3401ca..63a6ddbb 100644 --- a/profile_gls/src/main/java/no/nordicsemi/android/gls/details/view/GLSDetailsMappers.kt +++ b/profile_gls/src/main/java/no/nordicsemi/android/gls/details/view/GLSDetailsMappers.kt @@ -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) } } diff --git a/profile_gls/src/main/java/no/nordicsemi/android/gls/details/view/GLSDetailsScreen.kt b/profile_gls/src/main/java/no/nordicsemi/android/gls/details/view/GLSDetailsScreen.kt index 015e192b..67c0e49e 100644 --- a/profile_gls/src/main/java/no/nordicsemi/android/gls/details/view/GLSDetailsScreen.kt +++ b/profile_gls/src/main/java/no/nordicsemi/android/gls/details/view/GLSDetailsScreen.kt @@ -51,6 +51,6 @@ internal fun GLSDetailsScreen() { viewModel.navigateBack() } - GLSDetailsContentView(record) + GLSDetailsContentView(record.first, record.second) } } diff --git a/profile_gls/src/main/java/no/nordicsemi/android/gls/main/view/GLSContentView.kt b/profile_gls/src/main/java/no/nordicsemi/android/gls/main/view/GLSContentView.kt index 2657c505..f8c9830b 100644 --- a/profile_gls/src/main/java/no/nordicsemi/android/gls/main/view/GLSContentView.kt +++ b/profile_gls/src/main/java/no/nordicsemi/android/gls/main/view/GLSContentView.kt @@ -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, + ) + } } } } } diff --git a/profile_gls/src/main/java/no/nordicsemi/android/gls/main/view/GLSMapper.kt b/profile_gls/src/main/java/no/nordicsemi/android/gls/main/view/GLSMapper.kt index 356531e8..0e5e8658 100644 --- a/profile_gls/src/main/java/no/nordicsemi/android/gls/main/view/GLSMapper.kt +++ b/profile_gls/src/main/java/no/nordicsemi/android/gls/main/view/GLSMapper.kt @@ -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 { diff --git a/profile_gls/src/main/java/no/nordicsemi/android/gls/main/view/GLSScreen.kt b/profile_gls/src/main/java/no/nordicsemi/android/gls/main/view/GLSScreen.kt index fdd46501..f7df8e4b 100644 --- a/profile_gls/src/main/java/no/nordicsemi/android/gls/main/view/GLSScreen.kt +++ b/profile_gls/src/main/java/no/nordicsemi/android/gls/main/view/GLSScreen.kt @@ -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) - } - } -} diff --git a/profile_gls/src/main/java/no/nordicsemi/android/gls/main/view/GLSScreenViewEvent.kt b/profile_gls/src/main/java/no/nordicsemi/android/gls/main/view/GLSScreenViewEvent.kt index 1a964db9..2ca44d61 100644 --- a/profile_gls/src/main/java/no/nordicsemi/android/gls/main/view/GLSScreenViewEvent.kt +++ b/profile_gls/src/main/java/no/nordicsemi/android/gls/main/view/GLSScreenViewEvent.kt @@ -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 diff --git a/profile_gls/src/main/java/no/nordicsemi/android/gls/main/view/GLSState.kt b/profile_gls/src/main/java/no/nordicsemi/android/gls/main/view/GLSState.kt deleted file mode 100644 index c91183e1..00000000 --- a/profile_gls/src/main/java/no/nordicsemi/android/gls/main/view/GLSState.kt +++ /dev/null @@ -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) : GLSViewState() -internal object NoDeviceState : GLSViewState() diff --git a/profile_gls/src/main/java/no/nordicsemi/android/gls/main/view/GLSViewState.kt b/profile_gls/src/main/java/no/nordicsemi/android/gls/main/view/GLSViewState.kt new file mode 100644 index 00000000..9d5125c3 --- /dev/null +++ b/profile_gls/src/main/java/no/nordicsemi/android/gls/main/view/GLSViewState.kt @@ -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 + } +} diff --git a/profile_gls/src/main/java/no/nordicsemi/android/gls/main/viewmodel/GLSViewModel.kt b/profile_gls/src/main/java/no/nordicsemi/android/gls/main/viewmodel/GLSViewModel.kt index a789d575..1057714b 100644 --- a/profile_gls/src/main/java/no/nordicsemi/android/gls/main/viewmodel/GLSViewModel.kt +++ b/profile_gls/src/main/java/no/nordicsemi/android/gls/main/viewmodel/GLSViewModel.kt @@ -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(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) { + internal fun handleResult(result: NavigationResult) { 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) + } } } diff --git a/profile_gls/src/main/java/no/nordicsemi/android/gls/repository/GLSRepository.kt b/profile_gls/src/main/java/no/nordicsemi/android/gls/repository/GLSRepository.kt deleted file mode 100644 index cbf5c576..00000000 --- a/profile_gls/src/main/java/no/nordicsemi/android/gls/repository/GLSRepository.kt +++ /dev/null @@ -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> = 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() - } - } -} diff --git a/profile_gls/src/test/java/no/nordicsemi/android/gls/GLSViewModelTest.kt b/profile_gls/src/test/java/no/nordicsemi/android/gls/GLSViewModelTest.kt new file mode 100644 index 00000000..22f4c8ed --- /dev/null +++ b/profile_gls/src/test/java/no/nordicsemi/android/gls/GLSViewModelTest.kt @@ -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) + } +} diff --git a/profile_hrs/build.gradle.kts b/profile_hrs/build.gradle.kts index 5ebcb6f0..e03228fc 100644 --- a/profile_hrs/build.gradle.kts +++ b/profile_hrs/build.gradle.kts @@ -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) diff --git a/profile_hrs/src/main/java/no/nordicsemi/android/hrs/data/HRSManager.kt b/profile_hrs/src/main/java/no/nordicsemi/android/hrs/data/HRSManager.kt deleted file mode 100644 index d02ab5cc..00000000 --- a/profile_hrs/src/main/java/no/nordicsemi/android/hrs/data/HRSManager.kt +++ /dev/null @@ -1,135 +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.hrs.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.hr.BodySensorLocationResponse -import no.nordicsemi.android.ble.common.callback.hr.HeartRateMeasurementResponse -import no.nordicsemi.android.ble.ktx.asValidResponseFlow -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 HRS_SERVICE_UUID: UUID = UUID.fromString("0000180D-0000-1000-8000-00805f9b34fb") -private val BODY_SENSOR_LOCATION_CHARACTERISTIC_UUID = UUID.fromString("00002A38-0000-1000-8000-00805f9b34fb") -private val HEART_RATE_MEASUREMENT_CHARACTERISTIC_UUID = UUID.fromString("00002A37-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 HRSManager( - context: Context, - private val scope: CoroutineScope, - private val logger: NordicLogger -) : BleManager(context) { - - private var batteryLevelCharacteristic: BluetoothGattCharacteristic? = null - private var heartRateCharacteristic: BluetoothGattCharacteristic? = null - private var bodySensorLocationCharacteristic: BluetoothGattCharacteristic? = null - - private val data = MutableStateFlow(HRSData()) - val dataHolder = ConnectionObserverAdapter() - - 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 HeartRateManagerCallback() - } - - private inner class HeartRateManagerCallback : BleManagerGattCallback() { - override fun initialize() { - super.initialize() - - scope.launchWithCatch { - val readData = readCharacteristic(bodySensorLocationCharacteristic) - .suspendForValidResponse() - - data.value = data.value.copy(sensorLocation = readData.sensorLocation) - } - - setNotificationCallback(heartRateCharacteristic).asValidResponseFlow() - .onEach { - val result = data.value.heartRates.toMutableList().apply { - add(it.heartRate) - } - data.tryEmit(data.value.copy(heartRates = result)) - }.launchIn(scope) - enableNotifications(heartRateCharacteristic).enqueue() - - setNotificationCallback(batteryLevelCharacteristic).asValidResponseFlow().onEach { - data.value = data.value.copy(batteryLevel = it.batteryLevel) - }.launchIn(scope) - enableNotifications(batteryLevelCharacteristic).enqueue() - } - - override fun isRequiredServiceSupported(gatt: BluetoothGatt): Boolean { - gatt.getService(HRS_SERVICE_UUID)?.run { - heartRateCharacteristic = getCharacteristic(HEART_RATE_MEASUREMENT_CHARACTERISTIC_UUID) - bodySensorLocationCharacteristic = getCharacteristic(BODY_SENSOR_LOCATION_CHARACTERISTIC_UUID) - } - gatt.getService(BATTERY_SERVICE_UUID)?.run { - batteryLevelCharacteristic = getCharacteristic(BATTERY_LEVEL_CHARACTERISTIC_UUID) - } - return heartRateCharacteristic != null - } - - override fun onServicesInvalidated() { - bodySensorLocationCharacteristic = null - heartRateCharacteristic = null - batteryLevelCharacteristic = null - } - } -} diff --git a/profile_hrs/src/main/java/no/nordicsemi/android/hrs/data/HRSData.kt b/profile_hrs/src/main/java/no/nordicsemi/android/hrs/data/HRSServiceData.kt similarity index 68% rename from profile_hrs/src/main/java/no/nordicsemi/android/hrs/data/HRSData.kt rename to profile_hrs/src/main/java/no/nordicsemi/android/hrs/data/HRSServiceData.kt index a72fbc2e..b86fce95 100644 --- a/profile_hrs/src/main/java/no/nordicsemi/android/hrs/data/HRSData.kt +++ b/profile_hrs/src/main/java/no/nordicsemi/android/hrs/data/HRSServiceData.kt @@ -31,8 +31,25 @@ package no.nordicsemi.android.hrs.data -internal data class HRSData( - val heartRates: List = emptyList(), +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.hrs.data.HRSData + +internal data class HRSServiceData( + val data: List = emptyList(), + val bodySensorLocation: Int? = null, val batteryLevel: Int? = null, - val sensorLocation: Int = 0, -) + val connectionState: GattConnectionStateWithStatus? = null, + val zoomIn: Boolean = false, + val deviceName: String? = null, + val missingServices: Boolean = false +) { + + val disconnectStatus = if (missingServices) { + BleGattConnectionStatus.NOT_SUPPORTED + } else { + connectionState?.status ?: BleGattConnectionStatus.UNKNOWN + } + + val heartRates = data.map { it.heartRate } +} diff --git a/profile_hrs/src/main/java/no/nordicsemi/android/hrs/service/HRSRepository.kt b/profile_hrs/src/main/java/no/nordicsemi/android/hrs/service/HRSRepository.kt index b12efe66..220bd843 100644 --- a/profile_hrs/src/main/java/no/nordicsemi/android/hrs/service/HRSRepository.kt +++ b/profile_hrs/src/main/java/no/nordicsemi/android/hrs/service/HRSRepository.kt @@ -33,21 +33,19 @@ package no.nordicsemi.android.hrs.service 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.hrs.data.HRSData -import no.nordicsemi.android.hrs.data.HRSManager -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.hrs.data.HRSServiceData +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.hrs.data.HRSData +import no.nordicsemi.android.service.DisconnectAndStopEvent import no.nordicsemi.android.service.ServiceManager import no.nordicsemi.android.ui.view.StringConst import javax.inject.Inject @@ -58,56 +56,80 @@ class HRSRepository @Inject constructor( @ApplicationContext private val context: Context, private val serviceManager: ServiceManager, - private val loggerFactory: NordicLoggerFactory, private val stringConst: StringConst ) { - private var manager: HRSManager? = null - private var logger: NordicLogger? = null + private var logger: BleLoggerAndLauncher? = null - private val _data = MutableStateFlow>(IdleResult()) + private val _data = MutableStateFlow(HRSServiceData()) internal val data = _data.asStateFlow() - val isRunning = data.map { it.isRunning() } - val hasBeenDisconnected = data.map { it.hasBeenDisconnected() } + private val _stopEvent = simpleSharedFlow() + 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, "HRS", device.address) + _data.value = _data.value.copy(deviceName = device.name) serviceManager.startService(HRSService::class.java, device) } - fun start(device: DiscoveredBluetoothDevice, scope: CoroutineScope) { - val createdLogger = loggerFactory.create(stringConst.APP_NAME, "HRS", device.address).also { - logger = it - } - val manager = HRSManager(context, scope, createdLogger) - this.manager = manager + fun switchZoomIn() { + _data.value = _data.value.copy(zoomIn = !_data.value.zoomIn) + } - manager.dataHolder.status.onEach { - _data.value = it - }.launchIn(scope) + fun onConnectionStateChanged(connectionState: GattConnectionStateWithStatus?) { + _data.value = _data.value.copy(connectionState = connectionState) + } - scope.launch { - manager.start(device) - } + fun onHRSDataChanged(data: HRSData) { + _data.value = _data.value.copy(data = _data.value.data + data) + } + + fun onBodySensorLocationChanged(bodySensorLocation: Int) { + _data.value = _data.value.copy(bodySensorLocation = bodySensorLocation) + } + + fun onBatteryLevelChanged(batteryLevel: Int) { + _data.value = _data.value.copy(batteryLevel = batteryLevel) + } + + fun onMissingServices() { + _data.value = _data.value.copy(missingServices = true) + _stopEvent.tryEmit(DisconnectAndStopEvent()) } fun openLogger() { - NordicLogger.launch(context, logger) + logger?.launch() } - private suspend fun HRSManager.start(device: DiscoveredBluetoothDevice) { - try { - connect(device.device) - .useAutoConnect(false) - .retry(3, 100) - .suspend() - } catch (e: Exception) { - e.printStackTrace() - } + fun log(priority: Int, message: String) { + logger?.log(priority, message) } - fun release() { - manager?.disconnect()?.enqueue() + fun disconnect() { + _stopEvent.tryEmit(DisconnectAndStopEvent()) + } + + private fun clean() { logger = null - manager = null + _data.value = HRSServiceData() } } diff --git a/profile_hrs/src/main/java/no/nordicsemi/android/hrs/service/HRSService.kt b/profile_hrs/src/main/java/no/nordicsemi/android/hrs/service/HRSService.kt index fce7e486..af1ea88c 100644 --- a/profile_hrs/src/main/java/no/nordicsemi/android/hrs/service/HRSService.kt +++ b/profile_hrs/src/main/java/no/nordicsemi/android/hrs/service/HRSService.kt @@ -31,33 +31,119 @@ package no.nordicsemi.android.hrs.service +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.hrs.BodySensorLocationParser +import no.nordicsemi.android.kotlin.ble.profile.hrs.HRSDataParser import no.nordicsemi.android.service.DEVICE_DATA import no.nordicsemi.android.service.NotificationService +import java.util.* import javax.inject.Inject +val HRS_SERVICE_UUID: UUID = UUID.fromString("0000180D-0000-1000-8000-00805f9b34fb") +private val BODY_SENSOR_LOCATION_CHARACTERISTIC_UUID = UUID.fromString("00002A38-0000-1000-8000-00805f9b34fb") +private val HEART_RATE_MEASUREMENT_CHARACTERISTIC_UUID = UUID.fromString("00002A37-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 HRSService : NotificationService() { @Inject lateinit var repository: HRSRepository + private lateinit var client: ClientBleGatt + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { super.onStartCommand(intent, flags, startId) - val device = intent!!.getParcelableExtra(DEVICE_DATA)!! + repository.setServiceRunning(true) - repository.start(device, lifecycleScope) + val device = intent!!.getParcelableExtra(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@HRSService, device, logger = { p, s -> repository.log(p, s) }) + + client.waitForBonding() + + 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 hrsService = services.findService(HRS_SERVICE_UUID)!! + val hrsMeasurementCharacteristic = hrsService.findCharacteristic(HEART_RATE_MEASUREMENT_CHARACTERISTIC_UUID)!! + val bodySensorLocationCharacteristic = hrsService.findCharacteristic(BODY_SENSOR_LOCATION_CHARACTERISTIC_UUID)!! + val batteryService = services.findService(BATTERY_SERVICE_UUID)!! + val batteryLevelCharacteristic = batteryService.findCharacteristic(BATTERY_LEVEL_CHARACTERISTIC_UUID)!! + + val bodySensorLocation = bodySensorLocationCharacteristic.read() + BodySensorLocationParser.parse(bodySensorLocation)?.let { repository.onBodySensorLocationChanged(it) } + + batteryLevelCharacteristic.getNotifications() + .mapNotNull { BatteryLevelParser.parse(it) } + .onEach { repository.onBatteryLevelChanged(it) } + .catch { it.printStackTrace() } + .launchIn(lifecycleScope) + + hrsMeasurementCharacteristic.getNotifications() + .mapNotNull { HRSDataParser.parse(it) } + .onEach { repository.onHRSDataChanged(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) + } } diff --git a/profile_hrs/src/main/java/no/nordicsemi/android/hrs/view/HRSContentView.kt b/profile_hrs/src/main/java/no/nordicsemi/android/hrs/view/HRSContentView.kt index 4cd10842..52dbc95c 100644 --- a/profile_hrs/src/main/java/no/nordicsemi/android/hrs/view/HRSContentView.kt +++ b/profile_hrs/src/main/java/no/nordicsemi/android/hrs/view/HRSContentView.kt @@ -34,7 +34,6 @@ package no.nordicsemi.android.hrs.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.Icon import androidx.compose.material3.IconButton @@ -47,13 +46,13 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import no.nordicsemi.android.hrs.R -import no.nordicsemi.android.hrs.data.HRSData +import no.nordicsemi.android.hrs.data.HRSServiceData import no.nordicsemi.android.ui.view.BatteryLevelView import no.nordicsemi.android.ui.view.ScreenSection import no.nordicsemi.android.ui.view.SectionTitle @Composable -internal fun HRSContentView(state: HRSData, zoomIn: Boolean, onEvent: (HRSScreenViewEvent) -> Unit) { +internal fun HRSContentView(state: HRSServiceData, onEvent: (HRSScreenViewEvent) -> Unit) { Column( horizontalAlignment = Alignment.CenterHorizontally ) { @@ -62,12 +61,12 @@ internal fun HRSContentView(state: HRSData, zoomIn: Boolean, onEvent: (HRSScreen SectionTitle( resId = R.drawable.ic_chart_line, title = stringResource(id = R.string.hrs_section_data), - menu = { Menu(zoomIn, onEvent) } + menu = { Menu(state.zoomIn, onEvent) } ) Spacer(modifier = Modifier.height(16.dp)) - LineChartView(state, zoomIn) + LineChartView(state, state.zoomIn) } Spacer(modifier = Modifier.height(16.dp)) @@ -103,5 +102,5 @@ private fun Menu(zoomIn: Boolean, onEvent: (HRSScreenViewEvent) -> Unit) { @Preview @Composable private fun Preview() { - HRSContentView(state = HRSData(), zoomIn = false) { } + HRSContentView(state = HRSServiceData()) { } } diff --git a/profile_hrs/src/main/java/no/nordicsemi/android/hrs/view/HRSScreen.kt b/profile_hrs/src/main/java/no/nordicsemi/android/hrs/view/HRSScreen.kt index c146e360..991aed56 100644 --- a/profile_hrs/src/main/java/no/nordicsemi/android/hrs/view/HRSScreen.kt +++ b/profile_hrs/src/main/java/no/nordicsemi/android/hrs/view/HRSScreen.kt @@ -38,75 +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.hrs.R import no.nordicsemi.android.hrs.viewmodel.HRSViewModel -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 HRSScreen() { val viewModel: HRSViewModel = hiltViewModel() - val state by viewModel.state.collectAsStateWithLifecycle() + val state = viewModel.state.collectAsState().value val navigateUp = { viewModel.onEvent(NavigateUpEvent) } Scaffold( - topBar = { AppBar(state, navigateUp, viewModel) } + topBar = { + ProfileAppBar( + deviceName = state.deviceName, + connectionState = state.connectionState, + title = R.string.hrs_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 hrsState = state) { - NoDeviceState -> DeviceConnectingView() - is WorkingState -> when (hrsState.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 -> HRSContentView(hrsState.result.data, hrsState.zoomIn) { 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 -> HRSContentView(state) { viewModel.onEvent(it) } } } } } - -@Composable -private fun AppBar(state: HRSViewState, navigateUp: () -> Unit, viewModel: HRSViewModel) { - val toolbarName = (state as? WorkingState)?.let { - (it.result as? DeviceHolder)?.deviceName() - } - - if (toolbarName == null) { - BackIconAppBar(stringResource(id = R.string.hrs_title), navigateUp) - } else { - LoggerIconAppBar(toolbarName, navigateUp, { viewModel.onEvent(DisconnectEvent) }) { - viewModel.onEvent(OpenLoggerEvent) - } - } -} diff --git a/profile_hrs/src/main/java/no/nordicsemi/android/hrs/view/HRSState.kt b/profile_hrs/src/main/java/no/nordicsemi/android/hrs/view/HRSState.kt deleted file mode 100644 index b003daa7..00000000 --- a/profile_hrs/src/main/java/no/nordicsemi/android/hrs/view/HRSState.kt +++ /dev/null @@ -1,44 +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.hrs.view - -import no.nordicsemi.android.hrs.data.HRSData -import no.nordicsemi.android.service.BleManagerResult - -internal sealed class HRSViewState - -internal data class WorkingState( - val result: BleManagerResult, - val zoomIn: Boolean = false, -) : HRSViewState() - -internal object NoDeviceState : HRSViewState() diff --git a/profile_hrs/src/main/java/no/nordicsemi/android/hrs/view/LineChartView.kt b/profile_hrs/src/main/java/no/nordicsemi/android/hrs/view/LineChartView.kt index 9f0ad9c4..aa185af2 100644 --- a/profile_hrs/src/main/java/no/nordicsemi/android/hrs/view/LineChartView.kt +++ b/profile_hrs/src/main/java/no/nordicsemi/android/hrs/view/LineChartView.kt @@ -47,7 +47,7 @@ import com.github.mikephil.charting.data.Entry import com.github.mikephil.charting.data.LineData import com.github.mikephil.charting.data.LineDataSet import com.github.mikephil.charting.interfaces.datasets.ILineDataSet -import no.nordicsemi.android.hrs.data.HRSData +import no.nordicsemi.android.hrs.data.HRSServiceData private const val X_AXIS_ELEMENTS_COUNT = 40f @@ -55,7 +55,7 @@ private const val AXIS_MIN = 0 private const val AXIS_MAX = 300 @Composable -internal fun LineChartView(state: HRSData, zoomIn: Boolean,) { +internal fun LineChartView(state: HRSServiceData, zoomIn: Boolean,) { val items = state.heartRates.takeLast(X_AXIS_ELEMENTS_COUNT.toInt()).reversed() val isSystemInDarkTheme = isSystemInDarkTheme() AndroidView( @@ -119,8 +119,8 @@ internal fun createLineChartView( val entries = points.mapIndexed { i, v -> Entry(-i.toFloat(), v.toFloat()) }.reversed() - // create a dataset and give it a type + // create a dataset and give it a type if (data != null && data.dataSetCount > 0) { val set1 = data!!.getDataSetByIndex(0) as LineDataSet set1.values = entries @@ -133,13 +133,9 @@ internal fun createLineChartView( set1.setDrawIcons(false) set1.setDrawValues(false) - // draw dashed line - // draw dashed line set1.enableDashedLine(10f, 5f, 0f) - // black lines and points - // black lines and points if (isDarkTheme) { set1.color = Color.WHITE @@ -149,31 +145,21 @@ internal fun createLineChartView( set1.setCircleColor(Color.BLACK) } - // line thickness and point size - // line thickness and point size set1.lineWidth = 1f set1.circleRadius = 3f - // draw points as solid circles - // draw points as solid circles set1.setDrawCircleHole(false) - // customize legend entry - // customize legend entry set1.formLineWidth = 1f set1.formLineDashEffect = DashPathEffect(floatArrayOf(10f, 5f), 0f) set1.formSize = 15f - // text size of values - // text size of values set1.valueTextSize = 9f - // draw selection line as dashed - // draw selection line as dashed set1.enableDashedHighlightLine(10f, 5f, 0f) @@ -183,8 +169,6 @@ internal fun createLineChartView( // create a data object with the data sets val data = LineData(dataSets) - // set data - // set data setData(data) } diff --git a/profile_hrs/src/main/java/no/nordicsemi/android/hrs/viewmodel/HRSViewModel.kt b/profile_hrs/src/main/java/no/nordicsemi/android/hrs/viewmodel/HRSViewModel.kt index 5f276cd6..8f836513 100644 --- a/profile_hrs/src/main/java/no/nordicsemi/android/hrs/viewmodel/HRSViewModel.kt +++ b/profile_hrs/src/main/java/no/nordicsemi/android/hrs/viewmodel/HRSViewModel.kt @@ -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,18 +44,15 @@ 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.hrs.data.HRS_SERVICE_UUID import no.nordicsemi.android.hrs.service.HRSRepository +import no.nordicsemi.android.hrs.service.HRS_SERVICE_UUID import no.nordicsemi.android.hrs.view.DisconnectEvent import no.nordicsemi.android.hrs.view.HRSScreenViewEvent -import no.nordicsemi.android.hrs.view.HRSViewState import no.nordicsemi.android.hrs.view.NavigateUpEvent -import no.nordicsemi.android.hrs.view.NoDeviceState import no.nordicsemi.android.hrs.view.OpenLoggerEvent import no.nordicsemi.android.hrs.view.SwitchZoomEvent -import no.nordicsemi.android.hrs.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 @@ -68,10 +63,11 @@ internal class HRSViewModel @Inject constructor( private val analytics: AppAnalytics ) : ViewModel() { - private val _state = MutableStateFlow(NoDeviceState) - val state = _state.asStateFlow() + val state = repository.data init { + repository.setOnScreen(true) + viewModelScope.launch { if (repository.isRunning.firstOrNull() == false) { requestBluetoothDevice() @@ -79,10 +75,7 @@ internal class HRSViewModel @Inject constructor( } repository.data.onEach { - val zoomIn = (_state.value as? WorkingState)?.zoomIn ?: false - _state.value = WorkingState(it, zoomIn) - - (it as? ConnectedResult)?.let { + if (it.connectionState?.state == GattConnectionState.STATE_CONNECTED) { analytics.logEvent(ProfileConnectedEvent(Profile.HRS)) } }.launchIn(viewModelScope) @@ -96,13 +89,17 @@ internal class HRSViewModel @Inject constructor( .launchIn(viewModelScope) } - private fun handleResult(result: NavigationResult) { + private fun handleResult(result: NavigationResult) { 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: HRSScreenViewEvent) { when (event) { DisconnectEvent -> disconnect() @@ -113,13 +110,16 @@ internal class HRSViewModel @Inject constructor( } private fun onZoomButtonClicked() { - (_state.value as? WorkingState)?.let { - _state.value = it.copy(zoomIn = !it.zoomIn) - } + repository.switchZoomIn() } private fun disconnect() { - repository.release() + repository.disconnect() navigationManager.navigateUp() } + + override fun onCleared() { + super.onCleared() + repository.setOnScreen(false) + } } diff --git a/profile_hts/build.gradle.kts b/profile_hts/build.gradle.kts index 7bafc1e2..8355510b 100644 --- a/profile_hts/build.gradle.kts +++ b/profile_hts/build.gradle.kts @@ -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.theme) - implementation(libs.nordic.uiscanner) + implementation(libs.nordic.blek.uiscanner) implementation(libs.nordic.navigation) implementation(libs.nordic.uilogger) + implementation(libs.nordic.core) implementation(libs.androidx.hilt.navigation.compose) implementation(libs.androidx.compose.material.iconsExtended) diff --git a/profile_hts/src/main/java/no/nordicsemi/android/hts/data/HTSManager.kt b/profile_hts/src/main/java/no/nordicsemi/android/hts/data/HTSManager.kt deleted file mode 100644 index 5d4e8cb2..00000000 --- a/profile_hts/src/main/java/no/nordicsemi/android/hts/data/HTSManager.kt +++ /dev/null @@ -1,119 +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.hts.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.ht.TemperatureMeasurementResponse -import no.nordicsemi.android.ble.ktx.asValidResponseFlow -import no.nordicsemi.android.common.logger.NordicLogger -import no.nordicsemi.android.service.ConnectionObserverAdapter -import java.util.* - -val HTS_SERVICE_UUID: UUID = UUID.fromString("00001809-0000-1000-8000-00805f9b34fb") -private val HT_MEASUREMENT_CHARACTERISTIC_UUID = UUID.fromString("00002A1C-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 HTSManager internal constructor( - context: Context, - private val scope: CoroutineScope, - private val logger: NordicLogger -) : BleManager(context) { - - private var batteryLevelCharacteristic: BluetoothGattCharacteristic? = null - private var htCharacteristic: BluetoothGattCharacteristic? = null - - private val data = MutableStateFlow(HTSData()) - val dataHolder = ConnectionObserverAdapter() - - 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 HTManagerGattCallback() - } - - private inner class HTManagerGattCallback : BleManagerGattCallback() { - override fun initialize() { - super.initialize() - - setIndicationCallback(htCharacteristic) - .asValidResponseFlow() - .onEach { - data.tryEmit(data.value.copy(temperatureValue = it.temperature)) - }.launchIn(scope) - enableIndications(htCharacteristic).enqueue() - - setNotificationCallback(batteryLevelCharacteristic).asValidResponseFlow().onEach { - data.value = data.value.copy(batteryLevel = it.batteryLevel) - }.launchIn(scope) - enableNotifications(batteryLevelCharacteristic).enqueue() - } - - override fun isRequiredServiceSupported(gatt: BluetoothGatt): Boolean { - gatt.getService(HTS_SERVICE_UUID)?.run { - htCharacteristic = getCharacteristic(HT_MEASUREMENT_CHARACTERISTIC_UUID) - } - gatt.getService(BATTERY_SERVICE_UUID)?.run { - batteryLevelCharacteristic = getCharacteristic(BATTERY_LEVEL_CHARACTERISTIC_UUID) - } - return htCharacteristic != null - } - - override fun onServicesInvalidated() { - htCharacteristic = null - batteryLevelCharacteristic = null - } - } -} diff --git a/profile_hts/src/main/java/no/nordicsemi/android/hts/data/HTSData.kt b/profile_hts/src/main/java/no/nordicsemi/android/hts/data/HTSServiceData.kt similarity index 68% rename from profile_hts/src/main/java/no/nordicsemi/android/hts/data/HTSData.kt rename to profile_hts/src/main/java/no/nordicsemi/android/hts/data/HTSServiceData.kt index 1bfb09f1..d56e3f85 100644 --- a/profile_hts/src/main/java/no/nordicsemi/android/hts/data/HTSData.kt +++ b/profile_hts/src/main/java/no/nordicsemi/android/hts/data/HTSServiceData.kt @@ -31,7 +31,23 @@ package no.nordicsemi.android.hts.data -internal data class HTSData( - val temperatureValue: Float = 0f, +import no.nordicsemi.android.hts.view.TemperatureUnit +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.hts.data.HTSData + +internal data class HTSServiceData( + val data: HTSData = HTSData(), val batteryLevel: Int? = null, -) + val connectionState: GattConnectionStateWithStatus? = null, + val temperatureUnit: TemperatureUnit = TemperatureUnit.CELSIUS, + val deviceName: String? = null, + val missingServices: Boolean = false +) { + + val disconnectStatus = if (missingServices) { + BleGattConnectionStatus.NOT_SUPPORTED + } else { + connectionState?.status ?: BleGattConnectionStatus.UNKNOWN + } +} diff --git a/profile_hts/src/main/java/no/nordicsemi/android/hts/repository/HTSRepository.kt b/profile_hts/src/main/java/no/nordicsemi/android/hts/repository/HTSRepository.kt index 16e1f9f3..b84003b2 100644 --- a/profile_hts/src/main/java/no/nordicsemi/android/hts/repository/HTSRepository.kt +++ b/profile_hts/src/main/java/no/nordicsemi/android/hts/repository/HTSRepository.kt @@ -33,21 +33,20 @@ package no.nordicsemi.android.hts.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.hts.data.HTSData -import no.nordicsemi.android.hts.data.HTSManager -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.hts.data.HTSServiceData +import no.nordicsemi.android.hts.view.TemperatureUnit +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.hts.data.HTSData +import no.nordicsemi.android.service.DisconnectAndStopEvent import no.nordicsemi.android.service.ServiceManager import no.nordicsemi.android.ui.view.StringConst import javax.inject.Inject @@ -58,56 +57,76 @@ class HTSRepository @Inject constructor( @ApplicationContext private val context: Context, private val serviceManager: ServiceManager, - private val loggerFactory: NordicLoggerFactory, private val stringConst: StringConst ) { - private var manager: HTSManager? = null - private var logger: NordicLogger? = null + private var logger: BleLoggerAndLauncher? = null - private val _data = MutableStateFlow>(IdleResult()) + private val _data = MutableStateFlow(HTSServiceData()) internal val data = _data.asStateFlow() - val isRunning = data.map { it.isRunning() } - val hasBeenDisconnected = data.map { it.hasBeenDisconnected() } + private val _stopEvent = simpleSharedFlow() + 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) { + _data.value = _data.value.copy(deviceName = device.name) + logger = DefaultBleLogger.create(context, stringConst.APP_NAME, "HTS", device.address) serviceManager.startService(HTSService::class.java, device) } - fun start(device: DiscoveredBluetoothDevice, scope: CoroutineScope) { - val createdLogger = loggerFactory.create(stringConst.APP_NAME, "HTS", device.address).also { - logger = it - } - val manager = HTSManager(context, scope, createdLogger) - this.manager = manager + internal fun setTemperatureUnit(temperatureUnit: TemperatureUnit) { + _data.value = _data.value.copy(temperatureUnit = temperatureUnit) + } - manager.dataHolder.status.onEach { - _data.value = it - }.launchIn(scope) + fun onConnectionStateChanged(connectionState: GattConnectionStateWithStatus?) { + _data.value = _data.value.copy(connectionState = connectionState) + } - scope.launch { - manager.start(device) - } + fun onHTSDataChanged(data: HTSData) { + _data.value = _data.value.copy(data = data) + } + + fun onBatteryLevelChanged(batteryLevel: Int) { + _data.value = _data.value.copy(batteryLevel = batteryLevel) } fun openLogger() { - NordicLogger.launch(context, logger) + logger?.launch() } - private suspend fun HTSManager.start(device: DiscoveredBluetoothDevice) { - try { - connect(device.device) - .useAutoConnect(false) - .retry(3, 100) - .suspend() - } catch (e: Exception) { - e.printStackTrace() - } + fun log(priority: Int, message: String) { + logger?.log(priority, message) } - fun release() { - manager?.disconnect()?.enqueue() + fun disconnect() { + _stopEvent.tryEmit(DisconnectAndStopEvent()) + } + + fun onMissingServices() { + _data.value = _data.value.copy(missingServices = true) + _stopEvent.tryEmit(DisconnectAndStopEvent()) + } + + private fun clean() { logger = null - manager = null + _data.value = HTSServiceData() } } diff --git a/profile_hts/src/main/java/no/nordicsemi/android/hts/repository/HTSService.kt b/profile_hts/src/main/java/no/nordicsemi/android/hts/repository/HTSService.kt index 50bb962e..921e736c 100644 --- a/profile_hts/src/main/java/no/nordicsemi/android/hts/repository/HTSService.kt +++ b/profile_hts/src/main/java/no/nordicsemi/android/hts/repository/HTSService.kt @@ -31,33 +31,111 @@ package no.nordicsemi.android.hts.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.hts.HTSDataParser import no.nordicsemi.android.service.DEVICE_DATA import no.nordicsemi.android.service.NotificationService +import java.util.* import javax.inject.Inject +val HTS_SERVICE_UUID: UUID = UUID.fromString("00001809-0000-1000-8000-00805f9b34fb") +private val HTS_MEASUREMENT_CHARACTERISTIC_UUID = UUID.fromString("00002A1C-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 HTSService : NotificationService() { @Inject lateinit var repository: HTSRepository + private lateinit var client: ClientBleGatt + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { super.onStartCommand(intent, flags, startId) - val device = intent!!.getParcelableExtra(DEVICE_DATA)!! + repository.setServiceRunning(true) - repository.start(device, lifecycleScope) + val device = intent!!.getParcelableExtra(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@HTSService, 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 htsService = services.findService(HTS_SERVICE_UUID)!! + val htsMeasurementCharacteristic = htsService.findCharacteristic(HTS_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) + + htsMeasurementCharacteristic.getNotifications() + .mapNotNull { HTSDataParser.parse(it) } + .onEach { repository.onHTSDataChanged(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) + } } diff --git a/profile_hts/src/main/java/no/nordicsemi/android/hts/view/HTSContentView.kt b/profile_hts/src/main/java/no/nordicsemi/android/hts/view/HTSContentView.kt index 8858ab9d..4851bbc5 100644 --- a/profile_hts/src/main/java/no/nordicsemi/android/hts/view/HTSContentView.kt +++ b/profile_hts/src/main/java/no/nordicsemi/android/hts/view/HTSContentView.kt @@ -35,7 +35,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding import androidx.compose.material3.Button import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -46,14 +45,14 @@ 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.hts.R -import no.nordicsemi.android.hts.data.HTSData +import no.nordicsemi.android.hts.data.HTSServiceData 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 HTSContentView(state: HTSData, temperatureUnit: TemperatureUnit, onEvent: (HTSScreenViewEvent) -> Unit) { +internal fun HTSContentView(state: HTSServiceData, onEvent: (HTSScreenViewEvent) -> Unit) { Column( modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally @@ -63,7 +62,7 @@ internal fun HTSContentView(state: HTSData, temperatureUnit: TemperatureUnit, on Spacer(modifier = Modifier.height(16.dp)) - RadioButtonGroup(viewEntity = temperatureUnit.temperatureSettingsItems()) { + RadioButtonGroup(viewEntity = state.temperatureUnit.temperatureSettingsItems()) { onEvent(OnTemperatureUnitSelected(it.label.toTemperatureUnit())) } } @@ -77,7 +76,7 @@ internal fun HTSContentView(state: HTSData, temperatureUnit: TemperatureUnit, on KeyValueField( stringResource(id = R.string.hts_temperature), - displayTemperature(state.temperatureValue, temperatureUnit) + displayTemperature(state.data.temperature, state.temperatureUnit) ) } @@ -100,5 +99,5 @@ internal fun HTSContentView(state: HTSData, temperatureUnit: TemperatureUnit, on @Preview @Composable private fun Preview() { - HTSContentView(state = HTSData(), TemperatureUnit.CELSIUS) { } + HTSContentView(state = HTSServiceData()) { } } diff --git a/profile_hts/src/main/java/no/nordicsemi/android/hts/view/HTSScreen.kt b/profile_hts/src/main/java/no/nordicsemi/android/hts/view/HTSScreen.kt index 79141ca7..4e6cf825 100644 --- a/profile_hts/src/main/java/no/nordicsemi/android/hts/view/HTSScreen.kt +++ b/profile_hts/src/main/java/no/nordicsemi/android/hts/view/HTSScreen.kt @@ -35,79 +35,54 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState 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.hts.R import no.nordicsemi.android.hts.viewmodel.HTSViewModel -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 HTSScreen() { val viewModel: HTSViewModel = 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.hts_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 htsState = state.htsManagerState) { - NoDeviceState -> DeviceConnectingView() - is WorkingState -> when (htsState.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 -> HTSContentView(htsState.result.data, state.temperatureUnit) { 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 -> HTSContentView(state) { viewModel.onEvent(it) } } } } } - -@Composable -private fun AppBar(state: HTSViewState, navigateUp: () -> Unit, viewModel: HTSViewModel) { - val toolbarName = (state.htsManagerState as? WorkingState)?.let { - (it.result as? DeviceHolder)?.deviceName() - } - - if (toolbarName == null) { - BackIconAppBar(stringResource(id = R.string.hts_title), navigateUp) - } else { - LoggerIconAppBar(toolbarName, navigateUp, { viewModel.onEvent(DisconnectEvent) }) { - viewModel.onEvent(OpenLoggerEvent) - } - } -} - diff --git a/profile_hts/src/main/java/no/nordicsemi/android/hts/view/HTSState.kt b/profile_hts/src/main/java/no/nordicsemi/android/hts/view/HTSState.kt deleted file mode 100644 index 3e05d629..00000000 --- a/profile_hts/src/main/java/no/nordicsemi/android/hts/view/HTSState.kt +++ /dev/null @@ -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.hts.view - -import no.nordicsemi.android.hts.data.HTSData -import no.nordicsemi.android.service.BleManagerResult - -internal data class HTSViewState( - val temperatureUnit: TemperatureUnit = TemperatureUnit.CELSIUS, - val htsManagerState: HTSManagerState = NoDeviceState -) - -internal sealed class HTSManagerState - -internal data class WorkingState(val result: BleManagerResult) : HTSManagerState() - -internal object NoDeviceState : HTSManagerState() diff --git a/profile_hts/src/main/java/no/nordicsemi/android/hts/viewmodel/HTSViewModel.kt b/profile_hts/src/main/java/no/nordicsemi/android/hts/viewmodel/HTSViewModel.kt index 1ffb4bd4..c7d64d71 100644 --- a/profile_hts/src/main/java/no/nordicsemi/android/hts/viewmodel/HTSViewModel.kt +++ b/profile_hts/src/main/java/no/nordicsemi/android/hts/viewmodel/HTSViewModel.kt @@ -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,17 +44,15 @@ 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.hts.data.HTS_SERVICE_UUID import no.nordicsemi.android.hts.repository.HTSRepository +import no.nordicsemi.android.hts.repository.HTS_SERVICE_UUID import no.nordicsemi.android.hts.view.DisconnectEvent import no.nordicsemi.android.hts.view.HTSScreenViewEvent -import no.nordicsemi.android.hts.view.HTSViewState import no.nordicsemi.android.hts.view.NavigateUp import no.nordicsemi.android.hts.view.OnTemperatureUnitSelected import no.nordicsemi.android.hts.view.OpenLoggerEvent -import no.nordicsemi.android.hts.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 @@ -67,10 +63,11 @@ internal class HTSViewModel @Inject constructor( private val analytics: AppAnalytics ) : ViewModel() { - private val _state = MutableStateFlow(HTSViewState()) - val state = _state.asStateFlow() + val state = repository.data init { + repository.setOnScreen(true) + viewModelScope.launch { if (repository.isRunning.firstOrNull() == false) { requestBluetoothDevice() @@ -78,9 +75,7 @@ internal class HTSViewModel @Inject constructor( } repository.data.onEach { - _state.value = _state.value.copy(htsManagerState = WorkingState(it)) - - (it as? ConnectedResult)?.let { + if (it.connectionState?.state == GattConnectionState.STATE_CONNECTED) { analytics.logEvent(ProfileConnectedEvent(Profile.HTS)) } }.launchIn(viewModelScope) @@ -94,13 +89,17 @@ internal class HTSViewModel @Inject constructor( .launchIn(viewModelScope) } - private fun handleResult(result: NavigationResult) { + private fun handleResult(result: NavigationResult) { 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: HTSScreenViewEvent) { when (event) { DisconnectEvent -> disconnect() @@ -111,11 +110,16 @@ internal class HTSViewModel @Inject constructor( } private fun disconnect() { - repository.release() + repository.disconnect() navigationManager.navigateUp() } private fun onTemperatureUnitSelected(event: OnTemperatureUnitSelected) { - _state.value = _state.value.copy(temperatureUnit = event.value) + repository.setTemperatureUnit(event.value) + } + + override fun onCleared() { + super.onCleared() + repository.setOnScreen(false) } } diff --git a/profile_prx/build.gradle.kts b/profile_prx/build.gradle.kts index f8585a6f..a4583611 100644 --- a/profile_prx/build.gradle.kts +++ b/profile_prx/build.gradle.kts @@ -45,13 +45,18 @@ 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.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) + implementation(libs.nordic.core) implementation(libs.androidx.hilt.navigation.compose) implementation(libs.androidx.compose.material.iconsExtended) diff --git a/profile_prx/src/main/java/no/nordicsemi/android/prx/data/PRXManager.kt b/profile_prx/src/main/java/no/nordicsemi/android/prx/data/PRXManager.kt deleted file mode 100644 index 292b2bf8..00000000 --- a/profile_prx/src/main/java/no/nordicsemi/android/prx/data/PRXManager.kt +++ /dev/null @@ -1,178 +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.prx.data - -import android.bluetooth.BluetoothDevice -import android.bluetooth.BluetoothGatt -import android.bluetooth.BluetoothGattCharacteristic -import android.bluetooth.BluetoothGattServer -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.alert.AlertLevelDataCallback -import no.nordicsemi.android.ble.common.callback.battery.BatteryLevelResponse -import no.nordicsemi.android.ble.common.data.alert.AlertLevelData -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 PRX_SERVICE_UUID = UUID.fromString("00001802-0000-1000-8000-00805f9b34fb") -val LINK_LOSS_SERVICE_UUID = UUID.fromString("00001803-0000-1000-8000-00805f9b34fb") -val ALERT_LEVEL_CHARACTERISTIC_UUID = UUID.fromString("00002A06-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 PRXManager( - context: Context, - private val scope: CoroutineScope, - private val logger: NordicLogger -) : BleManager(context) { - - private var batteryLevelCharacteristic: BluetoothGattCharacteristic? = null - private var alertLevelCharacteristic: BluetoothGattCharacteristic? = null - private var linkLossCharacteristic: BluetoothGattCharacteristic? = null - - private var localAlertLevelCharacteristic: BluetoothGattCharacteristic? = null - private var linkLossServerCharacteristic: BluetoothGattCharacteristic? = null - - private var isAlertEnabled = false - - private val data = MutableStateFlow(PRXData()) - val dataHolder = ConnectionObserverAdapter() - - 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 - } - - private inner class ProximityManagerGattCallback : BleManagerGattCallback() { - override fun initialize() { - super.initialize() - - setWriteCallback(localAlertLevelCharacteristic) - .with(object : AlertLevelDataCallback() { - override fun onAlertLevelChanged(device: BluetoothDevice, level: Int) { - data.value = data.value.copy(localAlarmLevel = AlarmLevel.create(level)) - } - }) - - setWriteCallback(linkLossServerCharacteristic) - .with(object : AlertLevelDataCallback() { - override fun onAlertLevelChanged(device: BluetoothDevice, level: Int) { - data.value = data.value.copy(linkLossAlarmLevel = AlarmLevel.create(level)) - } - }) - - writeCharacteristic( - linkLossCharacteristic, - AlertLevelData.highAlert(), - BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT - ).enqueue() - - setNotificationCallback(batteryLevelCharacteristic) - .asValidResponseFlow() - .onEach { - data.value = data.value.copy(batteryLevel = it.batteryLevel) - }.launchIn(scope) - enableNotifications(batteryLevelCharacteristic).enqueue() - } - - override fun onServerReady(server: BluetoothGattServer) { - val immediateAlertService = server.getService(PRX_SERVICE_UUID) - if (immediateAlertService != null) { - localAlertLevelCharacteristic = immediateAlertService.getCharacteristic(ALERT_LEVEL_CHARACTERISTIC_UUID) - } - val linkLossService = server.getService(LINK_LOSS_SERVICE_UUID) - if (linkLossService != null) { - linkLossServerCharacteristic = linkLossService.getCharacteristic(ALERT_LEVEL_CHARACTERISTIC_UUID) - } - } - - override fun isRequiredServiceSupported(gatt: BluetoothGatt): Boolean { - gatt.getService(LINK_LOSS_SERVICE_UUID)?.run { - linkLossCharacteristic = getCharacteristic(ALERT_LEVEL_CHARACTERISTIC_UUID) - } - gatt.getService(BATTERY_SERVICE_UUID)?.run { - batteryLevelCharacteristic = getCharacteristic(BATTERY_LEVEL_CHARACTERISTIC_UUID) - } - gatt.getService(PRX_SERVICE_UUID)?.run { - alertLevelCharacteristic = getCharacteristic(ALERT_LEVEL_CHARACTERISTIC_UUID) - } - return linkLossCharacteristic != null - } - - override fun onServicesInvalidated() { - batteryLevelCharacteristic = null - alertLevelCharacteristic = null - linkLossCharacteristic = null - localAlertLevelCharacteristic = null - linkLossServerCharacteristic = null - isAlertEnabled = false - } - } - - fun writeImmediateAlert(on: Boolean) { - if (!isConnected) return - scope.launchWithCatch { - writeCharacteristic( - alertLevelCharacteristic, - if (on) AlertLevelData.highAlert() else AlertLevelData.noAlert(), - BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE - ).suspend() - - isAlertEnabled = on - data.value = data.value.copy(isRemoteAlarm = on) - } - } - - override fun getGattCallback(): BleManagerGattCallback { - return ProximityManagerGattCallback() - } -} diff --git a/profile_prx/src/main/java/no/nordicsemi/android/prx/data/PRXServiceData.kt b/profile_prx/src/main/java/no/nordicsemi/android/prx/data/PRXServiceData.kt new file mode 100644 index 00000000..7787532f --- /dev/null +++ b/profile_prx/src/main/java/no/nordicsemi/android/prx/data/PRXServiceData.kt @@ -0,0 +1,25 @@ +package no.nordicsemi.android.prx.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.prx.AlarmLevel + +data class PRXServiceData( + val localAlarmLevel: AlarmLevel = AlarmLevel.NONE, + val linkLossAlarmLevel: AlarmLevel = AlarmLevel.HIGH, + val batteryLevel: Int? = null, + val connectionState: GattConnectionStateWithStatus? = null, + val connectionStatus: BleGattConnectionStatus? = null, + val isRemoteAlarm: Boolean = false, + val deviceName: String? = null, + val missingServices: Boolean = false +) { + + val disconnectStatus = if (missingServices) { + BleGattConnectionStatus.NOT_SUPPORTED + } else { + connectionState?.status ?: BleGattConnectionStatus.UNKNOWN + } + + val isLinkLossDisconnected = connectionStatus?.isLinkLoss ?: false +} diff --git a/profile_prx/src/main/java/no/nordicsemi/android/prx/data/ProximityServerManager.kt b/profile_prx/src/main/java/no/nordicsemi/android/prx/data/ProximityServerManager.kt deleted file mode 100644 index b355086b..00000000 --- a/profile_prx/src/main/java/no/nordicsemi/android/prx/data/ProximityServerManager.kt +++ /dev/null @@ -1,76 +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.prx.data - -import android.bluetooth.BluetoothGattCharacteristic -import android.bluetooth.BluetoothGattService -import android.content.Context -import android.util.Log -import dagger.hilt.android.qualifiers.ApplicationContext -import no.nordicsemi.android.ble.BleServerManager -import no.nordicsemi.android.ble.common.data.alert.AlertLevelData -import javax.inject.Inject - -internal class ProximityServerManager @Inject constructor( - @ApplicationContext - context: Context -) : BleServerManager(context) { - - override fun log(priority: Int, message: String) { - Log.println(priority, "BleManager", message) - } - - override fun initializeServer(): List { - val services: MutableList = ArrayList() - services.add( - service( - PRX_SERVICE_UUID, - characteristic( - ALERT_LEVEL_CHARACTERISTIC_UUID, - BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE, - BluetoothGattCharacteristic.PERMISSION_WRITE - ) - ) - ) - services.add( - service( - LINK_LOSS_SERVICE_UUID, - characteristic( - ALERT_LEVEL_CHARACTERISTIC_UUID, - BluetoothGattCharacteristic.PROPERTY_WRITE or BluetoothGattCharacteristic.PROPERTY_READ, - BluetoothGattCharacteristic.PERMISSION_WRITE or BluetoothGattCharacteristic.PERMISSION_READ, - AlertLevelData.highAlert() - ) - ) - ) - return services - } -} diff --git a/profile_prx/src/main/java/no/nordicsemi/android/prx/repository/AlarmHandler.kt b/profile_prx/src/main/java/no/nordicsemi/android/prx/repository/AlarmHandler.kt index 2d779909..cb909807 100644 --- a/profile_prx/src/main/java/no/nordicsemi/android/prx/repository/AlarmHandler.kt +++ b/profile_prx/src/main/java/no/nordicsemi/android/prx/repository/AlarmHandler.kt @@ -35,7 +35,7 @@ import android.content.Context import android.media.RingtoneManager import android.os.Build import dagger.hilt.android.qualifiers.ApplicationContext -import no.nordicsemi.android.prx.data.AlarmLevel +import no.nordicsemi.android.kotlin.ble.profile.prx.AlarmLevel import javax.inject.Inject internal class AlarmHandler @Inject constructor( @@ -60,6 +60,10 @@ internal class AlarmHandler @Inject constructor( } fun playAlarm(alarmLevel: AlarmLevel) { + if (alarmLevel == AlarmLevel.NONE) { + pauseAlarm() + return + } val ringtone = when (alarmLevel) { AlarmLevel.NONE -> null AlarmLevel.MEDIUM -> mediumLevelRingtone diff --git a/profile_prx/src/main/java/no/nordicsemi/android/prx/repository/PRXRepository.kt b/profile_prx/src/main/java/no/nordicsemi/android/prx/repository/PRXRepository.kt index 9a9c42b6..9d790899 100644 --- a/profile_prx/src/main/java/no/nordicsemi/android/prx/repository/PRXRepository.kt +++ b/profile_prx/src/main/java/no/nordicsemi/android/prx/repository/PRXRepository.kt @@ -33,24 +33,20 @@ package no.nordicsemi.android.prx.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 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.prx.data.AlarmLevel -import no.nordicsemi.android.prx.data.PRXData -import no.nordicsemi.android.prx.data.PRXManager -import no.nordicsemi.android.prx.data.ProximityServerManager -import no.nordicsemi.android.service.BleManagerResult -import no.nordicsemi.android.service.IdleResult -import no.nordicsemi.android.service.LinkLossResult +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.prx.AlarmLevel +import no.nordicsemi.android.prx.data.PRXServiceData +import no.nordicsemi.android.service.DisconnectAndStopEvent import no.nordicsemi.android.service.ServiceManager -import no.nordicsemi.android.service.SuccessResult import no.nordicsemi.android.ui.view.StringConst import javax.inject.Inject import javax.inject.Singleton @@ -60,75 +56,88 @@ class PRXRepository @Inject internal constructor( @ApplicationContext private val context: Context, private val serviceManager: ServiceManager, - private val proximityServerManager: ProximityServerManager, - private val alarmHandler: AlarmHandler, - private val loggerFactory: NordicLoggerFactory, private val stringConst: StringConst ) { + private var logger: BleLoggerAndLauncher? = null - private var manager: PRXManager? = null - private var logger: NordicLogger? = null - - private val _data = MutableStateFlow>(IdleResult()) + private val _data = MutableStateFlow(PRXServiceData()) internal val data = _data.asStateFlow() - val isRunning = data.map { it.isRunning() } - val hasBeenDisconnectedWithoutLinkLoss = data.map { it.hasBeenDisconnectedWithoutLinkLoss() } + private val _stopEvent = simpleSharedFlow() + internal val stopEvent = _stopEvent.asSharedFlow() - fun launch(device: DiscoveredBluetoothDevice) { + private val _remoteAlarmLevel = simpleSharedFlow() + internal val remoteAlarmLevel = _remoteAlarmLevel.asSharedFlow() + + 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, "PRX", device.address) + _data.value = _data.value.copy(deviceName = device.name) serviceManager.startService(PRXService::class.java, device) - proximityServerManager.open() } - fun start(device: DiscoveredBluetoothDevice, scope: CoroutineScope) { - val createdLogger = loggerFactory.create(stringConst.APP_NAME, "PRX", device.address).also { - logger = it - } - val manager = PRXManager(context, scope, createdLogger) - this.manager = manager - manager.useServer(proximityServerManager) - - manager.dataHolder.status.onEach { - _data.value = it - handleLocalAlarm(it) - }.launchIn(scope) - - manager.connect(device.device) - .useAutoConnect(true) - .retry(3, 100) - .enqueue() + fun onConnectionStateChanged(connection: GattConnectionStateWithStatus) { + _data.value = _data.value.copy(connectionState = connection) } - private fun handleLocalAlarm(result: BleManagerResult) { - (result as? SuccessResult)?.let { - if (it.data.localAlarmLevel != AlarmLevel.NONE) { - alarmHandler.playAlarm(it.data.localAlarmLevel) - } else { - alarmHandler.pauseAlarm() - } - } - (result as? LinkLossResult)?.let { - val alarmLevel = it.data?.linkLossAlarmLevel ?: AlarmLevel.HIGH - alarmHandler.playAlarm(alarmLevel) - } + fun setLocalAlarmLevel(alarmLevel: AlarmLevel) { + _data.value = _data.value.copy(localAlarmLevel = alarmLevel) } - fun enableAlarm() { - manager?.writeImmediateAlert(true) + fun setLinkLossAlarmLevel(alarmLevel: AlarmLevel) { + _data.value = _data.value.copy(linkLossAlarmLevel = alarmLevel) } - fun disableAlarm() { - manager?.writeImmediateAlert(false) + fun onBatteryLevelChanged(batteryLevel: Int) { + _data.value = _data.value.copy(batteryLevel = batteryLevel) + } + + fun setRemoteAlarmLevel(alarmLevel: AlarmLevel) { + _remoteAlarmLevel.tryEmit(alarmLevel) + } + + fun onRemoteAlarmLevelSet(alarmLevel: AlarmLevel) { + _data.value = _data.value.copy(isRemoteAlarm = alarmLevel != AlarmLevel.NONE) } fun openLogger() { - NordicLogger.launch(context, logger) + logger?.launch() } - fun release() { - disableAlarm() - manager?.disconnect()?.enqueue() - manager = null + fun log(priority: Int, message: String) { + logger?.log(priority, message) + } + + fun onMissingServices() { + _data.value = _data.value.copy(missingServices = true) + _stopEvent.tryEmit(DisconnectAndStopEvent()) + } + + fun disconnect() { + _remoteAlarmLevel.tryEmit(AlarmLevel.NONE) + _stopEvent.tryEmit(DisconnectAndStopEvent()) + } + + private fun clean() { logger = null + _data.value = PRXServiceData() } } diff --git a/profile_prx/src/main/java/no/nordicsemi/android/prx/repository/PRXService.kt b/profile_prx/src/main/java/no/nordicsemi/android/prx/repository/PRXService.kt index 1b19f49e..be7dd8ac 100644 --- a/profile_prx/src/main/java/no/nordicsemi/android/prx/repository/PRXService.kt +++ b/profile_prx/src/main/java/no/nordicsemi/android/prx/repository/PRXService.kt @@ -31,33 +31,204 @@ package no.nordicsemi.android.prx.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.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.BleGattConnectOptions +import no.nordicsemi.android.kotlin.ble.core.data.BleGattConnectionStatus +import no.nordicsemi.android.kotlin.ble.core.data.BleGattPermission +import no.nordicsemi.android.kotlin.ble.core.data.BleGattProperty +import no.nordicsemi.android.kotlin.ble.core.data.BleWriteType +import no.nordicsemi.android.kotlin.ble.core.data.GattConnectionState +import no.nordicsemi.android.kotlin.ble.profile.battery.BatteryLevelParser +import no.nordicsemi.android.kotlin.ble.profile.prx.AlarmLevel +import no.nordicsemi.android.kotlin.ble.profile.prx.AlarmLevelParser +import no.nordicsemi.android.kotlin.ble.profile.prx.AlertLevelInputParser +import no.nordicsemi.android.kotlin.ble.server.main.ServerBleGatt +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 no.nordicsemi.android.service.DEVICE_DATA import no.nordicsemi.android.service.NotificationService +import java.util.* import javax.inject.Inject +val PRX_SERVICE_UUID = UUID.fromString("00001802-0000-1000-8000-00805f9b34fb") +private val LINK_LOSS_SERVICE_UUID = UUID.fromString("00001803-0000-1000-8000-00805f9b34fb") + +private val ALERT_LEVEL_CHARACTERISTIC_UUID = UUID.fromString("00002A06-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 PRXService : NotificationService() { @Inject lateinit var repository: PRXRepository + private lateinit var client: ClientBleGatt + private lateinit var server: ServerBleGatt + + private var alertLevelCharacteristic: ClientBleGattCharacteristic? = null + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { super.onStartCommand(intent, flags, startId) - val device = intent!!.getParcelableExtra(DEVICE_DATA)!! + repository.setServiceRunning(true) - repository.start(device, lifecycleScope) + val device = intent!!.getParcelableExtra(DEVICE_DATA)!! - repository.hasBeenDisconnectedWithoutLinkLoss.onEach { - if (it) stopSelf() - }.launchIn(lifecycleScope) + startServer(device) + + repository.stopEvent + .onEach { disconnect() } + .launchIn(lifecycleScope) return START_REDELIVER_INTENT } + + private fun startServer(device: ServerDevice) = lifecycleScope.launch { + val alertLevelCharacteristic = ServerBleGattCharacteristicConfig( + uuid = ALERT_LEVEL_CHARACTERISTIC_UUID, + properties = listOf(BleGattProperty.PROPERTY_WRITE_NO_RESPONSE), + permissions = listOf(BleGattPermission.PERMISSION_WRITE) + ) + val prxServiceConfig = ServerBleGattServiceConfig( + uuid = PRX_SERVICE_UUID, + type = ServerBleGattServiceType.SERVICE_TYPE_PRIMARY, + characteristicConfigs = listOf(alertLevelCharacteristic) + ) + + val linkLossCharacteristic = ServerBleGattCharacteristicConfig( + uuid = ALERT_LEVEL_CHARACTERISTIC_UUID, + properties = listOf(BleGattProperty.PROPERTY_WRITE, BleGattProperty.PROPERTY_READ), + permissions = listOf(BleGattPermission.PERMISSION_WRITE, BleGattPermission.PERMISSION_READ), + initialValue = AlertLevelInputParser.parse(AlarmLevel.HIGH) + ) + + val linkLossServiceConfig = ServerBleGattServiceConfig( + uuid = LINK_LOSS_SERVICE_UUID, + type =ServerBleGattServiceType.SERVICE_TYPE_PRIMARY, + characteristicConfigs = listOf(linkLossCharacteristic) + ) + + server = ServerBleGatt.create(this@PRXService, prxServiceConfig, linkLossServiceConfig) + + //Order is important. We don't want to connect before services have been added to the server. + startGattClient(device) + + server.onNewConnection + .onEach { setUpServerConnection(it) } + .launchIn(lifecycleScope) + } + + private fun setUpServerConnection(connection: ServerBluetoothGattConnection) { + val prxService = connection.services.findService(PRX_SERVICE_UUID)!! + val linkLossService = connection.services.findService(LINK_LOSS_SERVICE_UUID)!! + + val prxCharacteristic = prxService.findCharacteristic(ALERT_LEVEL_CHARACTERISTIC_UUID)!! + val linkLossCharacteristic = linkLossService.findCharacteristic(ALERT_LEVEL_CHARACTERISTIC_UUID)!! + + prxCharacteristic.value + .mapNotNull { AlarmLevelParser.parse(it) } + .onEach { repository.setLocalAlarmLevel(it) } + .launchIn(lifecycleScope) + + linkLossCharacteristic.value + .mapNotNull { AlarmLevelParser.parse(it) } + .onEach { repository.setLinkLossAlarmLevel(it) } + .launchIn(lifecycleScope) + } + + private fun startGattClient(device: ServerDevice) = lifecycleScope.launch { + client = ClientBleGatt.connect( + this@PRXService, + device, + logger = { p, s -> repository.log(p, s) }, + options = BleGattConnectOptions(autoConnect = true) + ) + + client.waitForBonding() + + client.connectionStateWithStatus + .filterNotNull() + .onEach { repository.onConnectionStateChanged(it) } + .onEach { stopIfDisconnected(it.state, it.status) } + .launchIn(lifecycleScope) + + if (!client.isConnected) { + return@launch + } + + try { + val services = client.discoverServices() + configureGatt(services) + } catch (e: Exception) { + repository.onMissingServices() + } + + repository.remoteAlarmLevel + .onEach { writeAlertLevel(it) } + .launchIn(lifecycleScope) + } + + private suspend fun configureGatt(services: ClientBleGattServices) { + val prxService = services.findService(PRX_SERVICE_UUID)!! + alertLevelCharacteristic = prxService.findCharacteristic(ALERT_LEVEL_CHARACTERISTIC_UUID)!! + val linkLossService = services.findService(LINK_LOSS_SERVICE_UUID)!! + val linkLossCharacteristic = linkLossService.findCharacteristic(ALERT_LEVEL_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) + + linkLossCharacteristic.write(AlertLevelInputParser.parse(AlarmLevel.HIGH)) + } + + private suspend fun writeAlertLevel(alarmLevel: AlarmLevel) { + try { + alertLevelCharacteristic?.run { + write(AlertLevelInputParser.parse(alarmLevel), BleWriteType.NO_RESPONSE) + repository.onRemoteAlarmLevelSet(alarmLevel) + } + } catch (e: Exception) { + e.printStackTrace() + } + } + + private fun stopIfDisconnected(connectionState: GattConnectionState, connectionStatus: BleGattConnectionStatus) { + if (connectionState == GattConnectionState.STATE_DISCONNECTED && !connectionStatus.isLinkLoss) { + server.stopServer() + repository.disconnect() + stopSelf() + } + } + + private fun disconnect() { + client.disconnect() + server.stopServer() + } + + override fun onDestroy() { + super.onDestroy() + repository.setServiceRunning(false) + } } diff --git a/profile_prx/src/main/java/no/nordicsemi/android/prx/view/PRXContentView.kt b/profile_prx/src/main/java/no/nordicsemi/android/prx/view/PRXContentView.kt index d7da2a3b..1f2a13f8 100644 --- a/profile_prx/src/main/java/no/nordicsemi/android/prx/view/PRXContentView.kt +++ b/profile_prx/src/main/java/no/nordicsemi/android/prx/view/PRXContentView.kt @@ -34,7 +34,6 @@ package no.nordicsemi.android.prx.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 @@ -45,14 +44,14 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import no.nordicsemi.android.prx.R -import no.nordicsemi.android.prx.data.PRXData +import no.nordicsemi.android.prx.data.PRXServiceData 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 ContentView(state: PRXData, onEvent: (PRXScreenViewEvent) -> Unit) { +internal fun ContentView(state: PRXServiceData, onEvent: (PRXScreenViewEvent) -> Unit) { Column( horizontalAlignment = Alignment.CenterHorizontally ) { @@ -79,7 +78,7 @@ internal fun ContentView(state: PRXData, onEvent: (PRXScreenViewEvent) -> Unit) } @Composable -private fun SettingsSection(state: PRXData, onEvent: (PRXScreenViewEvent) -> Unit) { +private fun SettingsSection(state: PRXServiceData, onEvent: (PRXScreenViewEvent) -> Unit) { ScreenSection { SectionTitle(icon = Icons.Default.Settings, title = stringResource(R.string.prx_settings)) @@ -112,7 +111,7 @@ private fun TurnAlarmOffButton(onEvent: (PRXScreenViewEvent) -> Unit) { } @Composable -private fun RecordsSection(state: PRXData) { +private fun RecordsSection(state: PRXServiceData) { ScreenSection { SectionTitle(resId = R.drawable.ic_records, title = stringResource(id = R.string.prx_records)) diff --git a/profile_prx/src/main/java/no/nordicsemi/android/prx/view/PRXMapper.kt b/profile_prx/src/main/java/no/nordicsemi/android/prx/view/PRXMapper.kt index f687a018..4f9bf5d2 100644 --- a/profile_prx/src/main/java/no/nordicsemi/android/prx/view/PRXMapper.kt +++ b/profile_prx/src/main/java/no/nordicsemi/android/prx/view/PRXMapper.kt @@ -33,8 +33,8 @@ package no.nordicsemi.android.prx.view import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource +import no.nordicsemi.android.kotlin.ble.profile.prx.AlarmLevel import no.nordicsemi.android.prx.R -import no.nordicsemi.android.prx.data.AlarmLevel @Composable internal fun Boolean.toDisplayString(): String { diff --git a/profile_prx/src/main/java/no/nordicsemi/android/prx/view/PRXScreen.kt b/profile_prx/src/main/java/no/nordicsemi/android/prx/view/PRXScreen.kt index 1df40811..3f511756 100644 --- a/profile_prx/src/main/java/no/nordicsemi/android/prx/view/PRXScreen.kt +++ b/profile_prx/src/main/java/no/nordicsemi/android/prx/view/PRXScreen.kt @@ -38,75 +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.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.prx.R import no.nordicsemi.android.prx.viewmodel.PRXViewModel -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.ui.view.NavigateUpButton +import no.nordicsemi.android.ui.view.ProfileAppBar -@OptIn(ExperimentalMaterial3Api::class) @Composable fun PRXScreen() { val viewModel: PRXViewModel = hiltViewModel() - val state by viewModel.state.collectAsStateWithLifecycle() + val state = viewModel.state.collectAsState().value val navigateUp = { viewModel.onEvent(NavigateUpEvent) } Scaffold( - topBar = { AppBar(state, navigateUp, viewModel) } + topBar = { + ProfileAppBar( + deviceName = state.deviceName, + connectionState = state.connectionState, + title = R.string.prx_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 prxState = state) { - NoDeviceState -> DeviceConnectingView() - is WorkingState -> when (prxState.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 -> ContentView(prxState.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 -> ContentView(state) { viewModel.onEvent(it) } } } } } - -@Composable -private fun AppBar(state: PRXViewState, navigateUp: () -> Unit, viewModel: PRXViewModel) { - val toolbarName = (state as? WorkingState)?.let { - (it.result as? DeviceHolder)?.deviceName() - } - - if (toolbarName == null) { - BackIconAppBar(stringResource(id = R.string.prx_title), navigateUp) - } else { - LoggerIconAppBar(toolbarName, navigateUp, { viewModel.onEvent(DisconnectEvent) }) { - viewModel.onEvent(OpenLoggerEvent) - } - } -} diff --git a/profile_prx/src/main/java/no/nordicsemi/android/prx/view/PRXState.kt b/profile_prx/src/main/java/no/nordicsemi/android/prx/view/PRXState.kt deleted file mode 100644 index c95e4004..00000000 --- a/profile_prx/src/main/java/no/nordicsemi/android/prx/view/PRXState.kt +++ /dev/null @@ -1,41 +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.prx.view - -import no.nordicsemi.android.prx.data.PRXData -import no.nordicsemi.android.service.BleManagerResult - -internal sealed class PRXViewState - -internal data class WorkingState(val result: BleManagerResult) : PRXViewState() - -internal object NoDeviceState : PRXViewState() diff --git a/profile_prx/src/main/java/no/nordicsemi/android/prx/viewmodel/PRXViewModel.kt b/profile_prx/src/main/java/no/nordicsemi/android/prx/viewmodel/PRXViewModel.kt index 2143bfee..3d88ffb3 100644 --- a/profile_prx/src/main/java/no/nordicsemi/android/prx/viewmodel/PRXViewModel.kt +++ b/profile_prx/src/main/java/no/nordicsemi/android/prx/viewmodel/PRXViewModel.kt @@ -35,10 +35,10 @@ 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.distinctUntilChanged import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import no.nordicsemi.android.analytics.AppAnalytics @@ -46,19 +46,18 @@ 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.prx.data.PRX_SERVICE_UUID +import no.nordicsemi.android.kotlin.ble.core.ServerDevice +import no.nordicsemi.android.kotlin.ble.core.data.GattConnectionState +import no.nordicsemi.android.kotlin.ble.profile.prx.AlarmLevel +import no.nordicsemi.android.prx.repository.AlarmHandler import no.nordicsemi.android.prx.repository.PRXRepository +import no.nordicsemi.android.prx.repository.PRX_SERVICE_UUID import no.nordicsemi.android.prx.view.DisconnectEvent import no.nordicsemi.android.prx.view.NavigateUpEvent -import no.nordicsemi.android.prx.view.NoDeviceState import no.nordicsemi.android.prx.view.OpenLoggerEvent import no.nordicsemi.android.prx.view.PRXScreenViewEvent -import no.nordicsemi.android.prx.view.PRXViewState import no.nordicsemi.android.prx.view.TurnOffAlert import no.nordicsemi.android.prx.view.TurnOnAlert -import no.nordicsemi.android.prx.view.WorkingState -import no.nordicsemi.android.service.ConnectedResult import no.nordicsemi.android.toolbox.scanner.ScannerDestinationId import javax.inject.Inject @@ -66,13 +65,15 @@ import javax.inject.Inject internal class PRXViewModel @Inject constructor( private val repository: PRXRepository, private val navigationManager: Navigator, - private val analytics: AppAnalytics + private val analytics: AppAnalytics, + private val alarmHandler: AlarmHandler ) : ViewModel() { - private val _state = MutableStateFlow(NoDeviceState) - val state = _state.asStateFlow() + val state = repository.data init { + repository.setOnScreen(true) + viewModelScope.launch { if (repository.isRunning.firstOrNull() == false) { requestBluetoothDevice() @@ -80,12 +81,20 @@ internal class PRXViewModel @Inject constructor( } repository.data.onEach { - _state.value = WorkingState(it) + if (it.isLinkLossDisconnected) { + alarmHandler.playAlarm(it.linkLossAlarmLevel) + } - (it as? ConnectedResult)?.let { + if (it.connectionState?.state == GattConnectionState.STATE_CONNECTED) { analytics.logEvent(ProfileConnectedEvent(Profile.PRX)) } }.launchIn(viewModelScope) + + repository.data + .map { it.localAlarmLevel } + .distinctUntilChanged() + .onEach { alarmHandler.playAlarm(it) } + .launchIn(viewModelScope) } private fun requestBluetoothDevice() { @@ -96,7 +105,7 @@ internal class PRXViewModel @Inject constructor( .launchIn(viewModelScope) } - private fun handleResult(result: NavigationResult) { + private fun handleResult(result: NavigationResult) { when (result) { is NavigationResult.Cancelled -> navigationManager.navigateUp() is NavigationResult.Success -> repository.launch(result.value) @@ -106,15 +115,22 @@ internal class PRXViewModel @Inject constructor( fun onEvent(event: PRXScreenViewEvent) { when (event) { DisconnectEvent -> disconnect() - TurnOffAlert -> repository.disableAlarm() - TurnOnAlert -> repository.enableAlarm() + TurnOffAlert -> repository.setRemoteAlarmLevel(AlarmLevel.NONE) + TurnOnAlert -> repository.setRemoteAlarmLevel(AlarmLevel.HIGH) NavigateUpEvent -> navigationManager.navigateUp() OpenLoggerEvent -> repository.openLogger() } } private fun disconnect() { - repository.release() + alarmHandler.pauseAlarm() navigationManager.navigateUp() + repository.disconnect() + } + + override fun onCleared() { + super.onCleared() + alarmHandler.pauseAlarm() + repository.setOnScreen(false) } } diff --git a/profile_rscs/build.gradle.kts b/profile_rscs/build.gradle.kts index 92d50647..e1452d04 100644 --- a/profile_rscs/build.gradle.kts +++ b/profile_rscs/build.gradle.kts @@ -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.theme) - implementation(libs.nordic.uiscanner) + implementation(libs.nordic.blek.uiscanner) implementation(libs.nordic.navigation) implementation(libs.nordic.uilogger) + implementation(libs.nordic.core) implementation(libs.androidx.hilt.navigation.compose) implementation(libs.androidx.compose.material.iconsExtended) diff --git a/profile_rscs/src/main/java/no/nordicsemi/android/rscs/data/RSCSManager.kt b/profile_rscs/src/main/java/no/nordicsemi/android/rscs/data/RSCSManager.kt deleted file mode 100644 index 30668a64..00000000 --- a/profile_rscs/src/main/java/no/nordicsemi/android/rscs/data/RSCSManager.kt +++ /dev/null @@ -1,126 +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.rscs.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.rsc.RunningSpeedAndCadenceMeasurementResponse -import no.nordicsemi.android.ble.ktx.asValidResponseFlow -import no.nordicsemi.android.common.logger.NordicLogger -import no.nordicsemi.android.service.ConnectionObserverAdapter -import java.util.* - -val RSCS_SERVICE_UUID: UUID = UUID.fromString("00001814-0000-1000-8000-00805F9B34FB") -private val RSC_MEASUREMENT_CHARACTERISTIC_UUID = UUID.fromString("00002A53-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 RSCSManager internal constructor( - context: Context, - private val scope: CoroutineScope, - private val logger: NordicLogger -) : BleManager(context) { - - private var batteryLevelCharacteristic: BluetoothGattCharacteristic? = null - private var rscMeasurementCharacteristic: BluetoothGattCharacteristic? = null - - private val data = MutableStateFlow(RSCSData()) - val dataHolder = ConnectionObserverAdapter() - - 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 - } - - private inner class RSCManagerGattCallback : BleManagerGattCallback() { - - override fun initialize() { - super.initialize() - setNotificationCallback(rscMeasurementCharacteristic).asValidResponseFlow() - .onEach { - data.tryEmit(data.value.copy( - running = it.isRunning, - instantaneousCadence = it.instantaneousCadence, - instantaneousSpeed = it.instantaneousSpeed, - strideLength = it.strideLength, - totalDistance = it.totalDistance - )) - }.launchIn(scope) - enableNotifications(rscMeasurementCharacteristic).enqueue() - - setNotificationCallback(batteryLevelCharacteristic) - .asValidResponseFlow() - .onEach { - data.value = data.value.copy(batteryLevel = it.batteryLevel) - }.launchIn(scope) - enableNotifications(batteryLevelCharacteristic).enqueue() - } - - public override fun isRequiredServiceSupported(gatt: BluetoothGatt): Boolean { - gatt.getService(RSCS_SERVICE_UUID)?.run { - rscMeasurementCharacteristic = getCharacteristic(RSC_MEASUREMENT_CHARACTERISTIC_UUID) - } - gatt.getService(BATTERY_SERVICE_UUID)?.run { - batteryLevelCharacteristic = getCharacteristic(BATTERY_LEVEL_CHARACTERISTIC_UUID) - } - return rscMeasurementCharacteristic != null - } - - override fun onServicesInvalidated() { - rscMeasurementCharacteristic = null - batteryLevelCharacteristic = null - } - } - - override fun getGattCallback(): BleManagerGattCallback { - return RSCManagerGattCallback() - } -} diff --git a/profile_rscs/src/main/java/no/nordicsemi/android/rscs/data/RSCSData.kt b/profile_rscs/src/main/java/no/nordicsemi/android/rscs/data/RSCSServiceData.kt similarity index 58% rename from profile_rscs/src/main/java/no/nordicsemi/android/rscs/data/RSCSData.kt rename to profile_rscs/src/main/java/no/nordicsemi/android/rscs/data/RSCSServiceData.kt index 75f303ea..6251a857 100644 --- a/profile_rscs/src/main/java/no/nordicsemi/android/rscs/data/RSCSData.kt +++ b/profile_rscs/src/main/java/no/nordicsemi/android/rscs/data/RSCSServiceData.kt @@ -31,37 +31,52 @@ package no.nordicsemi.android.rscs.data -internal data class RSCSData( +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +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.rscs.data.RSCSData +import no.nordicsemi.android.rscs.R + +internal data class RSCSServiceData( + val data: RSCSData = RSCSData(), val batteryLevel: Int? = null, - val running: Boolean = false, - val instantaneousSpeed: Float = 1.0f, - val instantaneousCadence: Int = 0, - val strideLength: Int? = null, - val totalDistance: Long? = null + val connectionState: GattConnectionStateWithStatus? = null, + val deviceName: String? = null, + val missingServices: Boolean = false ) { + val disconnectStatus = if (missingServices) { + BleGattConnectionStatus.NOT_SUPPORTED + } else { + connectionState?.status ?: BleGattConnectionStatus.UNKNOWN + } + + @Composable fun displayActivity(): String { - return if (running) { - "Running" + return if (data.running) { + stringResource(id = R.string.rscs_running) } else { - "Walking" + stringResource(id = R.string.rscs_walking) } } + @Composable fun displayPace(): String { - return "$instantaneousCadence min/km" + return stringResource(id = R.string.rscs_speed, data.instantaneousSpeed) } - + @Composable fun displayCadence(): String { - return "$instantaneousCadence RPM" + return stringResource(id = R.string.rscs_rpm, data.instantaneousCadence) } + @Composable fun displayNumberOfSteps(): String? { - if (totalDistance == null || strideLength == null) { + if (data.totalDistance == null || data.strideLength == null) { return null } - val numberOfSteps = totalDistance/strideLength - return "Number of Steps $numberOfSteps" + val numberOfSteps = data.totalDistance!! / data.strideLength!!.toLong() + return stringResource(id = R.string.rscs_steps, numberOfSteps) } } diff --git a/profile_rscs/src/main/java/no/nordicsemi/android/rscs/repository/RSCSRepository.kt b/profile_rscs/src/main/java/no/nordicsemi/android/rscs/repository/RSCSRepository.kt index fe7af444..53861d4f 100644 --- a/profile_rscs/src/main/java/no/nordicsemi/android/rscs/repository/RSCSRepository.kt +++ b/profile_rscs/src/main/java/no/nordicsemi/android/rscs/repository/RSCSRepository.kt @@ -33,21 +33,19 @@ package no.nordicsemi.android.rscs.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.rscs.data.RSCSData -import no.nordicsemi.android.rscs.data.RSCSManager -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.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.rscs.data.RSCSData +import no.nordicsemi.android.rscs.data.RSCSServiceData +import no.nordicsemi.android.service.DisconnectAndStopEvent import no.nordicsemi.android.service.ServiceManager import no.nordicsemi.android.ui.view.StringConst import javax.inject.Inject @@ -58,56 +56,72 @@ class RSCSRepository @Inject constructor( @ApplicationContext private val context: Context, private val serviceManager: ServiceManager, - private val loggerFactory: NordicLoggerFactory, private val stringConst: StringConst ) { - private var manager: RSCSManager? = null - private var logger: NordicLogger? = null + private var logger: BleLoggerAndLauncher? = null - private val _data = MutableStateFlow>(IdleResult()) + private val _data = MutableStateFlow(RSCSServiceData()) internal val data = _data.asStateFlow() - val isRunning = data.map { it.isRunning() } - val hasBeenDisconnected = data.map { it.hasBeenDisconnected() } + private val _stopEvent = simpleSharedFlow() + 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, "RSCS", device.address) + _data.value = _data.value.copy(deviceName = device.name) serviceManager.startService(RSCSService::class.java, device) } - fun start(device: DiscoveredBluetoothDevice, scope: CoroutineScope) { - val createdLogger = loggerFactory.create(stringConst.APP_NAME, "RSCS", device.address).also { - logger = it - } - val manager = RSCSManager(context, scope, createdLogger) - this.manager = manager + fun onConnectionStateChanged(connectionState: GattConnectionStateWithStatus?) { + _data.value = _data.value.copy(connectionState = connectionState) + } - manager.dataHolder.status.onEach { - _data.value = it - }.launchIn(scope) + fun onRSCSDataChanged(data: RSCSData) { + _data.value = _data.value.copy(data = data) + } - scope.launch { - manager.start(device) - } + fun onBatteryLevelChanged(batteryLevel: Int) { + _data.value = _data.value.copy(batteryLevel = batteryLevel) + } + + fun onMissingServices() { + _data.value = _data.value.copy(missingServices = true) + _stopEvent.tryEmit(DisconnectAndStopEvent()) } fun openLogger() { - NordicLogger.launch(context, logger) + logger?.launch() } - private suspend fun RSCSManager.start(device: DiscoveredBluetoothDevice) { - try { - connect(device.device) - .useAutoConnect(false) - .retry(3, 100) - .suspend() - } catch (e: Exception) { - e.printStackTrace() - } + fun log(priority: Int, message: String) { + logger?.log(priority, message) } - fun release() { - manager?.disconnect()?.enqueue() - manager = null + fun disconnect() { + _stopEvent.tryEmit(DisconnectAndStopEvent()) + } + + private fun clean() { logger = null + _data.value = RSCSServiceData() } } diff --git a/profile_rscs/src/main/java/no/nordicsemi/android/rscs/repository/RSCSService.kt b/profile_rscs/src/main/java/no/nordicsemi/android/rscs/repository/RSCSService.kt index 4359dab3..7384411f 100644 --- a/profile_rscs/src/main/java/no/nordicsemi/android/rscs/repository/RSCSService.kt +++ b/profile_rscs/src/main/java/no/nordicsemi/android/rscs/repository/RSCSService.kt @@ -31,33 +31,111 @@ package no.nordicsemi.android.rscs.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.rscs.RSCSDataParser import no.nordicsemi.android.service.DEVICE_DATA import no.nordicsemi.android.service.NotificationService +import java.util.* import javax.inject.Inject +val RSCS_SERVICE_UUID: UUID = UUID.fromString("00001814-0000-1000-8000-00805F9B34FB") +private val RSC_MEASUREMENT_CHARACTERISTIC_UUID = UUID.fromString("00002A53-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 RSCSService : NotificationService() { @Inject lateinit var repository: RSCSRepository + private lateinit var client: ClientBleGatt + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { super.onStartCommand(intent, flags, startId) - val device = intent!!.getParcelableExtra(DEVICE_DATA)!! + repository.setServiceRunning(true) - repository.start(device, lifecycleScope) + val device = intent!!.getParcelableExtra(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@RSCSService, 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 rscsService = services.findService(RSCS_SERVICE_UUID)!! + val rscsMeasurementCharacteristic = rscsService.findCharacteristic(RSC_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) + + rscsMeasurementCharacteristic.getNotifications() + .mapNotNull { RSCSDataParser.parse(it) } + .onEach { repository.onRSCSDataChanged(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) + } } diff --git a/profile_rscs/src/main/java/no/nordicsemi/android/rscs/view/RSCSContentView.kt b/profile_rscs/src/main/java/no/nordicsemi/android/rscs/view/RSCSContentView.kt index 785429c7..f423ebaa 100644 --- a/profile_rscs/src/main/java/no/nordicsemi/android/rscs/view/RSCSContentView.kt +++ b/profile_rscs/src/main/java/no/nordicsemi/android/rscs/view/RSCSContentView.kt @@ -43,11 +43,11 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import no.nordicsemi.android.rscs.R -import no.nordicsemi.android.rscs.data.RSCSData +import no.nordicsemi.android.rscs.data.RSCSServiceData import no.nordicsemi.android.ui.view.BatteryLevelView @Composable -internal fun RSCSContentView(state: RSCSData, onEvent: (RSCScreenViewEvent) -> Unit) { +internal fun RSCSContentView(state: RSCSServiceData, onEvent: (RSCScreenViewEvent) -> Unit) { Column( horizontalAlignment = Alignment.CenterHorizontally, ) { @@ -74,5 +74,5 @@ internal fun RSCSContentView(state: RSCSData, onEvent: (RSCScreenViewEvent) -> U @Preview @Composable private fun RSCSContentViewPreview() { - RSCSContentView(RSCSData()) { } + RSCSContentView(RSCSServiceData()) { } } diff --git a/profile_rscs/src/main/java/no/nordicsemi/android/rscs/view/RSCSScreen.kt b/profile_rscs/src/main/java/no/nordicsemi/android/rscs/view/RSCSScreen.kt index f351c46f..486561c3 100644 --- a/profile_rscs/src/main/java/no/nordicsemi/android/rscs/view/RSCSScreen.kt +++ b/profile_rscs/src/main/java/no/nordicsemi/android/rscs/view/RSCSScreen.kt @@ -35,78 +35,54 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState 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.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.rscs.R import no.nordicsemi.android.rscs.viewmodel.RSCSViewModel -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.ui.view.NavigateUpButton +import no.nordicsemi.android.ui.view.ProfileAppBar -@OptIn(ExperimentalMaterial3Api::class) @Composable fun RSCSScreen() { val viewModel: RSCSViewModel = hiltViewModel() - val state by viewModel.state.collectAsStateWithLifecycle() + val state = viewModel.state.collectAsState().value val navigateUp = { viewModel.onEvent(NavigateUpEvent) } Scaffold( - topBar = { AppBar(state, navigateUp, viewModel) } + topBar = { + ProfileAppBar( + deviceName = state.deviceName, + connectionState = state.connectionState, + title = R.string.rscs_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 rscsState = state) { - NoDeviceState -> DeviceConnectingView() - is WorkingState -> when (rscsState.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 -> RSCSContentView(rscsState.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 -> RSCSContentView(state) { viewModel.onEvent(it) } } } } } - -@Composable -private fun AppBar(state: RSCSViewState, navigateUp: () -> Unit, viewModel: RSCSViewModel) { - val toolbarName = (state as? WorkingState)?.let { - (it.result as? DeviceHolder)?.deviceName() - } - - if (toolbarName == null) { - BackIconAppBar(stringResource(id = R.string.rscs_title), navigateUp) - } else { - LoggerIconAppBar(toolbarName, navigateUp, { viewModel.onEvent(DisconnectEvent) }) { - viewModel.onEvent(OpenLoggerEvent) - } - } -} diff --git a/profile_rscs/src/main/java/no/nordicsemi/android/rscs/view/RSCSState.kt b/profile_rscs/src/main/java/no/nordicsemi/android/rscs/view/RSCSState.kt deleted file mode 100644 index 23084a89..00000000 --- a/profile_rscs/src/main/java/no/nordicsemi/android/rscs/view/RSCSState.kt +++ /dev/null @@ -1,41 +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.rscs.view - -import no.nordicsemi.android.rscs.data.RSCSData -import no.nordicsemi.android.service.BleManagerResult - -internal sealed class RSCSViewState - -internal data class WorkingState(val result: BleManagerResult) : RSCSViewState() - -internal object NoDeviceState : RSCSViewState() diff --git a/profile_rscs/src/main/java/no/nordicsemi/android/rscs/view/SensorsReadingView.kt b/profile_rscs/src/main/java/no/nordicsemi/android/rscs/view/SensorsReadingView.kt index 1c89e3f9..54360961 100644 --- a/profile_rscs/src/main/java/no/nordicsemi/android/rscs/view/SensorsReadingView.kt +++ b/profile_rscs/src/main/java/no/nordicsemi/android/rscs/view/SensorsReadingView.kt @@ -33,20 +33,19 @@ package no.nordicsemi.android.rscs.view import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height -import androidx.compose.material3.OutlinedCard 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.rscs.R -import no.nordicsemi.android.rscs.data.RSCSData +import no.nordicsemi.android.rscs.data.RSCSServiceData 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: RSCSData) { +internal fun SensorsReadingView(state: RSCSServiceData) { ScreenSection { SectionTitle(resId = R.drawable.ic_records, title = "Records") @@ -67,5 +66,5 @@ internal fun SensorsReadingView(state: RSCSData) { @Preview @Composable private fun Preview() { - SensorsReadingView(RSCSData()) + SensorsReadingView(RSCSServiceData()) } diff --git a/profile_rscs/src/main/java/no/nordicsemi/android/rscs/viewmodel/RSCSViewModel.kt b/profile_rscs/src/main/java/no/nordicsemi/android/rscs/viewmodel/RSCSViewModel.kt index 2dbbe12a..74309784 100644 --- a/profile_rscs/src/main/java/no/nordicsemi/android/rscs/viewmodel/RSCSViewModel.kt +++ b/profile_rscs/src/main/java/no/nordicsemi/android/rscs/viewmodel/RSCSViewModel.kt @@ -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,17 +44,14 @@ 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.rscs.data.RSCS_SERVICE_UUID +import no.nordicsemi.android.kotlin.ble.core.ServerDevice +import no.nordicsemi.android.kotlin.ble.core.data.GattConnectionState import no.nordicsemi.android.rscs.repository.RSCSRepository +import no.nordicsemi.android.rscs.repository.RSCS_SERVICE_UUID import no.nordicsemi.android.rscs.view.DisconnectEvent import no.nordicsemi.android.rscs.view.NavigateUpEvent -import no.nordicsemi.android.rscs.view.NoDeviceState import no.nordicsemi.android.rscs.view.OpenLoggerEvent -import no.nordicsemi.android.rscs.view.RSCSViewState import no.nordicsemi.android.rscs.view.RSCScreenViewEvent -import no.nordicsemi.android.rscs.view.WorkingState -import no.nordicsemi.android.service.ConnectedResult import no.nordicsemi.android.toolbox.scanner.ScannerDestinationId import javax.inject.Inject @@ -67,10 +62,11 @@ internal class RSCSViewModel @Inject constructor( private val analytics: AppAnalytics ) : ViewModel() { - private val _state = MutableStateFlow(NoDeviceState) - val state = _state.asStateFlow() + val state = repository.data init { + repository.setOnScreen(true) + viewModelScope.launch { if (repository.isRunning.firstOrNull() == false) { requestBluetoothDevice() @@ -78,9 +74,7 @@ internal class RSCSViewModel @Inject constructor( } repository.data.onEach { - _state.value = WorkingState(it) - - (it as? ConnectedResult)?.let { + if (it.connectionState?.state == GattConnectionState.STATE_CONNECTED) { analytics.logEvent(ProfileConnectedEvent(Profile.RSCS)) } }.launchIn(viewModelScope) @@ -94,13 +88,17 @@ internal class RSCSViewModel @Inject constructor( .launchIn(viewModelScope) } - private fun handleResult(result: NavigationResult) { + private fun handleResult(result: NavigationResult) { 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: RSCScreenViewEvent) { when (event) { DisconnectEvent -> disconnect() @@ -110,7 +108,12 @@ internal class RSCSViewModel @Inject constructor( } private fun disconnect() { - repository.release() + repository.disconnect() navigationManager.navigateUp() } + + override fun onCleared() { + super.onCleared() + repository.setOnScreen(false) + } } diff --git a/profile_rscs/src/main/res/values/strings.xml b/profile_rscs/src/main/res/values/strings.xml index 35cf1e57..eb061fc4 100644 --- a/profile_rscs/src/main/res/values/strings.xml +++ b/profile_rscs/src/main/res/values/strings.xml @@ -37,4 +37,10 @@ Pace Cadence Number of steps + + Walking + Running + %.1f min/km + %d RPM + Number of Steps %d diff --git a/profile_uart/build.gradle.kts b/profile_uart/build.gradle.kts index eda73918..bca8be56 100644 --- a/profile_uart/build.gradle.kts +++ b/profile_uart/build.gradle.kts @@ -32,16 +32,15 @@ plugins { alias(libs.plugins.nordic.feature) alias(libs.plugins.kotlin.serialization) - alias(libs.plugins.kotlin.kapt) - alias(libs.plugins.wire) + alias(libs.plugins.ksp) } android { namespace = "no.nordicsemi.android.uart" -} -wire { - kotlin {} + testOptions { + unitTests.isIncludeAndroidResources = true + } } dependencies { @@ -51,9 +50,15 @@ dependencies { implementation(project(":lib_ui")) implementation(project(":lib_utils")) + implementation(libs.nordic.blek.client) + implementation(libs.nordic.blek.profile) + implementation(libs.nordic.blek.core) + implementation(libs.nordic.blek.server) + implementation(libs.nordic.blek.advertiser) + implementation(libs.room.runtime) implementation(libs.room.ktx) - kapt(libs.room.compiler) + ksp(libs.room.compiler) implementation(libs.accompanist.pager) implementation(libs.accompanist.pagerindicators) @@ -62,9 +67,10 @@ dependencies { implementation(libs.nordic.ble.ktx) implementation(libs.nordic.theme) - implementation(libs.nordic.uiscanner) implementation(libs.nordic.navigation) implementation(libs.nordic.uilogger) + implementation(libs.nordic.core) + implementation(libs.nordic.blek.uiscanner) implementation(libs.androidx.dataStore.core) implementation(libs.androidx.dataStore.preferences) @@ -76,6 +82,18 @@ dependencies { implementation(libs.androidx.activity.compose) implementation(libs.androidx.lifecycle.service) + testImplementation(libs.hilt.android.testing) + kaptTest(libs.hilt.compiler) + testImplementation(libs.androidx.test.rules) + + 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) + implementation("org.simpleframework:simple-xml:2.7.1") { exclude(group = "stax", module = "stax-api") exclude(group = "xpp3", module = "xpp3") diff --git a/profile_uart/src/debug/java/no/nordicsemi/android/uart/UartServer.kt b/profile_uart/src/debug/java/no/nordicsemi/android/uart/UartServer.kt new file mode 100644 index 00000000..ecba01fb --- /dev/null +++ b/profile_uart/src/debug/java/no/nordicsemi/android/uart/UartServer.kt @@ -0,0 +1,183 @@ +package no.nordicsemi.android.uart + +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.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 no.nordicsemi.android.uart.repository.BATTERY_LEVEL_CHARACTERISTIC_UUID +import no.nordicsemi.android.uart.repository.BATTERY_SERVICE_UUID +import no.nordicsemi.android.uart.repository.UART_RX_CHARACTERISTIC_UUID +import no.nordicsemi.android.uart.repository.UART_SERVICE_UUID +import no.nordicsemi.android.uart.repository.UART_TX_CHARACTERISTIC_UUID +import javax.inject.Inject +import javax.inject.Singleton + +private const val STANDARD_DELAY = 1000L + +@SuppressLint("MissingPermission") +@Singleton +class UartServer @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 rxCharacteristic = ServerBleGattCharacteristicConfig( + UART_RX_CHARACTERISTIC_UUID, + listOf(BleGattProperty.PROPERTY_NOTIFY), + listOf() + ) + + val txCharacteristic = ServerBleGattCharacteristicConfig( + UART_TX_CHARACTERISTIC_UUID, + listOf(BleGattProperty.PROPERTY_INDICATE, BleGattProperty.PROPERTY_WRITE), + listOf(BleGattPermission.PERMISSION_WRITE) + ) + + val uartService = ServerBleGattServiceConfig( + UART_SERVICE_UUID, + ServerBleGattServiceType.SERVICE_TYPE_PRIMARY, + listOf(rxCharacteristic, txCharacteristic) + ) + + 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(uartService, 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) + } + } + } +} \ No newline at end of file diff --git a/profile_uart/src/main/java/no/nordicsemi/android/uart/DaoHiltModule.kt b/profile_uart/src/main/java/no/nordicsemi/android/uart/DaoHiltModule.kt new file mode 100644 index 00000000..68b70208 --- /dev/null +++ b/profile_uart/src/main/java/no/nordicsemi/android/uart/DaoHiltModule.kt @@ -0,0 +1,20 @@ +package no.nordicsemi.android.uart + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import no.nordicsemi.android.uart.db.ConfigurationsDao +import no.nordicsemi.android.uart.db.ConfigurationsDatabase +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +class DaoHiltModule { + + @Provides + @Singleton + internal fun provideDao(db: ConfigurationsDatabase): ConfigurationsDao { + return db.dao() + } +} diff --git a/profile_uart/src/main/java/no/nordicsemi/android/uart/DbHiltModule.kt b/profile_uart/src/main/java/no/nordicsemi/android/uart/DbHiltModule.kt new file mode 100644 index 00000000..327198d4 --- /dev/null +++ b/profile_uart/src/main/java/no/nordicsemi/android/uart/DbHiltModule.kt @@ -0,0 +1,26 @@ +package no.nordicsemi.android.uart + +import android.content.Context +import androidx.room.Room +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import no.nordicsemi.android.uart.db.ConfigurationsDatabase +import no.nordicsemi.android.uart.db.MIGRATION_1_2 +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +class DbHiltModule { + + @Provides + @Singleton + internal fun provideDB(@ApplicationContext context: Context): ConfigurationsDatabase { + return Room.databaseBuilder( + context, + ConfigurationsDatabase::class.java, "toolbox_uart.db" + ).addMigrations(MIGRATION_1_2).build() + } +} diff --git a/profile_uart/src/main/java/no/nordicsemi/android/uart/HiltModule.kt b/profile_uart/src/main/java/no/nordicsemi/android/uart/HiltModule.kt deleted file mode 100644 index e3a8dcc3..00000000 --- a/profile_uart/src/main/java/no/nordicsemi/android/uart/HiltModule.kt +++ /dev/null @@ -1,65 +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.uart - -import android.content.Context -import androidx.room.Room -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.android.qualifiers.ApplicationContext -import dagger.hilt.components.SingletonComponent -import no.nordicsemi.android.uart.db.ConfigurationsDao -import no.nordicsemi.android.uart.db.ConfigurationsDatabase -import no.nordicsemi.android.uart.db.MIGRATION_1_2 -import javax.inject.Singleton - -@Module -@InstallIn(SingletonComponent::class) -class HiltModule { - - @Provides - @Singleton - internal fun provideDB(@ApplicationContext context: Context): ConfigurationsDatabase { - return Room.databaseBuilder( - context, - ConfigurationsDatabase::class.java, "toolbox_uart.db" - ).addMigrations(MIGRATION_1_2).build() - } - - @Provides - @Singleton - internal fun provideDao(db: ConfigurationsDatabase): ConfigurationsDao { - return db.dao() - } - -} diff --git a/profile_uart/src/main/java/no/nordicsemi/android/uart/data/MacroSerializer.kt b/profile_uart/src/main/java/no/nordicsemi/android/uart/data/MacroSerializer.kt deleted file mode 100644 index f42e91be..00000000 --- a/profile_uart/src/main/java/no/nordicsemi/android/uart/data/MacroSerializer.kt +++ /dev/null @@ -1,56 +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.uart.data - -import androidx.datastore.core.CorruptionException -import androidx.datastore.core.Serializer -import androidx.datastore.preferences.protobuf.InvalidProtocolBufferException -import no.nordicsemi.android.MacroSettings -import java.io.InputStream -import java.io.OutputStream - -object MacroSerializer : Serializer { - override val defaultValue: MacroSettings = MacroSettings() - - override suspend fun readFrom(input: InputStream): MacroSettings { - try { - return MacroSettings.ADAPTER.decode(input) - } catch (exception: InvalidProtocolBufferException) { - throw CorruptionException("Cannot read proto.", exception) - } - } - - override suspend fun writeTo( - t: MacroSettings, - output: OutputStream - ) = MacroSettings.ADAPTER.encode(output, t) -} diff --git a/profile_uart/src/main/java/no/nordicsemi/android/uart/data/UARTManager.kt b/profile_uart/src/main/java/no/nordicsemi/android/uart/data/UARTManager.kt deleted file mode 100644 index fe0c165f..00000000 --- a/profile_uart/src/main/java/no/nordicsemi/android/uart/data/UARTManager.kt +++ /dev/null @@ -1,189 +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.uart.data - -import android.annotation.SuppressLint -import android.bluetooth.BluetoothGatt -import android.bluetooth.BluetoothGattCharacteristic -import android.bluetooth.BluetoothGattService -import android.content.Context -import android.util.Log -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.* -import no.nordicsemi.android.ble.BleManager -import no.nordicsemi.android.ble.WriteRequest -import no.nordicsemi.android.ble.common.callback.battery.BatteryLevelResponse -import no.nordicsemi.android.ble.ktx.asFlow -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.EMPTY -import no.nordicsemi.android.utils.launchWithCatch -import java.util.* - -val UART_SERVICE_UUID: UUID = UUID.fromString("6E400001-B5A3-F393-E0A9-E50E24DCCA9E") -private val UART_RX_CHARACTERISTIC_UUID = UUID.fromString("6E400002-B5A3-F393-E0A9-E50E24DCCA9E") -private val UART_TX_CHARACTERISTIC_UUID = UUID.fromString("6E400003-B5A3-F393-E0A9-E50E24DCCA9E") - -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 UARTManager( - context: Context, - private val scope: CoroutineScope, - private val logger: NordicLogger -) : BleManager(context) { - - private var batteryLevelCharacteristic: BluetoothGattCharacteristic? = null - - private var rxCharacteristic: BluetoothGattCharacteristic? = null - private var txCharacteristic: BluetoothGattCharacteristic? = null - - private var useLongWrite = true - - private val data = MutableStateFlow(UARTData()) - val dataHolder = ConnectionObserverAdapter() - - 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 - } - - private inner class UARTManagerGattCallback : BleManagerGattCallback() { - - @SuppressLint("WrongConstant") - override fun initialize() { - setNotificationCallback(txCharacteristic).asFlow() - .flowOn(Dispatchers.IO) - .map { - val text: String = it.getStringValue(0) ?: String.EMPTY - log(10, "\"$text\" received") - val messages = data.value.messages + UARTRecord(text, UARTRecordType.OUTPUT) - messages.takeLast(50) - } - .onEach { - data.value = data.value.copy(messages = it) - }.launchIn(scope) - - requestMtu(517).enqueue() - enableNotifications(txCharacteristic).enqueue() - - setNotificationCallback(batteryLevelCharacteristic).asValidResponseFlow() - .onEach { - data.value = data.value.copy(batteryLevel = it.batteryLevel) - }.launchIn(scope) - enableNotifications(batteryLevelCharacteristic).enqueue() - } - - override fun isRequiredServiceSupported(gatt: BluetoothGatt): Boolean { - val service: BluetoothGattService? = gatt.getService(UART_SERVICE_UUID) - if (service != null) { - rxCharacteristic = service.getCharacteristic(UART_RX_CHARACTERISTIC_UUID) - txCharacteristic = service.getCharacteristic(UART_TX_CHARACTERISTIC_UUID) - } - var writeRequest = false - var writeCommand = false - - rxCharacteristic?.let { - val rxProperties: Int = it.properties - writeRequest = rxProperties and BluetoothGattCharacteristic.PROPERTY_WRITE > 0 - writeCommand = - rxProperties and BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE > 0 - - // Set the WRITE REQUEST type when the characteristic supports it. - // This will allow to send long write (also if the characteristic support it). - // In case there is no WRITE REQUEST property, this manager will divide texts - // longer then MTU-3 bytes into up to MTU-3 bytes chunks. - if (!writeRequest) { - useLongWrite = false - } - } - gatt.getService(BATTERY_SERVICE_UUID)?.run { - batteryLevelCharacteristic = getCharacteristic(BATTERY_LEVEL_CHARACTERISTIC_UUID) - } - return rxCharacteristic != null && txCharacteristic != null && (writeRequest || writeCommand) - } - - override fun onServicesInvalidated() { - batteryLevelCharacteristic = null - rxCharacteristic = null - txCharacteristic = null - useLongWrite = true - } - } - - @SuppressLint("WrongConstant") - fun send(text: String) { - if (rxCharacteristic == null) return - scope.launchWithCatch { - val writeType = if (useLongWrite) { - BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT - } else { - BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE - } - val request: WriteRequest = - writeCharacteristic(rxCharacteristic, text.toByteArray(), writeType) - if (!useLongWrite) { - request.split() - } - request.suspend() - data.value = data.value.copy( - messages = data.value.messages + UARTRecord( - text, - UARTRecordType.INPUT - ) - ) - log(10, "\"$text\" sent") - } - } - - fun clearItems() { - data.value = data.value.copy(messages = emptyList()) - } - - override fun getGattCallback(): BleManagerGattCallback { - return UARTManagerGattCallback() - } -} diff --git a/profile_uart/src/main/java/no/nordicsemi/android/uart/data/UARTData.kt b/profile_uart/src/main/java/no/nordicsemi/android/uart/data/UARTServiceData.kt similarity index 78% rename from profile_uart/src/main/java/no/nordicsemi/android/uart/data/UARTData.kt rename to profile_uart/src/main/java/no/nordicsemi/android/uart/data/UARTServiceData.kt index a1e8722d..ee7b0ac8 100644 --- a/profile_uart/src/main/java/no/nordicsemi/android/uart/data/UARTData.kt +++ b/profile_uart/src/main/java/no/nordicsemi/android/uart/data/UARTServiceData.kt @@ -31,11 +31,23 @@ package no.nordicsemi.android.uart.data -internal data class UARTData( +import no.nordicsemi.android.kotlin.ble.core.data.BleGattConnectionStatus +import no.nordicsemi.android.kotlin.ble.core.data.GattConnectionStateWithStatus + +internal data class UARTServiceData( val messages: List = emptyList(), + val connectionState: GattConnectionStateWithStatus? = null, val batteryLevel: Int? = null, + val deviceName: String? = null, + val missingServices: Boolean = false ) { + val disconnectStatus = if (missingServices) { + BleGattConnectionStatus.NOT_SUPPORTED + } else { + connectionState?.status ?: BleGattConnectionStatus.UNKNOWN + } + val displayMessages = messages } diff --git a/profile_uart/src/main/java/no/nordicsemi/android/uart/db/XmlMacro.java b/profile_uart/src/main/java/no/nordicsemi/android/uart/db/XmlMacro.java index 202097bd..726b9b34 100644 --- a/profile_uart/src/main/java/no/nordicsemi/android/uart/db/XmlMacro.java +++ b/profile_uart/src/main/java/no/nordicsemi/android/uart/db/XmlMacro.java @@ -35,9 +35,8 @@ import org.simpleframework.xml.Attribute; import org.simpleframework.xml.Root; import org.simpleframework.xml.Text; -//import no.nordicsemi.android.uart.data.MacroEol; -//import no.nordicsemi.android.uart.data.MacroIcon; -import no.nordicsemi.android.uart.data.*; +import no.nordicsemi.android.uart.data.MacroEol; +import no.nordicsemi.android.uart.data.MacroIcon; @Root public class XmlMacro { diff --git a/profile_uart/src/main/java/no/nordicsemi/android/uart/repository/UARTRepository.kt b/profile_uart/src/main/java/no/nordicsemi/android/uart/repository/UARTRepository.kt index a01b1bcf..80e19783 100644 --- a/profile_uart/src/main/java/no/nordicsemi/android/uart/repository/UARTRepository.kt +++ b/profile_uart/src/main/java/no/nordicsemi/android/uart/repository/UARTRepository.kt @@ -33,28 +33,26 @@ package no.nordicsemi.android.uart.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.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.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.service.DisconnectAndStopEvent import no.nordicsemi.android.service.ServiceManager import no.nordicsemi.android.uart.data.ConfigurationDataSource import no.nordicsemi.android.uart.data.MacroEol -import no.nordicsemi.android.uart.data.UARTData import no.nordicsemi.android.uart.data.UARTMacro -import no.nordicsemi.android.uart.data.UARTManager +import no.nordicsemi.android.uart.data.UARTRecord +import no.nordicsemi.android.uart.data.UARTRecordType +import no.nordicsemi.android.uart.data.UARTServiceData import no.nordicsemi.android.uart.data.parseWithNewLineChar +import no.nordicsemi.android.ui.view.NordicLoggerFactory import no.nordicsemi.android.ui.view.StringConst -import no.nordicsemi.android.utils.EMPTY import javax.inject.Inject import javax.inject.Singleton @@ -64,75 +62,101 @@ class UARTRepository @Inject internal constructor( private val context: Context, private val serviceManager: ServiceManager, private val configurationDataSource: ConfigurationDataSource, - private val loggerFactory: NordicLoggerFactory, - private val stringConst: StringConst + private val stringConst: StringConst, + private val loggerFactory: NordicLoggerFactory ) { - private var manager: UARTManager? = null - private var logger: NordicLogger? = null + private var logger: BleLoggerAndLauncher? = null - private val _data = MutableStateFlow>(IdleResult()) + private val _data = MutableStateFlow(UARTServiceData()) internal val data = _data.asStateFlow() - val isRunning = data.map { it.isRunning() } - val hasBeenDisconnected = data.map { it.hasBeenDisconnected() } + private val _stopEvent = simpleSharedFlow() + internal val stopEvent = _stopEvent.asSharedFlow() + + private val _command = simpleSharedFlow() + internal val command = _command.asSharedFlow() + + val isRunning = data.map { it.connectionState?.state == GattConnectionState.STATE_CONNECTED } val lastConfigurationName = configurationDataSource.lastConfigurationName - fun launch(device: DiscoveredBluetoothDevice) { + 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 = loggerFactory.createNordicLogger(context, stringConst.APP_NAME, "UART", device.address) + _data.value = _data.value.copy(deviceName = device.name) serviceManager.startService(UARTService::class.java, device) } - fun start(device: DiscoveredBluetoothDevice, scope: CoroutineScope) { - val createdLogger = loggerFactory.create(stringConst.APP_NAME, "UART", device.address).also { - logger = it - } - val manager = UARTManager(context, scope, createdLogger) - this.manager = manager + fun onConnectionStateChanged(connectionState: GattConnectionStateWithStatus?) { + _data.value = _data.value.copy(connectionState = connectionState) + } - manager.dataHolder.status.onEach { - _data.value = it - }.launchIn(scope) + fun onBatteryLevelChanged(batteryLevel: Int) { + _data.value = _data.value.copy(batteryLevel = batteryLevel) + } - scope.launch { - manager.start(device) - } + fun onNewMessageReceived(value: String) { + _data.value = _data.value.copy(messages = _data.value.messages + UARTRecord(value, UARTRecordType.OUTPUT)) + } + + fun onNewMessageSent(value: String) { + _data.value = _data.value.copy(messages = _data.value.messages + UARTRecord(value, UARTRecordType.INPUT)) } fun sendText(text: String, newLineChar: MacroEol) { - manager?.send(text.parseWithNewLineChar(newLineChar)) + _command.tryEmit(text.parseWithNewLineChar(newLineChar)) } fun runMacro(macro: UARTMacro) { - val command = macro.command?.parseWithNewLineChar(macro.newLineChar) - manager?.send(command ?: String.EMPTY) + if (macro.command == null) { + return + } + _command.tryEmit(macro.command.parseWithNewLineChar(macro.newLineChar)) } fun clearItems() { - manager?.clearItems() + _data.value = _data.value.copy(messages = emptyList()) } fun openLogger() { - NordicLogger.launch(context, logger) + logger?.launch() + } + + fun log(priority: Int, message: String) { + logger?.log(priority, message) + } + + fun onMissingServices() { + _data.value = _data.value.copy(missingServices = true) + _stopEvent.tryEmit(DisconnectAndStopEvent()) } suspend fun saveConfigurationName(name: String) { configurationDataSource.saveConfigurationName(name) } - private suspend fun UARTManager.start(device: DiscoveredBluetoothDevice) { - try { - connect(device.device) - .useAutoConnect(false) - .retry(3, 100) - .suspend() - } catch (e: Exception) { - e.printStackTrace() - } + fun disconnect() { + _stopEvent.tryEmit(DisconnectAndStopEvent()) } - fun release() { - manager?.disconnect()?.enqueue() - manager = null + private fun clean() { logger = null + _data.value = UARTServiceData() } } diff --git a/profile_uart/src/main/java/no/nordicsemi/android/uart/repository/UARTService.kt b/profile_uart/src/main/java/no/nordicsemi/android/uart/repository/UARTService.kt index da4e4283..d1496fee 100644 --- a/profile_uart/src/main/java/no/nordicsemi/android/uart/repository/UARTService.kt +++ b/profile_uart/src/main/java/no/nordicsemi/android/uart/repository/UARTService.kt @@ -31,33 +31,135 @@ package no.nordicsemi.android.uart.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.common.core.DataByteArray +import no.nordicsemi.android.kotlin.ble.client.main.callback.ClientBleGatt +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.BleGattConnectionStatus +import no.nordicsemi.android.kotlin.ble.core.data.BleGattProperty +import no.nordicsemi.android.kotlin.ble.core.data.BleWriteType +import no.nordicsemi.android.kotlin.ble.core.data.GattConnectionState +import no.nordicsemi.android.kotlin.ble.core.data.Mtu +import no.nordicsemi.android.kotlin.ble.profile.battery.BatteryLevelParser import no.nordicsemi.android.service.DEVICE_DATA import no.nordicsemi.android.service.NotificationService +import java.util.* import javax.inject.Inject +val UART_SERVICE_UUID: UUID = UUID.fromString("6E400001-B5A3-F393-E0A9-E50E24DCCA9E") +internal val UART_RX_CHARACTERISTIC_UUID = UUID.fromString("6E400002-B5A3-F393-E0A9-E50E24DCCA9E") +internal val UART_TX_CHARACTERISTIC_UUID = UUID.fromString("6E400003-B5A3-F393-E0A9-E50E24DCCA9E") + +internal val BATTERY_SERVICE_UUID = UUID.fromString("0000180F-0000-1000-8000-00805f9b34fb") +internal val BATTERY_LEVEL_CHARACTERISTIC_UUID = UUID.fromString("00002A19-0000-1000-8000-00805f9b34fb") + +@SuppressLint("MissingPermission") @AndroidEntryPoint internal class UARTService : NotificationService() { @Inject lateinit var repository: UARTRepository + private lateinit var client: ClientBleGatt + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { super.onStartCommand(intent, flags, startId) - val device = intent!!.getParcelableExtra(DEVICE_DATA)!! + repository.setServiceRunning(true) - repository.start(device, lifecycleScope) + val device = intent!!.getParcelableExtra(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@UARTService, device, logger = { p, s -> repository.log(p, s) }) + + if (!client.isConnected) { + return@launch + } + + client.requestMtu(Mtu.max) + + try { + val services = client.discoverServices() + configureGatt(services) + } catch (e: Exception) { + repository.onMissingServices() + } + + client.connectionStateWithStatus + .filterNotNull() + .onEach { repository.onConnectionStateChanged(it) } + .onEach { stopIfDisconnected(it.state, it.status) } + .filterNotNull() + .launchIn(lifecycleScope) + } + + private suspend fun configureGatt(services: ClientBleGattServices) { + val uartService = services.findService(UART_SERVICE_UUID)!! + val rxCharacteristic = uartService.findCharacteristic(UART_RX_CHARACTERISTIC_UUID)!! + val txCharacteristic = uartService.findCharacteristic(UART_TX_CHARACTERISTIC_UUID)!! + + val batteryService = services.findService(BATTERY_SERVICE_UUID) + + batteryService?.findCharacteristic(BATTERY_LEVEL_CHARACTERISTIC_UUID)?.getNotifications() + ?.mapNotNull { BatteryLevelParser.parse(it) } + ?.onEach { repository.onBatteryLevelChanged(it) } + ?.catch { it.printStackTrace() } + ?.launchIn(lifecycleScope) + + txCharacteristic.getNotifications() + .onEach { repository.onNewMessageReceived(String(it.value)) } + .onEach { repository.log(10, "Received: ${String(it.value)}") } + .catch { it.printStackTrace() } + .launchIn(lifecycleScope) + + repository.command + .onEach { rxCharacteristic.splitWrite(DataByteArray.from(it), getWriteType(rxCharacteristic)) } + .onEach { repository.onNewMessageSent(it) } + .onEach { repository.log(10, "Sent: $it") } + .launchIn(lifecycleScope) + } + + private fun getWriteType(characteristic: ClientBleGattCharacteristic): BleWriteType { + return if (characteristic.properties.contains(BleGattProperty.PROPERTY_WRITE)) { + BleWriteType.DEFAULT + } else { + BleWriteType.NO_RESPONSE + } + } + + private fun stopIfDisconnected(connectionState: GattConnectionState, connectionStatus: BleGattConnectionStatus) { + if (connectionState == GattConnectionState.STATE_DISCONNECTED && !connectionStatus.isLinkLoss) { + repository.disconnect() + stopSelf() + } + } + + private fun disconnect() { + client.disconnect() + } + + override fun onDestroy() { + super.onDestroy() + repository.setServiceRunning(false) + } } diff --git a/profile_uart/src/main/java/no/nordicsemi/android/uart/view/OutputSection.kt b/profile_uart/src/main/java/no/nordicsemi/android/uart/view/OutputSection.kt index a8fb21d2..42145413 100644 --- a/profile_uart/src/main/java/no/nordicsemi/android/uart/view/OutputSection.kt +++ b/profile_uart/src/main/java/no/nordicsemi/android/uart/view/OutputSection.kt @@ -53,8 +53,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -66,7 +64,7 @@ import no.nordicsemi.android.uart.data.UARTRecord import no.nordicsemi.android.uart.data.UARTRecordType import no.nordicsemi.android.ui.view.SectionTitle import java.text.SimpleDateFormat -import java.util.* +import java.util.Locale @Composable internal fun OutputSection(records: List, onEvent: (UARTViewEvent) -> Unit) { @@ -90,9 +88,6 @@ internal fun OutputSection(records: List, onEvent: (UARTViewEvent) - Spacer(modifier = Modifier.size(16.dp)) val scrollState = rememberLazyListState() - val scrollDown = remember { - derivedStateOf { scrollState.isScrolledToTheEnd() } - } LazyColumn( modifier = Modifier.fillMaxWidth(), @@ -114,8 +109,8 @@ internal fun OutputSection(records: List, onEvent: (UARTViewEvent) - } } - LaunchedEffect(records, scrollDown.value) { - if (!scrollDown.value || records.isEmpty()) { + LaunchedEffect(records) { + if (scrollState.isScrolledToTheEnd() || records.isEmpty()) { return@LaunchedEffect } launch { diff --git a/profile_uart/src/main/java/no/nordicsemi/android/uart/view/UARTContentView.kt b/profile_uart/src/main/java/no/nordicsemi/android/uart/view/UARTContentView.kt index 5527a910..c918480c 100644 --- a/profile_uart/src/main/java/no/nordicsemi/android/uart/view/UARTContentView.kt +++ b/profile_uart/src/main/java/no/nordicsemi/android/uart/view/UARTContentView.kt @@ -42,12 +42,12 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import no.nordicsemi.android.uart.data.UARTData +import no.nordicsemi.android.uart.data.UARTServiceData import no.nordicsemi.android.ui.view.ScreenSection @Composable internal fun UARTContentView( - state: UARTData, + state: UARTServiceData, onEvent: (UARTViewEvent) -> Unit ) { Column( diff --git a/profile_uart/src/main/java/no/nordicsemi/android/uart/view/UARTScreen.kt b/profile_uart/src/main/java/no/nordicsemi/android/uart/view/UARTScreen.kt index de5920d4..62a3d5fe 100644 --- a/profile_uart/src/main/java/no/nordicsemi/android/uart/view/UARTScreen.kt +++ b/profile_uart/src/main/java/no/nordicsemi/android/uart/view/UARTScreen.kt @@ -35,67 +35,57 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -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.runtime.remember import androidx.compose.runtime.rememberCoroutineScope 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 com.google.accompanist.pager.ExperimentalPagerApi import no.nordicsemi.android.common.theme.view.PagerView import no.nordicsemi.android.common.theme.view.PagerViewEntity import no.nordicsemi.android.common.theme.view.PagerViewItem -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.kotlin.ble.core.data.GattConnectionState import no.nordicsemi.android.uart.R import no.nordicsemi.android.uart.viewmodel.UARTViewModel -import no.nordicsemi.android.ui.view.BackIconAppBar -import no.nordicsemi.android.ui.view.LoggerIconAppBar import no.nordicsemi.android.ui.view.NavigateUpButton +import no.nordicsemi.android.ui.view.ProfileAppBar +import no.nordicsemi.android.kotlin.ble.ui.scanner.view.DeviceConnectingView +import no.nordicsemi.android.kotlin.ble.ui.scanner.view.DeviceDisconnectedView -@OptIn(ExperimentalMaterial3Api::class) @Composable fun UARTScreen() { val viewModel: UARTViewModel = hiltViewModel() - val state by viewModel.state.collectAsStateWithLifecycle() + val state = viewModel.state.collectAsState().value val navigateUp = { viewModel.onEvent(NavigateUp) } Scaffold( - topBar = { AppBar(state, navigateUp) { viewModel.onEvent(it) } } + topBar = { + ProfileAppBar( + deviceName = state.uartManagerState.deviceName, + connectionState = state.uartManagerState.connectionState, + title = R.string.uart_title, + navigateUp = navigateUp, + disconnect = { viewModel.onEvent(DisconnectEvent) }, + openLogger = { viewModel.onEvent(OpenLogger) } + ) + } ) { Column( modifier = Modifier.padding(it) ) { - when (val uartState = state.uartManagerState) { - NoDeviceState -> PaddingBox { DeviceConnectingView() } - is WorkingState -> when (uartState.result) { - is IdleResult, - is ConnectingResult -> PaddingBox { DeviceConnectingView { NavigateUpButton(navigateUp) } } - is ConnectedResult -> PaddingBox { DeviceConnectingView { NavigateUpButton(navigateUp) } } - is DisconnectedResult -> PaddingBox { DeviceDisconnectedView(Reason.USER) { NavigateUpButton(navigateUp) } } - is LinkLossResult -> PaddingBox { DeviceDisconnectedView(Reason.LINK_LOSS) { NavigateUpButton(navigateUp) } } - is MissingServiceResult -> PaddingBox { DeviceDisconnectedView(Reason.MISSING_SERVICE) { NavigateUpButton(navigateUp) } } - is UnknownErrorResult -> PaddingBox { DeviceDisconnectedView(Reason.UNKNOWN) { NavigateUpButton(navigateUp) } } - is SuccessResult -> SuccessScreen() + when (state.uartManagerState.connectionState?.state) { + null, + GattConnectionState.STATE_CONNECTING -> PaddingBox { DeviceConnectingView { NavigateUpButton(navigateUp) } } + GattConnectionState.STATE_DISCONNECTED, + GattConnectionState.STATE_DISCONNECTING -> PaddingBox { + DeviceDisconnectedView(state.uartManagerState.disconnectStatus) { NavigateUpButton(navigateUp) } } + GattConnectionState.STATE_CONNECTED -> SuccessScreen() } } } @@ -108,32 +98,18 @@ private fun PaddingBox(content: @Composable () -> Unit) { } } -@Composable -private fun AppBar(state: UARTViewState, navigateUp: () -> Unit, onEvent: (UARTViewEvent) -> Unit) { - val toolbarName = (state.uartManagerState as? WorkingState)?.let { - (it.result as? DeviceHolder)?.deviceName() - } - - if (toolbarName == null) { - BackIconAppBar(stringResource(id = R.string.uart_title), navigateUp) - } else { - LoggerIconAppBar(toolbarName, navigateUp, { onEvent(DisconnectEvent) }) { - onEvent(OpenLogger) - } - } -} - -@OptIn(ExperimentalPagerApi::class) @Composable private fun SuccessScreen() { val input = stringResource(id = R.string.uart_input) val macros = stringResource(id = R.string.uart_macros) - val viewEntity = remember { PagerViewEntity( - listOf( - PagerViewItem(input) { KeyboardView() }, - PagerViewItem(macros) { MacroView() } + val viewEntity = remember { + PagerViewEntity( + listOf( + PagerViewItem(input) { KeyboardView() }, + PagerViewItem(macros) { MacroView() } + ) ) - ) } + } PagerView( viewEntity = viewEntity, modifier = Modifier.fillMaxSize(), @@ -146,28 +122,13 @@ private fun SuccessScreen() { @Composable private fun KeyboardView() { val viewModel: UARTViewModel = hiltViewModel() - val state by viewModel.state.collectAsStateWithLifecycle() - (state.uartManagerState as? WorkingState)?.let { - (it.result as? SuccessResult)?.let { - UARTContentView(it.data) { viewModel.onEvent(it) } - } - } + val state = viewModel.state.collectAsState().value + UARTContentView(state.uartManagerState) { viewModel.onEvent(it) } } @Composable private fun MacroView() { val viewModel: UARTViewModel = hiltViewModel() - val state by viewModel.state.collectAsStateWithLifecycle() - (state.uartManagerState as? WorkingState)?.let { - (it.result as? SuccessResult)?.let { - MacroSection(state) { viewModel.onEvent(it) } - } - } -} - -@Composable -fun Scroll(content: @Composable () -> Unit) { - Column(modifier = Modifier.verticalScroll(rememberScrollState())) { - content() - } + val state = viewModel.state.collectAsState().value + MacroSection(state) { viewModel.onEvent(it) } } diff --git a/profile_uart/src/main/java/no/nordicsemi/android/uart/view/UARTState.kt b/profile_uart/src/main/java/no/nordicsemi/android/uart/view/UARTState.kt index 0cd0bfa1..b3f1cc70 100644 --- a/profile_uart/src/main/java/no/nordicsemi/android/uart/view/UARTState.kt +++ b/profile_uart/src/main/java/no/nordicsemi/android/uart/view/UARTState.kt @@ -31,17 +31,16 @@ package no.nordicsemi.android.uart.view -import no.nordicsemi.android.service.BleManagerResult import no.nordicsemi.android.uart.data.UARTConfiguration -import no.nordicsemi.android.uart.data.UARTData import no.nordicsemi.android.uart.data.UARTMacro +import no.nordicsemi.android.uart.data.UARTServiceData internal data class UARTViewState( val editedPosition: Int? = null, val selectedConfigurationName: String? = null, val isConfigurationEdited: Boolean = false, val configurations: List = emptyList(), - val uartManagerState: HTSManagerState = NoDeviceState, + val uartManagerState: UARTServiceData = UARTServiceData(), val isInputVisible: Boolean = true ) { val showEditDialog: Boolean = editedPosition != null @@ -54,11 +53,3 @@ internal data class UARTViewState( } } } - -internal sealed class HTSManagerState - -internal data class WorkingState( - val result: BleManagerResult -) : HTSManagerState() - -internal object NoDeviceState : HTSManagerState() diff --git a/profile_uart/src/main/java/no/nordicsemi/android/uart/viewmodel/UARTViewModel.kt b/profile_uart/src/main/java/no/nordicsemi/android/uart/viewmodel/UARTViewModel.kt index 7d736e37..c528ef9d 100644 --- a/profile_uart/src/main/java/no/nordicsemi/android/uart/viewmodel/UARTViewModel.kt +++ b/profile_uart/src/main/java/no/nordicsemi/android/uart/viewmodel/UARTViewModel.kt @@ -51,16 +51,15 @@ import no.nordicsemi.android.analytics.UARTMode import no.nordicsemi.android.analytics.UARTSendAnalyticsEvent 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.service.IdleResult +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 no.nordicsemi.android.uart.data.MacroEol import no.nordicsemi.android.uart.data.UARTConfiguration import no.nordicsemi.android.uart.data.UARTMacro import no.nordicsemi.android.uart.data.UARTPersistentDataSource -import no.nordicsemi.android.uart.data.UART_SERVICE_UUID import no.nordicsemi.android.uart.repository.UARTRepository +import no.nordicsemi.android.uart.repository.UART_SERVICE_UUID import no.nordicsemi.android.uart.view.ClearOutputItems import no.nordicsemi.android.uart.view.DisconnectEvent import no.nordicsemi.android.uart.view.MacroInputSwitchClick @@ -78,7 +77,7 @@ import no.nordicsemi.android.uart.view.OnRunMacro import no.nordicsemi.android.uart.view.OpenLogger import no.nordicsemi.android.uart.view.UARTViewEvent import no.nordicsemi.android.uart.view.UARTViewState -import no.nordicsemi.android.uart.view.WorkingState +import no.nordicsemi.android.ui.view.NordicLoggerFactory import javax.inject.Inject @HiltViewModel @@ -86,13 +85,16 @@ internal class UARTViewModel @Inject constructor( private val repository: UARTRepository, private val navigationManager: Navigator, private val dataSource: UARTPersistentDataSource, - private val analytics: AppAnalytics + private val analytics: AppAnalytics, + private val loggerFactory: NordicLoggerFactory ) : ViewModel() { private val _state = MutableStateFlow(UARTViewState()) val state = _state.asStateFlow() init { + repository.setOnScreen(true) + viewModelScope.launch { if (repository.isRunning.firstOrNull() == false) { requestBluetoothDevice() @@ -100,12 +102,9 @@ internal class UARTViewModel @Inject constructor( } repository.data.onEach { - if (it is IdleResult) { - return@onEach - } - _state.value = _state.value.copy(uartManagerState = WorkingState(it)) + _state.value = _state.value.copy(uartManagerState = it) - (it as? ConnectedResult)?.let { + if (it.connectionState?.state == GattConnectionState.STATE_CONNECTED) { analytics.logEvent(ProfileConnectedEvent(Profile.UART)) } }.launchIn(viewModelScope) @@ -129,13 +128,17 @@ internal class UARTViewModel @Inject constructor( .launchIn(viewModelScope) } - private fun handleResult(result: NavigationResult) { + internal fun handleResult(result: NavigationResult) { 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: UARTViewEvent) { when (event) { is OnCreateMacro -> addNewMacro(event.macro) @@ -238,7 +241,12 @@ internal class UARTViewModel @Inject constructor( } private fun disconnect() { - repository.release() + repository.disconnect() navigationManager.navigateUp() } + + override fun onCleared() { + super.onCleared() + repository.setOnScreen(false) + } } diff --git a/profile_uart/src/main/proto/macro.proto b/profile_uart/src/main/proto/macro.proto deleted file mode 100644 index 6265d4b5..00000000 --- a/profile_uart/src/main/proto/macro.proto +++ /dev/null @@ -1,18 +0,0 @@ -syntax = "proto3"; - -option java_package = "no.nordicsemi.android"; -option java_multiple_files = true; - -message Macro { - string name = 1; - enum NewLineType { - LF = 0; - LF_CR = 1; - CR = 2; - } - NewLineType newLineType = 2; -} - -message MacroSettings { - repeated Macro macros = 1; -} diff --git a/profile_uart/src/test/java/no/nordicsemi/android/gls/NordicLoggerFactoryTestModule.kt b/profile_uart/src/test/java/no/nordicsemi/android/gls/NordicLoggerFactoryTestModule.kt new file mode 100644 index 00000000..8799220f --- /dev/null +++ b/profile_uart/src/test/java/no/nordicsemi/android/gls/NordicLoggerFactoryTestModule.kt @@ -0,0 +1,40 @@ +package no.nordicsemi.android.gls + +import android.content.Context +import dagger.Module +import dagger.Provides +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn +import no.nordicsemi.android.common.logger.BleLoggerAndLauncher +import no.nordicsemi.android.ui.view.NordicLoggerFactory +import no.nordicsemi.android.ui.view.NordicLoggerFactoryHiltModule + +@Module +@TestInstallIn( + components = [SingletonComponent::class], + replaces = [NordicLoggerFactoryHiltModule::class] +) +class NordicLoggerFactoryTestModule { + + @Provides + fun createLogger(): NordicLoggerFactory { + return object : NordicLoggerFactory { + override fun createNordicLogger( + context: Context, + profile: String?, + key: String, + name: String?, + ): BleLoggerAndLauncher { + return object : BleLoggerAndLauncher { + override fun launch() { + + } + + override fun log(priority: Int, log: String) { + println(log) + } + } + } + } + } +} diff --git a/profile_uart/src/test/java/no/nordicsemi/android/gls/ServiceManagerTestModule.kt b/profile_uart/src/test/java/no/nordicsemi/android/gls/ServiceManagerTestModule.kt new file mode 100644 index 00000000..70b59201 --- /dev/null +++ b/profile_uart/src/test/java/no/nordicsemi/android/gls/ServiceManagerTestModule.kt @@ -0,0 +1,57 @@ +package no.nordicsemi.android.gls + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import dagger.Module +import dagger.Provides +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn +import no.nordicsemi.android.kotlin.ble.core.MockServerDevice +import no.nordicsemi.android.kotlin.ble.core.ServerDevice +import no.nordicsemi.android.service.DEVICE_DATA +import no.nordicsemi.android.service.ServiceManager +import no.nordicsemi.android.service.ServiceManagerHiltModule +import no.nordicsemi.android.uart.repository.UARTService +import org.robolectric.Robolectric +import org.robolectric.android.controller.ServiceController +import javax.inject.Singleton + +@Module +@TestInstallIn( + components = [SingletonComponent::class], + replaces = [ServiceManagerHiltModule::class] +) +class ServiceManagerTestModule { + + private val componentName = ComponentName("org.robolectric", UARTService::class.java.name) + + @Provides + internal fun provideDevice(): MockServerDevice { + return MockServerDevice( + name = "GLS Server", + address = "55:44:33:22:11" + ) + } + + @Provides + internal fun provideServiceController( + @ApplicationContext context: Context, + device: MockServerDevice + ): ServiceController { + return Robolectric.buildService(UARTService::class.java, Intent(context, UARTService::class.java).apply { + putExtra(DEVICE_DATA, device) + }) + } + + @Provides + @Singleton + internal fun provideServiceManager(controller: ServiceController): ServiceManager { + return object : ServiceManager { + override fun startService(service: Class, device: ServerDevice) { + controller.create().startCommand(3, 4).get() + } + } + } +} diff --git a/profile_uart/src/test/java/no/nordicsemi/android/gls/TestDbHiltModule.kt b/profile_uart/src/test/java/no/nordicsemi/android/gls/TestDbHiltModule.kt new file mode 100644 index 00000000..3dab6188 --- /dev/null +++ b/profile_uart/src/test/java/no/nordicsemi/android/gls/TestDbHiltModule.kt @@ -0,0 +1,29 @@ +package no.nordicsemi.android.gls + +import android.content.Context +import androidx.room.Room +import dagger.Module +import dagger.Provides +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn +import no.nordicsemi.android.uart.DbHiltModule +import no.nordicsemi.android.uart.db.ConfigurationsDatabase +import no.nordicsemi.android.uart.db.MIGRATION_1_2 +import javax.inject.Singleton + +@Module +@TestInstallIn( + components = [SingletonComponent::class], + replaces = [DbHiltModule::class] +) +class TestDbHiltModule { + @Provides + @Singleton + internal fun provideDB(@ApplicationContext context: Context): ConfigurationsDatabase { + return Room.inMemoryDatabaseBuilder( + context, + ConfigurationsDatabase::class.java + ).addMigrations(MIGRATION_1_2).build() + } +} \ No newline at end of file diff --git a/profile_uart/src/test/java/no/nordicsemi/android/gls/TestHiltModule.kt b/profile_uart/src/test/java/no/nordicsemi/android/gls/TestHiltModule.kt new file mode 100644 index 00000000..36e316cb --- /dev/null +++ b/profile_uart/src/test/java/no/nordicsemi/android/gls/TestHiltModule.kt @@ -0,0 +1,13 @@ +package no.nordicsemi.android.gls + +import dagger.Module +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn + +//@Module +//@TestInstallIn( +// components = [SingletonComponent::class], +// replaces = [AnalyticsModule::class] +//) +//class TestHiltModule { +//} diff --git a/profile_uart/src/test/java/no/nordicsemi/android/gls/UARTViewModelTest.kt b/profile_uart/src/test/java/no/nordicsemi/android/gls/UARTViewModelTest.kt new file mode 100644 index 00000000..211d8b43 --- /dev/null +++ b/profile_uart/src/test/java/no/nordicsemi/android/gls/UARTViewModelTest.kt @@ -0,0 +1,207 @@ +package no.nordicsemi.android.gls + +import android.content.Context +import androidx.test.rule.ServiceTestRule +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.HiltTestApplication +import dagger.hilt.android.testing.UninstallModules +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.impl.annotations.RelaxedMockK +import io.mockk.junit4.MockKRule +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.core.ApplicationScope +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.navigation.di.NavigationModule +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.uart.UartServer +import no.nordicsemi.android.uart.data.UARTPersistentDataSource +import no.nordicsemi.android.uart.repository.UARTRepository +import no.nordicsemi.android.uart.view.DisconnectEvent +import no.nordicsemi.android.uart.viewmodel.UARTViewModel +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 org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import javax.inject.Inject + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@HiltAndroidTest +@Config(application = HiltTestApplication::class) +@UninstallModules(NavigationModule::class) +@RunWith(RobolectricTestRunner::class) +internal class UARTViewModelTest { + + @get:Rule + val mockkRule = MockKRule(this) + + @get:Rule + val serviceRule = ServiceTestRule() + + @get:Rule + var hiltRule = HiltAndroidRule(this) + + @BindValue + @JvmField + val analyticsService: Navigator = mockk(relaxed = true) + + @RelaxedMockK + lateinit var analytics: AppAnalytics + + @MockK + lateinit var stringConst: StringConst + + @RelaxedMockK + lateinit var context: Context + + @RelaxedMockK + lateinit var logger: BleLoggerAndLauncher + + @Inject + lateinit var repository: UARTRepository + + @Inject + lateinit var dataSource: UARTPersistentDataSource + + lateinit var viewModel: UARTViewModel + + lateinit var uartServer: UartServer + + @Inject + lateinit var device: MockServerDevice + + @Before + fun setUp() { + hiltRule.inject() + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + @After + fun release() { + Dispatchers.resetMain() + } + + @Before + fun before() { + viewModel = UARTViewModel(repository, mockk(relaxed = true), dataSource, mockk(relaxed = true), object : + NordicLoggerFactory { + override fun createNordicLogger( + context: Context, + profile: String?, + key: String, + name: String?, + ): BleLoggerAndLauncher { + return logger + } + + }) + runBlocking { + mockkStatic("no.nordicsemi.android.common.core.ApplicationScopeKt") + every { ApplicationScope } returns CoroutineScope(UnconfinedTestDispatcher()) + every { stringConst.APP_NAME } returns "Test" + + uartServer = UartServer(CoroutineScope(UnconfinedTestDispatcher())) + uartServer.start(spyk(), device) + } + } + + @Before + fun prepareLogger() { + mockkObject(DefaultBleLogger.Companion) + every { DefaultBleLogger.create(any(), any(), any(), any()) } returns mockk() + } + + @Test + fun `when connected should return state connected`() = runTest { + val connectedState = GattConnectionStateWithStatus( + GattConnectionState.STATE_CONNECTED, + BleGattConnectionStatus.SUCCESS + ) + viewModel.handleResult(NavigationResult.Success(device)) + + advanceUntilIdle() + + assertEquals(connectedState, viewModel.state.value.uartManagerState.connectionState) + } + + @Test + fun `when disconnected should return state connected`() = runTest { + val disconnectedState = GattConnectionStateWithStatus( + GattConnectionState.STATE_DISCONNECTED, + BleGattConnectionStatus.SUCCESS + ) + viewModel.handleResult(NavigationResult.Success(device)) + viewModel.onEvent(DisconnectEvent) + + advanceUntilIdle() + + assertEquals(disconnectedState, viewModel.state.value.uartManagerState.connectionState) + } +// +// @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) +// } +} diff --git a/renovate.json b/renovate.json new file mode 100644 index 00000000..352aedb7 --- /dev/null +++ b/renovate.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:base", + "group:all", + ":dependencyDashboard", + "schedule:daily" + ], + "commitMessageExtra": "{{{currentValue}}} to {{#if isPinDigest}}{{{newDigestShort}}}{{else}}{{#if isMajor}}{{prettyNewMajor}}{{else}}{{#if isSingleVersion}}{{prettyNewVersion}}{{else}}{{#if newValue}}{{{newValue}}}{{else}}{{{newDigestShort}}}{{/if}}{{/if}}{{/if}}{{/if}}", + "packageRules": [ + { + "matchPackagePatterns": [ + "androidx.compose.compiler:compiler" + ], + "groupName": "kotlin" + }, + { + "matchPackagePatterns": [ + "org.jetbrains.kotlin.*" + ], + "groupName": "kotlin" + }, + { + "matchPackagePatterns": [ + "com.google.devtools.ksp" + ], + "groupName": "kotlin" + } + ] +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 9178aeed..64d771a4 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -45,11 +45,12 @@ dependencyResolutionManagement { google() mavenCentral() gradlePluginPortal() + maven(url = "https://androidx.dev/storage/compose-compiler/repository/") maven(url = "https://jitpack.io") } versionCatalogs { create("libs") { - from("no.nordicsemi.android.gradle:version-catalog:1.2.6") + from("no.nordicsemi.android.gradle:version-catalog:1.8.2") } } } @@ -74,10 +75,10 @@ include(":lib_service") include(":lib_ui") include(":lib_utils") -//if (file("../Android-Common-Libraries").exists()) { -// includeBuild("../Android-Common-Libraries") -//} -// -//if (file('../Android-BLE-Library').exists()) { -// includeBuild('../Android-BLE-Library') -//} +if (file("../Android-Common-Libraries").exists()) { + includeBuild("../Android-Common-Libraries") +} + +if (file("../Kotlin-BLE-Library").exists()) { + includeBuild("../Kotlin-BLE-Library") +}