Health Connect: Fix duplicate and truncated sleep records across slice boundaries

SleepSyncer was inserting the same sleep session in multiple slices when
the session spanned a slice boundary. The 1-day look-back caused the
session to be re-discovered in adjacent slices, and the overlap check
allowed it to be synced in each one.
Assign each session to the slice where its start time falls
([sliceStart, sliceEnd)). This ownership rule ensures each session is
synced exactly once regardless of how many slices it overlaps with.
Add a 12-hour look-forward to the DB query for SLEEP so that sessions
starting near the slice end are fetched with their full data, preventing
truncation at the slice boundary.
Fixes: https://codeberg.org/Freeyourgadget/Gadgetbridge/issues/5873
This commit is contained in:
Gideon Zenz
2026-03-12 18:30:52 +01:00
parent f07779f249
commit f9d1f3874c
2 changed files with 15 additions and 6 deletions
@@ -242,6 +242,7 @@ class HealthConnectUtils {
val manager = GBApplication.app().deviceManager
val syncIntervalInSeconds: Long = 24 * 60 * 60 // 1 day slices
val lookBackInSeconds: Long = 24 * 60 * 60
val sleepLookForwardSeconds: Long = 12 * 60 * 60
for (targetAddress in selectedDevices) {
// Check if worker has been cancelled
@@ -363,14 +364,19 @@ class HealthConnectUtils {
updateSyncStatus(summary, true, summaryCallback, mainHandler)
val queryStartTs = currentSliceStartTs.minusSeconds(lookBackInSeconds)
LOG.info("$HC_SYNC_TAG Querying Gadgetbridge DB for {}({}) from {} to {}", gbDevice.aliasOrName, dataType.name, queryStartTs, currentSliceEndTs)
val queryEndTs = if (dataType == HealthConnectPermissionManager.HealthConnectDataType.SLEEP) {
currentSliceEndTs.plusSeconds(sleepLookForwardSeconds)
} else {
currentSliceEndTs
}
LOG.info("$HC_SYNC_TAG Querying Gadgetbridge DB for {}({}) from {} to {}", gbDevice.aliasOrName, dataType.name, queryStartTs, queryEndTs)
// Fetch activityBasedSamples under their own lock if needed for the current dataType
val activityBasedSamples: List<ActivitySample>? =
if (dataType == HealthConnectPermissionManager.HealthConnectDataType.ACTIVITY ||
dataType == HealthConnectPermissionManager.HealthConnectDataType.SLEEP) {
GBApplication.acquireDbReadOnly().use { db ->
getActivitySamples(db, gbDevice, queryStartTs.epochSecond.toInt(), currentSliceEndTs.epochSecond.toInt())
getActivitySamples(db, gbDevice, queryStartTs.epochSecond.toInt(), queryEndTs.epochSecond.toInt())
}
} else {
null
@@ -123,11 +123,14 @@ internal object SleepSyncer : ContextualActivitySampleSyncer {
val sessionBoundaryStart = analysisSession.sleepStart.toInstant()
val sessionBoundaryEndInclusive = analysisSession.sleepEnd.toInstant()
// Check if session overlaps with the current slice (inclusive boundaries [sliceStart, sliceEnd])
// Skip if: sessionStart > sliceEnd OR sessionEnd < sliceStart
if (sessionBoundaryStart.isAfter(sliceEndBoundary) || sessionBoundaryEndInclusive.isBefore(sliceStartBoundary)) {
// Only process this session if its START falls within the current slice [sliceStart, sliceEnd).
// The look-back query ensures full session data is available even for sessions starting near the
// previous slice boundary. The look-forward query ensures full session data for sessions starting
// near the current slice end. This ownership rule prevents duplicate records when the same session
// is discovered across multiple slices due to the look-back overlap.
if (sessionBoundaryStart.isBefore(sliceStartBoundary) || !sessionBoundaryStart.isBefore(sliceEndBoundary)) {
LOG.debug(
"Skipping sleep session (identified by SleepAnalysis) for device '{}' (Timeframe: {} to {}) as it does not overlap with current slice ({} to {}).",
"Skipping sleep session (identified by SleepAnalysis) for device '{}' (Timeframe: {} to {}) as its start does not fall within current slice [{} to {}).",
deviceName,
sessionBoundaryStart,
sessionBoundaryEndInclusive,