diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiCoordinator.java index 7ec9e3247..d5dc940a2 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiCoordinator.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiCoordinator.java @@ -229,7 +229,7 @@ public abstract class XiaomiCoordinator extends AbstractBLEDeviceCoordinator { @Override public boolean supportsActivityTracks() { - // TODO It does, but not yet fully working + // TODO It does, but not yet fully working - only in Mi Band 8 return false; } @@ -245,25 +245,19 @@ public abstract class XiaomiCoordinator extends AbstractBLEDeviceCoordinator { @Override public boolean supportsHeartRateStats() { - // TODO does it? + // TODO it does - see DailySummaryParser return false; } @Override public boolean supportsPai() { - // TODO does it? + // TODO it does - vitality score return false; } @Override public boolean supportsSleepRespiratoryRate() { - // TODO does it? - return false; - } - - @Override - public boolean supportsAlarmDescription(final GBDevice device) { - // TODO does it? + // TODO it does return false; } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/miband8/MiBand8Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/miband8/MiBand8Coordinator.java index eb32fc823..3f070e832 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/miband8/MiBand8Coordinator.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/miband8/MiBand8Coordinator.java @@ -73,6 +73,12 @@ public class MiBand8Coordinator extends XiaomiCoordinator { return true; } + @Override + public boolean supportsActivityTracks() { + // FIXME still has some issues + return true; + } + @Override public boolean supportsMultipleWeatherLocations() { return true; diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/export/ActivityTrackExporter.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/export/ActivityTrackExporter.java index c590359bb..2be498adc 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/export/ActivityTrackExporter.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/export/ActivityTrackExporter.java @@ -19,13 +19,9 @@ package nodomain.freeyourgadget.gadgetbridge.export; import java.io.File; import java.io.IOException; -import androidx.annotation.NonNull; import nodomain.freeyourgadget.gadgetbridge.model.ActivityTrack; public interface ActivityTrackExporter { - @NonNull - String getDefaultFileName(@NonNull ActivityTrack track); - void performExport(ActivityTrack track, File targetFile) throws IOException, GPXTrackEmptyException; class GPXTrackEmptyException extends Exception { diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/export/GPXExporter.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/export/GPXExporter.java index e80f61eba..ad8ff59e9 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/export/GPXExporter.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/export/GPXExporter.java @@ -19,7 +19,6 @@ package nodomain.freeyourgadget.gadgetbridge.export; import android.util.Xml; -import androidx.annotation.NonNull; import androidx.annotation.Nullable; import org.xmlpull.v1.XmlSerializer; @@ -39,7 +38,6 @@ import nodomain.freeyourgadget.gadgetbridge.model.ActivityPoint; import nodomain.freeyourgadget.gadgetbridge.model.ActivityTrack; import nodomain.freeyourgadget.gadgetbridge.model.GPSCoordinate; import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils; -import nodomain.freeyourgadget.gadgetbridge.util.FileUtils; public class GPXExporter implements ActivityTrackExporter { private static final String NS_GPX_URI = "http://www.topografix.com/GPX/1/1"; @@ -56,12 +54,6 @@ public class GPXExporter implements ActivityTrackExporter { private boolean includeHeartRate = true; private boolean includeHeartRateOfNearestSample = true; - @NonNull - @Override - public String getDefaultFileName(@NonNull ActivityTrack track) { - return FileUtils.makeValidFileName(track.getName()); - } - @Override public void performExport(ActivityTrack track, File targetFile) throws IOException, GPXTrackEmptyException { String encoding = StandardCharsets.UTF_8.name(); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivityPoint.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivityPoint.java index f87ac9bbc..cca603199 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivityPoint.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivityPoint.java @@ -38,9 +38,7 @@ public class ActivityPoint { private Date time; private GPSCoordinate location; private int heartRate; - private long speed4; - private long speed5; - private long speed6; + private float speed = -1; // e.g. to describe a pause during the activity private @Nullable String description; @@ -85,27 +83,11 @@ public class ActivityPoint { this.heartRate = heartRate; } - public long getSpeed4() { - return speed4; + public float getSpeed() { + return speed; } - public void setSpeed4(long speed4) { - this.speed4 = speed4; - } - - public long getSpeed5() { - return speed5; - } - - public void setSpeed5(long speed5) { - this.speed5 = speed5; - } - - public long getSpeed6() { - return speed6; - } - - public void setSpeed6(long speed6) { - this.speed6 = speed6; + public void setSpeed(float speed) { + this.speed = speed; } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/activity/XiaomiActivityFileFetcher.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/activity/XiaomiActivityFileFetcher.java index 6b0370372..f5547dd10 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/activity/XiaomiActivityFileFetcher.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/activity/XiaomiActivityFileFetcher.java @@ -28,7 +28,9 @@ import java.io.OutputStream; import java.text.SimpleDateFormat; import java.util.Arrays; import java.util.LinkedList; +import java.util.List; import java.util.Locale; +import java.util.PriorityQueue; import java.util.Queue; import java.util.TimeZone; @@ -47,7 +49,7 @@ public class XiaomiActivityFileFetcher { private final XiaomiHealthService mHealthService; - private final Queue mFetchQueue = new LinkedList<>(); + private final Queue mFetchQueue = new PriorityQueue<>(); private ByteArrayOutputStream mBuffer = new ByteArrayOutputStream(); private boolean isFetching = false; @@ -124,15 +126,15 @@ public class XiaomiActivityFileFetcher { LOG.warn("Failed to parse {}", fileId); } } catch (final Exception ex) { - LOG.error("addChunk(): failed to parse activity: ", ex); + LOG.error("Exception while parsing " + fileId, ex); } triggerNextFetch(); } } - public void fetch(final XiaomiActivityFileId fileId) { - mFetchQueue.add(fileId); + public void fetch(final List fileIds) { + mFetchQueue.addAll(fileIds); if (!isFetching) { // Currently not fetching anything, fetch the next isFetching = true; @@ -163,23 +165,12 @@ public class XiaomiActivityFileFetcher { } protected void dumpBytesToExternalStorage(final XiaomiActivityFileId fileId, final byte[] bytes) { - final SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMdd'T'HHmmss", Locale.US); - try { final File externalFilesDir = FileUtils.getExternalFilesDir(); final File targetDir = new File(externalFilesDir, "rawFetchOperations"); targetDir.mkdirs(); - final String filename = String.format( - Locale.ROOT, "xiaomi_%s_%02X_%02X_%02X_v%d.bin", - sdf.format(fileId.getTimestamp()), - fileId.getTypeCode(), - fileId.getSubtypeCode(), - fileId.getDetailTypeCode(), - fileId.getVersion() - ); - - final File outputFile = new File(targetDir, filename); + final File outputFile = new File(targetDir, fileId.getFilename()); final OutputStream outputStream = new FileOutputStream(outputFile); outputStream.write(bytes); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/activity/XiaomiActivityFileId.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/activity/XiaomiActivityFileId.java index dfe269783..a42d536ef 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/activity/XiaomiActivityFileId.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/activity/XiaomiActivityFileId.java @@ -18,13 +18,17 @@ package nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.activity; import androidx.annotation.NonNull; +import org.apache.commons.lang3.builder.CompareToBuilder; + import java.nio.ByteBuffer; import java.nio.ByteOrder; +import java.text.SimpleDateFormat; import java.util.Date; +import java.util.Locale; import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils; -public class XiaomiActivityFileId { +public class XiaomiActivityFileId implements Comparable { private final Date timestamp; private final int timezone; private final int type; @@ -133,6 +137,32 @@ public class XiaomiActivityFileId { "}"; } + @Override + public int compareTo(final XiaomiActivityFileId o) { + return new CompareToBuilder() + .append(timestamp, o.timestamp) + .append(timezone, o.timezone) + .append(type, o.type) + .append(subtype, o.subtype) + .append(getDetailType().getFetchOrder(), o.getDetailType().getFetchOrder()) + .append(version, o.version) + .build(); + } + + public String getFilename() { + final SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMdd'T'HHmmss", Locale.US); + + return String.format( + Locale.ROOT, + "xiaomi_%s_%02X_%02X_%02X_v%d.bin", + sdf.format(getTimestamp()), + getTypeCode(), + getSubtypeCode(), + getDetailTypeCode(), + getVersion() + ); + } + public enum Type { UNKNOWN(-1), ACTIVITY(0), @@ -166,6 +196,7 @@ public class XiaomiActivityFileId { SPORTS_OUTDOOR_RUNNING(Type.SPORTS, 0x01), SPORTS_FREESTYLE(Type.SPORTS, 0x08), SPORTS_ELLIPTICAL(Type.SPORTS, 0x0B), + SPORTS_OUTDOOR_WALKING(Type.SPORTS, 0x16), SPORTS_OUTDOOR_CYCLING(Type.SPORTS, 0x17), ; @@ -208,6 +239,21 @@ public class XiaomiActivityFileId { return code; } + public int getFetchOrder() { + // Fetch summary first, so we have the summary data for workouts + // before parsing the gps track + switch (this) { + case SUMMARY: + return 0; + case DETAILS: + return 1; + case GPS_TRACK: + return 2; + } + + return 3; + } + public static DetailType fromCode(final int code) { for (final DetailType detailType : values()) { if (detailType.getCode() == code) { diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/activity/XiaomiActivityParser.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/activity/XiaomiActivityParser.java index 9de6aeb38..d4b14b040 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/activity/XiaomiActivityParser.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/activity/XiaomiActivityParser.java @@ -21,9 +21,20 @@ import androidx.annotation.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.util.List; + +import de.greenrobot.dao.query.QueryBuilder; +import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary; +import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummaryDao; +import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession; +import nodomain.freeyourgadget.gadgetbridge.entities.Device; +import nodomain.freeyourgadget.gadgetbridge.entities.User; +import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind; import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiSupport; import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.activity.impl.DailyDetailsParser; +import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.activity.impl.DailySummaryParser; import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.activity.impl.SleepDetailsParser; +import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.activity.impl.WorkoutGpsParser; import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.activity.impl.WorkoutSummaryParser; public abstract class XiaomiActivityParser { @@ -31,6 +42,34 @@ public abstract class XiaomiActivityParser { public abstract boolean parse(final XiaomiSupport support, final XiaomiActivityFileId fileId, final byte[] bytes); + protected BaseActivitySummary findOrCreateBaseActivitySummary(final DaoSession session, + final Device device, + final User user, + final XiaomiActivityFileId fileId) { + final BaseActivitySummaryDao summaryDao = session.getBaseActivitySummaryDao(); + final QueryBuilder qb = summaryDao.queryBuilder(); + qb.where(BaseActivitySummaryDao.Properties.StartTime.eq(fileId.getTimestamp())); + qb.where(BaseActivitySummaryDao.Properties.DeviceId.eq(device.getId())); + qb.where(BaseActivitySummaryDao.Properties.UserId.eq(user.getId())); + final List summaries = qb.build().list(); + if (summaries.isEmpty()) { + final BaseActivitySummary summary = new BaseActivitySummary(); + summary.setStartTime(fileId.getTimestamp()); + summary.setDevice(device); + summary.setUser(user); + + // These will be set later, once we parse the summary + summary.setEndTime(fileId.getTimestamp()); + summary.setActivityKind(ActivityKind.TYPE_UNKNOWN); + + return summary; + } + if (summaries.size() > 1) { + LOG.warn("Found multiple summaries for {}", fileId); + } + return summaries.get(0); + } + @Nullable public static XiaomiActivityParser create(final XiaomiActivityFileId fileId) { switch (fileId.getType()) { @@ -52,6 +91,9 @@ public abstract class XiaomiActivityParser { if (fileId.getDetailType() == XiaomiActivityFileId.DetailType.DETAILS) { return new DailyDetailsParser(); } + if (fileId.getDetailType() == XiaomiActivityFileId.DetailType.SUMMARY) { + return new DailySummaryParser(); + } break; case ACTIVITY_SLEEP: @@ -71,6 +113,8 @@ public abstract class XiaomiActivityParser { switch (fileId.getDetailType()) { case SUMMARY: return new WorkoutSummaryParser(); + case GPS_TRACK: + return new WorkoutGpsParser(); } return null; diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/activity/impl/DailyDetailsParser.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/activity/impl/DailyDetailsParser.java index f5b7f1ad5..c3912dd9b 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/activity/impl/DailyDetailsParser.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/activity/impl/DailyDetailsParser.java @@ -69,6 +69,8 @@ public class DailyDetailsParser extends XiaomiActivityParser { final byte[] header = new byte[headerSize]; buf.get(header); + LOG.debug("Daily Details Header: {}", GB.hexdump(header)); + if ((buf.limit() - buf.position()) % sampleSize != 0) { LOG.warn("Remaining data in the buffer is not a multiple of {}", sampleSize); return false; @@ -85,8 +87,11 @@ public class DailyDetailsParser extends XiaomiActivityParser { sample.setSteps(buf.getShort()); - final byte[] unknown1 = new byte[4]; - buf.get(unknown1); // TODO intensity and kind? + final int calories = buf.get() & 0xff; + final int unk2 = buf.get() & 0xff; + final int distance = buf.getShort(); // not just walking, includes workouts like cycling + + // TODO persist calories and distance, add UI sample.setHeartRate(buf.get() & 0xff); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/activity/impl/DailySummaryParser.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/activity/impl/DailySummaryParser.java new file mode 100644 index 000000000..786fb23a0 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/activity/impl/DailySummaryParser.java @@ -0,0 +1,88 @@ +/* Copyright (C) 2023 José Rebelo + + 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.service.devices.xiaomi.activity.impl; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiSupport; +import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.activity.XiaomiActivityFileId; +import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.activity.XiaomiActivityParser; +import nodomain.freeyourgadget.gadgetbridge.util.GB; + +public class DailySummaryParser extends XiaomiActivityParser { + private static final Logger LOG = LoggerFactory.getLogger(DailySummaryParser.class); + + @Override + public boolean parse(final XiaomiSupport support, final XiaomiActivityFileId fileId, final byte[] bytes) { + final int version = fileId.getVersion(); + final int headerSize; + switch (version) { + case 5: + headerSize = 4; + break; + default: + LOG.warn("Unable to parse daily summary version {}", fileId.getVersion()); + return false; + } + + final ByteBuffer buf = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN); + final byte[] header = new byte[headerSize]; + buf.get(header); + + LOG.debug("Header: {}", GB.hexdump(header)); + + final int steps = buf.getInt(); + final int unk1 = buf.get() & 0xff; // 0 + final int unk2 = buf.get() & 0xff; // 0 + final int unk3 = buf.get() & 0xff; // 0 + final int hrResting = buf.get() & 0xff; + final int hrMax = buf.get() & 0xff; + final int hrMaxTs = buf.getInt(); + final int hrMin = buf.get() & 0xff; + final int hrMinTs = buf.getInt(); + final int hrAvg = buf.get() & 0xff; + final int stressAvg = buf.get() & 0xff; + final int stressMax = buf.get() & 0xff; + final int stressMin = buf.get() & 0xff; + final int unk4 = buf.get() & 0xff; // 0 + final int unk5 = buf.get() & 0xff; // 0 + final int unk6 = buf.get() & 0xff; // 0 + final int calories = buf.getShort(); + final int unk7 = buf.get() & 0xff; // 0 + final int unk8 = buf.get() & 0xff; // 0 + final int unk9 = buf.get() & 0xff; // 0 + final int spo2Max = buf.get() & 0xff; + final int spo2MaxTs = buf.getInt(); + final int spo2Min = buf.get() & 0xff; + final int spo2MinTs = buf.getInt(); + final int spo2Avg = buf.get() & 0xff; + final int trainingLoadMaybe1 = buf.getShort(); + final int trainingLoadMaybe2 = buf.getShort(); + final int trainingLoadMaybe3 = buf.get() & 0xff; + + // TODO vitality somewhere? + // TODO persist everything + + LOG.warn("Persisting daily summary is not implemented"); + + return true; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/activity/impl/WorkoutGpsParser.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/activity/impl/WorkoutGpsParser.java new file mode 100644 index 000000000..1a4ef530c --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/activity/impl/WorkoutGpsParser.java @@ -0,0 +1,159 @@ +/* Copyright (C) 2023 José Rebelo + + 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.service.devices.xiaomi.activity.impl; + +import android.widget.Toast; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.Date; + +import nodomain.freeyourgadget.gadgetbridge.GBApplication; +import nodomain.freeyourgadget.gadgetbridge.database.DBHandler; +import nodomain.freeyourgadget.gadgetbridge.database.DBHelper; +import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary; +import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession; +import nodomain.freeyourgadget.gadgetbridge.entities.Device; +import nodomain.freeyourgadget.gadgetbridge.entities.User; +import nodomain.freeyourgadget.gadgetbridge.export.ActivityTrackExporter; +import nodomain.freeyourgadget.gadgetbridge.export.GPXExporter; +import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind; +import nodomain.freeyourgadget.gadgetbridge.model.ActivityPoint; +import nodomain.freeyourgadget.gadgetbridge.model.ActivityTrack; +import nodomain.freeyourgadget.gadgetbridge.model.GPSCoordinate; +import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiSupport; +import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.activity.XiaomiActivityFileId; +import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.activity.XiaomiActivityParser; +import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils; +import nodomain.freeyourgadget.gadgetbridge.util.FileUtils; +import nodomain.freeyourgadget.gadgetbridge.util.GB; + +public class WorkoutGpsParser extends XiaomiActivityParser { + private static final Logger LOG = LoggerFactory.getLogger(WorkoutGpsParser.class); + + @Override + public boolean parse(final XiaomiSupport support, final XiaomiActivityFileId fileId, final byte[] bytes) { + final int version = fileId.getVersion(); + final int headerSize; + final int sampleSize; + switch (version) { + case 1: + case 2: + headerSize = 1; + sampleSize = 18; + break; + default: + LOG.warn("Unable to parse workout gps version {}", fileId.getVersion()); + return false; + } + + final ByteBuffer buf = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN); + final byte[] header = new byte[headerSize]; + buf.get(header); + + LOG.debug("Workout gps Header: {}", GB.hexdump(header)); + + if ((buf.limit() - buf.position()) % sampleSize != 0) { + LOG.warn("Remaining data in the buffer is not a multiple of {}", sampleSize); + return false; + } + + final ActivityTrack activityTrack = new ActivityTrack(); + + while (buf.position() < buf.limit()) { + final int ts = buf.getInt(); + final float longitude = buf.getFloat(); + final float latitude = buf.getFloat(); + final int unk1 = buf.getInt(); // 0 + final float speed = (buf.getShort() >> 2) / 10.0f; + + final ActivityPoint ap = new ActivityPoint(new Date(ts * 1000L)); + ap.setLocation(new GPSCoordinate(longitude, latitude, 0)); + activityTrack.addTrackPoint(ap); + + LOG.trace("ActivityPoint: ts={} lon={} lat={} unk1={} speed={}", ts, longitude, latitude, unk1, speed); + } + + try (DBHandler dbHandler = GBApplication.acquireDB()) { + final DaoSession session = dbHandler.getDaoSession(); + final Device device = DBHelper.getDevice(support.getDevice(), session); + final User user = DBHelper.getUser(session); + + // Find the matching summary + final BaseActivitySummary summary = findOrCreateBaseActivitySummary(session, device, user, fileId); + + // Set the info on the activity track + activityTrack.setUser(user); + activityTrack.setDevice(device); + activityTrack.setName(ActivityKind.asString(summary.getActivityKind(), support.getContext())); + + // Save the raw bytes + final String rawBytesPath = saveRawBytes(fileId, bytes); + + // Save the gpx file + final GPXExporter exporter = new GPXExporter(); + exporter.setCreator(GBApplication.app().getNameAndVersion()); + + final String gpxFileName = FileUtils.makeValidFileName("gadgetbridge-" + DateTimeUtils.formatIso8601(fileId.getTimestamp()) + ".gpx"); + final File gpxTargetFile = new File(FileUtils.getExternalFilesDir(), gpxFileName); + + boolean exportGpxSuccess = true; + try { + exporter.performExport(activityTrack, gpxTargetFile); + } catch (final ActivityTrackExporter.GPXTrackEmptyException ex) { + exportGpxSuccess = false; + GB.toast(support.getContext(), "This activity does not contain GPX tracks.", Toast.LENGTH_LONG, GB.ERROR, ex); + } + + if (exportGpxSuccess) { + summary.setGpxTrack(gpxTargetFile.getAbsolutePath()); + } + if (rawBytesPath != null) { + summary.setRawDetailsPath(rawBytesPath); + } + session.getBaseActivitySummaryDao().insertOrReplace(summary); + } catch (final Exception e) { + GB.toast(support.getContext(), "Error saving workout gps", Toast.LENGTH_LONG, GB.ERROR, e); + return false; + } + + return true; + } + + private String saveRawBytes(final XiaomiActivityFileId fileId, final byte[] bytes) { + try { + final File targetFolder = new File(FileUtils.getExternalFilesDir(), "rawDetails"); + targetFolder.mkdirs(); + final File targetFile = new File(targetFolder, fileId.getFilename()); + FileOutputStream outputStream = new FileOutputStream(targetFile); + outputStream.write(fileId.toBytes()); + outputStream.write(bytes); + outputStream.close(); + return targetFile.getAbsolutePath(); + } catch (final IOException e) { + LOG.error("Failed to save raw bytes", e); + } + + return null; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/activity/impl/WorkoutSummaryParser.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/activity/impl/WorkoutSummaryParser.java index 6b9dff69d..afd3ccd1a 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/activity/impl/WorkoutSummaryParser.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/activity/impl/WorkoutSummaryParser.java @@ -71,9 +71,14 @@ public class WorkoutSummaryParser extends XiaomiActivityParser implements Activi final DaoSession session = dbHandler.getDaoSession(); final Device device = DBHelper.getDevice(support.getDevice(), session); final User user = DBHelper.getUser(session); - summary.setDevice(device); - summary.setUser(user); - session.getBaseActivitySummaryDao().insertOrReplace(summary); + + final BaseActivitySummary existingSummary = findOrCreateBaseActivitySummary(session, device, user, fileId); + existingSummary.setEndTime(summary.getEndTime()); + existingSummary.setActivityKind(summary.getActivityKind()); + existingSummary.setRawSummaryData(summary.getRawSummaryData()); + existingSummary.setSummaryData(null); // remove json before saving to database + + session.getBaseActivitySummaryDao().insertOrReplace(existingSummary); } catch (final Exception e) { GB.toast(support.getContext(), "Error saving activity summary", Toast.LENGTH_LONG, GB.ERROR, e); return false; @@ -117,7 +122,9 @@ public class WorkoutSummaryParser extends XiaomiActivityParser implements Activi final int startTime = buf.getInt(); final int endTime = buf.getInt(); - summary.setStartTime(new Date(startTime * 1000L)); + // We don't set the start time, since we need it to match the fileId for the WorkoutGpsParser + // to find it. They also seem to match. + //summary.setStartTime(new Date(startTime * 1000L)); summary.setEndTime(new Date(endTime * 1000L)); final int duration = buf.getInt(); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/services/XiaomiHealthService.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/services/XiaomiHealthService.java index fb5c20cc5..2399df131 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/services/XiaomiHealthService.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/services/XiaomiHealthService.java @@ -29,9 +29,11 @@ import org.slf4j.LoggerFactory; import java.nio.ByteBuffer; import java.nio.ByteOrder; +import java.util.ArrayList; import java.util.Calendar; import java.util.Date; import java.util.GregorianCalendar; +import java.util.List; import java.util.Locale; import nodomain.freeyourgadget.gadgetbridge.GBApplication; @@ -775,12 +777,14 @@ public class XiaomiHealthService extends AbstractXiaomiService { LOG.debug("Got {} activity file IDs", recordIds.length / 7); final ByteBuffer buf = ByteBuffer.wrap(recordIds).order(ByteOrder.LITTLE_ENDIAN); + final List fileIds = new ArrayList<>(); while (buf.position() < buf.limit()) { final XiaomiActivityFileId fileId = XiaomiActivityFileId.from(buf); LOG.debug("Got activity to fetch: {}", fileId); - activityFetcher.fetch(fileId); + fileIds.add(fileId); } + activityFetcher.fetch(fileIds); if (subtype == CMD_ACTIVITY_FETCH_TODAY) { LOG.debug("Fetch recorded data from the past");