Health Connect: Drop out-of-range sample values to prevent sync abort

Health Connect enforces hard min/max bounds on each record-type field; a
single out-of-range value caused the record constructor to throw and
killed the entire batch insert. The trigger was a Garmin Venu 3
producing HRV > 200 ms, but several syncers shared the same gap (no
upper bound, only > 0 or no validation at all).

Add explicit per-syncer numeric guards matching the bounds enforced by
HC's record constructors, drop out-of-range samples, log them with the
existing [HC_SYNC] prefix, and continue the sync.

Bounds applied (per-record HC enforcement):
- HRV: 1..200 ms
- VO2Max: 0..100 ml/kg/min
- Weight: 0..1000 kg
- BloodGlucose: 0..900.91 mg/dL (= 50 mmol/L) -- previously unvalidated
- RespiratoryRate: 0..1000 breaths/min
- HeartRate / RestingHR: 1..300 / 0..300 bpm (widened from 20..250 to
  match HC's contract; downstream consumers can apply physiological
  filters)
- SpO2: 0..100 %
- Steps: 1..1000000 per record
- ActiveCalories: 0..1000000 kcal per record
- Distance: 0..1000000 m per record
- Skin temperature delta: -30..30 °C
- Body temperature: existing physiological clamp 25..45 °C kept (HC has
  no enforced bound)

Add Logger.skipOutOfRange extension in syncers/SyncerLogging.kt and
relax HC_SYNC_TAG visibility from private to internal so syncers can
share it.

HeartRateSync aggregates dropped-sample counts into one summary log
line per slice rather than per-sample, to avoid log spam from glitchy
firmware.

convertSample visibility relaxed from protected to internal in the two
abstract base syncers so the new boundary tests can call it directly.

30 new unit tests in SyncerRangeValidationTest cover boundary
conditions per syncer.
This commit is contained in:
Gideon Zenz
2026-05-27 11:44:35 +02:00
parent 35d926ce4e
commit 6c70aa371d
17 changed files with 340 additions and 42 deletions
@@ -495,7 +495,7 @@ class HealthConnectUtils {
internal const val MAX_SAMPLES_PER_HEART_RATE_RECORD = 1000 internal const val MAX_SAMPLES_PER_HEART_RATE_RECORD = 1000
private const val MAX_RETRIES = 5 private const val MAX_RETRIES = 5
private const val INITIAL_DELAY_MS = 1000L private const val INITIAL_DELAY_MS = 1000L
private const val HC_SYNC_TAG = "[HC_SYNC]" internal const val HC_SYNC_TAG = "[HC_SYNC]"
private fun getSyncTimestampRange( private fun getSyncTimestampRange(
context: Context, context: Context,
@@ -34,7 +34,7 @@ internal abstract class AbstractActivitySampleSyncer<TRecord : Record> : Activit
protected abstract val logger: Logger protected abstract val logger: Logger
protected abstract val recordClass: KClass<TRecord> protected abstract val recordClass: KClass<TRecord>
protected abstract fun convertSample( internal abstract fun convertSample(
sample: ActivitySample, sample: ActivitySample,
offset: ZoneOffset, offset: ZoneOffset,
metadata: Metadata, metadata: Metadata,
@@ -49,7 +49,7 @@ internal abstract class AbstractTimeSampleSyncer<TSample : TimeSample, TRecord :
daoSession: DaoSession daoSession: DaoSession
): TimeSampleProvider<out TSample>? ): TimeSampleProvider<out TSample>?
protected abstract fun convertSample( internal abstract fun convertSample(
sample: TSample, sample: TSample,
offset: ZoneOffset, offset: ZoneOffset,
metadata: Metadata, metadata: Metadata,
@@ -41,6 +41,11 @@ internal object ActiveCaloriesSyncer : AbstractActivitySampleSyncer<ActiveCalori
if (caloriesInMinute <= 0) { if (caloriesInMinute <= 0) {
return null return null
} }
// HC's ActiveCaloriesBurnedRecord caps energy at 1_000_000 kcal (= 1e9 cal).
if (caloriesInMinute > 1_000_000_000) {
logger.skipOutOfRange(deviceName, "ActiveCalories", "$caloriesInMinute cal", "<= 1000000 kcal per record")
return null
}
val endTs = Instant.ofEpochSecond(sample.timestamp.toLong()) val endTs = Instant.ofEpochSecond(sample.timestamp.toLong())
val startTs = endTs.minus(1, ChronoUnit.MINUTES) val startTs = endTs.minus(1, ChronoUnit.MINUTES)
@@ -47,11 +47,17 @@ internal object BloodGlucoseSyncer : AbstractTimeSampleSyncer<GlucoseSample, Blo
offset: ZoneOffset, offset: ZoneOffset,
metadata: Metadata, metadata: Metadata,
deviceName: String deviceName: String
): BloodGlucoseRecord { ): BloodGlucoseRecord? {
// HC's BloodGlucoseRecord caps level at 50 mmol/L = 900.91 mg/dL.
val mgDl = sample.valueMgDl
if (!mgDl.isFinite() || mgDl !in 0.0..900.91) {
logger.skipOutOfRange(deviceName, "BloodGlucose", "$mgDl mg/dL", "0..900.91 mg/dL (50 mmol/L)")
return null
}
return BloodGlucoseRecord( return BloodGlucoseRecord(
time = Instant.ofEpochMilli(sample.timestamp), time = Instant.ofEpochMilli(sample.timestamp),
zoneOffset = offset, zoneOffset = offset,
level = BloodGlucose.milligramsPerDeciliter(sample.valueMgDl), level = BloodGlucose.milligramsPerDeciliter(mgDl),
metadata = metadata metadata = metadata
) )
} }
@@ -41,6 +41,11 @@ internal object DistanceSyncer : AbstractActivitySampleSyncer<DistanceRecord>()
if (distanceCm <= 0 || distanceCm == ActivitySample.NOT_MEASURED) { if (distanceCm <= 0 || distanceCm == ActivitySample.NOT_MEASURED) {
return null return null
} }
// HC's DistanceRecord caps distance at 1_000_000 m (= 1e8 cm).
if (distanceCm > 100_000_000) {
logger.skipOutOfRange(deviceName, "Distance", "$distanceCm cm", "<= 1000000 m per record")
return null
}
val endTs = Instant.ofEpochSecond(sample.timestamp.toLong()) val endTs = Instant.ofEpochSecond(sample.timestamp.toLong())
val startTs = endTs.minus(1, ChronoUnit.MINUTES) val startTs = endTs.minus(1, ChronoUnit.MINUTES)
@@ -52,10 +52,24 @@ internal object HeartRateSyncer : ActivitySampleSyncer {
return SyncerStatistics(recordType = "HeartRate") return SyncerStatistics(recordType = "HeartRate")
} }
// 2. Relevant Input Data Check // 2. Relevant Input Data Check (HC enforces 1..300 bpm)
var droppedOutOfRange = 0
val validHRSamples = deviceSamples val validHRSamples = deviceSamples
.filter { it.heartRate in 20..250 } .filter {
val inRange = it.heartRate in 1..300
// 0 means "not measured" - common, don't count as out-of-range
if (!inRange && it.heartRate != 0) {
droppedOutOfRange++
}
inRange
}
.sortedBy { it.timestamp } .sortedBy { it.timestamp }
if (droppedOutOfRange > 0) {
LOG.info(
"${HealthConnectUtils.HC_SYNC_TAG} Dropped {} out-of-range HeartRate sample(s) for device '{}' in slice {} to {} (HC requires 1..300 bpm).",
droppedOutOfRange, deviceName, sliceStartBoundary, sliceEndBoundary
)
}
if (validHRSamples.isEmpty()) { if (validHRSamples.isEmpty()) {
LOG.info("No valid heart rate samples found for device '$deviceName' for slice $sliceStartBoundary to $sliceEndBoundary.") LOG.info("No valid heart rate samples found for device '$deviceName' for slice $sliceStartBoundary to $sliceEndBoundary.")
@@ -45,19 +45,16 @@ internal object HrvSyncer : AbstractTimeSampleSyncer<HrvValueSample, HeartRateVa
metadata: Metadata, metadata: Metadata,
deviceName: String deviceName: String
): HeartRateVariabilityRmssdRecord? { ): HeartRateVariabilityRmssdRecord? {
if (sample.value <= 0) { val value = sample.value.toDouble()
logger.debug( if (value !in 1.0..200.0 || !value.isFinite()) {
"Skipping HRV value sample for device '{}' due to non-positive value: {}.", logger.skipOutOfRange(deviceName, "HRV", value, "1..200 ms")
deviceName,
sample.value
)
return null return null
} }
return HeartRateVariabilityRmssdRecord( return HeartRateVariabilityRmssdRecord(
time = Instant.ofEpochMilli(sample.timestamp), time = Instant.ofEpochMilli(sample.timestamp),
zoneOffset = offset, zoneOffset = offset,
heartRateVariabilityMillis = sample.value.toDouble(), heartRateVariabilityMillis = value,
metadata = metadata metadata = metadata
) )
} }
@@ -45,14 +45,16 @@ internal object RespiratoryRateSyncer : AbstractTimeSampleSyncer<RespiratoryRate
metadata: Metadata, metadata: Metadata,
deviceName: String deviceName: String
): RespiratoryRateRecord? { ): RespiratoryRateRecord? {
if (sample.respiratoryRate <= 0) { val rate = sample.respiratoryRate.toDouble()
if (rate !in 0.0..1000.0 || !rate.isFinite()) {
logger.skipOutOfRange(deviceName, "RespiratoryRate", rate, "0..1000 breaths/min")
return null return null
} }
return RespiratoryRateRecord( return RespiratoryRateRecord(
time = Instant.ofEpochMilli(sample.timestamp), time = Instant.ofEpochMilli(sample.timestamp),
zoneOffset = offset, zoneOffset = offset,
rate = sample.respiratoryRate.toDouble(), rate = rate,
metadata = metadata metadata = metadata
) )
} }
@@ -45,7 +45,8 @@ internal object RestingHeartRateSyncer : AbstractTimeSampleSyncer<HeartRateSampl
metadata: Metadata, metadata: Metadata,
deviceName: String deviceName: String
): RestingHeartRateRecord? { ): RestingHeartRateRecord? {
if (sample.heartRate !in 20..250) { if (sample.heartRate !in 0..300) {
logger.skipOutOfRange(deviceName, "RestingHeartRate", sample.heartRate, "0..300 bpm")
return null return null
} }
@@ -48,12 +48,8 @@ internal object Spo2Syncer : AbstractTimeSampleSyncer<Spo2Sample, OxygenSaturati
): OxygenSaturationRecord? { ): OxygenSaturationRecord? {
val spo2AsDouble = sample.spo2.toDouble() val spo2AsDouble = sample.spo2.toDouble()
if (spo2AsDouble <= 0 || spo2AsDouble > 100) { if (spo2AsDouble !in 0.0..100.0 || !spo2AsDouble.isFinite()) {
logger.debug( logger.skipOutOfRange(deviceName, "SpO2", spo2AsDouble, "0..100 %")
"Skipping SpO2 sample for device '{}' with invalid value ({}). Valid range: >0 and <=100.",
deviceName,
spo2AsDouble
)
return null return null
} }
@@ -37,7 +37,12 @@ internal object StepsSyncer : AbstractActivitySampleSyncer<StepsRecord>() {
deviceName: String deviceName: String
): StepsRecord? { ): StepsRecord? {
val stepsInMinute = sample.steps.toLong() val stepsInMinute = sample.steps.toLong()
if (stepsInMinute <= 0) { // <= 0 means "no steps in that minute" - common, drop silently.
if (stepsInMinute <= 0L) {
return null
}
if (stepsInMinute > 1_000_000L) {
logger.skipOutOfRange(deviceName, "Steps", stepsInMinute, "1..1000000 per record")
return null return null
} }
@@ -0,0 +1,32 @@
/* Copyright (C) 2025 Gideon Zenz
This file is part of Gadgetbridge.
Gadgetbridge is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Gadgetbridge is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.util.healthconnect.syncers
import nodomain.freeyourgadget.gadgetbridge.util.healthconnect.HealthConnectUtils
import org.slf4j.Logger
internal fun Logger.skipOutOfRange(
deviceName: String,
fieldLabel: String,
value: Any?,
rangeDescription: String
) {
info(
"${HealthConnectUtils.HC_SYNC_TAG} Skipping {} for device '{}': value {} out of range ({}).",
fieldLabel, deviceName, value, rangeDescription
)
}
@@ -94,13 +94,8 @@ internal object TemperatureSyncer : HealthConnectSyncer {
LOG.info("Processing ${bodySamples.size} body temperature samples for '$deviceName'.") LOG.info("Processing ${bodySamples.size} body temperature samples for '$deviceName'.")
for (sample in bodySamples) { for (sample in bodySamples) {
val sampleTemp = sample.temperature.toDouble() val sampleTemp = sample.temperature.toDouble()
if (sampleTemp !in MIN_PLAUSIBLE_BODY_TEMP_C..MAX_PLAUSIBLE_BODY_TEMP_C) { if (sampleTemp !in MIN_PLAUSIBLE_BODY_TEMP_C..MAX_PLAUSIBLE_BODY_TEMP_C || !sampleTemp.isFinite()) {
LOG.debug( LOG.skipOutOfRange(deviceName, "BodyTemperature", "${sample.temperature}°C", "$MIN_PLAUSIBLE_BODY_TEMP_C..$MAX_PLAUSIBLE_BODY_TEMP_C °C")
"Skipping Body Temperature sample for device '{}' at {} due to implausible value: {}°C.",
deviceName,
Instant.ofEpochMilli(sample.timestamp),
sample.temperature
)
continue continue
} }
val timestamp = Instant.ofEpochMilli(sample.timestamp) val timestamp = Instant.ofEpochMilli(sample.timestamp)
@@ -165,6 +160,12 @@ internal object TemperatureSyncer : HealthConnectSyncer {
currentTempC - baselineForCurrentRecord currentTempC - baselineForCurrentRecord
} }
if (deltaValue !in -30.0..30.0 || !deltaValue.isFinite()) {
LOG.skipOutOfRange(deviceName, "SkinTemperatureDelta", deltaValue, "-30..30 °C")
previousTempC = currentTempC
continue
}
deltas.add( deltas.add(
SkinTemperatureRecord.Delta( SkinTemperatureRecord.Delta(
time = Instant.ofEpochMilli(sample.timestamp), time = Instant.ofEpochMilli(sample.timestamp),
@@ -45,15 +45,16 @@ internal object Vo2MaxSyncer : AbstractTimeSampleSyncer<Vo2MaxSample, Vo2MaxReco
metadata: Metadata, metadata: Metadata,
deviceName: String deviceName: String
): Vo2MaxRecord? { ): Vo2MaxRecord? {
if (sample.value <= 0) { val value = sample.value.toDouble()
logger.trace("Skipping VO2Max sample for device '$deviceName' due to invalid value: ${sample.value}.") if (value !in 0.0..100.0 || !value.isFinite()) {
logger.skipOutOfRange(deviceName, "VO2Max", value, "0..100 ml/kg/min")
return null return null
} }
return Vo2MaxRecord( return Vo2MaxRecord(
time = Instant.ofEpochMilli(sample.timestamp), time = Instant.ofEpochMilli(sample.timestamp),
zoneOffset = offset, zoneOffset = offset,
vo2MillilitersPerMinuteKilogram = sample.value.toDouble(), vo2MillilitersPerMinuteKilogram = value,
measurementMethod = Vo2MaxRecord.MEASUREMENT_METHOD_OTHER, measurementMethod = Vo2MaxRecord.MEASUREMENT_METHOD_OTHER,
metadata = metadata metadata = metadata
) )
@@ -46,21 +46,17 @@ internal object WeightSyncer : AbstractTimeSampleSyncer<WeightSample, WeightReco
metadata: Metadata, metadata: Metadata,
deviceName: String deviceName: String
): WeightRecord? { ): WeightRecord? {
val weightInKg = sample.getWeightKg() val weightInKg = sample.getWeightKg().toDouble()
if (weightInKg <= 0) { if (weightInKg !in 0.0..1000.0 || !weightInKg.isFinite()) {
logger.debug( logger.skipOutOfRange(deviceName, "Weight", weightInKg, "0..1000 kg")
"Skipping Weight sample for device '{}' due to invalid weight: {}kg (must be > 0).",
deviceName,
weightInKg
)
return null return null
} }
return WeightRecord( return WeightRecord(
time = Instant.ofEpochMilli(sample.timestamp), time = Instant.ofEpochMilli(sample.timestamp),
zoneOffset = offset, zoneOffset = offset,
weight = Mass.kilograms(weightInKg.toDouble()), weight = Mass.kilograms(weightInKg),
metadata = metadata metadata = metadata
) )
} }
@@ -0,0 +1,237 @@
package nodomain.freeyourgadget.gadgetbridge.util.healthconnect.syncers
import androidx.health.connect.client.records.metadata.Device
import androidx.health.connect.client.records.metadata.Metadata
import nodomain.freeyourgadget.gadgetbridge.entities.GlucoseSample
import nodomain.freeyourgadget.gadgetbridge.model.HeartRateSample
import nodomain.freeyourgadget.gadgetbridge.model.HrvValueSample
import nodomain.freeyourgadget.gadgetbridge.model.RespiratoryRateSample
import nodomain.freeyourgadget.gadgetbridge.model.Spo2Sample
import nodomain.freeyourgadget.gadgetbridge.model.Vo2MaxSample
import nodomain.freeyourgadget.gadgetbridge.model.WeightSample
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Test
import java.time.ZoneOffset
class SyncerRangeValidationTest {
private val offset: ZoneOffset = ZoneOffset.UTC
private val device = "test-device"
private val metadata: Metadata = Metadata.unknownRecordingMethod(
Device(type = Device.TYPE_WATCH, manufacturer = "test", model = "test")
)
// --- HRV ---
private fun hrvSample(value: Int, ts: Long = 1_700_000_000_000L): HrvValueSample =
object : HrvValueSample {
override fun getTimestamp(): Long = ts
override fun getValue(): Int = value
}
@Test
fun hrv_lowerBoundary_accepted() {
assertNotNull(HrvSyncer.convertSample(hrvSample(1), offset, metadata, device))
}
@Test
fun hrv_upperBoundary_accepted() {
assertNotNull(HrvSyncer.convertSample(hrvSample(200), offset, metadata, device))
}
@Test
fun hrv_belowLower_dropped() {
assertNull(HrvSyncer.convertSample(hrvSample(0), offset, metadata, device))
}
@Test
fun hrv_aboveUpper_dropped() {
// The actual #6190 value
assertNull(HrvSyncer.convertSample(hrvSample(211), offset, metadata, device))
}
@Test
fun hrv_negative_dropped() {
assertNull(HrvSyncer.convertSample(hrvSample(-1), offset, metadata, device))
}
// --- VO2 max ---
private fun vo2Sample(value: Float, ts: Long = 1_700_000_000_000L): Vo2MaxSample =
object : Vo2MaxSample {
override fun getTimestamp(): Long = ts
override fun getValue(): Float = value
override fun getType(): Vo2MaxSample.Type = Vo2MaxSample.Type.ANY
}
@Test
fun vo2_lowerBoundary_accepted() {
assertNotNull(Vo2MaxSyncer.convertSample(vo2Sample(0.0f), offset, metadata, device))
}
@Test
fun vo2_upperBoundary_accepted() {
assertNotNull(Vo2MaxSyncer.convertSample(vo2Sample(100.0f), offset, metadata, device))
}
@Test
fun vo2_aboveUpper_dropped() {
assertNull(Vo2MaxSyncer.convertSample(vo2Sample(101.0f), offset, metadata, device))
}
@Test
fun vo2_negative_dropped() {
assertNull(Vo2MaxSyncer.convertSample(vo2Sample(-0.5f), offset, metadata, device))
}
@Test
fun vo2_nan_dropped() {
assertNull(Vo2MaxSyncer.convertSample(vo2Sample(Float.NaN), offset, metadata, device))
}
// --- Weight ---
private fun weightSample(kg: Float, ts: Long = 1_700_000_000_000L): WeightSample =
object : WeightSample {
override fun getTimestamp(): Long = ts
override fun getWeightKg(): Float = kg
}
@Test
fun weight_lowerBoundary_accepted() {
// HC's WeightRecord.weight uses requireNotLess(weight, 0 kg), so 0.0 is at the bound and accepted.
assertNotNull(WeightSyncer.convertSample(weightSample(0.0f), offset, metadata, device))
}
@Test
fun weight_upperBoundary_accepted() {
assertNotNull(WeightSyncer.convertSample(weightSample(1000.0f), offset, metadata, device))
}
@Test
fun weight_aboveUpper_dropped() {
assertNull(WeightSyncer.convertSample(weightSample(1001.0f), offset, metadata, device))
}
@Test
fun weight_negative_dropped() {
assertNull(WeightSyncer.convertSample(weightSample(-0.1f), offset, metadata, device))
}
// --- Respiratory rate ---
private fun respSample(rate: Float, ts: Long = 1_700_000_000_000L): RespiratoryRateSample =
object : RespiratoryRateSample {
override fun getTimestamp(): Long = ts
override fun getRespiratoryRate(): Float = rate
}
@Test
fun resp_lowerBoundary_accepted() {
assertNotNull(RespiratoryRateSyncer.convertSample(respSample(0.0f), offset, metadata, device))
}
@Test
fun resp_upperBoundary_accepted() {
assertNotNull(RespiratoryRateSyncer.convertSample(respSample(1000.0f), offset, metadata, device))
}
@Test
fun resp_aboveUpper_dropped() {
assertNull(RespiratoryRateSyncer.convertSample(respSample(1001.0f), offset, metadata, device))
}
@Test
fun resp_negative_dropped() {
assertNull(RespiratoryRateSyncer.convertSample(respSample(-1.0f), offset, metadata, device))
}
// --- SpO2 ---
private fun spo2Sample(spo2: Int, ts: Long = 1_700_000_000_000L): Spo2Sample =
object : Spo2Sample {
override fun getTimestamp(): Long = ts
override fun getSpo2(): Int = spo2
override fun getType(): Spo2Sample.Type = Spo2Sample.Type.UNKNOWN
}
@Test
fun spo2_lowerBoundary_accepted() {
assertNotNull(Spo2Syncer.convertSample(spo2Sample(0), offset, metadata, device))
}
@Test
fun spo2_upperBoundary_accepted() {
assertNotNull(Spo2Syncer.convertSample(spo2Sample(100), offset, metadata, device))
}
@Test
fun spo2_aboveUpper_dropped() {
assertNull(Spo2Syncer.convertSample(spo2Sample(101), offset, metadata, device))
}
@Test
fun spo2_negative_dropped() {
assertNull(Spo2Syncer.convertSample(spo2Sample(-1), offset, metadata, device))
}
// --- Resting HR ---
private fun hrSample(bpm: Int, ts: Long = 1_700_000_000_000L): HeartRateSample =
object : HeartRateSample {
override fun getTimestamp(): Long = ts
override fun getHeartRate(): Int = bpm
}
@Test
fun restingHr_lowerBoundary_accepted() {
assertNotNull(RestingHeartRateSyncer.convertSample(hrSample(0), offset, metadata, device))
}
@Test
fun restingHr_upperBoundary_accepted() {
assertNotNull(RestingHeartRateSyncer.convertSample(hrSample(300), offset, metadata, device))
}
@Test
fun restingHr_aboveUpper_dropped() {
assertNull(RestingHeartRateSyncer.convertSample(hrSample(301), offset, metadata, device))
}
@Test
fun restingHr_negative_dropped() {
assertNull(RestingHeartRateSyncer.convertSample(hrSample(-1), offset, metadata, device))
}
// --- Blood glucose ---
private fun glucoseSample(mgDl: Double, ts: Long = 1_700_000_000_000L): GlucoseSample {
val s = GlucoseSample()
s.timestamp = ts
s.valueMgDl = mgDl
return s
}
@Test
fun bloodGlucose_lowerBoundary_accepted() {
assertNotNull(BloodGlucoseSyncer.convertSample(glucoseSample(0.0), offset, metadata, device))
}
@Test
fun bloodGlucose_upperBoundary_accepted() {
// 50 mmol/L ~= 900.91 mg/dL
assertNotNull(BloodGlucoseSyncer.convertSample(glucoseSample(900.0), offset, metadata, device))
}
@Test
fun bloodGlucose_aboveUpper_dropped() {
// > 50 mmol/L
assertNull(BloodGlucoseSyncer.convertSample(glucoseSample(950.0), offset, metadata, device))
}
@Test
fun bloodGlucose_negative_dropped() {
assertNull(BloodGlucoseSyncer.convertSample(glucoseSample(-1.0), offset, metadata, device))
}
}