From 6c70aa371d87b066b9dac7a3626e89c30d1764fd Mon Sep 17 00:00:00 2001 From: Gideon Zenz Date: Wed, 27 May 2026 11:44:35 +0200 Subject: [PATCH] Health Connect: Drop out-of-range sample values to prevent sync abort MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../util/healthconnect/HealthConnectUtils.kt | 2 +- .../syncers/AbstractActivitySampleSyncer.kt | 2 +- .../syncers/AbstractTimeSampleSyncer.kt | 2 +- .../syncers/ActiveCaloriesSyncer.kt | 5 + .../syncers/BloodGlucoseSyncer.kt | 10 +- .../healthconnect/syncers/DistanceSyncer.kt | 5 + .../healthconnect/syncers/HeartRateSync.kt | 18 +- .../util/healthconnect/syncers/HrvSyncer.kt | 11 +- .../syncers/RespiratoryRateSyncer.kt | 6 +- .../syncers/RestingHeartRateSyncer.kt | 3 +- .../util/healthconnect/syncers/Spo2Syncer.kt | 8 +- .../util/healthconnect/syncers/StepsSyncer.kt | 7 +- .../healthconnect/syncers/SyncerLogging.kt | 32 +++ .../syncers/TemperatureSyncer.kt | 15 +- .../healthconnect/syncers/Vo2MaxSyncer.kt | 7 +- .../healthconnect/syncers/WeightSyncer.kt | 12 +- .../syncers/SyncerRangeValidationTest.kt | 237 ++++++++++++++++++ 17 files changed, 340 insertions(+), 42 deletions(-) create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/SyncerLogging.kt create mode 100644 app/src/test/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/SyncerRangeValidationTest.kt diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/HealthConnectUtils.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/HealthConnectUtils.kt index 2c59b2c8c1..0996df08e2 100755 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/HealthConnectUtils.kt +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/HealthConnectUtils.kt @@ -495,7 +495,7 @@ class HealthConnectUtils { internal const val MAX_SAMPLES_PER_HEART_RATE_RECORD = 1000 private const val MAX_RETRIES = 5 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( context: Context, diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/AbstractActivitySampleSyncer.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/AbstractActivitySampleSyncer.kt index 2254969258..670f28c7a4 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/AbstractActivitySampleSyncer.kt +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/AbstractActivitySampleSyncer.kt @@ -34,7 +34,7 @@ internal abstract class AbstractActivitySampleSyncer : Activit protected abstract val logger: Logger protected abstract val recordClass: KClass - protected abstract fun convertSample( + internal abstract fun convertSample( sample: ActivitySample, offset: ZoneOffset, metadata: Metadata, diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/AbstractTimeSampleSyncer.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/AbstractTimeSampleSyncer.kt index 06fbfb27d4..05169148bf 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/AbstractTimeSampleSyncer.kt +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/AbstractTimeSampleSyncer.kt @@ -49,7 +49,7 @@ internal abstract class AbstractTimeSampleSyncer? - protected abstract fun convertSample( + internal abstract fun convertSample( sample: TSample, offset: ZoneOffset, metadata: Metadata, diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/ActiveCaloriesSyncer.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/ActiveCaloriesSyncer.kt index 73a1554a10..3a7f5ab2b6 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/ActiveCaloriesSyncer.kt +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/ActiveCaloriesSyncer.kt @@ -41,6 +41,11 @@ internal object ActiveCaloriesSyncer : AbstractActivitySampleSyncer 1_000_000_000) { + logger.skipOutOfRange(deviceName, "ActiveCalories", "$caloriesInMinute cal", "<= 1000000 kcal per record") + return null + } val endTs = Instant.ofEpochSecond(sample.timestamp.toLong()) val startTs = endTs.minus(1, ChronoUnit.MINUTES) diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/BloodGlucoseSyncer.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/BloodGlucoseSyncer.kt index 85d1c2c0ee..11a48f1d84 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/BloodGlucoseSyncer.kt +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/BloodGlucoseSyncer.kt @@ -47,11 +47,17 @@ internal object BloodGlucoseSyncer : AbstractTimeSampleSyncer() if (distanceCm <= 0 || distanceCm == ActivitySample.NOT_MEASURED) { 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 startTs = endTs.minus(1, ChronoUnit.MINUTES) diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/HeartRateSync.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/HeartRateSync.kt index 726736766b..8f12b208fc 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/HeartRateSync.kt +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/HeartRateSync.kt @@ -52,10 +52,24 @@ internal object HeartRateSyncer : ActivitySampleSyncer { 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 - .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 } + 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()) { LOG.info("No valid heart rate samples found for device '$deviceName' for slice $sliceStartBoundary to $sliceEndBoundary.") diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/HrvSyncer.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/HrvSyncer.kt index 5696dcf660..7a797491f0 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/HrvSyncer.kt +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/HrvSyncer.kt @@ -45,19 +45,16 @@ internal object HrvSyncer : AbstractTimeSampleSyncer 100) { - logger.debug( - "Skipping SpO2 sample for device '{}' with invalid value ({}). Valid range: >0 and <=100.", - deviceName, - spo2AsDouble - ) + if (spo2AsDouble !in 0.0..100.0 || !spo2AsDouble.isFinite()) { + logger.skipOutOfRange(deviceName, "SpO2", spo2AsDouble, "0..100 %") return null } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/StepsSyncer.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/StepsSyncer.kt index 0ab3b2a530..09f0327136 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/StepsSyncer.kt +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/StepsSyncer.kt @@ -37,7 +37,12 @@ internal object StepsSyncer : AbstractActivitySampleSyncer() { deviceName: String ): StepsRecord? { 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 } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/SyncerLogging.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/SyncerLogging.kt new file mode 100644 index 0000000000..5f64fff588 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/SyncerLogging.kt @@ -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 . */ +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 + ) +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/TemperatureSyncer.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/TemperatureSyncer.kt index 596af5b33c..54314928ba 100755 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/TemperatureSyncer.kt +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/TemperatureSyncer.kt @@ -94,13 +94,8 @@ internal object TemperatureSyncer : HealthConnectSyncer { LOG.info("Processing ${bodySamples.size} body temperature samples for '$deviceName'.") for (sample in bodySamples) { val sampleTemp = sample.temperature.toDouble() - if (sampleTemp !in MIN_PLAUSIBLE_BODY_TEMP_C..MAX_PLAUSIBLE_BODY_TEMP_C) { - LOG.debug( - "Skipping Body Temperature sample for device '{}' at {} due to implausible value: {}°C.", - deviceName, - Instant.ofEpochMilli(sample.timestamp), - sample.temperature - ) + if (sampleTemp !in MIN_PLAUSIBLE_BODY_TEMP_C..MAX_PLAUSIBLE_BODY_TEMP_C || !sampleTemp.isFinite()) { + LOG.skipOutOfRange(deviceName, "BodyTemperature", "${sample.temperature}°C", "$MIN_PLAUSIBLE_BODY_TEMP_C..$MAX_PLAUSIBLE_BODY_TEMP_C °C") continue } val timestamp = Instant.ofEpochMilli(sample.timestamp) @@ -165,6 +160,12 @@ internal object TemperatureSyncer : HealthConnectSyncer { currentTempC - baselineForCurrentRecord } + if (deltaValue !in -30.0..30.0 || !deltaValue.isFinite()) { + LOG.skipOutOfRange(deviceName, "SkinTemperatureDelta", deltaValue, "-30..30 °C") + previousTempC = currentTempC + continue + } + deltas.add( SkinTemperatureRecord.Delta( time = Instant.ofEpochMilli(sample.timestamp), diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/Vo2MaxSyncer.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/Vo2MaxSyncer.kt index b854a4e7a1..5fe4b3dcf5 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/Vo2MaxSyncer.kt +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/Vo2MaxSyncer.kt @@ -45,15 +45,16 @@ internal object Vo2MaxSyncer : AbstractTimeSampleSyncer 0).", - deviceName, - weightInKg - ) + if (weightInKg !in 0.0..1000.0 || !weightInKg.isFinite()) { + logger.skipOutOfRange(deviceName, "Weight", weightInKg, "0..1000 kg") return null } return WeightRecord( time = Instant.ofEpochMilli(sample.timestamp), zoneOffset = offset, - weight = Mass.kilograms(weightInKg.toDouble()), + weight = Mass.kilograms(weightInKg), metadata = metadata ) } diff --git a/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/SyncerRangeValidationTest.kt b/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/SyncerRangeValidationTest.kt new file mode 100644 index 0000000000..8a30477f8b --- /dev/null +++ b/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/SyncerRangeValidationTest.kt @@ -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)) + } +}