From 8e858e1ab73799c0ff08b1993a4c5dfeea84a3d3 Mon Sep 17 00:00:00 2001 From: Harald Hoyer Date: Wed, 21 Oct 2026 14:58:08 +0200 Subject: [PATCH 1/2] fix: a second Garmin device without gimporter no longer hides the one that has it Each connected device reported its own result, and the last one to answer won: a watch without gimporter showed "not installed" although the Edge next to it had the app. The best result of all devices is shown now. Fixes #15 Fixes gimportexportdevs/gimporter#14 --- .../org/surfsite/gexporter/MainActivity.java | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/org/surfsite/gexporter/MainActivity.java b/app/src/main/java/org/surfsite/gexporter/MainActivity.java index cacdb1e..66d919c 100644 --- a/app/src/main/java/org/surfsite/gexporter/MainActivity.java +++ b/app/src/main/java/org/surfsite/gexporter/MainActivity.java @@ -119,6 +119,8 @@ public class MainActivity extends AppCompatActivity implements WatchLink.Listene private ConnectIQ mConnectIQ = null; private IQDevice mConnectedDevice = null; private IQApp mConnectedApp = null; + // Best result of the devices checked since the last scan, see showDeviceState + private WatchState mDeviceState = null; // "device:app" pairs with a message listener; app and widget each need one private final Set mRegisteredApps = new HashSet<>(); @@ -227,6 +229,7 @@ public void onSdkShutDown() { private void launchIQApp(ConnectIQ connectIQ) { try { List devices = connectIQ.getConnectedDevices(); + mDeviceState = null; if (devices != null && !devices.isEmpty()) { for (IQDevice device : devices) { connectIQ.getApplicationInfo(CONNECT_IQ_GIMPORTER_APP, device, new ConnectIQ.IQApplicationInfoListener() { @@ -235,7 +238,7 @@ public void onApplicationInfoReceived(IQApp app) { mConnectedDevice = device; mConnectedApp = app; WatchLink.addApp(connectIQ, device, app, false); - setWatchState(WatchState.CONNECTED, device); + showDeviceState(WatchState.CONNECTED, device); requestWatchContent(); // Register the GET_PORT listener as soon as we know the @@ -287,10 +290,9 @@ public void onApplicationInfoReceived(IQApp widget) { WatchLink.addApp(connectIQ, device, widget, true); registerMessageListener(connectIQ, device, widget); requestWatchContent(); - if (!appInstalled) { + if (!appInstalled && showDeviceState(WatchState.WIDGET_ONLY, device)) { mConnectedDevice = device; mConnectedApp = widget; - setWatchState(WatchState.WIDGET_ONLY, device); // A widget cannot be opened from the phone; the location // waits until the user opens it. sendPendingLocation(); @@ -300,7 +302,7 @@ public void onApplicationInfoReceived(IQApp widget) { @Override public void onApplicationNotInstalled(String applicationId) { if (!appInstalled) { - setWatchState(WatchState.NOT_INSTALLED, device); + showDeviceState(WatchState.NOT_INSTALLED, device); } } }); @@ -399,8 +401,24 @@ private void sendToWatch(ServedFiles.Entry entry) { held.show(); } + // The per-device results are ordered from worst to best (showDeviceState). private enum WatchState { CONNECTING, NO_DEVICE, INIT_FAILED, NOT_INSTALLED, WIDGET_ONLY, CONNECTED } + /** + * Show the result of one connected device unless another device already + * did better: a second watch without gimporter must not hide the Edge + * that has it. Returns whether the result is shown. + */ + private boolean showDeviceState(WatchState state, IQDevice device) { + if (mDeviceState != null && mDeviceState.compareTo(state) > 0) { + Log.info("Ignoring {} for {}, another device is {}", state, device.getFriendlyName(), mDeviceState); + return false; + } + mDeviceState = state; + setWatchState(state, device); + return true; + } + /** Show the watch, its connection state and the action that fits it. */ private void setWatchState(WatchState state, @Nullable IQDevice device) { MaterialCardView card = findViewById(R.id.watch_card); From 4b406c785fca1380132f340dd3d453954ba50b6f Mon Sep 17 00:00:00 2001 From: Harald Hoyer Date: Wed, 21 Oct 2026 14:58:17 +0200 Subject: [PATCH 2/2] fix(convert): valid timestamps for Locus routes, distances for waypoints - Locus Map writes route times relative to 1970-01-01. Since the time parser handles fractional seconds (20.1.0), these times reached the FIT file as they are, before the FIT epoch; the Edge opened the course but rejected it once saved ("invalid course"). Times before the FIT epoch are now moved so the course starts at it, keeping the durations. - GPX waypoints became course points without a distance, so devices showed them all at the end of the course in "Up Ahead". Each now takes the distance and timestamp of the nearest track point, in course order. - A waypoint without crashed the conversion (CoursePoint.valueOf with null). TestCourseFit decodes the written FIT to check both. Fixes #36 Fixes #14 --- .../java/org/surfsite/gexporter/Gpx2Fit.java | 69 +++++++++- .../java/org/surfsite/gexporter/WayPoint.java | 4 + .../org/surfsite/gexporter/TestCourseFit.java | 118 ++++++++++++++++++ 3 files changed, 188 insertions(+), 3 deletions(-) create mode 100644 app/src/test/java/org/surfsite/gexporter/TestCourseFit.java diff --git a/app/src/main/java/org/surfsite/gexporter/Gpx2Fit.java b/app/src/main/java/org/surfsite/gexporter/Gpx2Fit.java index d1a86eb..fe18665 100644 --- a/app/src/main/java/org/surfsite/gexporter/Gpx2Fit.java +++ b/app/src/main/java/org/surfsite/gexporter/Gpx2Fit.java @@ -36,6 +36,8 @@ import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; import java.util.Date; import java.util.List; import java.util.Locale; @@ -101,6 +103,7 @@ public Gpx2Fit(String name, FileInputStream in, Gpx2FitOptions options) throws E parser.setInput(inputStream, null); parser.nextTag(); readGPX(parser); + shiftTimesBeforeFitEpoch(); return; } catch (Exception e) { last = e; @@ -381,6 +384,32 @@ private double readEle(XmlPullParser parser) throws IOException, XmlPullParserEx * Parse a required lat/lon attribute, rejecting null, non-numeric, * non-finite, or out-of-range values. */ + /** + * FIT timestamps count from the FIT epoch (1989-12-31) and cannot go + * below it. Some apps, like Locus Map, write route times relative to + * 1970-01-01; written as they are, they wrap around, and devices reject + * the course once it is saved ("invalid course"). Such times are moved so + * the earliest one is the FIT epoch: durations, and thereby speeds, stay. + */ + private void shiftTimesBeforeFitEpoch() { + long first = Long.MAX_VALUE; + for (List points : Arrays.asList(trkPoints, rtePoints, wayPoints)) { + for (WayPoint wpt : points) { + first = Math.min(first, wpt.getTime().getTime()); + } + } + if (first >= WayPoint.RefMilliSec) { + return; + } + long shift = WayPoint.RefMilliSec - first; + Log.info("Times before the FIT epoch, shifting them by {} ms", shift); + for (List points : Arrays.asList(trkPoints, rtePoints, wayPoints)) { + for (WayPoint wpt : points) { + wpt.setTime(new Date(wpt.getTime().getTime() + shift)); + } + } + } + private static double parseCoordinate(String value, double min, double max, String what) { if (value == null) throw new IllegalArgumentException("Missing " + what); @@ -670,11 +699,11 @@ public void writeFit(File outfile) { if (!skipExtraCP && !wayPoints.isEmpty()) { - writeWayPoints(encode, wayPoints); + writeWayPoints(encode, wayPoints, duration); } if (!skipExtraCP && !rtePoints.isEmpty()) { - writeWayPoints(encode, rtePoints); + writeWayPoints(encode, rtePoints, duration); } { @@ -826,8 +855,18 @@ public void writeFit(File outfile) { } } - private void writeWayPoints(FileEncoder encode, List points) { + /** + * Write GPX waypoints as course points. A course point needs the distance + * along the course (and the matching timestamp), or devices show all of + * them at the end of the course in "Up Ahead". Each waypoint takes them + * from the nearest track point; the points are written in course order. + */ + private void writeWayPoints(FileEncoder encode, List points, long duration) { + List cps = new ArrayList<>(); for (WayPoint wpt : points) { + int nearest = nearestTrackPoint(wpt); + WayPoint trk = trkPoints.get(nearest); + CoursePointMesg cp = new CoursePointMesg(); cp.setLocalNum(0); @@ -835,7 +874,31 @@ private void writeWayPoints(FileEncoder encode, List points) { cp.setPositionLong(wpt.getLonSemi()); cp.setName(defaultString(wpt.getName())); cp.setType(wpt.getPointType()); + cp.setDistance((float) trk.getTotaldist()); + // the same timestamp the record of that track point gets + if (duration != 0) + cp.setTimestamp(new DateTime(trk.getTime())); + else + cp.setTimestamp(new DateTime(new Date(WayPoint.RefMilliSec + (nearest + 1) * 1000L))); + cps.add(cp); + } + Collections.sort(cps, (a, b) -> Float.compare(a.getDistance(), b.getDistance())); + for (CoursePointMesg cp : cps) { encode.write(cp); } } + + /** Index of the track point closest to wpt. */ + private int nearestTrackPoint(WayPoint wpt) { + int nearest = 0; + double best = Double.MAX_VALUE; + for (int i = 0; i < trkPoints.size(); i++) { + double d = wpt.distance(trkPoints.get(i)); + if (d < best) { + best = d; + nearest = i; + } + } + return nearest; + } } diff --git a/app/src/main/java/org/surfsite/gexporter/WayPoint.java b/app/src/main/java/org/surfsite/gexporter/WayPoint.java index 19a3a01..4b3c61f 100644 --- a/app/src/main/java/org/surfsite/gexporter/WayPoint.java +++ b/app/src/main/java/org/surfsite/gexporter/WayPoint.java @@ -148,6 +148,10 @@ public CoursePoint getPointType() { } private CoursePoint parseStringToCoursePoint(String s) { + if (s == null) { + // most waypoints have no or ; valueOf(null) throws + return null; + } if (isNumeric(s)) { return CoursePoint.getByValue(toShort(s)); } else { diff --git a/app/src/test/java/org/surfsite/gexporter/TestCourseFit.java b/app/src/test/java/org/surfsite/gexporter/TestCourseFit.java new file mode 100644 index 0000000..5571e0f --- /dev/null +++ b/app/src/test/java/org/surfsite/gexporter/TestCourseFit.java @@ -0,0 +1,118 @@ +package org.surfsite.gexporter; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import com.garmin.fit.CoursePointMesg; +import com.garmin.fit.Decode; +import com.garmin.fit.MesgBroadcaster; +import com.garmin.fit.RecordMesg; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileWriter; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; + +/** + * Decodes the written course to check what devices rely on: course points + * carry their distance along the course, and no timestamp is before the FIT + * epoch. + */ +public class TestCourseFit { + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + private final List records = new ArrayList<>(); + private final List coursePoints = new ArrayList<>(); + + /** A straight track north, 0.01° (about 1.1 km) per point. */ + private File gpx(String wpts, String timeFormat) throws Exception { + StringBuilder sb = new StringBuilder(); + sb.append("\n") + .append("\n") + .append(wpts) + .append("t\n"); + for (int i = 0; i <= 10; i++) { + sb.append(String.format(java.util.Locale.ROOT, "", 48.0 + i * 0.01)); + if (timeFormat != null) { + sb.append(""); + } + sb.append("\n"); + } + sb.append("\n"); + File f = tmp.newFile("in.gpx"); + try (FileWriter w = new FileWriter(f)) { + w.write(sb.toString()); + } + return f; + } + + private void convertAndDecode(File gpx) throws Exception { + File fit = tmp.newFile("out.fit"); + try (FileInputStream in = new FileInputStream(gpx)) { + new Gpx2Fit("t", in, new Gpx2FitOptions()).writeFit(fit); + } + Decode decode = new Decode(); + MesgBroadcaster broadcaster = new MesgBroadcaster(decode); + broadcaster.addListener((com.garmin.fit.RecordMesgListener) records::add); + broadcaster.addListener((com.garmin.fit.CoursePointMesgListener) coursePoints::add); + try (InputStream in = new FileInputStream(fit)) { + assertTrue("FIT integrity", decode.checkFileIntegrity(in)); + } + try (InputStream in = new FileInputStream(fit)) { + decode.read(in, broadcaster, broadcaster); + } + } + + @Test + public void waypointsGetTheirDistanceAlongTheCourse() throws Exception { + // listed out of order, both slightly off the track + convertAndDecode(gpx( + "late\n" + + "early\n", + null)); + + assertEquals(2, coursePoints.size()); + CoursePointMesg early = coursePoints.get(0); + CoursePointMesg late = coursePoints.get(1); + assertEquals("early", early.getName()); + assertEquals("late", late.getName()); + assertNotNull(early.getDistance()); + // nearest track points are #2 and #8 + assertEquals(records.get(2).getDistance(), early.getDistance(), 0.5f); + assertEquals(records.get(8).getDistance(), late.getDistance(), 0.5f); + assertEquals(records.get(2).getTimestamp().getTimestamp(), early.getTimestamp().getTimestamp()); + assertEquals(records.get(8).getTimestamp().getTimestamp(), late.getTimestamp().getTimestamp()); + } + + @Test + public void timesRelativeTo1970AreMovedToTheFitEpoch() throws Exception { + // Locus Map writes route times relative to 1970-01-01 (issue #36) + convertAndDecode(gpx("", "1970-01-01T00:%02d:00.000Z")); + + assertEquals(11, records.size()); + long first = records.get(0).getTimestamp().getTimestamp(); + assertEquals(0L, first); // the FIT epoch + for (int i = 0; i < records.size(); i++) { + // one minute per point, as in the GPX + assertEquals(first + i * 60L, (long) records.get(i).getTimestamp().getTimestamp()); + } + } + + @Test + public void currentTimesAreKept() throws Exception { + convertAndDecode(gpx("", "2026-09-05T10:%02d:00Z")); + + // 2026-09-05T10:00:00Z in FIT seconds (since 1989-12-31T00:00:00Z) + long expected = (1788602400000L - 631065600000L) / 1000L; + assertEquals(expected, (long) records.get(0).getTimestamp().getTimestamp()); + } +}