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)) + } +}