diff --git a/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/export/FitExporterPaceTest.java b/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/export/FitExporterPaceTest.java
new file mode 100644
index 0000000000..02b2d1c870
--- /dev/null
+++ b/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/export/FitExporterPaceTest.java
@@ -0,0 +1,318 @@
+/* Copyright (C) 2026 Dany Mestas
+
+ 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 org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityPoint;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryData;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityTrack;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.FitFile;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.RecordData;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitLap;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitSession;
+
+/**
+ * Covers the FitExporter Part A (avg_speed fallback) and Part B (rowing distance synth)
+ * paths added to address the Endurain rowing/running pace bug.
+ *
+ * Tests build synthetic BaseActivitySummary + ActivityTrack inputs, run FitExporter
+ * against a temp file, then decode the FIT bytes via FitFile.parseIncoming and assert
+ * Session/Lap fields. No Android context, DB, or device is involved.
+ */
+public class FitExporterPaceTest {
+
+ @Rule
+ public TemporaryFolder tmp = new TemporaryFolder();
+
+ /** Indoor rowing (erg) session: 9 segments (4 active 0x81 of ~256 strokes each, 5 rest
+ * 0x82 of ~5 strokes), totalling 1208 strokes / 2550 s — matches the user's 20-Apr
+ * workout. ActivityKind.ROWING_MACHINE → FIT (sport=15, sub_sport=14) → indoor
+ * 6.0 m/stroke default. */
+ @Test
+ public void indoorRowingSession_synthesizesDistanceCyclesStrokeDistanceAvgSpeed() throws Exception {
+ final long start = 1776705018L;
+ final long elapsed = 2550L;
+ final BaseActivitySummary summary = newSummary(start, elapsed, ActivityKind.ROWING_MACHINE);
+ final ActivitySummaryData data = new ActivitySummaryData();
+ // STROKES is normally populated by WorkoutSummaryParser.getRowingParser; mirror that.
+ data.add(ActivitySummaryEntries.STROKES, 1208, ActivitySummaryEntries.UNIT_STROKES);
+ data.add(ActivitySummaryEntries.HR_AVG, 142, ActivitySummaryEntries.UNIT_BPM);
+ data.add(ActivitySummaryEntries.CALORIES_BURNT, 214, ActivitySummaryEntries.UNIT_KCAL);
+
+ final ActivityTrack track = newRowingTrack(start);
+
+ final File out = tmp.newFile("rowing-indoor.fit");
+ new FitExporter().performExport(track, summary, data, out);
+
+ final FitFile fit = FitFile.parseIncoming(out);
+ final FitSession session = onlySession(fit);
+ final List laps = laps(fit);
+
+ // ---- Session expectations ----
+ assertNotNull("session.totalDistance", session.getTotalDistance());
+ // 1208 strokes * 6.0 m/stroke = 7248 m. Getter returns raw meters (codec applies scale=100).
+ assertEquals(7248.0, session.getTotalDistance(), 0.5);
+ assertEquals(Long.valueOf(1208), session.getTotalCycles());
+ assertNotNull("session.avgStrokeDistance", session.getAvgStrokeDistance());
+ assertEquals(6.0f, session.getAvgStrokeDistance(), 0.01f);
+ assertNotNull("session.avgSpeed", session.getAvgSpeed());
+ // 7248 / 2550 ≈ 2.842 m/s
+ assertEquals(2.842f, session.getAvgSpeed(), 0.005f);
+ // ActivityKind.ROWING_MACHINE → reverse-lookup picks ROW_INDOOR(15,14) — the
+ // modern Garmin code — over the legacy FITNESS_EQUIPMENT/INDOOR_ROWING(4,14).
+ // FitExporter.isRowingSport accepts both forms for the stroke→distance synth.
+ assertEquals(Integer.valueOf(15), session.getSport());
+ assertEquals(Integer.valueOf(14), session.getSubSport());
+
+ // ---- Lap expectations: every active rowing lap has distance/cycles/speed set ----
+ // Both FitLap.getTotalDistance() and FitSession.getTotalDistance() return raw
+ // meters (codec applies scale=100 on decode).
+ double sumLapDistanceMeters = 0;
+ for (final FitLap lap : laps) {
+ assertNotNull("lap distance", lap.getTotalDistance());
+ sumLapDistanceMeters += lap.getTotalDistance();
+ assertNotNull("lap cycles", lap.getTotalCycles());
+ assertNotNull("lap avgStrokeDistance", lap.getAvgStrokeDistance());
+ // FIT lap.avg_stroke_distance is Integer cm-scaled (600 = 6.0 m)
+ assertEquals(600, lap.getAvgStrokeDistance().intValue());
+ // avg_speed must be set on every lap including rest laps (≈0 there).
+ assertNotNull("lap avgSpeed", lap.getAvgSpeed());
+ }
+ // Sum of lap distances should equal session distance.
+ assertEquals(7248.0, sumLapDistanceMeters, 0.5);
+ }
+
+ /** Outdoor (on-water) rowing uses a longer 9.0 m/stroke default. Same 1208/2550 input
+ * pinned to the ROWING (15, 0) FIT mapping to lock the indoor-vs-outdoor split. */
+ @Test
+ public void outdoorRowingSession_usesLongerStrokeLengthDefault() throws Exception {
+ final long start = 1776705018L;
+ final long elapsed = 2550L;
+ final BaseActivitySummary summary = newSummary(start, elapsed, ActivityKind.ROWING);
+ final ActivitySummaryData data = new ActivitySummaryData();
+ data.add(ActivitySummaryEntries.STROKES, 1208, ActivitySummaryEntries.UNIT_STROKES);
+
+ final ActivityTrack track = newRowingTrack(start);
+
+ final File out = tmp.newFile("rowing-outdoor.fit");
+ new FitExporter().performExport(track, summary, data, out);
+
+ final FitFile fit = FitFile.parseIncoming(out);
+ final FitSession session = onlySession(fit);
+ final List laps = laps(fit);
+
+ // 1208 * 9.0 = 10872 m. Getter returns raw meters.
+ assertNotNull("session.totalDistance", session.getTotalDistance());
+ assertEquals(10872.0, session.getTotalDistance(), 0.5);
+ assertEquals(9.0f, session.getAvgStrokeDistance(), 0.01f);
+ // 10872 / 2550 ≈ 4.264 m/s
+ assertEquals(4.264f, session.getAvgSpeed(), 0.005f);
+ assertEquals(Integer.valueOf(15), session.getSport());
+ assertEquals(Integer.valueOf(0), session.getSubSport());
+
+ for (final FitLap lap : laps) {
+ assertNotNull("lap avgStrokeDistance", lap.getAvgStrokeDistance());
+ assertEquals(900, lap.getAvgStrokeDistance().intValue());
+ }
+ }
+
+ /**
+ * Outdoor running with measured distance but no per-record speed (recorded summary
+ * SPEED_AVG=0). After fix, both lap and session avg_speed are derived from
+ * distance/elapsed so importers can show pace.
+ */
+ @Test
+ public void outdoorRunningSession_derivesAvgSpeedFromDistanceAndTime() throws Exception {
+ final long start = 1777747544L;
+ final long elapsed = 496L;
+ final BaseActivitySummary summary = newSummary(start, elapsed, ActivityKind.OUTDOOR_RUNNING);
+ final ActivitySummaryData data = new ActivitySummaryData();
+ data.add(ActivitySummaryEntries.DISTANCE_METERS, 386, ActivitySummaryEntries.UNIT_METERS);
+ // Watch reported SPEED_AVG=0 for the interval session — fallback should kick in.
+ data.add(ActivitySummaryEntries.SPEED_AVG, 0, ActivitySummaryEntries.UNIT_KMPH);
+
+ final ActivityTrack track = singleSegmentTrack(start, elapsed);
+
+ final File out = tmp.newFile("running.fit");
+ new FitExporter().performExport(track, summary, data, out);
+
+ final FitFile fit = FitFile.parseIncoming(out);
+ final FitSession session = onlySession(fit);
+ final List laps = laps(fit);
+
+ // 386 / 496 ≈ 0.7782 m/s
+ assertNotNull("session.avgSpeed", session.getAvgSpeed());
+ assertEquals(0.778f, session.getAvgSpeed(), 0.005f);
+ // Sport remapped via the OUTDOOR_RUNNING alias added earlier to GarminSport.
+ assertEquals(Integer.valueOf(1), session.getSport());
+
+ // Single-lap fallback path → exactly one lap with the same derivation.
+ assertEquals(1, laps.size());
+ final FitLap lap = laps.get(0);
+ assertNotNull("lap.avgSpeed", lap.getAvgSpeed());
+ assertEquals(0.778f, lap.getAvgSpeed(), 0.005f);
+ }
+
+ /**
+ * Treadmill session that already has a non-zero summary SPEED_AVG (per-record speed
+ * existed): the fallback must NOT overwrite it.
+ */
+ @Test
+ public void treadmillSession_keepsExistingNonZeroAvgSpeed() throws Exception {
+ final long start = 1777656743L;
+ final long elapsed = 2004L;
+ final BaseActivitySummary summary = newSummary(start, elapsed, ActivityKind.TREADMILL);
+ final ActivitySummaryData data = new ActivitySummaryData();
+ data.add(ActivitySummaryEntries.DISTANCE_METERS, 5000, ActivitySummaryEntries.UNIT_METERS);
+ // 9 km/h — matches the 30-Apr treadmill workout's per-record speed avg.
+ data.add(ActivitySummaryEntries.SPEED_AVG, 9.0, ActivitySummaryEntries.UNIT_KMPH);
+
+ final ActivityTrack track = singleSegmentTrack(start, elapsed);
+
+ final File out = tmp.newFile("treadmill.fit");
+ new FitExporter().performExport(track, summary, data, out);
+
+ final FitFile fit = FitFile.parseIncoming(out);
+ final FitSession session = onlySession(fit);
+
+ assertNotNull("session.avgSpeed", session.getAvgSpeed());
+ // Existing 2.5 m/s (= 9 km/h) preserved, NOT replaced by 5000/2004 ≈ 2.495.
+ assertEquals(2.5f, session.getAvgSpeed(), 0.005f);
+ }
+
+ /**
+ * Non-rowing distanceless workout (HIIT) — no synthesis should occur even though
+ * strokes/distance are absent.
+ */
+ @Test
+ public void hiitSession_doesNotSynthesizeDistance() throws Exception {
+ final long start = 1777656743L;
+ final long elapsed = 1200L;
+ final BaseActivitySummary summary = newSummary(start, elapsed, ActivityKind.HIIT);
+ final ActivitySummaryData data = new ActivitySummaryData();
+ data.add(ActivitySummaryEntries.HR_AVG, 150, ActivitySummaryEntries.UNIT_BPM);
+
+ final File out = tmp.newFile("hiit.fit");
+ new FitExporter().performExport(null, summary, data, out);
+
+ final FitFile fit = FitFile.parseIncoming(out);
+ final FitSession session = onlySession(fit);
+ // No distance and no avg_speed should be emitted — HIIT genuinely has none.
+ assertNull("session.totalDistance", session.getTotalDistance());
+ assertNull("session.avgSpeed", session.getAvgSpeed());
+ }
+
+ // ---------- helpers ----------
+
+ private static BaseActivitySummary newSummary(final long startSec,
+ final long elapsedSec,
+ final ActivityKind kind) {
+ final BaseActivitySummary s = new BaseActivitySummary();
+ s.setStartTime(new Date(startSec * 1000L));
+ s.setEndTime(new Date((startSec + elapsedSec) * 1000L));
+ s.setActivityKind(kind.getCode());
+ return s;
+ }
+
+ /** Build a 9-segment rowing track matching the 20-Apr workout structure (4 active + 5 rest)
+ * with the same per-segment strokes (151+4+256+6+261+9+257+1+263 = 1208 total). */
+ private static ActivityTrack newRowingTrack(final long startSec) {
+ final ActivityTrack track = new ActivityTrack();
+
+ final int[][] segs = {
+ // {durationSec, strokes, isActive(0x81 == 1)}
+ {372, 151, 1}, {16, 4, 0}, {461, 256, 1}, {114, 6, 0},
+ {461, 261, 1}, {111, 9, 0}, {455, 257, 1}, {118, 1, 0}, {442, 263, 1},
+ };
+
+ long ts = startSec;
+ boolean first = true;
+ for (final int[] s : segs) {
+ final ActivityTrack.SegmentInfo info = new ActivityTrack.SegmentInfo(
+ s[2] == 1 ? ActivityTrack.SegmentIntensity.ACTIVE : ActivityTrack.SegmentIntensity.REST,
+ null,
+ s[1]);
+ if (first) {
+ track.setCurrentSegmentInfo(info);
+ first = false;
+ } else {
+ track.startNewSegment(info);
+ }
+ // Add enough records that segments are not "tiny" (>= 5 records, >= 10s) so
+ // the FitExporter keeps them as separate laps. Use one record per second.
+ for (int i = 0; i < s[0]; i++) {
+ final ActivityPoint p = new ActivityPoint(new Date((ts + i) * 1000L));
+ p.setHeartRate(140);
+ track.addTrackPoint(p);
+ }
+ ts += s[0];
+ }
+ return track;
+ }
+
+ /** Single-segment track with `nSec` heart-rate-only records — no per-record distance
+ * or speed. Forces the exporter to rely on summary fields and the new fallbacks. */
+ private static ActivityTrack singleSegmentTrack(final long startSec, final long nSec) {
+ final ActivityTrack track = new ActivityTrack();
+ track.setCurrentSegmentInfo(new ActivityTrack.SegmentInfo(ActivityTrack.SegmentIntensity.ACTIVE));
+ for (long i = 0; i < nSec; i++) {
+ final ActivityPoint p = new ActivityPoint(new Date((startSec + i) * 1000L));
+ p.setHeartRate(150);
+ track.addTrackPoint(p);
+ }
+ return track;
+ }
+
+ private static FitSession onlySession(final FitFile fit) {
+ FitSession s = null;
+ for (final RecordData r : fit.getRecords()) {
+ if (r instanceof FitSession) {
+ if (s != null) {
+ throw new AssertionError("multiple sessions");
+ }
+ s = (FitSession) r;
+ }
+ }
+ assertNotNull("no session record", s);
+ return s;
+ }
+
+ private static List laps(final FitFile fit) {
+ final List out = new ArrayList<>();
+ for (final RecordData r : fit.getRecords()) {
+ if (r instanceof FitLap) out.add((FitLap) r);
+ }
+ assertTrue("expected at least one lap", !out.isEmpty());
+ return out;
+ }
+}
diff --git a/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/export/FitExporterReadmeRoundTripTest.java b/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/export/FitExporterReadmeRoundTripTest.java
new file mode 100644
index 0000000000..9a3f91823f
--- /dev/null
+++ b/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/export/FitExporterReadmeRoundTripTest.java
@@ -0,0 +1,758 @@
+/* Copyright (C) 2026 Dany Mestas
+
+ 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 org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import org.junit.Ignore;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import java.io.File;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityPoint;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryData;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityTrack;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.FieldDefinition;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.FitFile;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.RecordData;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.enums.GarminSport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitFileId;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitLap;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitLength;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitRecord;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitSession;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitSet;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitSplit;
+
+/**
+ * Round-trip every FIT file referenced in
+ * {@code app/src/test/resources/FIT-test-files-main/README.md}: parse → build a minimal
+ * BaseActivitySummary + ActivityTrack from the original FitSession/FitRecord stream →
+ * export with FitExporter → re-parse the output and assert key fields are preserved.
+ *
+ * Also logs a per-file summary of which messages and fields were present in the
+ * original (sport/subSport, manufacturer/product, record/lap/length/set/split counts) so
+ * the corpus can be reviewed for fields the exporter does not yet cover.
+ *
+ *
Pure JVM — no Robolectric, no Android context, no DB.
+ */
+@Ignore("requires FIT-test-files-main corpus, not committed to the repo — keep for local/future use")
+public class FitExporterReadmeRoundTripTest {
+
+ @Rule
+ public TemporaryFolder tmp = new TemporaryFolder();
+
+ private static final File README = new File(
+ "src/test/resources/FIT-test-files-main/README.md");
+ private static final File CORPUS_ROOT = new File(
+ "src/test/resources/FIT-test-files-main");
+ // README rows: | sport | subsport | [label](Activity/.../foo.fit) |
+ private static final Pattern README_LINK = Pattern.compile(
+ "\\(([^)\\s]+\\.fit)\\)");
+
+ @Test
+ public void roundTripAllReadmeFiles() throws Exception {
+ final List relPaths = readmeFitPaths();
+ assertTrue("README must list at least one fit file", !relPaths.isEmpty());
+ runRoundTripBatch(relPaths);
+ }
+
+ /** Walks every {@code .fit} file under {@code Activity//} for years ≥ 2015
+ * and round-trips it. Broader than {@link #roundTripAllReadmeFiles()} which only
+ * covers the curated README sample. Pre-2015 files (1989–2014) exercise legacy
+ * codec edge-cases unrelated to current devices and are skipped. */
+ @Test
+ public void roundTripAllActivityFitFiles2015Onward() throws Exception {
+ final java.nio.file.Path activityRoot = new File(CORPUS_ROOT, "Activity").toPath();
+ if (!java.nio.file.Files.exists(activityRoot)) {
+ // Test resource not present — skip silently. The README batch already
+ // runs against the curated subset.
+ return;
+ }
+ final java.util.regex.Pattern yearDir = java.util.regex.Pattern.compile(
+ "Activity/(\\d{4})/.*");
+ final List relPaths = new ArrayList<>();
+ try (java.util.stream.Stream walk = java.nio.file.Files.walk(activityRoot)) {
+ walk.filter(p -> p.toString().toLowerCase(java.util.Locale.ROOT).endsWith(".fit"))
+ .forEach(p -> {
+ final String rel = CORPUS_ROOT.toPath().relativize(p).toString().replace('\\', '/');
+ final Matcher m = yearDir.matcher(rel);
+ if (m.matches() && Integer.parseInt(m.group(1)) >= 2015) {
+ relPaths.add(rel);
+ }
+ });
+ }
+ assertTrue("Activity// must contain at least one fit file", !relPaths.isEmpty());
+ runRoundTripBatch(relPaths);
+ }
+
+ private void runRoundTripBatch(final List relPaths) throws Exception {
+
+ final Map results = new LinkedHashMap<>();
+ final List failures = new ArrayList<>();
+ final List codecSkips = new ArrayList<>();
+ final List noSessionSkips = new ArrayList<>();
+
+ for (final String rel : relPaths) {
+ final File in = new File(CORPUS_ROOT, rel);
+ if (!in.exists()) {
+ failures.add(rel + " — missing file");
+ continue;
+ }
+ try {
+ final RoundTripStats stats = roundTrip(in, rel);
+ if (stats == null) {
+ noSessionSkips.add(rel);
+ continue;
+ }
+ results.put(rel, stats);
+ } catch (final nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.exception.FitParseException
+ | IllegalArgumentException
+ | NullPointerException
+ | java.nio.BufferUnderflowException codec) {
+ // Codec edge case in input file (unsupported BaseType, malformed
+ // record header, etc.) — orthogonal to exporter behaviour. Warn and
+ // continue; these surface as a separate corpus health stat.
+ codecSkips.add(rel + " — " + codec.getClass().getSimpleName()
+ + ": " + codec.getMessage());
+ } catch (final Throwable t) {
+ failures.add(rel + " — " + t.getClass().getSimpleName() + ": " + t.getMessage());
+ }
+ }
+
+ // Print per-file stats so the test output documents the corpus coverage.
+ System.out.println("=== ReadmeRoundTrip results (" + results.size() + " files) ===");
+ for (final Map.Entry e : results.entrySet()) {
+ System.out.println(e.getKey() + " → " + e.getValue());
+ }
+ if (!codecSkips.isEmpty()) {
+ System.out.println("=== Codec-skip (" + codecSkips.size() + " files, parser limitation) ===");
+ for (final String s : codecSkips) System.out.println(" - " + s);
+ }
+ if (!noSessionSkips.isEmpty()) {
+ System.out.println("=== No-session skip (" + noSessionSkips.size()
+ + " files, sensor-only / tracker-upload, exporter-NA) ===");
+ for (final String s : noSessionSkips) System.out.println(" - " + s);
+ }
+
+ if (!failures.isEmpty()) {
+ final StringBuilder sb = new StringBuilder("Round-trip failures:\n");
+ for (final String f : failures) sb.append(" - ").append(f).append('\n');
+ fail(sb.toString());
+ }
+ }
+
+ // ---------- core round-trip ----------
+
+ private RoundTripStats roundTrip(final File in, final String relPath) throws Exception {
+ final FitFile inFit = FitFile.parseIncoming(in);
+
+ FitFileId fileId = null;
+ FitSession session = null;
+ final List records = new ArrayList<>();
+ final List laps = new ArrayList<>();
+ final List lengths = new ArrayList<>();
+ final List splits = new ArrayList<>();
+ final List setMessages = new ArrayList<>();
+ // Field-number tally per message type — schema seen in input vs schema emitted
+ // by the exporter. Surfaces device-vendor field-encoding gaps.
+ final java.util.Set inSessionFields = new java.util.TreeSet<>();
+ final java.util.Set inLapFields = new java.util.TreeSet<>();
+ final java.util.Set inRecordFields = new java.util.TreeSet<>();
+ for (final RecordData r : inFit.getRecords()) {
+ if (r instanceof FitFileId) fileId = (FitFileId) r;
+ else if (r instanceof FitSession) {
+ if (session == null) session = (FitSession) r; // first-only (multi-session not supported)
+ collectFieldNumbers(r, inSessionFields);
+ } else if (r instanceof FitRecord) {
+ records.add((FitRecord) r);
+ collectFieldNumbers(r, inRecordFields);
+ } else if (r instanceof FitLap) {
+ laps.add((FitLap) r);
+ collectFieldNumbers(r, inLapFields);
+ } else if (r instanceof FitLength) lengths.add((FitLength) r);
+ else if (r instanceof FitSplit) splits.add((FitSplit) r);
+ else if (r instanceof FitSet) setMessages.add((FitSet) r);
+ }
+ // Sensor-only / tracker-upload files (e.g. Vivosmart sleep, Edge cycling
+ // computer GPX-only logs) carry no session record. Exporter cannot produce
+ // a session-keyed FIT for them — surface as a separate skip stat rather
+ // than a failure.
+ if (session == null || fileId == null) return null;
+
+ final RoundTripStats stats = new RoundTripStats();
+ stats.inSize = in.length();
+ stats.manufacturer = fileId.getManufacturer();
+ stats.product = fileId.getProduct();
+ stats.sport = session.getSport();
+ stats.subSport = session.getSubSport();
+ stats.numRecords = records.size();
+ stats.numLaps = laps.size();
+ stats.numLengths = lengths.size();
+ stats.numSplits = splits.size();
+ stats.numSets = setMessages.size();
+
+ // Build the export inputs from the parsed file.
+ final BaseActivitySummary summary = buildSummaryFromSession(session, in.getName());
+ final ActivityTrack track = buildTrackFromRecordsAndLaps(records, laps);
+ // Pass lengths/splits/sets through so the exporter can re-emit them on
+ // round-trip. Mirrors what FitActivityTrackProvider does for the production
+ // import path.
+ for (final FitLength len : lengths) {
+ final Long startTime = len.getStartTime();
+ if (startTime == null) continue;
+ track.addLength(new ActivityTrack.LengthInfo(
+ startTime,
+ len.getTotalElapsedTime() != null ? len.getTotalElapsedTime() : 0.0,
+ len.getTotalTimerTime() != null ? len.getTotalTimerTime() : 0.0,
+ len.getTotalStrokes(),
+ len.getAvgSpeed(),
+ len.getSwimStroke(),
+ len.getLengthType(),
+ len.getAvgSwimmingCadence()));
+ }
+ for (final FitSplit sp : splits) {
+ final Long startTime = sp.getStartTime();
+ if (startTime == null) continue;
+ track.addSplit(new ActivityTrack.SplitInfo(
+ startTime,
+ sp.getEndTime(),
+ sp.getSplitType(),
+ sp.getTotalElapsedTime(),
+ sp.getTotalTimerTime(),
+ sp.getTotalDistance(),
+ sp.getAvgSpeed(),
+ sp.getMaxSpeed(),
+ sp.getTotalAscent(),
+ sp.getTotalDescent(),
+ sp.getTotalCalories(),
+ sp.getStartElevation(),
+ sp.getStartPositionLat(),
+ sp.getStartPositionLong(),
+ sp.getEndPositionLat(),
+ sp.getEndPositionLong()));
+ }
+ for (final FitSet sm : setMessages) {
+ final Long startTime = sm.getStartTime();
+ if (startTime == null) continue;
+ track.addSet(new ActivityTrack.SetInfo(
+ startTime,
+ sm.getDuration(),
+ sm.getRepetitions(),
+ sm.getWeight(),
+ sm.getSetType(),
+ sm.getWeightDisplayUnit(),
+ sm.getMessageIndex()));
+ }
+ final ActivitySummaryData data = buildSummaryDataFromSession(session);
+
+ // Export. Hash the relPath into the temp filename — the same basename
+ // (e.g. Lap-Swimming-Fenix6x.fit) appears under multiple year dirs in the
+ // corpus; without a hash the second one collides with TemporaryFolder.
+ final String tmpName = in.getName() + "."
+ + Integer.toHexString(relPath.hashCode()) + ".out.fit";
+ final File out = tmp.newFile(tmpName);
+ new FitExporter().performExport(track, summary, data, out);
+ stats.outSize = out.length();
+
+ // Re-parse and assert the key fields survived.
+ final FitFile outFit = FitFile.parseIncoming(out);
+ FitSession outSession = null;
+ int outRecords = 0;
+ int outLaps = 0;
+ int outLengths = 0;
+ int outSplits = 0;
+ int outSets = 0;
+ final java.util.Set outSessionFields = new java.util.TreeSet<>();
+ final java.util.Set outLapFields = new java.util.TreeSet<>();
+ final java.util.Set outRecordFields = new java.util.TreeSet<>();
+ for (final RecordData r : outFit.getRecords()) {
+ if (r instanceof FitSession) {
+ if (outSession == null) outSession = (FitSession) r;
+ collectFieldNumbers(r, outSessionFields);
+ } else if (r instanceof FitRecord) {
+ outRecords++;
+ collectFieldNumbers(r, outRecordFields);
+ } else if (r instanceof FitLap) {
+ outLaps++;
+ collectFieldNumbers(r, outLapFields);
+ } else if (r instanceof FitLength) {
+ outLengths++;
+ } else if (r instanceof FitSplit) {
+ outSplits++;
+ } else if (r instanceof FitSet) {
+ outSets++;
+ }
+ }
+ stats.outLengths = outLengths;
+ stats.outSplits = outSplits;
+ stats.outSets = outSets;
+ stats.fieldDiff = formatFieldDiff(inSessionFields, outSessionFields,
+ inLapFields, outLapFields,
+ inRecordFields, outRecordFields);
+ assertNotNull("exported file has no session: " + in.getName(), outSession);
+ // The exporter always emits at least one lap and (when a track is present) one
+ // record per timestamped point. Tiny segments may be merged into the first lap.
+ assertTrue("exported file has no laps: " + in.getName(), outLaps >= 1);
+ assertTrue("output should be < 5x input size: " + in.getName() + " in=" + in.length() + " out=" + out.length(),
+ out.length() < Math.max(in.length() * 5L, 32_768L));
+
+ // Sport/subSport: log mismatches instead of failing. The ActivityKind ↔ GarminSport
+ // round-trip is not 1:1 (multiple FIT codes alias to one ActivityKind, e.g. sport=0
+ // sub=15 ELLIPTICAL_TRAINER and sport=4 sub=15 FITNESS_EQUIPMENT both → ELLIPTICAL).
+ // These mapping gaps are tracked separately; the round-trip itself still works.
+ if (!java.util.Objects.equals(session.getSport(), outSession.getSport())
+ || !java.util.Objects.equals(session.getSubSport(), outSession.getSubSport())) {
+ stats.sportRemap = "in=" + session.getSport() + "/" + session.getSubSport()
+ + " out=" + outSession.getSport() + "/" + outSession.getSubSport();
+ }
+
+ // Per-track preservation: pick a stride of input records and verify the matching
+ // output record (by timestamp) preserves the meaningful fields the parser exposed.
+ verifyTrackPreservation(records, outFit, in.getName(), stats);
+
+ // Session aggregate preservation: distance, hr, speed, calories.
+ verifySessionPreservation(session, outSession, in.getName(), stats);
+
+ stats.outRecords = outRecords;
+ stats.outLaps = outLaps;
+ return stats;
+ }
+
+ /** Pick up to ~30 input records spread across the activity and assert the
+ * corresponding output record (matched by timestamp) preserves the fields that
+ * drive Strava/Endurain rendering: GPS, altitude, speed, HR, cadence, distance,
+ * power, temperature. Allows ±1 unit tolerance for re-encoded floats. */
+ private static void verifyTrackPreservation(final List inRecords,
+ final FitFile outFit,
+ final String fileName,
+ final RoundTripStats stats) {
+ // Index output records by timestamp.
+ final Map outByTs = new LinkedHashMap<>();
+ for (final RecordData r : outFit.getRecords()) {
+ if (r instanceof FitRecord rec) {
+ final Long ts = rec.getComputedTimestamp();
+ if (ts != null) outByTs.putIfAbsent(ts, rec);
+ }
+ }
+
+ // FitExporter dedups records sharing a 1s timestamp (some sources emit
+ // multiple records per second). Mirror that here so sampled input records
+ // correspond to what the exporter actually kept; otherwise a sample landing
+ // on the 2nd-of-pair would never find a matching output.
+ final List uniqueIn = new ArrayList<>(inRecords.size());
+ long lastTs = Long.MIN_VALUE;
+ for (final FitRecord r : inRecords) {
+ final Long ts = r.getComputedTimestamp();
+ if (ts == null || ts == lastTs) continue;
+ uniqueIn.add(r);
+ lastTs = ts;
+ }
+ final int sampleCount = Math.min(30, uniqueIn.size());
+ if (sampleCount == 0) return;
+ final int stride = Math.max(1, uniqueIn.size() / sampleCount);
+
+ int gpsHits = 0, gpsTotal = 0;
+ int altHits = 0, altTotal = 0;
+ int hrHits = 0, hrTotal = 0;
+ int spdHits = 0, spdTotal = 0;
+ int cadHits = 0, cadTotal = 0;
+ int distHits = 0, distTotal = 0;
+ int pwrHits = 0, pwrTotal = 0;
+ int tempHits = 0, tempTotal = 0;
+
+ for (int i = 0; i < uniqueIn.size(); i += stride) {
+ final FitRecord inRec = uniqueIn.get(i);
+ final Long ts = inRec.getComputedTimestamp();
+ if (ts == null) continue;
+ final FitRecord outRec = outByTs.get(ts);
+ if (outRec == null) continue; // dedup may drop duplicates — skip silently
+
+ // GPS — both lat & lon must round-trip. FIT semicircle scale is exact,
+ // so equality is OK after both encodes use the same scale.
+ if (inRec.getLatitude() != null && inRec.getLongitude() != null) {
+ gpsTotal++;
+ if (inRec.getLatitude().equals(outRec.getLatitude())
+ && inRec.getLongitude().equals(outRec.getLongitude())) {
+ gpsHits++;
+ }
+ }
+ // Altitude — input may use altitude OR enhanced_altitude; we always export
+ // enhanced_altitude. Compare whichever the input had against the same
+ // *canonicalised* meters value the parser computes.
+ final Double inAlt = inRec.getEnhancedAltitude() != null
+ ? inRec.getEnhancedAltitude()
+ : (inRec.getAltitude() != null ? inRec.getAltitude().doubleValue() : null);
+ if (inAlt != null) {
+ altTotal++;
+ final Double outAlt = outRec.getEnhancedAltitude() != null
+ ? outRec.getEnhancedAltitude()
+ : (outRec.getAltitude() != null ? outRec.getAltitude().doubleValue() : null);
+ if (outAlt != null && Math.abs(outAlt - inAlt) <= 0.5) altHits++;
+ }
+ // HR — integer bpm. Some recorders (Zwift virtual rides without a strap)
+ // write hr=0 as a sentinel for "no measurement"; FitExporter drops zeros
+ // explicitly. Mirror that — only count records with a real HR sample.
+ if (inRec.getHeartRate() != null && inRec.getHeartRate() > 0) {
+ hrTotal++;
+ if (java.util.Objects.equals(inRec.getHeartRate(), outRec.getHeartRate())) hrHits++;
+ }
+ // Speed — input may use speed OR enhanced_speed; exporter writes enhanced_speed.
+ final Double inSpd = inRec.getEnhancedSpeed() != null
+ ? inRec.getEnhancedSpeed()
+ : (inRec.getSpeed() != null ? inRec.getSpeed().doubleValue() : null);
+ if (inSpd != null) {
+ spdTotal++;
+ final Double outSpd = outRec.getEnhancedSpeed() != null
+ ? outRec.getEnhancedSpeed()
+ : (outRec.getSpeed() != null ? outRec.getSpeed().doubleValue() : null);
+ if (outSpd != null && Math.abs(outSpd - inSpd) <= 0.05) spdHits++;
+ }
+ // Cadence: same sensor-absent sentinel as HR — when the source has no
+ // cadence sensor it writes 0; FitExporter now drops cadence on tracks where
+ // every sample is 0. Only count records with a real measurement.
+ if (inRec.getCadence() != null && inRec.getCadence() > 0) {
+ cadTotal++;
+ if (java.util.Objects.equals(inRec.getCadence(), outRec.getCadence())) cadHits++;
+ }
+ if (inRec.getDistance() != null) {
+ distTotal++;
+ if (outRec.getDistance() != null
+ && Math.abs(outRec.getDistance() - inRec.getDistance()) <= 1.0) {
+ distHits++;
+ }
+ }
+ // Power: same sentinel handling.
+ if (inRec.getPower() != null && inRec.getPower() > 0) {
+ pwrTotal++;
+ if (java.util.Objects.equals(inRec.getPower(), outRec.getPower())) pwrHits++;
+ }
+ if (inRec.getTemperature() != null) {
+ tempTotal++;
+ if (java.util.Objects.equals(inRec.getTemperature(), outRec.getTemperature())) tempHits++;
+ }
+ }
+
+ stats.gpsKept = pct(gpsHits, gpsTotal);
+ stats.altKept = pct(altHits, altTotal);
+ stats.hrKept = pct(hrHits, hrTotal);
+ stats.spdKept = pct(spdHits, spdTotal);
+ stats.cadKept = pct(cadHits, cadTotal);
+ stats.distKept = pct(distHits, distTotal);
+ stats.pwrKept = pct(pwrHits, pwrTotal);
+ stats.tempKept = pct(tempHits, tempTotal);
+
+ // Hard assertions — values that are present in input must round-trip on the
+ // sampled records. Tolerate the very rare dedup miss by requiring ≥ 90%.
+ final List regressions = new ArrayList<>();
+ if (gpsTotal >= 5 && gpsHits * 100 / gpsTotal < 90) regressions.add("gps " + stats.gpsKept);
+ if (altTotal >= 5 && altHits * 100 / altTotal < 90) regressions.add("alt " + stats.altKept);
+ if (hrTotal >= 5 && hrHits * 100 / hrTotal < 90) regressions.add("hr " + stats.hrKept);
+ if (spdTotal >= 5 && spdHits * 100 / spdTotal < 90) regressions.add("spd " + stats.spdKept);
+ if (cadTotal >= 5 && cadHits * 100 / cadTotal < 90) regressions.add("cad " + stats.cadKept);
+ if (distTotal >= 5 && distHits * 100 / distTotal < 90) regressions.add("dist " + stats.distKept);
+ if (pwrTotal >= 5 && pwrHits * 100 / pwrTotal < 90) regressions.add("pwr " + stats.pwrKept);
+ if (tempTotal >= 5 && tempHits * 100 / tempTotal < 90) regressions.add("temp " + stats.tempKept);
+ if (!regressions.isEmpty()) {
+ throw new AssertionError(fileName + " field preservation regressions: " + regressions);
+ }
+ }
+
+ /** Re-parse session aggregates and compare against the input session. */
+ private static void verifySessionPreservation(final FitSession in,
+ final FitSession out,
+ final String fileName,
+ final RoundTripStats stats) {
+ final List mismatches = new ArrayList<>();
+ // Distance preservation: getter returns raw meters (codec applies scale=100).
+ // Skip when input is 0 (sentinel for "not measured" — common on rowing /
+ // strength / indoor sessions where the device wrote no distance but the
+ // exporter may legitimately derive a value from per-record GPS or strokes).
+ if (in.getTotalDistance() != null && out.getTotalDistance() != null
+ && in.getTotalDistance() > 0) {
+ final double inM = in.getTotalDistance();
+ final double outM = out.getTotalDistance();
+ // Accept ±1 m or 0.5% — exporter rounds via Math.round.
+ if (Math.abs(outM - inM) > Math.max(1.0, inM * 0.005)) {
+ mismatches.add("distance in=" + inM + "m out=" + outM + "m");
+ }
+ stats.sessionDistanceM = outM;
+ }
+ // HR. Skip when input avg=0 (no strap → sentinel; some devices record
+ // record-level HR but never aggregate to session — exporter computes one).
+ if (in.getAverageHeartRate() != null && out.getAverageHeartRate() != null
+ && in.getAverageHeartRate() > 0
+ && !in.getAverageHeartRate().equals(out.getAverageHeartRate())) {
+ mismatches.add("avgHr in=" + in.getAverageHeartRate() + " out=" + out.getAverageHeartRate());
+ }
+ // Avg speed: input scale is FIT m/s × 1000; we round-trip via Float.
+ // Exporter intentionally re-derives avg_speed from distance/elapsed when the
+ // source recorded 0 (devices without per-record speed) — see pace-fallback in
+ // FitExporter.buildSession. Skip the comparison in that case.
+ if (in.getAvgSpeed() != null && out.getAvgSpeed() != null
+ && in.getAvgSpeed() > 0.001f
+ && Math.abs(in.getAvgSpeed() - out.getAvgSpeed()) > 0.05) {
+ mismatches.add("avgSpeed in=" + in.getAvgSpeed() + " out=" + out.getAvgSpeed());
+ }
+ // Calories. Skip when input=0 — exporter may compute one from track data.
+ if (in.getTotalCalories() != null && out.getTotalCalories() != null
+ && in.getTotalCalories() > 0
+ && !in.getTotalCalories().equals(out.getTotalCalories())) {
+ mismatches.add("cal in=" + in.getTotalCalories() + " out=" + out.getTotalCalories());
+ }
+ if (!mismatches.isEmpty()) {
+ throw new AssertionError(fileName + " session aggregate regressions: " + mismatches);
+ }
+ }
+
+ private static String pct(final int hits, final int total) {
+ if (total == 0) return "—";
+ return hits + "/" + total + " (" + (hits * 100 / total) + "%)";
+ }
+
+ /** Add every FieldDefinition number from a parsed record's definition into the
+ * given set. The same field may appear across many records of the same type;
+ * collecting into a Set yields the schema actually emitted by the source. */
+ private static void collectFieldNumbers(final RecordData r, final java.util.Set dst) {
+ if (r.getRecordDefinition() == null) return;
+ final java.util.List defs = r.getRecordDefinition().getFieldDefinitions();
+ if (defs == null) return;
+ for (final FieldDefinition fd : defs) {
+ dst.add(fd.getNumber());
+ }
+ }
+
+ /** Build a one-line "in vs out" field-number diff for session/lap/record. The
+ * in-only side surfaces device-emitted fields the exporter does not yet write;
+ * the out-only side flags fields the exporter adds (e.g. enhanced_altitude on
+ * records when source only had legacy altitude). */
+ private static String formatFieldDiff(final java.util.Set inSession, final java.util.Set outSession,
+ final java.util.Set inLap, final java.util.Set outLap,
+ final java.util.Set inRec, final java.util.Set outRec) {
+ return "session{" + diff(inSession, outSession) + "} lap{" + diff(inLap, outLap)
+ + "} record{" + diff(inRec, outRec) + "}";
+ }
+
+ private static String diff(final java.util.Set in, final java.util.Set out) {
+ final java.util.Set inOnly = new java.util.TreeSet<>(in);
+ inOnly.removeAll(out);
+ final java.util.Set outOnly = new java.util.TreeSet<>(out);
+ outOnly.removeAll(in);
+ return "in-only=" + inOnly + " out-only=" + outOnly;
+ }
+
+ private static BaseActivitySummary buildSummaryFromSession(final FitSession session,
+ final String fileName) {
+ final BaseActivitySummary s = new BaseActivitySummary();
+ // FitSession.startTime is FIT epoch seconds (timestamp_t base), already absolute
+ // unix seconds — codec subtracts the Garmin epoch on decode.
+ final Long startSec = session.getStartTime();
+ final long startMs = startSec != null ? startSec * 1000L : 0L;
+ s.setStartTime(new Date(startMs));
+ // total_elapsed_time is stored unscaled (raw uint32 = ms per FIT spec scale=1000).
+ final Long elapsedMs = session.getTotalElapsedTime();
+ s.setEndTime(new Date(startMs + (elapsedMs != null ? elapsedMs : 0L)));
+ s.setName(fileName);
+ final ActivityKind kind = mapSportToKind(session.getSport(), session.getSubSport());
+ s.setActivityKind(kind.getCode());
+ return s;
+ }
+
+ private static ActivityKind mapSportToKind(final Integer sport, final Integer subSport) {
+ if (sport == null) return ActivityKind.UNKNOWN;
+ final Optional gs = GarminSport.fromCodes(sport, subSport != null ? subSport : 0);
+ return gs.map(GarminSport::getActivityKind).orElse(ActivityKind.UNKNOWN);
+ }
+
+ private static ActivityTrack buildTrackFromRecordsAndLaps(final List records,
+ final List laps) {
+ final ActivityTrack track = new ActivityTrack();
+ track.setCurrentSegmentInfo(new ActivityTrack.SegmentInfo(ActivityTrack.SegmentIntensity.ACTIVE));
+ // Lap boundaries: split records into segments by lap.startTime monotone walk.
+ final long[] lapBoundaries = laps.stream()
+ .map(FitLap::getStartTime)
+ .filter(java.util.Objects::nonNull)
+ .mapToLong(Long::longValue)
+ .sorted()
+ .toArray();
+ int boundaryIdx = 0;
+ boolean firstSeg = true;
+ for (final FitRecord rec : records) {
+ final ActivityPoint p = rec.toActivityPoint();
+ // Cross next lap boundary → start a new segment.
+ while (boundaryIdx < lapBoundaries.length
+ && p.getTime() != null
+ && p.getTime().getTime() / 1000L >= lapBoundaries[boundaryIdx]) {
+ if (!firstSeg) {
+ track.startNewSegment(new ActivityTrack.SegmentInfo(ActivityTrack.SegmentIntensity.ACTIVE));
+ }
+ firstSeg = false;
+ boundaryIdx++;
+ }
+ track.addTrackPoint(p);
+ }
+ return track;
+ }
+
+ /** Translate a small but useful subset of FitSession aggregate fields back into
+ * ActivitySummaryData entries so FitExporter populates the same lap/session
+ * aggregates on the way out. Mirrors the fields GarminWorkoutParser emits. */
+ private static ActivitySummaryData buildSummaryDataFromSession(final FitSession session) {
+ final ActivitySummaryData d = new ActivitySummaryData();
+ // Getter returns raw meters (codec applies scale=100 on decode).
+ final Double totalDistance = session.getTotalDistance();
+ if (totalDistance != null) {
+ d.add(ActivitySummaryEntries.DISTANCE_METERS,
+ totalDistance,
+ ActivitySummaryEntries.UNIT_METERS);
+ }
+ if (session.getTotalCalories() != null) {
+ d.add(ActivitySummaryEntries.CALORIES_BURNT,
+ session.getTotalCalories(),
+ ActivitySummaryEntries.UNIT_KCAL);
+ }
+ if (session.getAvgSpeed() != null) {
+ d.add(ActivitySummaryEntries.SPEED_AVG,
+ session.getAvgSpeed(),
+ ActivitySummaryEntries.UNIT_METERS_PER_SECOND);
+ }
+ if (session.getMaxSpeed() != null) {
+ d.add(ActivitySummaryEntries.SPEED_MAX,
+ session.getMaxSpeed(),
+ ActivitySummaryEntries.UNIT_METERS_PER_SECOND);
+ }
+ if (session.getAverageHeartRate() != null) {
+ d.add(ActivitySummaryEntries.HR_AVG,
+ session.getAverageHeartRate(),
+ ActivitySummaryEntries.UNIT_BPM);
+ }
+ if (session.getMaxHeartRate() != null) {
+ d.add(ActivitySummaryEntries.HR_MAX,
+ session.getMaxHeartRate(),
+ ActivitySummaryEntries.UNIT_BPM);
+ }
+ if (session.getAvgCadence() != null) {
+ d.add(ActivitySummaryEntries.CADENCE_AVG,
+ session.getAvgCadence(),
+ ActivitySummaryEntries.UNIT_NONE);
+ }
+ if (session.getMaxCadence() != null) {
+ d.add(ActivitySummaryEntries.CADENCE_MAX,
+ session.getMaxCadence(),
+ ActivitySummaryEntries.UNIT_NONE);
+ }
+ if (session.getTotalAscent() != null) {
+ d.add(ActivitySummaryEntries.ASCENT_METERS,
+ session.getTotalAscent(),
+ ActivitySummaryEntries.UNIT_METERS);
+ }
+ if (session.getTotalDescent() != null) {
+ d.add(ActivitySummaryEntries.DESCENT_METERS,
+ session.getTotalDescent(),
+ ActivitySummaryEntries.UNIT_METERS);
+ }
+ return d;
+ }
+
+ // ---------- README parsing ----------
+
+ private static List readmeFitPaths() throws Exception {
+ final String contents = new String(Files.readAllBytes(README.toPath()), StandardCharsets.UTF_8);
+ final List out = new ArrayList<>();
+ final Matcher m = README_LINK.matcher(contents);
+ while (m.find()) {
+ final String path = m.group(1);
+ // Skip any non-Activity links (e.g. AllFitMessageTypes.fit lives at root).
+ // Keep only relative paths into the corpus.
+ if (path.startsWith("Activity/")) {
+ out.add(path);
+ }
+ }
+ return out;
+ }
+
+ // ---------- stats ----------
+
+ private static final class RoundTripStats {
+ long inSize;
+ long outSize;
+ Integer manufacturer;
+ Integer product;
+ Integer sport;
+ Integer subSport;
+ int numRecords;
+ int numLaps;
+ int numLengths;
+ int numSplits;
+ int numSets;
+ int outRecords;
+ int outLaps;
+ int outLengths;
+ int outSplits;
+ int outSets;
+ String sportRemap;
+ String fieldDiff = "";
+ String gpsKept = "—";
+ String altKept = "—";
+ String hrKept = "—";
+ String spdKept = "—";
+ String cadKept = "—";
+ String distKept = "—";
+ String pwrKept = "—";
+ String tempKept = "—";
+ Double sessionDistanceM;
+
+ @Override
+ public String toString() {
+ final StringBuilder sb = new StringBuilder();
+ sb.append("in=").append(inSize).append("B out=").append(outSize).append("B")
+ .append(" mfr=").append(manufacturer).append(" prod=").append(product)
+ .append(" sport=").append(sport).append('/').append(subSport)
+ .append(" in[rec=").append(numRecords).append(",lap=").append(numLaps)
+ .append(",len=").append(numLengths).append(",split=").append(numSplits)
+ .append(",set=").append(numSets).append(']')
+ .append(" out[rec=").append(outRecords).append(",lap=").append(outLaps)
+ .append(",len=").append(outLengths)
+ .append(",split=").append(outSplits)
+ .append(",set=").append(outSets).append(']');
+ sb.append(" preserved[gps=").append(gpsKept).append(" alt=").append(altKept)
+ .append(" hr=").append(hrKept).append(" spd=").append(spdKept)
+ .append(" cad=").append(cadKept).append(" dist=").append(distKept)
+ .append(" pwr=").append(pwrKept).append(" temp=").append(tempKept).append(']');
+ if (sportRemap != null) sb.append(" sportRemap[").append(sportRemap).append(']');
+ if (fieldDiff != null && !fieldDiff.isEmpty()) sb.append("\n fieldDiff: ").append(fieldDiff);
+ return sb.toString();
+ }
+ }
+}
diff --git a/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/baseTypes/BaseTypeEncodeRoundTripTest.java b/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/baseTypes/BaseTypeEncodeRoundTripTest.java
new file mode 100644
index 0000000000..22ab8fe1c4
--- /dev/null
+++ b/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/baseTypes/BaseTypeEncodeRoundTripTest.java
@@ -0,0 +1,146 @@
+/* Copyright (C) 2026 Dany Mestas
+
+ 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.garmin.fit.baseTypes;
+
+import static org.junit.Assert.assertEquals;
+
+import org.junit.Test;
+
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+
+/**
+ * Round-trip tests for the FIT base-type encode path.
+ *
+ * Background: commit {@code f2f6536ea} (Aug 2024) introduced the spec-correct
+ * {@code stored = (physical + offset) * scale} formula in {@code BaseTypeShort/Int/Byte
+ * .encode}, but kept an {@code intValue()} / {@code longValue()} cast that truncated
+ * fractional {@code Float} / {@code Double} inputs before applying the scale multiplier.
+ * Commit {@code fd1e81ff6} (Aug 2024) fixed the symmetric truncation on the decode path
+ * but missed the encode side. The result: every fractional value passed to a scaled
+ * UINT8/16/32/64 field was silently rounded toward zero on disk.
+ *
+ *
For example, on a UINT16 scale=1000 field (FIT {@code session.avg_speed}):
+ * {@code setAvgSpeed(2.5f)} → encoded {@code (int)2.5 * 1000 = 2000} → decoded {@code 2.0}
+ * instead of {@code 2.5}. {@code setAvgSpeed(0.778f)} → encoded {@code 0} → decoded {@code 0}.
+ *
+ *
This test class encodes a fractional value, decodes it back through the same
+ * BaseType, and asserts the value survives the round-trip. Each assertion fails
+ * deterministically against the pre-fix encoder (the decoded value is the truncated
+ * integer rather than the original fractional value) and passes against the fixed
+ * encoder. The tests therefore double as regression guards.
+ */
+public class BaseTypeEncodeRoundTripTest {
+
+ private static double roundTrip(final BaseType type, final Object physical,
+ final double scale, final int offset) {
+ final ByteBuffer enc = ByteBuffer.allocate(16).order(ByteOrder.LITTLE_ENDIAN);
+ type.encode(enc, physical, scale, offset);
+ enc.flip();
+ final Object decoded = type.decode(enc, scale, offset);
+ return ((Number) decoded).doubleValue();
+ }
+
+ // ---------------- UINT16 (BaseTypeShort) ----------------
+
+ @Test
+ public void uint16_avgSpeedScale1000_preservesHalfMs() {
+ // Real bug-bait: FIT session.avg_speed (UINT16 scale=1000 unit=m/s).
+ // Pre-fix encoder: (int)2.5 * 1000 = 2000 stored → 2.0 decoded.
+ assertEquals(2.5, roundTrip(BaseType.UINT16, 2.5f, 1000, 0), 0.0005);
+ }
+
+ @Test
+ public void uint16_avgSpeedScale1000_preservesSlowSubMeterPerSecond() {
+ // Pre-fix: (int)0.778 * 1000 = 0 → decoded 0.0 (the running-pace bug).
+ assertEquals(0.778, roundTrip(BaseType.UINT16, 0.778f, 1000, 0), 0.001);
+ }
+
+ @Test
+ public void uint16_avgStrokeDistanceScale100_preservesQuarterMeter() {
+ // FIT session.avg_stroke_distance (UINT16 scale=100 unit=m).
+ // Pre-fix: (int)2.5 * 100 = 200 → decoded 2.0.
+ assertEquals(2.5, roundTrip(BaseType.UINT16, 2.5f, 100, 0), 0.005);
+ }
+
+ @Test
+ public void uint16_altitudeScale5Offset500_preservesHalfMeter() {
+ // FIT record.altitude (UINT16 scale=5 offset=500).
+ // Encoder: (123.5 + 500) * 5 = 3117.5 → stored 3117. Pre-fix: ((int)123.5+500)*5 = 3115.
+ // Decoded: 3117/5 - 500 = 123.4. Pre-fix: 3115/5 - 500 = 123.
+ assertEquals(123.4, roundTrip(BaseType.UINT16, 123.5, 5, 500), 0.05);
+ }
+
+ @Test
+ public void uint16_integerInputUnchanged() {
+ // Integer/Long inputs should be unaffected by the doubleValue() change.
+ assertEquals(1500.0, roundTrip(BaseType.UINT16, 1500, 1, 0), 0.0001);
+ assertEquals(2.0, roundTrip(BaseType.UINT16, 2, 1000, 0), 0.0001);
+ }
+
+ // ---------------- UINT32 (BaseTypeInt) ----------------
+
+ @Test
+ public void uint32_speedScale1000_preservesHalfMs() {
+ // FIT enhanced_avg_speed (UINT32 scale=1000 unit=m/s).
+ // Pre-fix: (long)3.7 * 1000 = 3000 → 3.0.
+ assertEquals(3.7, roundTrip(BaseType.UINT32, 3.7f, 1000, 0), 0.0005);
+ }
+
+ @Test
+ public void uint32_distanceScale100_preservesHalfMeter() {
+ // FIT lap.total_distance (UINT32 scale=100 unit=m).
+ // Pre-fix: (long)1234.5 * 100 = 123400 → 1234.0. Fix: 123450 → 1234.5.
+ assertEquals(1234.5, roundTrip(BaseType.UINT32, 1234.5, 100, 0), 0.005);
+ }
+
+ @Test
+ public void uint32_integerInputUnchanged() {
+ assertEquals(38600.0, roundTrip(BaseType.UINT32, 38600L, 1, 0), 0.0001);
+ }
+
+ // ---------------- UINT8 (BaseTypeByte) ----------------
+
+ @Test
+ public void uint8_scale10_preservesTenth() {
+ // FIT pct fields (UINT8 scale=10 unit=%).
+ // Pre-fix: (int)5.5 * 10 = 50 → 5.0. Fix: 55 → 5.5.
+ assertEquals(5.5, roundTrip(BaseType.UINT8, 5.5f, 10, 0), 0.05);
+ }
+
+ @Test
+ public void uint8_integerInputUnchanged() {
+ assertEquals(75.0, roundTrip(BaseType.UINT8, 75, 1, 0), 0.0001);
+ }
+
+ // ---------------- SINT16 ----------------
+
+ @Test
+ public void sint16_negativeFractional_preserved() {
+ // Negative fractional. Pre-fix: (int)-1.5 = -1, *100 = -100 → -1.0. Fix: -150 → -1.5.
+ assertEquals(-1.5, roundTrip(BaseType.SINT16, -1.5f, 100, 0), 0.005);
+ }
+
+ // ---------------- UINT64 (BaseTypeLong) ----------------
+ // BaseTypeLong shares the same encoder pattern. No production fields with non-1
+ // scale currently use it, so this test guards against future regressions only.
+
+ @Test
+ public void uint64_integerInputUnchanged() {
+ assertEquals(123456789L, ((Number) roundTrip(BaseType.UINT64, 123456789L, 1, 0)).longValue());
+ }
+}
diff --git a/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/enums/GarminSportTest.java b/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/enums/GarminSportTest.java
index 26b7e223d3..16d2e4a412 100644
--- a/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/enums/GarminSportTest.java
+++ b/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/enums/GarminSportTest.java
@@ -1,5 +1,3 @@
-package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.enums;
-
/* Copyright (C) 2024-2026 José Rebelo, Thomas Kuehne
This file is part of Gadgetbridge.
@@ -16,6 +14,9 @@ package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.enums;
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.garmin.fit.enums;
+
+import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import android.util.Pair;
@@ -58,4 +59,29 @@ public class GarminSportTest extends TestBase {
}
}
}
+
+ @Test
+ public void outdoorAliasesFallBackToGenericSport() {
+ // Xiaomi-specific OUTDOOR_* kinds had no GarminSport entry, causing exports to fall
+ // back to GENERIC (sport=0) — third-party importers showed these as unrecognised
+ // "workout" sessions. They now resolve onto the matching street sub-variant
+ // (running/walking/cycling sport with subtype=2 STREET), which keeps the FIT sport
+ // code recognised instead of generic. OUTDOOR_RUNNING has no direct entry and is
+ // aliased to STREET_RUNNING (STREET_RUN 1/2); OUTDOOR_WALKING / OUTDOOR_CYCLING map
+ // directly to STREET_WALKING (11/2) / STREET_CYCLING (2/2).
+ final Optional run = GarminSport.fromActivityKind(ActivityKind.OUTDOOR_RUNNING);
+ assertTrue("OUTDOOR_RUNNING should map", run.isPresent());
+ assertEquals(1, run.get().getType());
+ assertEquals(2, run.get().getSubtype());
+
+ final Optional walk = GarminSport.fromActivityKind(ActivityKind.OUTDOOR_WALKING);
+ assertTrue("OUTDOOR_WALKING should map", walk.isPresent());
+ assertEquals(11, walk.get().getType());
+ assertEquals(2, walk.get().getSubtype());
+
+ final Optional bike = GarminSport.fromActivityKind(ActivityKind.OUTDOOR_CYCLING);
+ assertTrue("OUTDOOR_CYCLING should map", bike.isPresent());
+ assertEquals(2, bike.get().getType());
+ assertEquals(2, bike.get().getSubtype());
+ }
}
diff --git a/app/src/test/resources/TestGpxImport.course.fit b/app/src/test/resources/TestGpxImport.course.fit
index 2ac027f023..b0c38756c4 100644
Binary files a/app/src/test/resources/TestGpxImport.course.fit and b/app/src/test/resources/TestGpxImport.course.fit differ
diff --git a/app/src/test/resources/gpx-exporter-test-SampleTrack.course.fit b/app/src/test/resources/gpx-exporter-test-SampleTrack.course.fit
index 173aa86cdd..2566a47961 100644
Binary files a/app/src/test/resources/gpx-exporter-test-SampleTrack.course.fit and b/app/src/test/resources/gpx-exporter-test-SampleTrack.course.fit differ
diff --git a/app/src/test/resources/gpx-parser-test-multiple-segments.course.fit b/app/src/test/resources/gpx-parser-test-multiple-segments.course.fit
index 8c8ade750f..20e5bd3b9b 100644
Binary files a/app/src/test/resources/gpx-parser-test-multiple-segments.course.fit and b/app/src/test/resources/gpx-parser-test-multiple-segments.course.fit differ