Merge pull request 'Health Connect: Harden sync-start selection against non-positive sample timestamps' (#6207)

Reviewed-on: https://codeberg.org/Freeyourgadget/Gadgetbridge/pulls/6207
This commit is contained in:
José Rebelo
2026-06-13 19:50:12 +02:00
7 changed files with 92 additions and 2 deletions
@@ -182,6 +182,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() {
@@ -16,11 +16,13 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.devices.generic_hr
import nodomain.freeyourgadget.gadgetbridge.database.DBHelper
import nodomain.freeyourgadget.gadgetbridge.devices.GenericHeartRateSampleProvider
import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider
import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession
import nodomain.freeyourgadget.gadgetbridge.entities.GenericActivitySample
import nodomain.freeyourgadget.gadgetbridge.entities.GenericHeartRateSample
import nodomain.freeyourgadget.gadgetbridge.entities.GenericHeartRateSampleDao
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice
import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind
@@ -88,6 +90,21 @@ open class GenericHeartRateActivitySampleProvider(device: GBDevice, session: Dao
return heartRateProvider.firstSample?.let { hrSample -> toGenericActivitySample(hrSample) }
}
override fun getFirstActivitySample(after: Int): GenericActivitySample? {
val afterMillis = after.toLong() * 1000
val device = DBHelper.findDevice(heartRateProvider.device, heartRateProvider.session) ?: return null
val samples = heartRateProvider.sampleDao.queryBuilder()
.where(
GenericHeartRateSampleDao.Properties.Timestamp.gt(afterMillis),
GenericHeartRateSampleDao.Properties.DeviceId.eq(device.id)
)
.orderAsc(GenericHeartRateSampleDao.Properties.Timestamp)
.limit(1)
.build()
.list()
return samples.firstOrNull()?.let { toGenericActivitySample(it) }
}
private fun toGenericActivitySample(hrSample: GenericHeartRateSample): GenericActivitySample {
val activitySample = GenericActivitySample()
activitySample.provider = this
@@ -137,6 +137,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)
@@ -497,6 +497,11 @@ class HealthConnectUtils {
private const val INITIAL_DELAY_MS = 1000L
internal const val HC_SYNC_TAG = "[HC_SYNC]"
// Floor the sync-start at 2015 (Gadgetbridge predates it). A bogus near-epoch sample
// timestamp otherwise resolves the start to ~1970 and triggers a full historical resync.
private const val MIN_VALID_SAMPLE_SECONDS = 1420070400L // 2015-01-01T00:00:00Z
private const val MIN_VALID_SAMPLE_MILLIS = MIN_VALID_SAMPLE_SECONDS * 1000
private fun getSyncTimestampRange(
context: Context,
gbDevice: GBDevice,
@@ -683,10 +688,10 @@ class HealthConnectUtils {
): Instant? {
return when (val provider = getProviderForDataType(deviceCoordinator, device, db, dataType)) {
is TimeSampleProvider<*> -> {
provider.firstSample?.timestamp?.takeIf { it > 0 }?.let { Instant.ofEpochMilli(it) }
provider.firstSample?.timestamp?.takeIf { it > MIN_VALID_SAMPLE_MILLIS }?.let { Instant.ofEpochMilli(it) }
}
is SampleProvider<*> -> { // For ActivitySample based providers
provider.firstActivitySample?.timestamp?.takeIf { it > 0 }?.let { Instant.ofEpochSecond(it.toLong()) }
provider.getFirstActivitySample(MIN_VALID_SAMPLE_SECONDS.toInt())?.timestamp?.takeIf { it > MIN_VALID_SAMPLE_SECONDS }?.let { Instant.ofEpochSecond(it.toLong()) }
}
is BaseActivitySummaryDao -> {
val deviceEntity = DBHelper.getDevice(device, db.daoSession) ?: return null
@@ -19,6 +19,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;
@@ -170,6 +171,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