Health Connect: Debounce event-triggered sync and fix resting HR lower bound

Two robustness fixes for the event-triggered Health Connect sync.

Debounce: an activity fetch can broadcast ACTION_NEW_DATA several times in
quick succession - multi-phase fetches and some chatty drivers emit the finish
signal mid-fetch - and each broadcast immediately enqueued a sync. A sync that
runs while the fetch is still in progress reads a half-populated database and
can drop or duplicate records for the in-flight range. Enqueue the per-device
worker with a short initial delay and ExistingWorkPolicy.REPLACE, so a burst of
broadcasts collapses into one sync that runs once the fetch has settled. The
per-device work name is unchanged (devices debounce independently) and the
manual/debug sync paths use separate work names, so "Sync now" is unaffected.

Resting heart rate: the platform RestingHeartRateRecord rejects bpm < 1, but
the filter allowed 0..300, so a 0 bpm sample threw IllegalArgumentException and
aborted that data type's slice. Filter 1..300 instead, matching the regular
HeartRate syncer. Update the existing boundary test accordingly.
This commit is contained in:
Gideon Zenz
2026-06-18 16:18:21 +02:00
parent 03b8f3b74d
commit a666b4acb1
3 changed files with 21 additions and 8 deletions
@@ -35,6 +35,7 @@ import org.slf4j.LoggerFactory;
import java.util.Collections; import java.util.Collections;
import java.util.Locale; import java.util.Locale;
import java.util.Set; import java.util.Set;
import java.util.concurrent.TimeUnit;
import nodomain.freeyourgadget.gadgetbridge.GBApplication; import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
@@ -43,6 +44,11 @@ import nodomain.freeyourgadget.gadgetbridge.util.healthconnect.HealthConnectSync
public class NewDataReceiver extends BroadcastReceiver { public class NewDataReceiver extends BroadcastReceiver {
private static final String HEALTH_CONNECT_SYNC_WORKER_TAG = "HealthConnectSyncWorker"; private static final String HEALTH_CONNECT_SYNC_WORKER_TAG = "HealthConnectSyncWorker";
// Debounce window: a fetch can emit several ACTION_NEW_DATA broadcasts in quick succession
// (multi-phase or chatty drivers fire mid-fetch), and a sync that runs against a half-fetched
// database can miss or duplicate records. Delay the sync and restart the timer on every new
// broadcast (REPLACE), so a single sync runs once the fetch has settled.
private static final long DEBOUNCE_SECONDS = 10;
private final Logger LOG = LoggerFactory.getLogger(NewDataReceiver.class); private final Logger LOG = LoggerFactory.getLogger(NewDataReceiver.class);
private Context context; private Context context;
private boolean registered = false; private boolean registered = false;
@@ -91,12 +97,12 @@ public class NewDataReceiver extends BroadcastReceiver {
return; return;
} }
// For device-specific syncs, use a device-specific work name and APPEND policy // Per-device unique work name so each device debounces independently.
// This ensures each device's HC sync is queued and executed sequentially
String workName = HEALTH_CONNECT_SYNC_WORKER_TAG + "_" + deviceAddress; String workName = HEALTH_CONNECT_SYNC_WORKER_TAG + "_" + deviceAddress;
OneTimeWorkRequest syncRequest = new OneTimeWorkRequest.Builder(HealthConnectSyncWorker.class) OneTimeWorkRequest syncRequest = new OneTimeWorkRequest.Builder(HealthConnectSyncWorker.class)
.addTag(HEALTH_CONNECT_SYNC_WORKER_TAG) .addTag(HEALTH_CONNECT_SYNC_WORKER_TAG)
.setInitialDelay(DEBOUNCE_SECONDS, TimeUnit.SECONDS)
.setInputData( .setInputData(
new Data.Builder() new Data.Builder()
.putString(HealthConnectSyncWorker.INPUT_DEVICE_ADDRESS, deviceAddress) .putString(HealthConnectSyncWorker.INPUT_DEVICE_ADDRESS, deviceAddress)
@@ -106,10 +112,11 @@ public class NewDataReceiver extends BroadcastReceiver {
LOG.debug("Scheduling HC sync for device: {} with work name: {}", deviceAddress, workName); LOG.debug("Scheduling HC sync for device: {} with work name: {}", deviceAddress, workName);
// Use APPEND so multiple syncs for the same device queue up // REPLACE so a burst of broadcasts during one fetch cancels the pending (delayed) sync
// and re-arms it; the sync runs once, DEBOUNCE_SECONDS after the last broadcast.
WorkManager.getInstance(context).enqueueUniqueWork( WorkManager.getInstance(context).enqueueUniqueWork(
workName, workName,
ExistingWorkPolicy.APPEND, ExistingWorkPolicy.REPLACE,
syncRequest syncRequest
); );
} }
@@ -45,9 +45,10 @@ internal object RestingHeartRateSyncer : AbstractTimeSampleSyncer<HeartRateSampl
metadata: Metadata, metadata: Metadata,
deviceName: String deviceName: String
): RestingHeartRateRecord? { ): RestingHeartRateRecord? {
// HC enforces 0..300 bpm; exclude 255, the bad-measurement sentinel GB uses for HR values. // HC enforces 1..300 bpm (the platform RestingHeartRateRecord rejects 0, unlike the
if (sample.heartRate !in 0..300 || sample.heartRate == 255) { // androidx client); also exclude 255, the bad-measurement sentinel GB uses for HR values.
logger.skipOutOfRange(deviceName, "RestingHeartRate", sample.heartRate, "0..300 bpm (255 excluded)") if (sample.heartRate !in 1..300 || sample.heartRate == 255) {
logger.skipOutOfRange(deviceName, "RestingHeartRate", sample.heartRate, "1..300 bpm (255 excluded)")
return null return null
} }
@@ -191,7 +191,12 @@ class SyncerRangeValidationTest {
@Test @Test
fun restingHr_lowerBoundary_accepted() { fun restingHr_lowerBoundary_accepted() {
assertNotNull(RestingHeartRateSyncer.convertSample(hrSample(0), offset, metadata, device)) assertNotNull(RestingHeartRateSyncer.convertSample(hrSample(1), offset, metadata, device))
}
@Test
fun restingHr_zero_dropped() {
assertNull(RestingHeartRateSyncer.convertSample(hrSample(0), offset, metadata, device))
} }
@Test @Test