diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminCoordinator.java index d053daa08..30172d821 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminCoordinator.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminCoordinator.java @@ -1,6 +1,10 @@ package nodomain.freeyourgadget.gadgetbridge.devices.garmin; +import android.content.Context; +import android.net.Uri; + import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import java.util.Collections; import java.util.List; @@ -12,6 +16,7 @@ import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpec import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer; import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsScreen; import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator; +import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler; import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider; import nodomain.freeyourgadget.gadgetbridge.devices.TimeSampleProvider; import nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.GarminCapability; @@ -265,4 +270,14 @@ public abstract class GarminCoordinator extends AbstractBLEDeviceCoordinator { return getPrefs(device).getStringSet(GarminPreferences.PREF_GARMIN_CAPABILITIES, Collections.emptySet()) .contains(capability.name()); } + + @Nullable + @Override + public InstallHandler findInstallHandler(Uri uri, Context context) { + + final GarminGpxRouteInstallHandler garminGpxRouteInstallHandler = new GarminGpxRouteInstallHandler(uri, context); + if (garminGpxRouteInstallHandler.isValid()) + return garminGpxRouteInstallHandler; + return null; + } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminGpxRouteInstallHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminGpxRouteInstallHandler.java new file mode 100644 index 000000000..5afb29ef8 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminGpxRouteInstallHandler.java @@ -0,0 +1,139 @@ +/* Copyright (C) 2023-2024 Daniel Dakhno, 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.devices.garmin; + +import android.content.Context; +import android.net.Uri; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.BufferedInputStream; +import java.io.IOException; +import java.io.InputStream; + +import nodomain.freeyourgadget.gadgetbridge.R; +import nodomain.freeyourgadget.gadgetbridge.activities.InstallActivity; +import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator; +import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.model.GenericItem; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.GpxRouteFileConverter; +import nodomain.freeyourgadget.gadgetbridge.util.FileUtils; +import nodomain.freeyourgadget.gadgetbridge.util.UriHelper; + +import static nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.GarminCapability.COURSE_DOWNLOAD; + +public class GarminGpxRouteInstallHandler implements InstallHandler { + private static final Logger LOG = LoggerFactory.getLogger(GarminGpxRouteInstallHandler.class); + + protected final Context mContext; + public byte[] rawBytes; + private GpxRouteFileConverter gpxRouteFileConverter; + + public GarminGpxRouteInstallHandler(final Uri uri, final Context context) { + this.mContext = context; + + final UriHelper uriHelper; + try { + uriHelper = UriHelper.get(uri, context); + } catch (final IOException e) { + LOG.error("Failed to get uri", e); + return; + } + try (InputStream in = new BufferedInputStream(uriHelper.openInputStream())) { + rawBytes = FileUtils.readAll(in, 1024 * 1024); // 1MB + + final GpxRouteFileConverter gpxRouteFileConverter1 = new GpxRouteFileConverter(rawBytes); + if (gpxRouteFileConverter1.isValid()) { + this.gpxRouteFileConverter = gpxRouteFileConverter1; + } + } catch (final Exception e) { + LOG.error("Failed to read file", e); + } + } + + @Override + public boolean isValid() { + return gpxRouteFileConverter != null; + } + + @Override + public void validateInstallation(final InstallActivity installActivity, final GBDevice device) { + if (device.isBusy()) { + installActivity.setInfoText(device.getBusyTask()); + installActivity.setInstallEnabled(false); + return; + } + + final DeviceCoordinator coordinator = device.getDeviceCoordinator(); + if (!(coordinator instanceof GarminCoordinator)) { + LOG.warn("Coordinator is not a GarminCoordinator: {}", coordinator.getClass()); + installActivity.setInfoText(mContext.getString(R.string.fwapp_install_device_not_supported)); + installActivity.setInstallEnabled(false); + return; + } + final GarminCoordinator garminCoordinator = (GarminCoordinator) coordinator; + if (!garminCoordinator.supports(device, COURSE_DOWNLOAD)) { + installActivity.setInfoText(mContext.getString(R.string.fwapp_install_device_not_supported)); + installActivity.setInstallEnabled(false); + return; + } + + if (!device.isInitialized()) { + installActivity.setInfoText(mContext.getString(R.string.fwapp_install_device_not_ready)); + installActivity.setInstallEnabled(false); + return; + } + + final GenericItem fwItem = createInstallItem(device); + fwItem.setIcon(coordinator.getDefaultIconResource()); + + if (gpxRouteFileConverter == null) { + fwItem.setDetails(mContext.getString(R.string.miband_fwinstaller_incompatible_version)); + installActivity.setInfoText(mContext.getString(R.string.fwinstaller_firmware_not_compatible_to_device)); + installActivity.setInstallEnabled(false); + return; + } + + final StringBuilder builder = new StringBuilder(); + final String gpxRoute = mContext.getString(R.string.kind_gpx_route); + builder.append(mContext.getString(R.string.fw_upgrade_notice, gpxRoute)); + installActivity.setInfoText(builder.toString()); + installActivity.setInstallItem(fwItem); + installActivity.setInstallEnabled(true); + } + + @Override + public void onStartInstall(final GBDevice device) { + } + + public GpxRouteFileConverter getGpxRouteFileConverter() { + return gpxRouteFileConverter; + } + + private GenericItem createInstallItem(final GBDevice device) { + DeviceCoordinator coordinator = device.getDeviceCoordinator(); + final String firmwareName = mContext.getString( + R.string.installhandler_firmware_name, + mContext.getString(coordinator.getDeviceNameResource()), + mContext.getString(R.string.kind_gpx_route), + gpxRouteFileConverter.getName() + ); + return new GenericItem(firmwareName); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/GPSCoordinate.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/GPSCoordinate.java index 6d8ab5b1d..94c310daa 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/GPSCoordinate.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/GPSCoordinate.java @@ -16,6 +16,8 @@ along with this program. If not, see . */ package nodomain.freeyourgadget.gadgetbridge.model; +import android.location.Location; + import java.math.BigDecimal; import java.math.RoundingMode; import java.util.Comparator; @@ -51,6 +53,34 @@ public class GPSCoordinate { return altitude; } + public double getDistance(GPSCoordinate source) { + final Location end = new Location("end"); + end.setLatitude(this.getLatitude()); + end.setLongitude(this.getLongitude()); + + final Location start = new Location("start"); + start.setLatitude(source.getLatitude()); + start.setLongitude(source.getLongitude()); + + return end.distanceTo(start); + } + + public double getAltitudeDifference(GPSCoordinate source) { + if (this.getAltitude() == UNKNOWN_ALTITUDE) + return 0; + if (source.getAltitude() == UNKNOWN_ALTITUDE) + return 0; + return this.getAltitude() - source.getAltitude(); + } + + public double getAscent(GPSCoordinate source) { + return Math.max(0, this.getAltitudeDifference(source)); + } + + public double getDescent(GPSCoordinate source) { + return Math.max(0, -this.getAltitudeDifference(source)); + } + @Override public boolean equals(Object o) { if (this == o) return true; diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/FileTransferHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/FileTransferHandler.java index 6abb94bb4..d60ebc547 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/FileTransferHandler.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/FileTransferHandler.java @@ -17,6 +17,7 @@ import java.util.Locale; import java.util.Set; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.deviceevents.FileDownloadedDeviceEvent; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.CreateFileMessage; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.DownloadRequestMessage; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.FileTransferDataMessage; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.GFDIMessage; @@ -87,10 +88,10 @@ public class FileTransferHandler implements MessageHandler { // return new DownloadRequestMessage(0, 0, DownloadRequestMessage.REQUEST_TYPE.NEW, 0, 0); // } // -// public CreateFileMessage initiateUpload(byte[] fileAsByteArray, FileType.FILETYPE filetype) { -// upload.setCurrentlyUploading(new FileFragment(new DirectoryEntry(0, filetype, 0, 0, 0, fileAsByteArray.length, null), fileAsByteArray)); -// return new CreateFileMessage(fileAsByteArray.length, filetype); -// } +public CreateFileMessage initiateUpload(byte[] fileAsByteArray, FileType.FILETYPE filetype) { + upload.setCurrentlyUploading(new FileFragment(new DirectoryEntry(0, filetype, 0, 0, 0, fileAsByteArray.length, null), fileAsByteArray)); + return new CreateFileMessage(fileAsByteArray.length, filetype); +} public class Download { @@ -296,7 +297,7 @@ public class FileTransferHandler implements MessageHandler { private FileTransferDataMessage take() { final int currentOffset = this.dataHolder.position(); - final byte[] chunk = new byte[Math.min(this.dataHolder.remaining(), getMaxBlockSize())]; + final byte[] chunk = new byte[Math.min(this.dataHolder.remaining(), getMaxBlockSize() - 13)]; //actual payload in FileTransferDataMessage this.dataHolder.get(chunk); setRunningCrc(ChecksumCalculator.computeCrc(getRunningCrc(), chunk, 0, chunk.length)); return new FileTransferDataMessage(chunk, currentOffset, getRunningCrc()); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/FileType.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/FileType.java index 4a5f7b49e..670cc78f9 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/FileType.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/FileType.java @@ -79,6 +79,7 @@ public class FileType { FBT_PTD_BACKUP(128, 74), // Other files + DOWNLOAD_COURSE(255, 4), ERROR_SHUTDOWN_REPORTS(255, 245), IQ_ERROR_REPORTS(255, 244), ULF_LOGS(255, 247), diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/GarminSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/GarminSupport.java index 801ba875f..5afca0659 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/GarminSupport.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/GarminSupport.java @@ -37,6 +37,7 @@ import nodomain.freeyourgadget.gadgetbridge.database.DBHandler; import nodomain.freeyourgadget.gadgetbridge.database.DBHelper; import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEvent; import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator; +import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminGpxRouteInstallHandler; import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminPreferences; import nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.GarminCapability; import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession; @@ -797,6 +798,14 @@ public class GarminSupport extends AbstractBTLEDeviceSupport implements ICommuni } } + @Override + public void onInstallApp(Uri uri) { + final GarminGpxRouteInstallHandler garminGpxRouteInstallHandler = new GarminGpxRouteInstallHandler(uri, getContext()); + if (garminGpxRouteInstallHandler.isValid()) { + communicator.sendMessage("upload course file", fileTransferHandler.initiateUpload(garminGpxRouteInstallHandler.getGpxRouteFileConverter().getConvertedFile().getOutgoingMessage(), FileType.FILETYPE.DOWNLOAD_COURSE).getOutgoingMessage()); + } + } + @Override public void onTestNewFunction() { parseAllFitFilesFromStorage(); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/GarminUtils.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/GarminUtils.java index 75c6e5c65..099a23d75 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/GarminUtils.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/GarminUtils.java @@ -10,10 +10,6 @@ public final class GarminUtils { // utility class } - public static double semicirclesToDegrees(final long semicircles) { - return semicircles * (180.0D / 0x80000000L); - } - public static GdiCore.CoreService.LocationData toLocationData(final Location location, final GdiCore.CoreService.DataType dataType) { final GdiCore.CoreService.LatLon positionForWatch = GdiCore.CoreService.LatLon.newBuilder() .setLat((int) ((location.getLatitude() * 2.147483648E9d) / 180.0d)) diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/FieldDefinitionFactory.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/FieldDefinitionFactory.java index 640a95d0c..44426d45f 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/FieldDefinitionFactory.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/FieldDefinitionFactory.java @@ -2,6 +2,7 @@ package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.baseTypes.BaseType; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions.FieldDefinitionAlarm; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions.FieldDefinitionCoordinate; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions.FieldDefinitionDayOfWeek; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions.FieldDefinitionFileType; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions.FieldDefinitionGoalSource; @@ -47,6 +48,8 @@ public class FieldDefinitionFactory { return new FieldDefinitionSleepStage(localNumber, size, baseType, name); case WEATHER_AQI: return new FieldDefinitionWeatherAqi(localNumber, size, baseType, name); + case COORDINATE: + return new FieldDefinitionCoordinate(localNumber, size, baseType, name); default: return new FieldDefinition(localNumber, size, baseType, name); } @@ -66,5 +69,6 @@ public class FieldDefinitionFactory { LANGUAGE, SLEEP_STAGE, WEATHER_AQI, + COORDINATE } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/FitFile.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/FitFile.java index 6579d7d45..4ac3e83d2 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/FitFile.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/FitFile.java @@ -144,7 +144,12 @@ public class FitFile { this.header.generateOutgoingDataPayload(writer); writer.writeBytes(temporary.getBytes()); writer.writeShort(ChecksumCalculator.computeCrc(writer.getBytes(), this.header.getHeaderSize(), writer.getBytes().length - this.header.getHeaderSize())); + } + public byte[] getOutgoingMessage() { + final MessageWriter writer = new MessageWriter(); + this.generateOutgoingDataPayload(writer); + return writer.getBytes(); } @NonNull diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/GlobalFITMessage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/GlobalFITMessage.java index 3b5916796..f7d33ce73 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/GlobalFITMessage.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/GlobalFITMessage.java @@ -91,8 +91,8 @@ public class GlobalFITMessage { new FieldDefinitionPrimitive(0, BaseType.ENUM, "event"), // 9 lap new FieldDefinitionPrimitive(1, BaseType.ENUM, "event_type"), // 1 stop new FieldDefinitionPrimitive(2, BaseType.UINT32, "start_time"), - new FieldDefinitionPrimitive(3, BaseType.SINT32, "start_latitude"), - new FieldDefinitionPrimitive(4, BaseType.SINT32, "start_longitude"), + new FieldDefinitionPrimitive(3, BaseType.SINT32, "start_latitude", FieldDefinitionFactory.FIELD.COORDINATE), + new FieldDefinitionPrimitive(4, BaseType.SINT32, "start_longitude", FieldDefinitionFactory.FIELD.COORDINATE), new FieldDefinitionPrimitive(5, BaseType.ENUM, "sport"), new FieldDefinitionPrimitive(6, BaseType.ENUM, "sub_sport"), new FieldDefinitionPrimitive(7, BaseType.UINT32, "total_elapsed_time"), // with pauses @@ -106,16 +106,27 @@ public class GlobalFITMessage { )); public static GlobalFITMessage LAP = new GlobalFITMessage(19, "LAP", Arrays.asList( + new FieldDefinitionPrimitive(3, BaseType.SINT32, "start_lat", FieldDefinitionFactory.FIELD.COORDINATE), + new FieldDefinitionPrimitive(4, BaseType.SINT32, "start_long", FieldDefinitionFactory.FIELD.COORDINATE), + new FieldDefinitionPrimitive(5, BaseType.SINT32, "end_lat", FieldDefinitionFactory.FIELD.COORDINATE), + new FieldDefinitionPrimitive(6, BaseType.SINT32, "end_long", FieldDefinitionFactory.FIELD.COORDINATE), + new FieldDefinitionPrimitive(7, BaseType.UINT32, "total_elapsed_time", 1000, 0), // s + new FieldDefinitionPrimitive(8, BaseType.UINT32, "total_timer_time", 1000, 0), // s + new FieldDefinitionPrimitive(9, BaseType.UINT32, "total_distance", 100, 0), // m + new FieldDefinitionPrimitive(21, BaseType.UINT16, "total_ascent"), // m + new FieldDefinitionPrimitive(22, BaseType.UINT16, "total_descent"), // m new FieldDefinitionPrimitive(253, BaseType.UINT32, "timestamp", FieldDefinitionFactory.FIELD.TIMESTAMP) )); public static GlobalFITMessage RECORD = new GlobalFITMessage(20, "RECORD", Arrays.asList( - new FieldDefinitionPrimitive(0, BaseType.SINT32, "latitude"), - new FieldDefinitionPrimitive(1, BaseType.SINT32, "longitude"), + new FieldDefinitionPrimitive(0, BaseType.SINT32, "latitude", FieldDefinitionFactory.FIELD.COORDINATE), + new FieldDefinitionPrimitive(1, BaseType.SINT32, "longitude", FieldDefinitionFactory.FIELD.COORDINATE), + new FieldDefinitionPrimitive(2, BaseType.UINT16, "altitude", 5, 500), // m new FieldDefinitionPrimitive(3, BaseType.UINT8, "heart_rate"), new FieldDefinitionPrimitive(5, BaseType.UINT32, "distance", 100, 0), // m + new FieldDefinitionPrimitive(6, BaseType.UINT16, "speed", 1000, 0), // m/s new FieldDefinitionPrimitive(73, BaseType.UINT32, "enhanced_speed"), // mm/s - new FieldDefinitionPrimitive(78, BaseType.UINT32, "enhanced_altitude"), // dm + new FieldDefinitionPrimitive(78, BaseType.UINT32, "enhanced_altitude", 5, 500), // m new FieldDefinitionPrimitive(136, BaseType.UINT8, "wrist_heart_rate"), new FieldDefinitionPrimitive(143, BaseType.UINT8, "body_battery"), new FieldDefinitionPrimitive(253, BaseType.UINT32, "timestamp", FieldDefinitionFactory.FIELD.TIMESTAMP) @@ -137,6 +148,11 @@ public class GlobalFITMessage { new FieldDefinitionPrimitive(253, BaseType.UINT32, "timestamp", FieldDefinitionFactory.FIELD.TIMESTAMP) )); + public static GlobalFITMessage COURSE = new GlobalFITMessage(31, "COURSE", Arrays.asList( + new FieldDefinitionPrimitive(4, BaseType.ENUM, "sport"), + new FieldDefinitionPrimitive(5, BaseType.STRING, 16, "name") + )); + public static GlobalFITMessage FILE_CREATOR = new GlobalFITMessage(49, "FILE_CREATOR", Arrays.asList( new FieldDefinitionPrimitive(0, BaseType.UINT16, "software_version"), new FieldDefinitionPrimitive(1, BaseType.UINT8, "hardware_version") @@ -282,6 +298,7 @@ public class GlobalFITMessage { put(20, RECORD); put(21, EVENT); put(23, DEVICE_INFO); + put(31, COURSE); put(49, FILE_CREATOR); put(55, MONITORING); put(127, CONNECTIVITY); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/GpxRouteFileConverter.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/GpxRouteFileConverter.java new file mode 100644 index 000000000..a6804d15e --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/GpxRouteFileConverter.java @@ -0,0 +1,196 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.nio.ByteOrder; +import java.util.ArrayList; +import java.util.List; + +import nodomain.freeyourgadget.gadgetbridge.model.GPSCoordinate; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.FileType; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.enums.GarminSport; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitRecordDataFactory; +import nodomain.freeyourgadget.gadgetbridge.util.StringUtils; +import nodomain.freeyourgadget.gadgetbridge.util.gpx.GpxParser; +import nodomain.freeyourgadget.gadgetbridge.util.gpx.model.GpxFile; +import nodomain.freeyourgadget.gadgetbridge.util.gpx.model.GpxTrack; +import nodomain.freeyourgadget.gadgetbridge.util.gpx.model.GpxTrackPoint; +import nodomain.freeyourgadget.gadgetbridge.util.gpx.model.GpxTrackSegment; + +public class GpxRouteFileConverter { + private static final Logger LOG = LoggerFactory.getLogger(GpxRouteFileConverter.class); + final double speed = 1.4; // m/s // TODO: make this configurable (and activity dependent?) + final int activity = GarminSport.RUN.getType(); //TODO: make this configurable + private final long timestamp; + private final GpxFile gpxFile; + private FitFile convertedFile; + private String name; + + public GpxRouteFileConverter(byte[] xmlBytes) { + this.timestamp = System.currentTimeMillis() / 1000; + this.gpxFile = GpxParser.parseGpx(xmlBytes); + try { + this.convertedFile = convertGpxToRoute(gpxFile); + } catch (Exception e) { + LOG.error(e.getMessage()); + this.convertedFile = null; + } + } + + private static RecordData getFileCreatorRecordData() { + final RecordData fileCreatorRecord = FitRecordDataFactory.create( + new RecordDefinition(new RecordHeader((byte) 0x41), ByteOrder.BIG_ENDIAN, GlobalFITMessage.FILE_CREATOR, GlobalFITMessage.FILE_CREATOR.getFieldDefinitions(0), null), + new RecordHeader((byte) 0x01)); + fileCreatorRecord.setFieldByName("software_version", 1); + return fileCreatorRecord; + } + + public FitFile getConvertedFile() { + return convertedFile; + } + + public boolean isValid() { + return this.convertedFile != null; + } + + public String getName() { + if (gpxFile == null) { + return ""; + } + + if (!StringUtils.isNullOrEmpty(this.name)) + return this.name; + + if (!StringUtils.isNullOrEmpty(gpxFile.getName())) { + return gpxFile.getName(); + } else { + return String.valueOf(timestamp); + } + } + + private FitFile convertGpxToRoute(GpxFile gpxFile) { + if (gpxFile.getTracks().isEmpty()) { + LOG.error("Gpx file contains no Tracks."); + return null; + } + //GPX files may contain multiple tracks, we use only the first one + final GpxTrack track = gpxFile.getTracks().get(0); + + if (track.getTrackSegments().isEmpty()) { + LOG.error("Gpx track contains no segment."); + return null; + } + //GPX track may contain multiple segments, we use only the first one + GpxTrackSegment gpxTrackSegment = track.getTrackSegments().get(0); + + List gpxTrackPointList = gpxTrackSegment.getTrackPoints(); + if (gpxTrackPointList.isEmpty()) { + LOG.error("Gpx track segment contains no point"); + return null; + } + + this.name = track.getName(); + + final RecordHeader gpxDataPointRecordHeader = new RecordHeader((byte) 0x05); + final RecordDefinition gpxDataPointRecordDefinition = new RecordDefinition(new RecordHeader((byte) 0x45), ByteOrder.BIG_ENDIAN, GlobalFITMessage.RECORD, GlobalFITMessage.RECORD.getFieldDefinitions(0, 1, 2, 5, 253), null); + List gpxPointDataRecords = new ArrayList<>(); + + double totalAscent = 0; + double totalDescent = 0; + double totalDistance = 0; + long runningTs = timestamp; + + GPSCoordinate prevPoint = gpxTrackPointList.get(0); + + for (GPSCoordinate point : + gpxTrackPointList) { + totalAscent += point.getAscent(prevPoint); + totalDescent += point.getDescent(prevPoint); + totalDistance += point.getDistance(prevPoint); + runningTs += (long) (point.getDistance(prevPoint) / speed); + final RecordData gpxDataPointRecord = FitRecordDataFactory.create(gpxDataPointRecordDefinition, gpxDataPointRecordHeader); + + gpxDataPointRecord.setFieldByName("latitude", point.getLatitude()); + gpxDataPointRecord.setFieldByName("longitude", point.getLongitude()); + gpxDataPointRecord.setFieldByName("altitude", point.getAltitude()); + gpxDataPointRecord.setFieldByName("distance", totalDistance); + gpxDataPointRecord.setFieldByName("timestamp", runningTs); + + prevPoint = point; + gpxPointDataRecords.add(gpxDataPointRecord); + } + + final RecordData lapRecord = getLapRecordData(gpxTrackPointList); + lapRecord.setFieldByName("total_distance", totalDistance); + lapRecord.setFieldByName("total_ascent", totalAscent); + lapRecord.setFieldByName("total_descent", totalDescent); + lapRecord.setFieldByName("total_elapsed_time", (runningTs - timestamp)); + lapRecord.setFieldByName("total_timer_time", (runningTs - timestamp)); + + final List courseFileDataRecords = new ArrayList<>(); + courseFileDataRecords.add(getFileIdRecordData()); + courseFileDataRecords.add(getFileCreatorRecordData()); + courseFileDataRecords.add(getCourseRecordData()); + courseFileDataRecords.add(lapRecord); + + final RecordHeader eventRecordHeader = new RecordHeader((byte) 0x04); + final RecordDefinition eventRecordDefinition = new RecordDefinition(new RecordHeader((byte) 0x44), ByteOrder.BIG_ENDIAN, GlobalFITMessage.EVENT, GlobalFITMessage.EVENT.getFieldDefinitions(0, 1, 4, 253), null); + courseFileDataRecords.add(getEventRecordData(eventRecordDefinition, eventRecordHeader, timestamp, 0)); + courseFileDataRecords.add(getEventRecordData(eventRecordDefinition, eventRecordHeader, runningTs, 9)); + + courseFileDataRecords.addAll(gpxPointDataRecords); + + return new FitFile(courseFileDataRecords); + } + + private RecordData getEventRecordData(RecordDefinition eventRecordDefinition, RecordHeader eventRecordHeader, long timestamp, int eventType) { + final RecordData startEvent = FitRecordDataFactory.create( + eventRecordDefinition, + eventRecordHeader); + + startEvent.setFieldByName("timestamp", timestamp); + startEvent.setFieldByName("event", 0); + startEvent.setFieldByName("event_group", 0); + startEvent.setFieldByName("event_type", eventType); + return startEvent; + } + + private RecordData getLapRecordData(List gpxTrackPointList) { + final GPSCoordinate first = gpxTrackPointList.get(0); + final GPSCoordinate last = gpxTrackPointList.get(gpxTrackPointList.size() - 1); + + final RecordData lapRecord = FitRecordDataFactory.create( + new RecordDefinition(new RecordHeader((byte) 0x43), ByteOrder.BIG_ENDIAN, GlobalFITMessage.LAP, GlobalFITMessage.LAP.getFieldDefinitions(3, 4, 5, 6, 7, 8, 9, 21, 22, 253), null), + new RecordHeader((byte) 0x03)); + lapRecord.setFieldByName("start_lat", first.getLatitude()); + lapRecord.setFieldByName("start_long", first.getLongitude()); + lapRecord.setFieldByName("end_lat", last.getLatitude()); + lapRecord.setFieldByName("end_long", last.getLongitude()); + lapRecord.setFieldByName("timestamp", timestamp); + return lapRecord; + } + + private RecordData getCourseRecordData() { + final RecordData courseRecord = FitRecordDataFactory.create( + new RecordDefinition(new RecordHeader((byte) 0x42), ByteOrder.BIG_ENDIAN, GlobalFITMessage.COURSE, GlobalFITMessage.COURSE.getFieldDefinitions(4, 5), null), + new RecordHeader((byte) 0x02)); + courseRecord.setFieldByName("sport", activity); //TODO use track.getType() + courseRecord.setFieldByName("name", this.getName()); + return courseRecord; + } + + private RecordData getFileIdRecordData() { + final RecordData fileIdRecord = FitRecordDataFactory.create( + new RecordDefinition(new RecordHeader((byte) 0x40), ByteOrder.BIG_ENDIAN, GlobalFITMessage.FILE_ID, GlobalFITMessage.FILE_ID.getFieldDefinitions(0, 1, 2, 3, 4, 5), null), + new RecordHeader((byte) 0x00)); + fileIdRecord.setFieldByName("type", FileType.FILETYPE.COURSES.getSubType()); + fileIdRecord.setFieldByName("manufacturer", 1); + fileIdRecord.setFieldByName("product", 65534); + fileIdRecord.setFieldByName("time_created", timestamp); + fileIdRecord.setFieldByName("serial_number", 1); + fileIdRecord.setFieldByName("number", 1); + return fileIdRecord; + } + +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/RecordHeader.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/RecordHeader.java index 33eb6ef6d..1440ea97d 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/RecordHeader.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/RecordHeader.java @@ -69,7 +69,7 @@ public class RecordHeader { public byte generateOutgoingDataPayload() { //TODO: unclear if correct if (!definition && !developerData) { - assert timeOffset != null; + if (timeOffset != null) return (byte) (timeOffset | (((byte) localMessageType) << 5)); } byte base = (byte) localMessageType; diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/codegen/FitCodeGen.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/codegen/FitCodeGen.java index e324bea48..83c06ed83 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/codegen/FitCodeGen.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/codegen/FitCodeGen.java @@ -252,6 +252,8 @@ public class FitCodeGen { return FieldDefinitionSleepStage.SleepStage.class; case WEATHER_AQI: return FieldDefinitionWeatherAqi.AQI_LEVELS.class; + case COORDINATE: + return Double.class; } throw new RuntimeException("Unknown field type " + primitive.getType()); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/fieldDefinitions/FieldDefinitionCoordinate.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/fieldDefinitions/FieldDefinitionCoordinate.java new file mode 100644 index 000000000..dd2705f69 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/fieldDefinitions/FieldDefinitionCoordinate.java @@ -0,0 +1,27 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions; + +import java.nio.ByteBuffer; + +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.FieldDefinition; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.baseTypes.BaseType; + +public class FieldDefinitionCoordinate extends FieldDefinition { + + final double conversionFactor = (180.0D / 0x80000000L); + + public FieldDefinitionCoordinate(int localNumber, int size, BaseType baseType, String name) { + super(localNumber, size, baseType, name, 1, 0); + } + + @Override + public Object decode(ByteBuffer byteBuffer) { + return ((long) baseType.decode(byteBuffer, 1, 0)) * conversionFactor; + } + + @Override + public void encode(ByteBuffer byteBuffer, Object o) { + baseType.encode(byteBuffer, (int) Math.round((double) o / conversionFactor), 1, 0); + } + + +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitCourse.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitCourse.java new file mode 100644 index 000000000..cce2cd0e4 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitCourse.java @@ -0,0 +1,32 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages; + +import androidx.annotation.Nullable; + +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.RecordData; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.RecordDefinition; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.RecordHeader; + +// +// WARNING: This class was auto-generated, please avoid modifying it directly. +// See nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.codegen.FitCodeGen +// +public class FitCourse extends RecordData { + public FitCourse(final RecordDefinition recordDefinition, final RecordHeader recordHeader) { + super(recordDefinition, recordHeader); + + final int globalNumber = recordDefinition.getGlobalFITMessage().getNumber(); + if (globalNumber != 31) { + throw new IllegalArgumentException("FitCourse expects global messages of " + 31 + ", got " + globalNumber); + } + } + + @Nullable + public Integer getSport() { + return (Integer) getFieldByNumber(4); + } + + @Nullable + public String getName() { + return (String) getFieldByNumber(5); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitLap.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitLap.java index 43a889892..34dafcee4 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitLap.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitLap.java @@ -20,6 +20,51 @@ public class FitLap extends RecordData { } } + @Nullable + public Double getStartLat() { + return (Double) getFieldByNumber(3); + } + + @Nullable + public Double getStartLong() { + return (Double) getFieldByNumber(4); + } + + @Nullable + public Double getEndLat() { + return (Double) getFieldByNumber(5); + } + + @Nullable + public Double getEndLong() { + return (Double) getFieldByNumber(6); + } + + @Nullable + public Long getTotalElapsedTime() { + return (Long) getFieldByNumber(7); + } + + @Nullable + public Long getTotalTimerTime() { + return (Long) getFieldByNumber(8); + } + + @Nullable + public Long getTotalDistance() { + return (Long) getFieldByNumber(9); + } + + @Nullable + public Integer getTotalAscent() { + return (Integer) getFieldByNumber(21); + } + + @Nullable + public Integer getTotalDescent() { + return (Integer) getFieldByNumber(22); + } + @Nullable public Long getTimestamp() { return (Long) getFieldByNumber(253); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitRecord.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitRecord.java index aff3aec2c..4a89bb250 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitRecord.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitRecord.java @@ -26,13 +26,18 @@ public class FitRecord extends RecordData { } @Nullable - public Long getLatitude() { - return (Long) getFieldByNumber(0); + public Double getLatitude() { + return (Double) getFieldByNumber(0); } @Nullable - public Long getLongitude() { - return (Long) getFieldByNumber(1); + public Double getLongitude() { + return (Double) getFieldByNumber(1); + } + + @Nullable + public Integer getAltitude() { + return (Integer) getFieldByNumber(2); } @Nullable @@ -45,6 +50,11 @@ public class FitRecord extends RecordData { return (Long) getFieldByNumber(5); } + @Nullable + public Integer getSpeed() { + return (Integer) getFieldByNumber(6); + } + @Nullable public Long getEnhancedSpeed() { return (Long) getFieldByNumber(73); @@ -77,9 +87,9 @@ public class FitRecord extends RecordData { activityPoint.setTime(new Date(getComputedTimestamp())); if (getLatitude() != null && getLongitude() != null) { activityPoint.setLocation(new GPSCoordinate( - GarminUtils.semicirclesToDegrees(getLongitude().longValue()), - GarminUtils.semicirclesToDegrees(getLatitude().longValue()), - getEnhancedAltitude() != null ? getEnhancedAltitude() / 10d : GPSCoordinate.UNKNOWN_ALTITUDE + getLongitude(), + getLatitude(), + getEnhancedAltitude() != null ? getEnhancedAltitude() : GPSCoordinate.UNKNOWN_ALTITUDE )); } if (getHeartRate() != null) { diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitRecordDataFactory.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitRecordDataFactory.java index 0ce3b1821..a7f392df8 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitRecordDataFactory.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitRecordDataFactory.java @@ -37,6 +37,8 @@ public class FitRecordDataFactory { return new FitEvent(recordDefinition, recordHeader); case 23: return new FitDeviceInfo(recordDefinition, recordHeader); + case 31: + return new FitCourse(recordDefinition, recordHeader); case 49: return new FitFileCreator(recordDefinition, recordHeader); case 55: diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/operations/ZeppOsGpxRouteFile.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/operations/ZeppOsGpxRouteFile.java index 0f3267b84..d5d0e4005 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/operations/ZeppOsGpxRouteFile.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/operations/ZeppOsGpxRouteFile.java @@ -18,12 +18,9 @@ package nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.operat import android.location.Location; -import androidx.annotation.Nullable; - import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.nio.ByteBuffer; @@ -31,9 +28,7 @@ import java.util.List; import nodomain.freeyourgadget.gadgetbridge.model.GPSCoordinate; import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions; -import nodomain.freeyourgadget.gadgetbridge.util.ArrayUtils; import nodomain.freeyourgadget.gadgetbridge.util.StringUtils; -import nodomain.freeyourgadget.gadgetbridge.util.gpx.GpxParseException; import nodomain.freeyourgadget.gadgetbridge.util.gpx.GpxParser; import nodomain.freeyourgadget.gadgetbridge.util.gpx.model.GpxFile; import nodomain.freeyourgadget.gadgetbridge.util.gpx.model.GpxTrackPoint; @@ -44,15 +39,6 @@ public class ZeppOsGpxRouteFile { private static final double COORD_MULTIPLIER = 3000000.0; - public static final byte[] XML_HEADER = new byte[]{ - '<', '?', 'x', 'm', 'l' - }; - - // Some gpx files start with ". */ package nodomain.freeyourgadget.gadgetbridge.util.gpx; +import androidx.annotation.Nullable; + import com.google.gson.internal.bind.util.ISO8601Utils; import org.slf4j.Logger; @@ -24,12 +26,14 @@ import org.xmlpull.v1.XmlPullParser; import org.xmlpull.v1.XmlPullParserException; import org.xmlpull.v1.XmlPullParserFactory; +import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; import java.text.ParsePosition; import java.util.Date; import nodomain.freeyourgadget.gadgetbridge.model.GPSCoordinate; +import nodomain.freeyourgadget.gadgetbridge.util.ArrayUtils; import nodomain.freeyourgadget.gadgetbridge.util.gpx.model.GpxFile; import nodomain.freeyourgadget.gadgetbridge.util.gpx.model.GpxTrack; import nodomain.freeyourgadget.gadgetbridge.util.gpx.model.GpxTrackPoint; @@ -39,11 +43,44 @@ import nodomain.freeyourgadget.gadgetbridge.util.gpx.model.GpxWaypoint; public class GpxParser { private static final Logger LOG = LoggerFactory.getLogger(GpxParser.class); + public static final byte[] XML_HEADER = new byte[]{ + '<', '?', 'x', 'm', 'l' + }; + + // Some gpx files start with " trackSegments; - public GpxTrack(final List trackSegments) { + public GpxTrack(String name, String type, final List trackSegments) { + this.name = name; + this.type = type; this.trackSegments = trackSegments; } + public String getName() { + return name; + } + + public String getType() { + return type; + } + public List getTrackSegments() { return trackSegments; } @@ -41,15 +54,27 @@ public class GpxTrack { } public static class Builder { + private String name; + private String type; private final List trackSegments = new ArrayList<>(); + public Builder withName(final String name) { + this.name = name; + return this; + } + + public Builder withType(final String type) { + this.type = type; + return this; + } + public Builder withTrackSegment(final GpxTrackSegment trackSegment) { trackSegments.add(trackSegment); return this; } public GpxTrack build() { - return new GpxTrack(trackSegments); + return new GpxTrack(name, type, trackSegments); } } } diff --git a/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/GarminSupportTest.java b/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/GarminSupportTest.java index 047f5c96f..1601dc0bb 100644 --- a/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/GarminSupportTest.java +++ b/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/GarminSupportTest.java @@ -364,9 +364,9 @@ public class GarminSupportTest extends TestBase { "FitFileId{manufacturer=15, type=ACTIVITY, product=9001, serial_number=1701}, " + "FitDeveloperData{application_id=[1,1,2,3,5,8,13,21,34,55,89,144,233,121,98,219], developer_data_index=0}, " + "FitFieldDescription{developer_data_index=0, field_definition_number=0, fit_base_type_id=1, field_name=doughnuts_earned, units=doughnuts}, " + - "FitRecord{heart_rate=140, unknown_4(UINT8/1)=88, distance=510, unknown_6(UINT16/2)=47488, doughnuts_earned=1}, " + - "FitRecord{heart_rate=143, unknown_4(UINT8/1)=90, distance=2080, unknown_6(UINT16/2)=36416, doughnuts_earned=2}, " + - "FitRecord{heart_rate=144, unknown_4(UINT8/1)=92, distance=3710, unknown_6(UINT16/2)=35344, doughnuts_earned=3}" + + "FitRecord{heart_rate=140, unknown_4(UINT8/1)=88, distance=510, speed=47, doughnuts_earned=1}, " + + "FitRecord{heart_rate=143, unknown_4(UINT8/1)=90, distance=2080, speed=36, doughnuts_earned=2}, " + + "FitRecord{heart_rate=144, unknown_4(UINT8/1)=92, distance=3710, speed=35, doughnuts_earned=3}" + "]"; FitFile fitFile = FitFile.parseIncoming(fileContents);