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 1e9f21401e..46419aeadf 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/export/GPXExporter.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/export/GPXExporter.java @@ -1,5 +1,5 @@ -/* Copyright (C) 2017-2024 Andreas Shimokawa, AndrewH, Carsten Pfeiffer, - Daniele Gobbetti, Dikay900, José Rebelo, Nick Spacek, Petr Vaněk +/* Copyright (C) 2017-2025 Andreas Shimokawa, AndrewH, Carsten Pfeiffer, + Daniele Gobbetti, Dikay900, José Rebelo, Nick Spacek, Petr Vaněk, Thomas Kuehne This file is part of Gadgetbridge. @@ -21,16 +21,20 @@ import android.util.Xml; import androidx.annotation.Nullable; +import org.jetbrains.annotations.TestOnly; import org.xmlpull.v1.XmlSerializer; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; -import java.math.BigDecimal; import java.math.RoundingMode; import java.nio.charset.StandardCharsets; +import java.text.DecimalFormat; +import java.text.DecimalFormatSymbols; +import java.text.NumberFormat; import java.util.Date; import java.util.List; +import java.util.Locale; import java.util.UUID; import nodomain.freeyourgadget.gadgetbridge.GBApplication; @@ -45,15 +49,33 @@ public class GPXExporter implements ActivityTrackExporter { private static final String NS_GPX_URI = "http://www.topografix.com/GPX/1/1"; private static final String NS_GPX_PREFIX = ""; private static final String NS_TRACKPOINT_EXTENSION = "gpxtpx"; - private static final String NS_TRACKPOINT_EXTENSION_URI = "https://www8.garmin.com/xmlschemas/TrackPointExtensionv2.xsd"; + private static final String NS_TRACKPOINT_EXTENSION_URI = "http://www.garmin.com/xmlschemas/TrackPointExtension/v2"; private static final String NS_XSI_URI = "http://www.w3.org/2001/XMLSchema-instance"; + private static final String TRACKPOINT_EXTENSION_XSD = "http://www.garmin.com/xmlschemas/TrackPointExtensionv2.xsd"; private static final String TOPOGRAFIX_NAMESPACE_XSD = "http://www.topografix.com/GPX/1/1/gpx.xsd"; private static final String OPENTRACKS_PREFIX = "opentracks"; private static final String OPENTRACKS_NAMESPACE_URI = "http://opentracksapp.com/xmlschemas/v1"; + private static final String OPENTRACKS_XSD = "https://raw.githubusercontent.com/OpenTracksApp/OpenTracks/main/doc/opentracks-schema-1.0.xsd"; private String creator; + private Date date; private boolean includeHeartRate = true; private boolean includeHeartRateOfNearestSample = true; + private UUID uuid; + + private final DecimalFormat doubleFormat; + private final DecimalFormat locationFormat; + + public GPXExporter(){ + doubleFormat = new DecimalFormat("0", DecimalFormatSymbols.getInstance(Locale.ROOT)); + doubleFormat.setMaximumFractionDigits(15); + doubleFormat.setRoundingMode(RoundingMode.HALF_UP); + + locationFormat = new DecimalFormat("0", DecimalFormatSymbols.getInstance(Locale.ROOT)); + locationFormat.setMinimumFractionDigits(GPSCoordinate.GPS_DECIMAL_DEGREES_SCALE); + locationFormat.setMaximumFractionDigits(GPSCoordinate.GPS_DECIMAL_DEGREES_SCALE); + locationFormat.setRoundingMode(RoundingMode.HALF_UP); + } @Override public void performExport(ActivityTrack track, File targetFile) throws IOException, GPXTrackEmptyException { @@ -74,7 +96,9 @@ public class GPXExporter implements ActivityTrackExporter { } else { ser.attribute(null, "creator", GBApplication.app().getNameAndVersion()); } - ser.attribute(NS_XSI_URI, "schemaLocation",NS_GPX_URI + " " + TOPOGRAFIX_NAMESPACE_XSD); + ser.attribute(NS_XSI_URI, "schemaLocation",NS_GPX_URI + " " + TOPOGRAFIX_NAMESPACE_XSD + + " " + NS_TRACKPOINT_EXTENSION_URI + " " + TRACKPOINT_EXTENSION_XSD + + " " + OPENTRACKS_NAMESPACE_URI + " " + OPENTRACKS_XSD); exportMetadata(ser, track); exportTrack(ser, track); @@ -98,17 +122,21 @@ public class GPXExporter implements ActivityTrackExporter { ser.endTag(NS_GPX_URI, "author"); } - ser.startTag(NS_GPX_URI, "time").text(formatTime(new Date())).endTag(NS_GPX_URI, "time"); + Date time = date; + if (time == null){ + time = new Date(); + } + ser.startTag(NS_GPX_URI, "time").text(formatTime(time)).endTag(NS_GPX_URI, "time"); ser.endTag(NS_GPX_URI, "metadata"); } private String formatTime(Date date) { - return DateTimeUtils.formatIso8601(date); + return DateTimeUtils.formatIso8601UTC(date); } private void exportTrack(XmlSerializer ser, ActivityTrack track) throws IOException, GPXTrackEmptyException { - String uuid = UUID.randomUUID().toString(); + String uuid = ((this.uuid != null) ? this.uuid : UUID.randomUUID()).toString(); ser.startTag(NS_GPX_URI, "trk"); ser.startTag(NS_GPX_URI, "extensions"); ser.startTag(NS_GPX_URI, OPENTRACKS_PREFIX + ":trackid").text(uuid).endTag(NS_GPX_URI, OPENTRACKS_PREFIX + ":trackid"); @@ -143,12 +171,15 @@ public class GPXExporter implements ActivityTrackExporter { } ser.startTag(NS_GPX_URI, "trkpt"); // lon and lat attributes do not have an explicit namespace - ser.attribute(null, "lon", formatDouble(location.getLongitude())); - ser.attribute(null, "lat", formatDouble(location.getLatitude())); - if (location.getAltitude() != GPSCoordinate.UNKNOWN_ALTITUDE) { + ser.attribute(null, "lon", formatLocation(location.getLongitude())); + ser.attribute(null, "lat", formatLocation(location.getLatitude())); + if (location.hasAltitude()) { ser.startTag(NS_GPX_URI, "ele").text(formatDouble(location.getAltitude())).endTag(NS_GPX_URI, "ele"); } - ser.startTag(NS_GPX_URI, "time").text(DateTimeUtils.formatIso8601UTC(point.getTime())).endTag(NS_GPX_URI, "time"); + Date time = point.getTime(); + if (time != null) { + ser.startTag(NS_GPX_URI, "time").text(formatTime(time)).endTag(NS_GPX_URI, "time"); + } String description = point.getDescription(); if (description != null) { ser.startTag(NS_GPX_URI, "desc").text(description).endTag(NS_GPX_URI, "desc"); @@ -176,6 +207,8 @@ public class GPXExporter implements ActivityTrackExporter { return; } + double temperature = point.getTemperature(); + double depth = point.getDepth(); float speed = point.getSpeed(); int cadence = point.getCadence(); int hr = point.getHeartRate(); @@ -189,8 +222,12 @@ public class GPXExporter implements ActivityTrackExporter { } boolean exportHr = HeartRateUtils.getInstance().isValidHeartRateValue(hr) && includeHeartRate; + boolean exportCadence = cadence >= 0; + boolean exportSpeed = speed >= 0.0f; + boolean exportTemperature = temperature > -273.0; + boolean exportDepth = !Double.isNaN(depth) && depth != -1.0; - if (!exportHr && speed < 0 && cadence < 0) { + if (!(exportHr || exportCadence || exportSpeed || exportTemperature || exportDepth)) { // No valid data to export in extensions return; } @@ -198,13 +235,19 @@ public class GPXExporter implements ActivityTrackExporter { ser.startTag(NS_GPX_URI, "extensions"); ser.setPrefix(NS_TRACKPOINT_EXTENSION, NS_TRACKPOINT_EXTENSION_URI); ser.startTag(NS_TRACKPOINT_EXTENSION_URI, "TrackPointExtension"); + if (exportTemperature) { + ser.startTag(NS_TRACKPOINT_EXTENSION_URI, "atemp").text(formatDouble(temperature)).endTag(NS_TRACKPOINT_EXTENSION_URI, "atemp"); + } + if (exportDepth) { + ser.startTag(NS_TRACKPOINT_EXTENSION_URI, "depth").text(formatDouble(depth)).endTag(NS_TRACKPOINT_EXTENSION_URI, "depth"); + } if (exportHr) { - ser.startTag(NS_TRACKPOINT_EXTENSION_URI, "hr").text(String.valueOf(hr)).endTag(NS_TRACKPOINT_EXTENSION_URI, "hr"); + ser.startTag(NS_TRACKPOINT_EXTENSION_URI, "hr").text(formatLong(hr)).endTag(NS_TRACKPOINT_EXTENSION_URI, "hr"); } - if (cadence >= 0) { - ser.startTag(NS_TRACKPOINT_EXTENSION_URI, "cad").text(String.valueOf(cadence)).endTag(NS_TRACKPOINT_EXTENSION_URI, "cad"); + if (exportCadence) { + ser.startTag(NS_TRACKPOINT_EXTENSION_URI, "cad").text(formatLong(cadence)).endTag(NS_TRACKPOINT_EXTENSION_URI, "cad"); } - if (speed >= 0) { + if (exportSpeed) { ser.startTag(NS_TRACKPOINT_EXTENSION_URI, "speed").text(formatDouble(speed)).endTag(NS_TRACKPOINT_EXTENSION_URI, "speed"); } ser.endTag(NS_TRACKPOINT_EXTENSION_URI, "TrackPointExtension"); @@ -212,6 +255,9 @@ public class GPXExporter implements ActivityTrackExporter { } private @Nullable ActivityPoint findClosestSensibleActivityPoint(Date time, Iterable trackPoints) { + if (time == null) { + return null; + } ActivityPoint closestPointItem = null; HeartRateUtils heartRateUtilsInstance = HeartRateUtils.getInstance(); @@ -220,6 +266,9 @@ public class GPXExporter implements ActivityTrackExporter { int hrItem = pointItem.getHeartRate(); if (heartRateUtilsInstance.isValidHeartRateValue(hrItem)) { Date timeItem = pointItem.getTime(); + if (timeItem == null) { + continue; + } if (timeItem.after(time) || timeItem.equals(time)) { break; // we assume that the given trackPoints are sorted in time ascending order (oldest first) } @@ -233,8 +282,16 @@ public class GPXExporter implements ActivityTrackExporter { return closestPointItem; } + private String formatLocation(double value) { + return locationFormat.format(value); + } + private String formatDouble(double value) { - return new BigDecimal(value).setScale(GPSCoordinate.GPS_DECIMAL_DEGREES_SCALE, RoundingMode.HALF_UP).toPlainString(); + return doubleFormat.format(value); + } + + private String formatLong(long value) { + return NumberFormat.getNumberInstance(Locale.ROOT).format(value); } public String getCreator() { @@ -245,6 +302,10 @@ public class GPXExporter implements ActivityTrackExporter { this.creator = creator; } + public void setDate(Date date) { + this.date = date; + } + public void setIncludeHeartRate(boolean includeHeartRate) { this.includeHeartRate = includeHeartRate; } @@ -252,4 +313,9 @@ public class GPXExporter implements ActivityTrackExporter { public boolean isIncludeHeartRate() { return includeHeartRate; } + + @TestOnly + public void setUuid(@Nullable UUID id) { + uuid = id; + } } 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 8b1703e243..8552a25b12 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/GPSCoordinate.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/GPSCoordinate.java @@ -79,6 +79,10 @@ public class GPSCoordinate implements Parcelable { return altitude; } + public boolean hasAltitude() { + return altitude > UNKNOWN_ALTITUDE; + } + public void setHdop(double hdop) { this.hdop = hdop; } diff --git a/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/export/GPXExporterTest.java b/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/export/GPXExporterTest.java index 7c61c2387f..2cd74e85b6 100644 --- a/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/export/GPXExporterTest.java +++ b/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/export/GPXExporterTest.java @@ -1,7 +1,26 @@ +/* Copyright (C) 2019-2025 Nick Spacek, Thomas Kuehne + + 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.export; +import static nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.GarminSupportTest.readBinaryResource; + import com.google.gson.internal.bind.util.ISO8601Utils; +import org.junit.Assert; import org.junit.Test; import org.xml.sax.SAXException; @@ -10,11 +29,14 @@ import java.io.File; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; +import java.nio.file.Files; import java.text.ParseException; import java.text.ParsePosition; +import java.time.Instant; import java.util.ArrayList; import java.util.Date; import java.util.List; +import java.util.UUID; import javax.xml.XMLConstants; import javax.xml.parsers.ParserConfigurationException; @@ -30,7 +52,11 @@ import nodomain.freeyourgadget.gadgetbridge.export.ActivityTrackExporter.GPXTrac import nodomain.freeyourgadget.gadgetbridge.model.ActivityPoint; import nodomain.freeyourgadget.gadgetbridge.model.ActivityTrack; import nodomain.freeyourgadget.gadgetbridge.model.GPSCoordinate; +import nodomain.freeyourgadget.gadgetbridge.test.GBTestApplication; import nodomain.freeyourgadget.gadgetbridge.test.TestBase; +import nodomain.freeyourgadget.gadgetbridge.util.gpx.GpxParseException; +import nodomain.freeyourgadget.gadgetbridge.util.gpx.GpxParser; +import nodomain.freeyourgadget.gadgetbridge.util.gpx.model.GpxFile; public class GPXExporterTest extends TestBase { @Test @@ -39,7 +65,7 @@ public class GPXExporterTest extends TestBase { final GPXExporter gpxExporter = new GPXExporter(); gpxExporter.setCreator("Gadgetbridge Test"); - final ActivityTrack track = createTestTrack(points); + final ActivityTrack track = createTestTrack(points, new Date()); final File tempFile = File.createTempFile("gpx-exporter-test-track", ".gpx"); tempFile.deleteOnExit(); @@ -54,7 +80,7 @@ public class GPXExporterTest extends TestBase { final GPXExporter gpxExporter = new GPXExporter(); gpxExporter.setCreator("Gadgetbridge Test"); - final ActivityTrack track = createTestTrack(points); + final ActivityTrack track = createTestTrack(points, new Date()); final File tempFile = File.createTempFile("gpx-exporter-test-track", ".gpx"); tempFile.deleteOnExit(); @@ -63,7 +89,7 @@ public class GPXExporterTest extends TestBase { validateGpxFile(tempFile); } - private ActivityTrack createTestTrack(List points) { + private ActivityTrack createTestTrack(List points, Date time) { final User user = new User(); user.setName("Test User"); @@ -72,7 +98,7 @@ public class GPXExporterTest extends TestBase { final ActivityTrack track = new ActivityTrack(); track.setName("Test Track"); - track.setBaseTime(new Date()); + track.setBaseTime(time); track.setUser(user); track.setDevice(device); @@ -117,8 +143,59 @@ public class GPXExporterTest extends TestBase { private void validateGpxFile(File tempFile) throws SAXException, IOException { final Source xmlFile = new StreamSource(tempFile); final SchemaFactory schemaFactory = SchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI); - final Schema schema = schemaFactory.newSchema(new StreamSource(getClass().getResourceAsStream("/gpx.xsd"))); + final StreamSource[] sources = { + new StreamSource(getClass().getResourceAsStream("/gpx.xsd")), + new StreamSource(getClass().getResourceAsStream("/opentracks-schema-1.0.xsd")), + new StreamSource(getClass().getResourceAsStream("/TrackPointExtensionv2.xsd")) + }; + final Schema schema = schemaFactory.newSchema(sources); final Validator validator = schema.newValidator(); validator.validate(xmlFile); } + + /// GPX (import -> export) with different locales to ensure parsing and formating work correctly + @Test + public void TestImportExport() throws Exception { + try { + GBTestApplication.setLanguage("bn"); + Assert.assertEquals("-১২,৩৫৬.৬৫৪৩২১", String.format("%,f", -12356.654321)); + ImportExport(); + GBTestApplication.setLanguage("dz"); + Assert.assertEquals("-༡༢,༣༥༦.༦༥༤༣༢༡", String.format("%,f", -12356.654321)); + ImportExport(); + GBTestApplication.setLanguage("my"); + Assert.assertEquals("-၁၂,၃၅၆.၆၅၄၃၂၁", String.format("%,f", -12356.654321)); + ImportExport(); + GBTestApplication.setLanguage("sa"); + Assert.assertEquals("-१२,३५६.६५४३२१", String.format("%,f", -12356.654321)); + ImportExport(); + }finally { + GBTestApplication.setLanguage("en"); + Assert.assertEquals("-12,356.654321", String.format("%,f", -12356.654321)); + } + } + + public void ImportExport() throws IOException, GpxParseException, GPXTrackEmptyException { + final GpxFile imported; + try (final InputStream gpx = getClass().getResourceAsStream("/TestGpxImport.gpx")) { + imported = new GpxParser(gpx).getGpxFile(); + } + + final List points = imported.getActivityPoints(); + + final GPXExporter gpxExporter = new GPXExporter(); + gpxExporter.setCreator("Gadgetbridge Test"); + gpxExporter.setDate(Date.from(Instant.parse("2025-10-17T21:00:00-00:00"))); + gpxExporter.setUuid(UUID.fromString("c5185301-d578-4e52-bb9f-c2a7afa044e2")); + final ActivityTrack track = createTestTrack(points, imported.getTime()); + + final File tempFile = File.createTempFile("gpx-exporter-test-import-export", ".gpx"); + tempFile.deleteOnExit(); + + gpxExporter.performExport(track, tempFile); + + byte[] exported = Files.readAllBytes(tempFile.toPath()); + byte[] expected = readBinaryResource("/TestGpxExport.gpx"); + Assert.assertArrayEquals(expected, exported); + } } \ No newline at end of file diff --git a/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/util/gpx/GPXParserTest.java b/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/util/gpx/GPXParserTest.java index 7fd23853ff..300c6117be 100644 --- a/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/util/gpx/GPXParserTest.java +++ b/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/util/gpx/GPXParserTest.java @@ -163,6 +163,9 @@ public class GPXParserTest extends TestBase { Assert.assertEquals(28.3f, trkpt0.getTemperature(), 0.0001f); Assert.assertEquals(26.3f, trkpt0.getDepth(), 0.0001f); + GpxTrackPoint trkpt1 = trkseg0.getTrackPoints().get(1); + Assert.assertEquals(Date.from(Instant.parse("2025-10-03T21:06:07+05:45")), trkpt1.getTime()); + byte[] raw = readBinaryResource("/TestGpxImport.gpx"); GpxFile file2 = GpxParser.parseGpx(raw); Assert.assertNotNull(file2); diff --git a/app/src/test/resources/TestGpxExport.gpx b/app/src/test/resources/TestGpxExport.gpx new file mode 100644 index 0000000000..32761e2c0e --- /dev/null +++ b/app/src/test/resources/TestGpxExport.gpx @@ -0,0 +1 @@ +Test TrackTest Userc5185301-d578-4e52-bb9f-c2a7afa044e2-440.2a description1.43.21.528.29999923706054726.2999992370605471232223.2999992370605478849.2a comment34.299999237060551034540.40000152587890641.400001525878906424344.400001525878906 \ No newline at end of file diff --git a/app/src/test/resources/TestGpxImport.gpx b/app/src/test/resources/TestGpxImport.gpx index 90c456201b..29fb8b01d6 100644 --- a/app/src/test/resources/TestGpxImport.gpx +++ b/app/src/test/resources/TestGpxImport.gpx @@ -96,18 +96,19 @@ 8849.2 - + Tp2 a comment Water 34.3 - 33.3 + 10345 + 40.4 diff --git a/app/src/test/resources/TestGpxImport.xml b/app/src/test/resources/TestGpxImport.xml index afdcc76843..6d06b94fbf 100644 --- a/app/src/test/resources/TestGpxImport.xml +++ b/app/src/test/resources/TestGpxImport.xml @@ -5,8 +5,8 @@ - - + + diff --git a/app/src/test/resources/TrackPointExtensionv2.xsd b/app/src/test/resources/TrackPointExtensionv2.xsd new file mode 100644 index 0000000000..c5cc317b39 --- /dev/null +++ b/app/src/test/resources/TrackPointExtensionv2.xsd @@ -0,0 +1,94 @@ + + + + + This schema defines Garmin extensions to be used with the GPX 1.1 schema. + The root element defined by this schema is intended to be used as a child + element of the "extensions" elements in the trkpt element in the GPX 1.1 schema. + The GPX 1.1 schema is available at http://www.topografix.com/GPX/1/1/gpx.xsd. + This is a replacement for TrackPointExtension in + http://www.garmin.com/xmlschemas/GpxExtensions/v3 + + + + + + + This type contains data fields that cannot + be represented in track points in GPX 1.1 instances. + + + + + + + + + + + + + + + + + This type contains a temperature value measured in degrees Celsius. + + + + + + + This type contains a distance value measured in meters. + + + + + + + This type contains a heart rate measured in beats per minute. + + + + + + + + + This type contains a cadence measured in revolutions per minute. + + + + + + + + + This type contains a speed measured in meters per second. + + + + + + + This type contains an angle measured in degrees in a clockwise direction from the true north line. + + + + + + + + + + This type provides the ability to extend any data type that includes it. + + + + + + + diff --git a/app/src/test/resources/opentracks-schema-1.0.xsd b/app/src/test/resources/opentracks-schema-1.0.xsd new file mode 100644 index 0000000000..89b187fb14 --- /dev/null +++ b/app/src/test/resources/opentracks-schema-1.0.xsd @@ -0,0 +1,63 @@ + + + + + + + A unique track identifier stored as UUID. + + + + + + + + The lost elevation in meters from the previous to the current TrackPoint. + Only used in GPX. + + + + + + + The gained elevation in meters from the previous to the current TrackPoint. + Only used in GPX. + + + + + + + Horizontal accuracy in meters of the current TrackPoint; 68% chance that the actual + location is + within this radius around the measured location. + Only used in GPX. + + + + + + + Vertical accuracy in meters of the current TrackPoint; 68% chance that the actual + location is + within this radius around the measured location. + Only used in GPX. + + + + + + + User-defined translation of GPX's ]]>. + Only used in GPX. + + + + \ No newline at end of file