Health Connect: Harden sync-start selection against non-positive sample timestamps

When determining the Health Connect sync start point for activity-sample
based data types (ACTIVITY, SLEEP), getFirstSampleTimestamp inspects only
the single oldest row and discards it if its timestamp is not positive.
If that oldest row has a 0 or otherwise non-positive timestamp, the start
point is null and sync is skipped for every data type backed by that table,
while data types on separate tables keep syncing.

Add a getFirstActivitySample(after) overload returning the oldest sample
with a timestamp strictly greater than the given limit, and use it with 0
so an invalid head row no longer disables start-point determination.
This commit is contained in:
Gideon Zenz
2026-05-29 15:31:12 +02:00
parent 7832f8a4a8
commit c516b6e379
7 changed files with 73 additions and 1 deletions
@@ -172,6 +172,29 @@ public abstract class AbstractSampleProvider<T extends AbstractActivitySample> i
return sample;
}
@Nullable
@Override
public T getFirstActivitySample(final int after) {
QueryBuilder<T> qb = getSampleDao().queryBuilder();
Device dbDevice = DBHelper.findDevice(getDevice(), getSession());
if (dbDevice == null) {
// no device, no sample
return null;
}
Property deviceProperty = getDeviceIdentifierSampleProperty();
Property timestampProperty = getTimestampSampleProperty();
qb.where(timestampProperty.gt(after))
.where(deviceProperty.eq(dbDevice.getId()))
.orderAsc(timestampProperty).limit(1);
List<T> samples = qb.build().list();
if (samples.isEmpty()) {
return null;
}
T sample = samples.get(0);
sample.setProvider(this);
return sample;
}
/**
* Get the activity samples between two timestamps (inclusive). Exactly one every minute.
* @param timestamp_from Start timestamp
@@ -119,4 +119,11 @@ public interface SampleProvider<T extends AbstractActivitySample> {
@Nullable
T getFirstActivitySample();
/**
* Returns the activity sample with the oldest timestamp strictly after the given limit, or null if none
* @return the oldest sample after the limit or null
*/
@Nullable
T getFirstActivitySample(int after);
}
@@ -106,6 +106,12 @@ public class UnknownDeviceCoordinator extends AbstractDeviceCoordinator {
return null;
}
@Nullable
@Override
public AbstractActivitySample getFirstActivitySample(final int after) {
return null;
}
}
public UnknownDeviceCoordinator() {
@@ -88,6 +88,10 @@ open class GenericHeartRateActivitySampleProvider(device: GBDevice, session: Dao
return heartRateProvider.firstSample?.let { hrSample -> toGenericActivitySample(hrSample) }
}
override fun getFirstActivitySample(after: Int): GenericActivitySample? {
return getFirstActivitySample()?.takeIf { it.timestamp > after }
}
private fun toGenericActivitySample(hrSample: GenericHeartRateSample): GenericActivitySample {
val activitySample = GenericActivitySample()
activitySample.provider = this
@@ -136,6 +136,12 @@ open class GloryFitActivitySampleProvider(device: GBDevice, session: DaoSession)
return null
}
override fun getFirstActivitySample(after: Int): GenericActivitySample? {
// TODO getFirstActivitySample
LOG.warn("getFirstActivitySample(after) not implemented");
return null
}
fun overlaySleep(samples: MutableList<GenericActivitySample>, timestampFrom: Int, timestampTo: Int) {
val stagesMap = RangeMap<Long, ActivityKind?>(RangeMap.Mode.LOWER_BOUND)
@@ -686,7 +686,7 @@ class HealthConnectUtils {
provider.firstSample?.timestamp?.takeIf { it > 0 }?.let { Instant.ofEpochMilli(it) }
}
is SampleProvider<*> -> { // For ActivitySample based providers
provider.firstActivitySample?.timestamp?.takeIf { it > 0 }?.let { Instant.ofEpochSecond(it.toLong()) }
provider.getFirstActivitySample(0)?.timestamp?.takeIf { it > 0 }?.let { Instant.ofEpochSecond(it.toLong()) }
}
is BaseActivitySummaryDao -> {
val deviceEntity = DBHelper.getDevice(device, db.daoSession) ?: return null
@@ -18,6 +18,7 @@ import nodomain.freeyourgadget.gadgetbridge.test.TestBase;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
@@ -169,6 +170,31 @@ public class SampleProviderTest extends TestBase {
// FIXME assertEquals(1, activitySamples.size());
}
@Test
public void testGetFirstActivitySampleSkipsNonPositiveTimestamp() {
MiBandSampleProvider sampleProvider = new MiBandSampleProvider(dummyGBDevice, daoSession);
User user = DBHelper.getUser(daoSession);
Device device = DBHelper.getDevice(dummyGBDevice, daoSession);
MiBandActivitySample zero = createSample(sampleProvider, MiBandSampleProvider.TYPE_ACTIVITY, 0, 10, 70, 1000, user, device);
MiBandActivitySample valid = createSample(sampleProvider, MiBandSampleProvider.TYPE_ACTIVITY, 100, 20, 80, 1030, user, device);
sampleProvider.addGBActivitySamples(new MiBandActivitySample[] { zero, valid });
// oldest row overall is the timestamp 0 row
assertEquals(0, sampleProvider.getFirstActivitySample().getTimestamp());
// but the overload skips it and returns the first row with timestamp > 0
MiBandActivitySample first = sampleProvider.getFirstActivitySample(0);
assertNotNull(first);
assertEquals(100, first.getTimestamp());
}
@Test
public void testGetFirstActivitySampleNoSamples() {
MiBandSampleProvider sampleProvider = new MiBandSampleProvider(dummyGBDevice, daoSession);
assertNull(sampleProvider.getFirstActivitySample(0));
}
@Test
public void testHighResSamples() {
// Mi Band sample provider does not support this at the moment, so we use the Huawei sample provider