Skip to content

Commit

Permalink
VIT-6645: Device SDK auto-posting samples (#128)
Browse files Browse the repository at this point in the history
Move timeseries POST methods to VitalPrivateService.

Mark `createConnectedSourceIfNotExist` as VitalPrivateApi.

Devices SDK would now try to auto-post glucose and blood pressure samples after reading them, if the Core SDK has signed-in with a user.
  • Loading branch information
andersio authored Jun 14, 2024
1 parent b479b2d commit 134f028
Show file tree
Hide file tree
Showing 13 changed files with 151 additions and 60 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -44,29 +44,6 @@ class TimeSeriesService private constructor(private val timeSeries: TimeSeries)
)
}

suspend fun sendBloodPressure(
userId: String,
timeseriesPayload: TimeseriesPayload<List<BloodPressureSamplePayload>>
) {
timeSeries.bloodPressureTimeseriesPost(
userId = userId,
resource = "blood_pressure",
payload = timeseriesPayload
)
}

suspend fun sendQuantitySamples(
resource: IngestibleTimeseriesResource,
userId: String,
timeseriesPayload: TimeseriesPayload<List<QuantitySamplePayload>>
) {
timeSeries.timeseriesPost(
userId = userId,
resource = resource.toString(),
payload = timeseriesPayload
)
}

companion object {
fun create(retrofit: Retrofit): TimeSeriesService {
return TimeSeriesService(retrofit.create(TimeSeries::class.java))
Expand All @@ -93,19 +70,5 @@ private interface TimeSeries {
@Query("provider") provider: String? = null,
@Query("next_cursor") nextCursor: String? = null,
): GroupedSamplesResponse<BloodPressureSample>

@POST("timeseries/{user_id}/{resource}")
suspend fun timeseriesPost(
@Path("user_id") userId: String,
@Path("resource", encoded = true) resource: String,
@Body payload: TimeseriesPayload<List<QuantitySamplePayload>>
): Response<Unit>

@POST("timeseries/{user_id}/{resource}")
suspend fun bloodPressureTimeseriesPost(
@Path("user_id") userId: String,
@Path("resource", encoded = true) resource: String,
@Body payload: TimeseriesPayload<List<BloodPressureSamplePayload>>
): Response<Unit>
}

Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,14 @@ import ManualProviderResponse
import UserSDKSyncStateBody
import UserSDKSyncStateResponse
import io.tryvital.client.services.data.ActivityPayload
import io.tryvital.client.services.data.BloodPressureSamplePayload
import io.tryvital.client.services.data.BodyPayload
import io.tryvital.client.services.data.ManualProviderSlug
import io.tryvital.client.services.data.ProfilePayload
import io.tryvital.client.services.data.QuantitySamplePayload
import io.tryvital.client.services.data.SleepPayload
import io.tryvital.client.services.data.SummaryPayload
import io.tryvital.client.services.data.TimeseriesPayload
import io.tryvital.client.services.data.WorkoutPayload
import retrofit2.Response
import retrofit2.Retrofit
Expand Down Expand Up @@ -65,6 +68,19 @@ interface VitalPrivateService {
@Body body: SummaryPayload<List<SleepPayload>>
): Response<Unit>

@POST("timeseries/{user_id}/{resource}")
suspend fun timeseriesPost(
@Path("user_id") userId: String,
@Path("resource", encoded = true) resource: String,
@Body payload: TimeseriesPayload<List<QuantitySamplePayload>>
): Response<Unit>

@POST("timeseries/{user_id}/blood_pressure")
suspend fun bloodPressureTimeseriesPost(
@Path("user_id") userId: String,
@Body payload: TimeseriesPayload<List<BloodPressureSamplePayload>>
): Response<Unit>

companion object {
fun create(retrofit: Retrofit): VitalPrivateService {
return retrofit.create(VitalPrivateService::class.java)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ fun VitalClient.hasUserConnectedTo(provider: ProviderSlug): Boolean {
)
}

@VitalPrivateApi
suspend fun VitalClient.createConnectedSourceIfNotExist(provider: ManualProviderSlug) {
val userId = VitalClient.checkUserId()
val slug = provider.toProviderSlug()
Expand All @@ -35,7 +36,6 @@ suspend fun VitalClient.createConnectedSourceIfNotExist(provider: ManualProvider
.apply()
}

@OptIn(VitalPrivateApi::class)
try {
// Remote Miss: Try to create the manual connected source.
vitalPrivateService.manualProvider(
Expand Down
10 changes: 10 additions & 0 deletions VitalDevices/src/main/java/io/tryvital/vitaldevices/Brand.kt
Original file line number Diff line number Diff line change
@@ -1,9 +1,19 @@
package io.tryvital.vitaldevices

import io.tryvital.client.services.data.ManualProviderSlug

sealed class Brand(val name: String) {
object Omron : Brand("Omron")
object AccuChek : Brand("Accu-Chek")
object Contour : Brand("Contour")
object Beurer : Brand("Beurer")
object Libre : Brand("FreeStyle Libre")

fun toManualProviderSlug(): ManualProviderSlug = when (this) {
is Omron -> ManualProviderSlug.OmronBLE
is AccuChek -> ManualProviderSlug.AccuchekBLE
is Contour -> ManualProviderSlug.ContourBLE
is Beurer -> ManualProviderSlug.BeurerBLE
is Libre -> ManualProviderSlug.LibreBLE
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package io.tryvital.vitaldevices.devices

import android.bluetooth.BluetoothDevice
import android.content.Context
import io.tryvital.client.services.data.BloodPressureSamplePayload
import io.tryvital.client.services.data.QuantitySamplePayload
import io.tryvital.client.services.data.SampleType
import io.tryvital.vitaldevices.ScannedDevice
Expand All @@ -17,23 +18,27 @@ private val bloodPressureMeasurementCharacteristicUUID =

interface BloodPressureReader {
suspend fun pair()
suspend fun read(): List<BloodPressureSample>
suspend fun read(): List<BloodPressureSamplePayload>
}

class BloodPressureReader1810(
context: Context,
scannedBluetoothDevice: BluetoothDevice,
scannedDevice: ScannedDevice,
) : GATTMeterWithNoRACP<BloodPressureSample>(
) : GATTMeterWithNoRACP<BloodPressureSamplePayload>(
context,
serviceID = bpsServiceUUID,
measurementCharacteristicID = bloodPressureMeasurementCharacteristicUUID,
scannedBluetoothDevice = scannedBluetoothDevice,
scannedDevice = scannedDevice
), BloodPressureReader {
override fun onReceivedAll(samples: List<BloodPressureSamplePayload>) {
postBloodPressureSamples(context, scannedDevice.deviceModel.brand.toManualProviderSlug(), samples)
}

override fun mapRawData(
device: BluetoothDevice, data: Data
): BloodPressureSample? {
): BloodPressureSamplePayload? {
val response = BloodPressureMeasurementResponse().apply {
onDataReceived(device, data)
}
Expand All @@ -43,7 +48,7 @@ class BloodPressureReader1810(
val measurementTime = response.timestamp?.time?.toInstant() ?: return null
val idPrefix = "${measurementTime.epochSecond}-"

return BloodPressureSample(
return BloodPressureSamplePayload(
systolic = QuantitySamplePayload(
id = idPrefix + "systolic",
value = response.systolic.toDouble(),
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ abstract class GATTMeter<Sample>(
private val serviceID: UUID,
private val measurementCharacteristicID: UUID,
private val scannedBluetoothDevice: BluetoothDevice,
private val scannedDevice: ScannedDevice,
protected val scannedDevice: ScannedDevice,
) : BleManager(context) {
private val vitalLogger = VitalLogger.getOrCreate()

Expand All @@ -47,6 +47,7 @@ abstract class GATTMeter<Sample>(
private var deviceReady = MutableStateFlow(false)

abstract fun mapRawData(device: BluetoothDevice, data: Data): Sample?
abstract fun onReceivedAll(samples: List<Sample>)

@Suppress("MemberVisibilityCanBePrivate")
suspend fun pair(): Unit = suspendCancellableCoroutine { continuation ->
Expand Down Expand Up @@ -130,8 +131,11 @@ abstract class GATTMeter<Sample>(
assert(!this@channelFlow.isClosedForSend)
vitalLogger.logI("Emitting ${samples.count()} samples from ${scannedDevice.name}.")

val sampleList = samples.toList()
onReceivedAll(sampleList)

// Send out the samples, and close the channel normally.
this@channelFlow.send(samples.toList())
this@channelFlow.send(sampleList)
close()
} else {
vitalLogger.logE("Unexpected sample collector completion from ${scannedDevice.name}.", e)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ abstract class GATTMeterWithNoRACP<Sample>(
private val serviceID: UUID,
private val measurementCharacteristicID: UUID,
private val scannedBluetoothDevice: BluetoothDevice,
private val scannedDevice: ScannedDevice,
protected val scannedDevice: ScannedDevice,
private val waitForNextValueTimeout: Duration = 2.seconds,
private val listenTimeout: Duration = 30.seconds,
) : BleManager(context) {
Expand All @@ -47,6 +47,7 @@ abstract class GATTMeterWithNoRACP<Sample>(
private var deviceReady = MutableStateFlow(false)

abstract fun mapRawData(device: BluetoothDevice, data: Data): Sample?
abstract fun onReceivedAll(samples: List<Sample>)

@Suppress("MemberVisibilityCanBePrivate")
suspend fun pair() {
Expand Down Expand Up @@ -170,8 +171,11 @@ abstract class GATTMeterWithNoRACP<Sample>(
assert(!this@channelFlow.isClosedForSend)
vitalLogger.logI("Emitting ${samples.count()} samples from ${scannedDevice.name}.")

val sampleList = samples.toList()
onReceivedAll(sampleList)

// Send out the samples, and close the channel normally.
this@channelFlow.send(samples.toList())
this@channelFlow.send(sampleList)
close()
} else {
vitalLogger.logE("Unexpected sample collector completion from ${scannedDevice.name}.", e)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ class GlucoseMeter1808(
scannedBluetoothDevice = scannedBluetoothDevice,
scannedDevice = scannedDevice
), GlucoseMeter {
override fun onReceivedAll(samples: List<QuantitySamplePayload>) {
postGlucoseSamples(context, scannedDevice.deviceModel.brand.toManualProviderSlug(), samples)
}

override fun mapRawData(
device: BluetoothDevice, data: Data
): QuantitySamplePayload? {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package io.tryvital.vitaldevices.devices

import android.app.Activity
import android.content.pm.PackageManager.PERMISSION_GRANTED
import io.tryvital.client.services.data.ManualProviderSlug
import io.tryvital.client.services.data.QuantitySamplePayload
import io.tryvital.vitaldevices.PermissionMissing
import io.tryvital.vitaldevices.devices.nfc.Glucose
Expand Down Expand Up @@ -81,6 +82,8 @@ internal class Libre1ReaderImpl(private val activity: Activity): Libre1Reader {
continuation.invokeOnCancellation { nfc?.close() }
}

val samples = glucose.map { quantitySampleFromGlucose(it) }
postGlucoseSamples(activity.applicationContext, ManualProviderSlug.LibreBLE, samples)

return Libre1Read(
samples = glucose.map { quantitySampleFromGlucose(it) },
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
@file:OptIn(VitalPrivateApi::class, DelicateCoroutinesApi::class)

package io.tryvital.vitaldevices.devices

import android.content.Context
import io.tryvital.client.VitalClient
import io.tryvital.client.createConnectedSourceIfNotExist
import io.tryvital.client.services.VitalPrivateApi
import io.tryvital.client.services.data.BloodPressureSamplePayload
import io.tryvital.client.services.data.DataStage
import io.tryvital.client.services.data.ManualProviderSlug
import io.tryvital.client.services.data.QuantitySamplePayload
import io.tryvital.client.services.data.TimeseriesPayload
import io.tryvital.client.utils.VitalLogger
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import java.util.TimeZone

internal fun postGlucoseSamples(context: Context, provider: ManualProviderSlug, samples: List<QuantitySamplePayload>) {
if (samples.isEmpty()) {
return
}

GlobalScope.launch {
try {
postGlucoseSamplesImpl(context, provider, samples)
VitalLogger.getOrCreate().logI("[Device] posted ${samples.count()} glucose for $provider")

} catch (e: Throwable) {
VitalLogger.getOrCreate().logE("[Device] failed to post ${samples.count()} glucose for $provider", e)
}
}
}

internal fun postBloodPressureSamples(context: Context, provider: ManualProviderSlug, samples: List<BloodPressureSamplePayload>) {
if (samples.isEmpty()) {
return
}

GlobalScope.launch {
try {
postBloodPressureSamplesImpl(context, provider, samples)
VitalLogger.getOrCreate().logI("[Device] posted ${samples.count()} BP for $provider")

} catch (e: Throwable) {
VitalLogger.getOrCreate().logE("[Device] failed to post ${samples.count()} BP for $provider", e)
}
}
}

private suspend fun postGlucoseSamplesImpl(context: Context, provider: ManualProviderSlug, samples: List<QuantitySamplePayload>) {
val client = VitalClient.getOrCreate(context)
if (VitalClient.Status.SignedIn !in VitalClient.status) {
return
}

client.createConnectedSourceIfNotExist(provider)
client.vitalPrivateService.timeseriesPost(
userId = VitalClient.checkUserId(),
resource = "glucose",
payload = TimeseriesPayload(
stage = DataStage.Daily,
data = samples,
startDate = null,
endDate = null,
timeZoneId = TimeZone.getDefault().id,
provider = provider,
)
)
}

private suspend fun postBloodPressureSamplesImpl(context: Context, provider: ManualProviderSlug, samples: List<BloodPressureSamplePayload>) {
val client = VitalClient.getOrCreate(context)
if (VitalClient.Status.SignedIn !in VitalClient.status) {
return
}

client.createConnectedSourceIfNotExist(provider)
client.vitalPrivateService.bloodPressureTimeseriesPost(
userId = VitalClient.checkUserId(),
payload = TimeseriesPayload(
stage = DataStage.Daily,
data = samples,
startDate = null,
endDate = null,
timeZoneId = TimeZone.getDefault().id,
provider = provider,
)
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -197,8 +197,8 @@ class VitalClientRecordUploader(private val vitalClient: VitalClient) : RecordUp
quantitySamples: List<QuantitySamplePayload>,
stage: DataStage,
) {
vitalClient.vitalsService.sendQuantitySamples(
resource, userId, TimeseriesPayload(
vitalClient.vitalPrivateService.timeseriesPost(
userId, resource.toString(), TimeseriesPayload(
stage = stage,
provider = ManualProviderSlug.HealthConnect,
startDate = startDate,
Expand All @@ -217,7 +217,7 @@ class VitalClientRecordUploader(private val vitalClient: VitalClient) : RecordUp
bloodPressurePayloads: List<BloodPressureSamplePayload>,
stage: DataStage,
) {
vitalClient.vitalsService.sendBloodPressure(
vitalClient.vitalPrivateService.bloodPressureTimeseriesPost(
userId, TimeseriesPayload(
stage = stage,
provider = ManualProviderSlug.HealthConnect,
Expand Down
Loading

0 comments on commit 134f028

Please sign in to comment.