/* Copyright (C) 2016-2024 Andreas Shimokawa, Carsten Pfeiffer, Daniel Dakhno, Daniele Gobbetti, José Rebelo, Petr Vaněk This file is part of Gadgetbridge. Gadgetbridge is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Gadgetbridge is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . */ package nodomain.freeyourgadget.gadgetbridge.devices; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.time.Instant; import java.time.LocalDate; import java.util.ArrayList; import java.util.Calendar; import java.util.Collections; import java.util.LinkedList; import java.util.List; import java.util.ListIterator; import java.util.Set; import de.greenrobot.dao.AbstractDao; import de.greenrobot.dao.Property; import de.greenrobot.dao.query.QueryBuilder; import de.greenrobot.dao.query.WhereCondition; import nodomain.freeyourgadget.gadgetbridge.database.DBHelper; import nodomain.freeyourgadget.gadgetbridge.entities.AbstractActivitySample; import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession; import nodomain.freeyourgadget.gadgetbridge.entities.Device; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind; import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample; /** * Base class for all sample providers. A Sample provider is device specific and provides * access to the device specific samples. There are both read and write operations. * @param the sample type */ public abstract class AbstractSampleProvider implements SampleProvider { private static final Logger LOG = LoggerFactory.getLogger(AbstractSampleProvider.class); private static final WhereCondition[] NO_CONDITIONS = new WhereCondition[0]; private final DaoSession mSession; private final GBDevice mDevice; protected AbstractSampleProvider(GBDevice device, DaoSession session) { mDevice = device; mSession = session; } public GBDevice getDevice() { return mDevice; } public DaoSession getSession() { return mSession; } @NonNull @Override public List getAllActivitySamples(int timestamp_from, int timestamp_to) { return getGBActivitySamples(timestamp_from, timestamp_to); } @NonNull @Override public List getAllActivitySamplesHighRes(int timestamp_from, int timestamp_to) { return getGBActivitySamplesHighRes(timestamp_from, timestamp_to); } @Override public boolean hasHighResData() { return false; } @NonNull @Override @Deprecated // use getAllActivitySamples public List getActivitySamples(int timestamp_from, int timestamp_to) { if (getRawKindSampleProperty() != null) { return getGBActivitySamples(timestamp_from, timestamp_to); } else { return getActivitySamplesByActivityFilter(timestamp_from, timestamp_to, Collections.singleton(ActivityKind.ACTIVITY)); } } @Override public void addGBActivitySample(T activitySample) { getSampleDao().insertOrReplace(activitySample); } @Override public void addGBActivitySamples(T[] activitySamples) { getSampleDao().insertOrReplaceInTx(activitySamples); } @Nullable @Override public T getLatestActivitySample() { QueryBuilder qb = getSampleDao().queryBuilder(); Device dbDevice = DBHelper.findDevice(getDevice(), getSession()); if (dbDevice == null) { // no device, no sample return null; } Property deviceProperty = getDeviceIdentifierSampleProperty(); qb.where(deviceProperty.eq(dbDevice.getId())).orderDesc(getTimestampSampleProperty()).limit(1); List samples = qb.build().list(); if (samples.isEmpty()) { return null; } T sample = samples.get(0); sample.setProvider(this); return sample; } @Nullable @Override public T getLatestActivitySample(final int until) { QueryBuilder 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.le(until)) .where(deviceProperty.eq(dbDevice.getId())) .orderDesc(timestampProperty).limit(1); List samples = qb.build().list(); if (samples.isEmpty()) { return null; } T sample = samples.get(0); sample.setProvider(this); return sample; } @Nullable @Override public T getFirstActivitySample() { QueryBuilder qb = getSampleDao().queryBuilder(); Device dbDevice = DBHelper.findDevice(getDevice(), getSession()); if (dbDevice == null) { // no device, no sample return null; } Property deviceProperty = getDeviceIdentifierSampleProperty(); qb.where(deviceProperty.eq(dbDevice.getId())).orderAsc(getTimestampSampleProperty()).limit(1); List 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 * @param timestamp_to End timestamp * @return Exactly one sample for every minute */ protected List getGBActivitySamples(int timestamp_from, int timestamp_to) { QueryBuilder qb = getSampleDao().queryBuilder(); Property timestampProperty = getTimestampSampleProperty(); Device dbDevice = DBHelper.findDevice(getDevice(), getSession()); if (dbDevice == null) { // no device, no samples return Collections.emptyList(); } Property deviceProperty = getDeviceIdentifierSampleProperty(); qb.where(deviceProperty.eq(dbDevice.getId()), timestampProperty.ge(timestamp_from)) .where(timestampProperty.le(timestamp_to)); List samples = qb.build().list(); for (T sample : samples) { sample.setProvider(this); } detachFromSession(); return samples; } /** * Get the activity samples between two timestamps (inclusive). * Differs from {@link #getGBActivitySamples(int, int)} in that it supplies as many samples as * available. * It assumes {@link #getGBActivitySamples(int, int)} returns the highest resolution data unless * this is overwritten. * @param timestamp_from Start timestamp * @param timestamp_to End timestamp * @return All the samples between start and end timestamp (inclusive) */ protected List getGBActivitySamplesHighRes(int timestamp_from, int timestamp_to) { return getGBActivitySamples(timestamp_from, timestamp_to); } /** * Detaches all samples of this type from the session. Changes to them may not be * written back to the database. *

* Subclasses should call this method after performing custom queries. */ protected void detachFromSession() { getSampleDao().detachAll(); } private List getActivitySamplesByActivityFilter(int timestamp_from, int timestamp_to, Set activityFilter) { List samples = getAllActivitySamples(timestamp_from, timestamp_to); List filteredSamples = new ArrayList<>(); for (T sample : samples) { if (activityFilter.contains(sample.getKind())) { filteredSamples.add(sample); } } return filteredSamples; } public abstract AbstractDao getSampleDao(); @Nullable protected abstract Property getRawKindSampleProperty(); @NonNull protected abstract Property getTimestampSampleProperty(); @NonNull protected abstract Property getDeviceIdentifierSampleProperty(); public void convertCumulativeSteps(final List samples, final Property stepsSampleProperty) { // Fix over-counting at the turn of day final T lastSample = getLastSampleWithStepsBefore(samples.get(0).getTimestamp(), stepsSampleProperty); if (lastSample != null && sameDay(lastSample, samples.get(0))) { if (samples.get(0).getSteps() > 0) { samples.get(0).setSteps(samples.get(0).getSteps() - lastSample.getSteps()); } if (samples.get(0).getDistanceCm() > 0) { samples.get(0).setDistanceCm(samples.get(0).getDistanceCm() - lastSample.getDistanceCm()); } if (samples.get(0).getActiveCalories() > 0) { samples.get(0).setActiveCalories(samples.get(0).getActiveCalories() - lastSample.getActiveCalories()); } } // This slightly breaks activity recognition, because we don't have per-minute granularity... int prevSteps = samples.get(0).getSteps(); int prevDistance = samples.get(0).getDistanceCm(); int prevActiveCalories = samples.get(0).getActiveCalories(); samples.get(0).setTimestamp((samples.get(0).getTimestamp() / 60) * 60); int bak; for (int i = 1; i < samples.size(); i++) { final T s1 = samples.get(i - 1); final T s2 = samples.get(i); s2.setTimestamp((s2.getTimestamp() / 60) * 60); if (!sameDay(s1, s2)) { // went past midnight - reset steps prevSteps = s2.getSteps() > 0 ? s2.getSteps() : 0; prevDistance = s2.getDistanceCm() > 0 ? s2.getDistanceCm() : 0; prevActiveCalories = s2.getActiveCalories() > 0 ? s2.getActiveCalories() : 0; } else { // New value for the current day - subtract the previous seen sample if (s2.getSteps() > 0) { bak = s2.getSteps(); s2.setSteps(s2.getSteps() - prevSteps); prevSteps = bak; } if (s2.getDistanceCm() > 0) { bak = s2.getDistanceCm(); s2.setDistanceCm(s2.getDistanceCm() - prevDistance); prevDistance = bak; } if (s2.getActiveCalories() > 0) { bak = s2.getActiveCalories(); s2.setActiveCalories(s2.getActiveCalories() - prevActiveCalories); prevActiveCalories = bak; } } } } @Nullable public T getLastSampleWithStepsBefore(final int timestampTo, final Property stepsSampleProperty) { final Device dbDevice = DBHelper.findDevice(getDevice(), getSession()); if (dbDevice == null) { // no device, no sample return null; } final List samples = getSampleDao().queryBuilder() .where( getDeviceIdentifierSampleProperty().eq(dbDevice.getId()), getTimestampSampleProperty().le(timestampTo), stepsSampleProperty.gt(-1) ).orderDesc(getTimestampSampleProperty()) .limit(1) .list(); return !samples.isEmpty() ? samples.get(0) : null; } public boolean sameDay(final T s1, final T s2) { final Calendar cal = Calendar.getInstance(); cal.setTimeInMillis(s1.getTimestamp() * 1000L - 1000L); final LocalDate d1 = LocalDate.of(cal.get(Calendar.YEAR), cal.get(Calendar.MONTH) + 1, cal.get(Calendar.DAY_OF_MONTH)); cal.setTimeInMillis(s2.getTimestamp() * 1000L - 1000L); final LocalDate d2 = LocalDate.of(cal.get(Calendar.YEAR), cal.get(Calendar.MONTH) + 1, cal.get(Calendar.DAY_OF_MONTH)); return d1.equals(d2); } protected List fillGaps(final List samples, final int timestamp_from, final int timestamp_to) { if (samples.isEmpty()) { return samples; } final long nanoStart = System.nanoTime(); final List ret = new LinkedList<>(samples); //ret.sort(Comparator.comparingLong(T::getTimestamp)); final int firstTimestamp = ret.get(0).getTimestamp(); if (firstTimestamp - timestamp_from > 60) { // Gap at the start for (int ts = timestamp_from; ts <= firstTimestamp + 60; ts += 60) { ret.add(0, createDummySample(ts)); } } final int lastTimestamp = ret.get(ret.size() - 1).getTimestamp(); // Do not generate fake samples into the future final long minTo = Math.min(timestamp_to, System.currentTimeMillis() / 1000L); if (minTo - lastTimestamp > 60) { // Gap at the end for (int ts = lastTimestamp + 60; ts <= minTo; ts += 60) { ret.add(createDummySample(ts)); } } final ListIterator it = ret.listIterator(); T previousSample = it.next(); while (it.hasNext()) { final T sample = it.next(); if (sample.getTimestamp() - previousSample.getTimestamp() > 60) { LOG.trace("Filling gap between {} and {}", Instant.ofEpochSecond(previousSample.getTimestamp() + 60), Instant.ofEpochSecond(sample.getTimestamp())); for (int ts = previousSample.getTimestamp() + 60; ts < sample.getTimestamp(); ts += 60) { it.add(createDummySample(ts)); } } previousSample = sample; } final long nanoEnd = System.nanoTime(); final long executionTime = (nanoEnd - nanoStart) / 1000000; final int dummyCount = ret.size() - samples.size(); LOG.trace("Filled gaps with {} samples in {}ms", dummyCount, executionTime); return ret; } private T createDummySample(final int ts) { final T dummySample = createActivitySample(); dummySample.setTimestamp(ts); dummySample.setRawKind(ActivityKind.UNKNOWN.getCode()); dummySample.setRawIntensity(ActivitySample.NOT_MEASURED); dummySample.setSteps(ActivitySample.NOT_MEASURED); dummySample.setHeartRate(ActivitySample.NOT_MEASURED); dummySample.setDistanceCm(ActivitySample.NOT_MEASURED); dummySample.setActiveCalories(ActivitySample.NOT_MEASURED); dummySample.setProvider(this); return dummySample; } }