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 index c260f292..3ecb8d4d 100644 --- 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 @@ -60,8 +60,7 @@ private val GF_CHARACTERISTIC = UUID.fromString("00002A51-0000-1000-8000-00805f9 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") +private val BATTERY_LEVEL_CHARACTERISTIC_UUID = UUID.fromString("00002A19-0000-1000-8000-00805f9b34fb") internal class GLSManager( context: Context, @@ -74,8 +73,8 @@ internal class GLSManager( private var glucoseMeasurementContextCharacteristic: BluetoothGattCharacteristic? = null private var recordAccessControlPointCharacteristic: BluetoothGattCharacteristic? = null - private val data = MutableStateFlow(GLSData()) - val dataHolder = ConnectionObserverAdapter() + private val data = MutableStateFlow(GLSServiceData()) + val dataHolder = ConnectionObserverAdapter() init { connectionObserver = dataHolder 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 91% 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..c8637c27 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,11 @@ package no.nordicsemi.android.gls.data -internal data class GLSData( +import no.nordicsemi.android.kotlin.ble.core.data.GattConnectionState + +internal data class GLSServiceData( val records: List = emptyList(), val batteryLevel: Int? = null, + val connectionState: GattConnectionState? = null, val requestStatus: RequestStatus = RequestStatus.IDLE ) 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..958e8100 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,7 +57,7 @@ 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.GLSServiceData import no.nordicsemi.android.gls.data.GLSRecord import no.nordicsemi.android.gls.data.RequestStatus import no.nordicsemi.android.gls.data.WorkingMode @@ -67,7 +67,7 @@ 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,7 +133,7 @@ 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") 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 index c91183e1..3acd3eeb 100644 --- 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 @@ -31,10 +31,19 @@ package no.nordicsemi.android.gls.main.view -import no.nordicsemi.android.gls.data.GLSData -import no.nordicsemi.android.service.BleManagerResult +import no.nordicsemi.android.gls.data.GLSServiceData +import no.nordicsemi.android.gls.data.RequestStatus -internal sealed class GLSViewState +internal data class GLSViewState( + val glsServiceData: GLSServiceData = GLSServiceData(), + val deviceName: String? = null +) { -internal data class WorkingState(val result: BleManagerResult) : GLSViewState() -internal object NoDeviceState : GLSViewState() + fun copyAndClear(): GLSViewState { + return copy(glsServiceData = glsServiceData.copy(records = emptyList(), requestStatus = RequestStatus.IDLE)) + } + + fun copyWithNewRequestStatus(requestStatus: RequestStatus): GLSViewState { + return copy(glsServiceData = glsServiceData.copy(requestStatus = requestStatus)) + } +} 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 7513f8c9..fa188806 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,43 +31,77 @@ package no.nordicsemi.android.gls.main.viewmodel +import android.annotation.SuppressLint +import android.bluetooth.BluetoothGattCharacteristic +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.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.ble.ktx.suspend import no.nordicsemi.android.common.navigation.NavigationResult import no.nordicsemi.android.common.navigation.Navigator import no.nordicsemi.android.gls.GlsDetailsDestinationId import no.nordicsemi.android.gls.data.GLS_SERVICE_UUID +import no.nordicsemi.android.gls.data.RequestStatus 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.kotlin.ble.core.ServerDevice +import no.nordicsemi.android.kotlin.ble.core.client.callback.BleGattClient +import no.nordicsemi.android.kotlin.ble.core.client.service.BleGattCharacteristic +import no.nordicsemi.android.kotlin.ble.core.client.service.BleGattServices +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.gls.RecordAccessControlPointInputParser +import no.nordicsemi.android.kotlin.ble.profile.gls.data.RecordAccessControlPointData +import no.nordicsemi.android.kotlin.ble.profile.hrs.HRSDataParser import no.nordicsemi.android.service.ConnectedResult import no.nordicsemi.android.toolbox.scanner.ScannerDestinationId +import no.nordicsemi.android.utils.launchWithCatch +import java.util.* import javax.inject.Inject +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") + +@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 ) : ViewModel() { - private val _state = MutableStateFlow(NoDeviceState) + private lateinit var client: BleGattClient + + private lateinit var glucoseMeasurementCharacteristic: BleGattCharacteristic + private lateinit var glucoseMeasurementContextCharacteristic: BleGattCharacteristic + private lateinit var recordAccessControlPointCharacteristic: BleGattCharacteristic + + private val _state = MutableStateFlow(GLSViewState()) val state = _state.asStateFlow() init { @@ -81,7 +115,7 @@ internal class GLSViewModel @Inject constructor( private fun handleResult(result: NavigationResult) { when (result) { is NavigationResult.Cancelled -> navigationManager.navigateUp() - is NavigationResult.Success -> connectDevice(result.value) + is NavigationResult.Success -> onDeviceSelected(result.value) } } @@ -95,6 +129,11 @@ internal class GLSViewModel @Inject constructor( } } + private fun onDeviceSelected(device: ServerDevice) { + _state.value = _state.value.copy(deviceName = device.name) + startGattClient(device) + } + private fun connectDevice(device: ServerDevice) { repository.downloadData(viewModelScope, device).onEach { _state.value = WorkingState(it) @@ -104,4 +143,66 @@ internal class GLSViewModel @Inject constructor( } }.launchIn(viewModelScope) } + + private fun startGattClient(blinkyDevice: ServerDevice) = viewModelScope.launch { + client = blinkyDevice.connect(context) + + client.connectionState + .onEach { _state.value = _state.value.copy() } + .filterNotNull() + .onEach { stopIfDisconnected(it) } + .launchIn(viewModelScope) + + client.services + .filterNotNull() + .onEach { configureGatt(it) } + .launchIn(viewModelScope) + } + + private suspend fun configureGatt(services: BleGattServices) { + val glsService = services.findService(GLS_SERVICE_UUID)!! + glucoseMeasurementCharacteristic = glsService.findCharacteristic(GM_CHARACTERISTIC)!! + glucoseMeasurementContextCharacteristic = glsService.findCharacteristic(GM_CONTEXT_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 { repository.onBatteryLevelChanged(it) } + .launchIn(viewModelScope) + + htsMeasurementCharacteristic.getNotifications() + .mapNotNull { HRSDataParser.parse(it) } + .onEach { repository.onHRSDataChanged(it) } + .launchIn(viewModelScope) + } + + private fun stopIfDisconnected(connectionState: GattConnectionState) { + if (connectionState == GattConnectionState.STATE_DISCONNECTED) { + stopSelf() + } + } + + private fun clear() { + _state.value = _state.value.copyAndClear() + } + + suspend fun requestLastRecord() { + recordAccessControlPointCharacteristic.write(RecordAccessControlPointInputParser.reportLastStoredRecord().value) + clear() + _state.value = _state.value.copyWithNewRequestStatus(RequestStatus.PENDING) + } + + suspend fun requestFirstRecord() { + recordAccessControlPointCharacteristic.write(RecordAccessControlPointInputParser.reportFirstStoredRecord().value) + clear() + _state.value = _state.value.copyWithNewRequestStatus(RequestStatus.PENDING) + } + + suspend fun requestAllRecords() { + recordAccessControlPointCharacteristic.write(RecordAccessControlPointInputParser.reportNumberOfAllStoredRecords().value) + clear() + _state.value = _state.value.copyWithNewRequestStatus(RequestStatus.PENDING) + } } 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 index 86050cca..c0c0ff6e 100644 --- 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 @@ -31,82 +31,12 @@ 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.gls.data.GLSData -import no.nordicsemi.android.gls.data.GLSManager -import no.nordicsemi.android.gls.data.WorkingMode -import no.nordicsemi.android.kotlin.ble.core.ServerDevice -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: ServerDevice): 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: ServerDevice) { -// 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() - } - } }