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.
This commit is contained in:
Gideon Zenz
2026-04-05 23:40:57 +02:00
parent bc63bfa588
commit 746bc16bfa
8 changed files with 33 additions and 26 deletions
@@ -238,7 +238,7 @@ class HealthConnectUtils {
val recordsSyncedByType = mutableMapOf<String, Int>() // Track records per data type
val dataTypesWithErrors = mutableSetOf<String>() // 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<String>,
@@ -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<TRecord : Record> : Activit
healthConnectClient: HealthConnectClient,
gbDevice: GBDevice,
metadata: Metadata,
offset: ZoneOffset,
offset: ZoneId,
sliceStartBoundary: Instant,
sliceEndBoundary: Instant,
grantedPermissions: Set<String>,
@@ -89,7 +90,7 @@ internal abstract class AbstractActivitySampleSyncer<TRecord : Record> : 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
@@ -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<TSample : TimeSample, TRecord :
healthConnectClient: HealthConnectClient,
gbDevice: GBDevice,
metadata: Metadata,
offset: ZoneOffset,
offset: ZoneId,
sliceStartBoundary: Instant,
sliceEndBoundary: Instant,
grantedPermissions: Set<String>
@@ -114,9 +115,10 @@ internal abstract class AbstractTimeSampleSyncer<TSample : TimeSample, TRecord :
}
return@filter true
}.mapNotNull { sample ->
val sampleOffset = offset.rules.getOffset(Instant.ofEpochMilli(sample.timestamp))
convertSample(
sample,
offset,
sampleOffset,
metadata,
deviceName
)?.also {
@@ -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<String>
@@ -62,7 +62,7 @@ internal interface ActivitySampleSyncer {
healthConnectClient: HealthConnectClient,
gbDevice: GBDevice,
metadata: Metadata,
offset: ZoneOffset,
offset: ZoneId,
sliceStartBoundary: Instant,
sliceEndBoundary: Instant,
grantedPermissions: Set<String>,
@@ -79,7 +79,7 @@ internal interface ContextualActivitySampleSyncer {
healthConnectClient: HealthConnectClient,
gbDevice: GBDevice,
metadata: Metadata,
offset: ZoneOffset,
offset: ZoneId,
sliceStartBoundary: Instant,
sliceEndBoundary: Instant,
grantedPermissions: Set<String>,
@@ -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<String>,
@@ -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.")
}
@@ -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<String>,
@@ -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<Record>()
val activityKind = ActivityKind.fromCode(workout.activityKind)
val exerciseType = WorkoutSyncerUtils.mapActivityKindToExerciseType(activityKind)
@@ -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<String>,
@@ -114,7 +114,7 @@ internal object SleepSyncer : ContextualActivitySampleSyncer {
sortedDeviceSamples: List<ActivitySample>,
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,
@@ -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<String>
@@ -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