Save track to GPX file when stopping OpenTracks recording

This commit is contained in:
Arjan Schrijver
2025-07-26 22:22:00 +02:00
committed by Arjan Schrijver
parent 26fdfb5df0
commit 3840595b35
4 changed files with 281 additions and 3 deletions
@@ -23,18 +23,31 @@ import android.database.ContentObserver;
import android.net.Uri;
import android.os.Handler;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import nodomain.freeyourgadget.gadgetbridge.model.ActivityPoint;
import nodomain.freeyourgadget.gadgetbridge.model.GPSCoordinate;
public class OpenTracksContentObserver extends ContentObserver {
private static final Logger LOG = LoggerFactory.getLogger(OpenTracksContentObserver.class);
private Context mContext;
private Uri tracksUri;
private Uri trackpointsUri;
private int protocolVersion;
private int totalTimeMillis;
private float totalDistanceMeter;
private final List<ActivityPoint> activityPoints;
private long previousTimeMillis = 0;
private float previousDistanceMeter = 0;
private long lastTrackPointId;
public int getTotalTimeMillis() {
return totalTimeMillis;
@@ -42,6 +55,9 @@ public class OpenTracksContentObserver extends ContentObserver {
public float getTotalDistanceMeter() {
return totalDistanceMeter;
}
public List<ActivityPoint> getActivityPoints() {
return activityPoints;
}
public long getTimeMillisChange() {
/**
@@ -59,13 +75,16 @@ public class OpenTracksContentObserver extends ContentObserver {
return distanceMeterDelta;
}
public OpenTracksContentObserver(Context context, final Uri tracksUri, final int protocolVersion) {
public OpenTracksContentObserver(Context context, final Uri tracksUri, final Uri trackpointsUri, final int protocolVersion) {
super(new Handler());
this.mContext = context;
this.tracksUri = tracksUri;
this.trackpointsUri = trackpointsUri;
this.protocolVersion = protocolVersion;
this.previousTimeMillis = System.currentTimeMillis();
this.activityPoints = new ArrayList<>();
LOG.debug("Initializing OpenTracksContentObserver...");
}
@Override
@@ -81,6 +100,21 @@ public class OpenTracksContentObserver extends ContentObserver {
totalDistanceMeter = statistics.getTotalDistanceMeter();
}
}
if (trackpointsUri.toString().startsWith(uri.toString())) {
final TrackPointsBySegments trackPointsBySegments = TrackPoint.readTrackPointsBySegments(mContext.getContentResolver(), trackpointsUri, lastTrackPointId, protocolVersion);
if (!trackPointsBySegments.isEmpty()) {
for (List<TrackPoint> segment : trackPointsBySegments.segments()) {
for (TrackPoint trackPoint : segment) {
lastTrackPointId = trackPoint.getTrackPointId();
ActivityPoint activityPoint = new ActivityPoint();
activityPoint.setLocation(new GPSCoordinate(trackPoint.getLongitude(), trackPoint.getLatitude()));
activityPoint.setTime(new Date());
activityPoints.add(activityPoint);
LOG.debug("Trackpoint received from OpenTracks: {}/{}", trackPoint.getLatitude(), trackPoint.getLongitude());
}
}
}
}
}
public void unregister() {
@@ -27,10 +27,21 @@ import android.widget.Toast;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
import java.io.IOException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.export.ActivityTrackExporter;
import nodomain.freeyourgadget.gadgetbridge.export.GPXExporter;
import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
import nodomain.freeyourgadget.gadgetbridge.model.ActivityPoint;
import nodomain.freeyourgadget.gadgetbridge.model.ActivityTrack;
import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
import nodomain.freeyourgadget.gadgetbridge.util.GB;
import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
@@ -76,10 +87,13 @@ public class OpenTracksController extends Activity {
gbApp.getOpenTracksObserver().unregister();
}
Uri tracksUri = uris.get(0);
Uri trackpointsUri = uris.get(1);
LOG.info("Registering OpenTracksContentObserver with tracks URI: " + tracksUri);
gbApp.setOpenTracksObserver(new OpenTracksContentObserver(this, tracksUri, protocolVersion));
LOG.info("Registering OpenTracksContentObserver with trackpoints URI: " + trackpointsUri);
gbApp.setOpenTracksObserver(new OpenTracksContentObserver(this, tracksUri, trackpointsUri, protocolVersion));
try {
getContentResolver().registerContentObserver(tracksUri, false, gbApp.getOpenTracksObserver());
getContentResolver().registerContentObserver(trackpointsUri, false, gbApp.getOpenTracksObserver());
} catch (final SecurityException se) {
LOG.error("Error registering OpenTracksContentObserver", se);
}
@@ -138,6 +152,7 @@ public class OpenTracksController extends Activity {
sendIntent(context, "de.dennisguse.opentracks.publicapi.StopRecording", null, null);
OpenTracksContentObserver openTracksObserver = GBApplication.app().getOpenTracksObserver();
if (openTracksObserver != null) {
saveToGpx(openTracksObserver.getActivityPoints());
openTracksObserver.finish();
}
GBApplication.app().setOpenTracksObserver(null);
@@ -151,4 +166,23 @@ public class OpenTracksController extends Activity {
stopRecording(context);
}
}
private static void saveToGpx(List<ActivityPoint> activityPoints) {
final SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault());
final String gpxName = sdf.format(new Date());
final ActivityTrack activityTrack = new ActivityTrack();
activityTrack.setName(gpxName);
activityTrack.addTrackPoints(activityPoints);
try {
final File gpxDir = FileUtils.getExternalFilesDir();
final File gpxFile = new File(gpxDir, gpxName + ".gpx");
final GPXExporter gpxExporter = new GPXExporter();
gpxExporter.performExport(activityTrack, gpxFile);
LOG.info("Saved GPX received from OpenTracks to {}", gpxFile.getPath());
} catch (IOException | ActivityTrackExporter.GPXTrackEmptyException e) {
LOG.error("Error while writing generated GPX file", e);
}
}
}
@@ -0,0 +1,164 @@
/* Copyright (C) 2025 Arjan Schrijver
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 <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.externalevents.opentracks;
import android.content.ContentResolver;
import android.database.Cursor;
import android.net.Uri;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.List;
/**
* This class was copied and modified from
* https://github.com/OpenTracksApp/OSMDashboard/blob/v4.3.0/src/main/java/de/storchp/opentracks/osmplugin/dashboardapi/TrackPoint.java
*/
public class TrackPoint {
private static final Logger LOG = LoggerFactory.getLogger(TrackPoint.class);
public static final String _ID = "_id";
public static final String TRACKID = "trackid";
public static final String LONGITUDE = "longitude";
public static final String LATITUDE = "latitude";
public static final String TIME = "time";
public static final String TYPE = "type";
public static final String SPEED = "speed";
public static final double PAUSE_LATITUDE = 100.0;
public static final double LAT_LON_FACTOR = 1E6;
public static final String[] PROJECTION_V1 = {
_ID,
TRACKID,
LATITUDE,
LONGITUDE,
TIME,
SPEED
};
public static final String[] PROJECTION_V2 = {
_ID,
TRACKID,
LATITUDE,
LONGITUDE,
TIME,
TYPE,
SPEED
};
private final long trackPointId;
private final long trackId;
private final double latitude;
private final double longitude;
private final boolean pause;
private final double speed;
public TrackPoint(long trackId, long trackPointId, double latitude, double longitude, Integer type, double speed) {
this.trackId = trackId;
this.trackPointId = trackPointId;
this.latitude = latitude;
this.longitude = longitude;
this.pause = type != null ? type == 3 : latitude == PAUSE_LATITUDE;
this.speed = speed;
}
public boolean hasValidLocation() {
return latitude != 0 || longitude != 0;
}
public boolean isPause() {
return pause;
}
/**
* Reads the TrackPoints from the Content Uri and split by segments.
* Pause TrackPoints and different Track IDs split the segments.
*/
public static TrackPointsBySegments readTrackPointsBySegments(ContentResolver resolver, Uri data, long lastTrackPointId, int protocolVersion) {
var segments = new ArrayList<List<TrackPoint>>();
var projection = PROJECTION_V2;
var typeQuery = " AND " + TrackPoint.TYPE + " IN (-2, -1, 0, 1, 3)";
if (protocolVersion < 2) { // fallback to old Dashboard API
projection = PROJECTION_V1;
typeQuery = "";
}
try (Cursor cursor = resolver.query(data, projection, TrackPoint._ID + "> ?" + typeQuery, new String[]{Long.toString(lastTrackPointId)}, null)) {
TrackPoint lastTrackPoint = null;
List<TrackPoint> segment = null;
while (cursor.moveToNext()) {
var trackPointId = cursor.getLong(cursor.getColumnIndexOrThrow(TrackPoint._ID));
var trackId = cursor.getLong(cursor.getColumnIndexOrThrow(TrackPoint.TRACKID));
var latitude = cursor.getInt(cursor.getColumnIndexOrThrow(TrackPoint.LATITUDE)) / LAT_LON_FACTOR;
var longitude = cursor.getInt(cursor.getColumnIndexOrThrow(TrackPoint.LONGITUDE)) / LAT_LON_FACTOR;
var typeIndex = cursor.getColumnIndex(TrackPoint.TYPE);
var speed = cursor.getDouble(cursor.getColumnIndexOrThrow(TrackPoint.SPEED));
Integer type = null;
if (typeIndex > -1) {
type = cursor.getInt(typeIndex);
}
if (lastTrackPoint == null || lastTrackPoint.trackId != trackId) {
segment = new ArrayList<>();
segments.add(segment);
}
lastTrackPoint = new TrackPoint(trackId, trackPointId, latitude, longitude, type, speed);
if (lastTrackPoint.hasValidLocation()) {
segment.add(lastTrackPoint);
} else if (!lastTrackPoint.isPause()) {
// invalid trackpoint
}
if (lastTrackPoint.isPause()) {
if (!lastTrackPoint.hasValidLocation()) {
if (!segment.isEmpty()) {
var previousTrackpoint = segment.get(segment.size() - 1);
if (previousTrackpoint.hasValidLocation()) {
segment.add(new TrackPoint(trackId, trackPointId, previousTrackpoint.getLatitude(), previousTrackpoint.getLongitude(), type, speed));
}
}
}
lastTrackPoint = null;
}
}
} catch (Exception e) {
LOG.error("Couldn't read trackpoints from OpenTracks URI", e);
}
return new TrackPointsBySegments(segments);
}
public long getTrackPointId() {
return trackPointId;
}
public long getTrackId() {
return trackId;
}
public double getSpeed() {
return speed;
}
public double getLatitude() {
return latitude;
}
public double getLongitude() {
return longitude;
}
}
@@ -0,0 +1,46 @@
/* Copyright (C) 2025 Arjan Schrijver
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 <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.externalevents.opentracks;
import androidx.annotation.NonNull;
import java.util.List;
import java.util.stream.DoubleStream;
/**
* This class was copied and modified from
* https://github.com/OpenTracksApp/OSMDashboard/blob/v4.3.0/src/main/java/de/storchp/opentracks/osmplugin/dashboardapi/TrackPointsBySegments.java
*/
public record TrackPointsBySegments(List<List<TrackPoint>> segments) {
public boolean isEmpty() {
return segments.isEmpty();
}
public double calcAverageSpeed() {
return streamTrackPointsWithSpeed().average().orElse(0.0);
}
public double calcMaxSpeed() {
return streamTrackPointsWithSpeed().max().orElse(0.0);
}
@NonNull
private DoubleStream streamTrackPointsWithSpeed() {
return segments.stream().flatMap(List::stream).mapToDouble(TrackPoint::getSpeed).filter(speed -> speed > 0);
}
}