Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 66 additions & 3 deletions app/src/main/java/org/surfsite/gexporter/Gpx2Fit.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<WayPoint> 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<WayPoint> 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);
Expand Down Expand Up @@ -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);
}

{
Expand Down Expand Up @@ -826,16 +855,50 @@ public void writeFit(File outfile) {
}
}

private void writeWayPoints(FileEncoder encode, List<WayPoint> 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<WayPoint> points, long duration) {
List<CoursePointMesg> cps = new ArrayList<>();
for (WayPoint wpt : points) {
int nearest = nearestTrackPoint(wpt);
WayPoint trk = trkPoints.get(nearest);

CoursePointMesg cp = new CoursePointMesg();
cp.setLocalNum(0);

cp.setPositionLat(wpt.getLatSemi());
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;
}
}
26 changes: 22 additions & 4 deletions app/src/main/java/org/surfsite/gexporter/MainActivity.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> mRegisteredApps = new HashSet<>();

Expand Down Expand Up @@ -227,6 +229,7 @@ public void onSdkShutDown() {
private void launchIQApp(ConnectIQ connectIQ) {
try {
List<IQDevice> devices = connectIQ.getConnectedDevices();
mDeviceState = null;
if (devices != null && !devices.isEmpty()) {
for (IQDevice device : devices) {
connectIQ.getApplicationInfo(CONNECT_IQ_GIMPORTER_APP, device, new ConnectIQ.IQApplicationInfoListener() {
Expand All @@ -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
Expand Down Expand Up @@ -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();
Expand All @@ -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);
}
}
});
Expand Down Expand Up @@ -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);
Expand Down
4 changes: 4 additions & 0 deletions app/src/main/java/org/surfsite/gexporter/WayPoint.java
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,10 @@ public CoursePoint getPointType() {
}

private CoursePoint parseStringToCoursePoint(String s) {
if (s == null) {
// most waypoints have no <type> or <sym>; valueOf(null) throws
return null;
}
if (isNumeric(s)) {
return CoursePoint.getByValue(toShort(s));
} else {
Expand Down
118 changes: 118 additions & 0 deletions app/src/test/java/org/surfsite/gexporter/TestCourseFit.java
Original file line number Diff line number Diff line change
@@ -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<RecordMesg> records = new ArrayList<>();
private final List<CoursePointMesg> 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("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n")
.append("<gpx version=\"1.1\" creator=\"test\" xmlns=\"http://www.topografix.com/GPX/1/1\">\n")
.append(wpts)
.append("<trk><name>t</name><trkseg>\n");
for (int i = 0; i <= 10; i++) {
sb.append(String.format(java.util.Locale.ROOT, "<trkpt lat=\"%.2f\" lon=\"9.0\">", 48.0 + i * 0.01));
if (timeFormat != null) {
sb.append("<time>").append(String.format(timeFormat, i)).append("</time>");
}
sb.append("</trkpt>\n");
}
sb.append("</trkseg></trk></gpx>\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(
"<wpt lat=\"48.0801\" lon=\"9.0001\"><name>late</name></wpt>\n"
+ "<wpt lat=\"48.0199\" lon=\"8.9999\"><name>early</name></wpt>\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());
}
}
Loading