From 746bc16bfa5dd12c512512c55cdcda7612e225a8 Mon Sep 17 00:00:00 2001 From: Gideon Zenz Date: Sun, 29 Mar 2026 15:33:09 +0200 Subject: [PATCH] Health Connect: Use per-timestamp timezone offset for DST correctness Replace single sync-time ZoneOffset with ZoneId, resolving the correct offset per record timestamp via ZoneId.rules.getOffset(instant). Fixes wrong offset metadata on records crossing DST boundaries (e.g. sleep sessions starting in CET and ending in CEST). Also fixes leading space in device model name after manufacturer prefix strip. --- .../util/healthconnect/HealthConnectUtils.kt | 8 ++++---- .../syncers/AbstractActivitySampleSyncer.kt | 5 +++-- .../healthconnect/syncers/AbstractTimeSampleSyncer.kt | 6 ++++-- .../util/healthconnect/syncers/HealthConnectSyncer.kt | 8 ++++---- .../util/healthconnect/syncers/HeartRateSync.kt | 7 ++++--- .../healthconnect/syncers/RecordedWorkoutSyncer.kt | 5 ++++- .../util/healthconnect/syncers/SleepSyncer.kt | 10 +++++----- .../util/healthconnect/syncers/TemperatureSyncer.kt | 10 +++++----- 8 files changed, 33 insertions(+), 26 deletions(-) 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 84e9b20389..1ed104a9ad 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 @@ -238,7 +238,7 @@ class HealthConnectUtils { val recordsSyncedByType = mutableMapOf() // Track records per data type val dataTypesWithErrors = mutableSetOf() // Track which data types had errors - val offset = ZonedDateTime.now(TimeZone.getDefault().toZoneId()).offset + val zoneId = TimeZone.getDefault().toZoneId() val manager = GBApplication.app().deviceManager val syncIntervalInSeconds: Long = 24 * 60 * 60 // 1 day slices val lookBackInSeconds: Long = 24 * 60 * 60 @@ -263,7 +263,7 @@ class HealthConnectUtils { val manufacturer = deviceCoordinator.manufacturer var deviceName = context.getString(deviceCoordinator.deviceNameResource) if (deviceName.startsWith(manufacturer) && deviceName != manufacturer) { - deviceName = deviceName.replace(manufacturer, ""); + deviceName = deviceName.replace(manufacturer, "").trim(); } val device = Device( type = when (deviceCoordinator.getDeviceKind(gbDevice)) { @@ -393,7 +393,7 @@ class HealthConnectUtils { healthConnectClient = healthConnectClient, gbDevice = gbDevice, metadata = metadata, - offset = offset, + offset = zoneId, currentSliceStartTs = currentSliceStartTs, currentSliceEndTs = currentSliceEndTs, grantedPermissions = grantedPermissions, @@ -575,7 +575,7 @@ class HealthConnectUtils { healthConnectClient: HealthConnectClient, gbDevice: GBDevice, metadata: Metadata, - offset: java.time.ZoneOffset, + offset: java.time.ZoneId, currentSliceStartTs: Instant, currentSliceEndTs: Instant, grantedPermissions: Set, 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 f486f01e94..a31e015365 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 @@ -25,6 +25,7 @@ import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample import nodomain.freeyourgadget.gadgetbridge.util.healthconnect.HealthConnectUtils import org.slf4j.Logger import java.time.Instant +import java.time.ZoneId import java.time.ZoneOffset import java.time.temporal.ChronoUnit import kotlin.reflect.KClass @@ -44,7 +45,7 @@ internal abstract class AbstractActivitySampleSyncer : Activit healthConnectClient: HealthConnectClient, gbDevice: GBDevice, metadata: Metadata, - offset: ZoneOffset, + offset: ZoneId, sliceStartBoundary: Instant, sliceEndBoundary: Instant, grantedPermissions: Set, @@ -89,7 +90,7 @@ internal abstract class AbstractActivitySampleSyncer : Activit return@forEach } - val record = convertSample(sample = currentSample, offset, metadata, deviceName) + val record = convertSample(sample = currentSample, offset.rules.getOffset(endTs), metadata, deviceName) if (record == null) { skippedCount++ return@forEach 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 c446a41dde..4df673700f 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 @@ -29,6 +29,7 @@ import nodomain.freeyourgadget.gadgetbridge.util.healthconnect.HealthConnectUtil import nodomain.freeyourgadget.gadgetbridge.util.healthconnect.SyncException import org.slf4j.Logger import java.time.Instant +import java.time.ZoneId import java.time.ZoneOffset import kotlin.reflect.KClass @@ -59,7 +60,7 @@ internal abstract class AbstractTimeSampleSyncer @@ -114,9 +115,10 @@ internal abstract class AbstractTimeSampleSyncer + val sampleOffset = offset.rules.getOffset(Instant.ofEpochMilli(sample.timestamp)) convertSample( sample, - offset, + sampleOffset, metadata, deviceName )?.also { diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/HealthConnectSyncer.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/HealthConnectSyncer.kt index 661c507846..1ea2f76293 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/HealthConnectSyncer.kt +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/HealthConnectSyncer.kt @@ -22,7 +22,7 @@ import androidx.health.connect.client.records.metadata.Metadata import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample import java.time.Instant -import java.time.ZoneOffset +import java.time.ZoneId /** * Statistics returned by a syncer after processing a slice. @@ -46,7 +46,7 @@ internal sealed interface HealthConnectSyncer { healthConnectClient: HealthConnectClient, gbDevice: GBDevice, metadata: Metadata, - offset: ZoneOffset, + offset: ZoneId, sliceStartBoundary: Instant, sliceEndBoundary: Instant, grantedPermissions: Set @@ -62,7 +62,7 @@ internal interface ActivitySampleSyncer { healthConnectClient: HealthConnectClient, gbDevice: GBDevice, metadata: Metadata, - offset: ZoneOffset, + offset: ZoneId, sliceStartBoundary: Instant, sliceEndBoundary: Instant, grantedPermissions: Set, @@ -79,7 +79,7 @@ internal interface ContextualActivitySampleSyncer { healthConnectClient: HealthConnectClient, gbDevice: GBDevice, metadata: Metadata, - offset: ZoneOffset, + offset: ZoneId, sliceStartBoundary: Instant, sliceEndBoundary: Instant, grantedPermissions: Set, 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 75535459ad..726736766b 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 @@ -26,6 +26,7 @@ import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample import nodomain.freeyourgadget.gadgetbridge.util.healthconnect.HealthConnectUtils import org.slf4j.LoggerFactory import java.time.Instant +import java.time.ZoneId import java.time.ZoneOffset import java.time.ZonedDateTime @@ -36,7 +37,7 @@ internal object HeartRateSyncer : ActivitySampleSyncer { healthConnectClient: HealthConnectClient, gbDevice: GBDevice, metadata: Metadata, - offset: ZoneOffset, + offset: ZoneId, sliceStartBoundary: Instant, sliceEndBoundary: Instant, grantedPermissions: Set, @@ -99,7 +100,7 @@ internal object HeartRateSyncer : ActivitySampleSyncer { recordEndTime, currentHcSamples.size ) - heartRateRecordList.add(HeartRateRecord(recordStartTime, offset, recordEndTime, offset, ArrayList(currentHcSamples), metadata)) + heartRateRecordList.add(HeartRateRecord(recordStartTime, offset.rules.getOffset(recordStartTime), recordEndTime, offset.rules.getOffset(recordEndTime), ArrayList(currentHcSamples), metadata)) } else { LOG.warn("Skipping HeartRateRecord for device '$deviceName' from $recordStartTime to $recordEndTime due to invalid duration even after adjustment.") } @@ -126,7 +127,7 @@ internal object HeartRateSyncer : ActivitySampleSyncer { recordEndTime, currentHcSamples.size ) - heartRateRecordList.add(HeartRateRecord(recordStartTime, offset, recordEndTime, offset, ArrayList(currentHcSamples), metadata)) + heartRateRecordList.add(HeartRateRecord(recordStartTime, offset.rules.getOffset(recordStartTime), recordEndTime, offset.rules.getOffset(recordEndTime), ArrayList(currentHcSamples), metadata)) } else { LOG.warn("Skipping final HeartRateRecord for device '$deviceName' from $recordStartTime to $recordEndTime due to invalid duration even after adjustment.") } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/RecordedWorkoutSyncer.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/RecordedWorkoutSyncer.kt index 13038d8158..01c871df85 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/RecordedWorkoutSyncer.kt +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/RecordedWorkoutSyncer.kt @@ -52,6 +52,7 @@ import nodomain.freeyourgadget.gadgetbridge.util.Prefs import nodomain.freeyourgadget.gadgetbridge.util.healthconnect.HealthConnectUtils import org.slf4j.LoggerFactory import java.time.Instant +import java.time.ZoneId import java.time.ZoneOffset import java.util.Date @@ -69,7 +70,7 @@ internal object RecordedWorkoutSyncer { healthConnectClient: HealthConnectClient, gbDevice: GBDevice, metadata: Metadata, - offset: ZoneOffset, + zoneId: ZoneId, sliceStartBoundary: Instant, sliceEndBoundary: Instant, grantedPermissions: Set, @@ -116,6 +117,8 @@ internal object RecordedWorkoutSyncer { workoutsProcessedInThisSlice++ LOG.info("Processing workout for device '$deviceName' (Type: ${workout.activityKind}, Start: $workoutStartInstant, End: $workoutEndInstant).") + val offset = zoneId.rules.getOffset(workoutStartInstant) + val recordsToInsert = mutableListOf() val activityKind = ActivityKind.fromCode(workout.activityKind) val exerciseType = WorkoutSyncerUtils.mapActivityKindToExerciseType(activityKind) diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/SleepSyncer.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/SleepSyncer.kt index 541d85971f..87b000fec3 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/SleepSyncer.kt +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/SleepSyncer.kt @@ -28,7 +28,7 @@ import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample import nodomain.freeyourgadget.gadgetbridge.util.healthconnect.HealthConnectUtils import org.slf4j.LoggerFactory import java.time.Instant -import java.time.ZoneOffset +import java.time.ZoneId private val LOG = LoggerFactory.getLogger("SleepSyncer") @@ -38,7 +38,7 @@ internal object SleepSyncer : ContextualActivitySampleSyncer { healthConnectClient: HealthConnectClient, gbDevice: GBDevice, metadata: Metadata, - offset: ZoneOffset, + offset: ZoneId, sliceStartBoundary: Instant, sliceEndBoundary: Instant, grantedPermissions: Set, @@ -114,7 +114,7 @@ internal object SleepSyncer : ContextualActivitySampleSyncer { sortedDeviceSamples: List, sliceStartBoundary: Instant, sliceEndBoundary: Instant, - offset: ZoneOffset, + offset: ZoneId, metadata: Metadata, context: Context, deviceName: String @@ -182,9 +182,9 @@ internal object SleepSyncer : ContextualActivitySampleSyncer { return SleepSessionRecord( startTime = recordFinalStartTime, - startZoneOffset = offset, + startZoneOffset = offset.rules.getOffset(recordFinalStartTime), endTime = recordFinalEndTime, - endZoneOffset = offset, + endZoneOffset = offset.rules.getOffset(recordFinalEndTime), title = context.getString(nodomain.freeyourgadget.gadgetbridge.R.string.health_connect_sleep_session_title, deviceName), notes = context.getString(nodomain.freeyourgadget.gadgetbridge.R.string.health_connect_sleep_session_notes, deviceName), stages = stages, 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 fd1ebf6c2a..bf3bfce0c9 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 @@ -31,7 +31,7 @@ import nodomain.freeyourgadget.gadgetbridge.util.healthconnect.HealthConnectUtil import nodomain.freeyourgadget.gadgetbridge.util.healthconnect.SyncException import org.slf4j.LoggerFactory import java.time.Instant -import java.time.ZoneOffset +import java.time.ZoneId import java.time.temporal.ChronoUnit private val LOG = LoggerFactory.getLogger("TemperatureSyncer") @@ -47,7 +47,7 @@ internal object TemperatureSyncer : HealthConnectSyncer { healthConnectClient: HealthConnectClient, gbDevice: GBDevice, metadata: Metadata, - offset: ZoneOffset, + offset: ZoneId, sliceStartBoundary: Instant, sliceEndBoundary: Instant, grantedPermissions: Set @@ -108,7 +108,7 @@ internal object TemperatureSyncer : HealthConnectSyncer { recordsToInsert.add( BodyTemperatureRecord( time = timestamp, - zoneOffset = offset, + zoneOffset = offset.rules.getOffset(timestamp), temperature = Temperature.celsius(sampleTemp), measurementLocation = sample.temperatureLocation, metadata = metadata @@ -178,9 +178,9 @@ internal object TemperatureSyncer : HealthConnectSyncer { recordsToInsert.add( SkinTemperatureRecord( startTime = recordStartTime, - startZoneOffset = offset, + startZoneOffset = offset.rules.getOffset(recordStartTime), endTime = recordEndTime, - endZoneOffset = offset, + endZoneOffset = offset.rules.getOffset(recordEndTime), deltas = deltas, baseline = Temperature.celsius(baselineForCurrentRecord), metadata = metadata