diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/opentracks/OpenTracksContentObserver.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/opentracks/OpenTracksContentObserver.java index f9c60d1163..c1b8c2c183 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/opentracks/OpenTracksContentObserver.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/opentracks/OpenTracksContentObserver.java @@ -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 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 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 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() { diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/opentracks/OpenTracksController.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/opentracks/OpenTracksController.java index a99ea36efd..9c1652431a 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/opentracks/OpenTracksController.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/opentracks/OpenTracksController.java @@ -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 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); + } + } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/opentracks/TrackPoint.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/opentracks/TrackPoint.java new file mode 100644 index 0000000000..25adfde150 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/opentracks/TrackPoint.java @@ -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 . */ +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>(); + 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 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; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/opentracks/TrackPointsBySegments.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/opentracks/TrackPointsBySegments.java new file mode 100644 index 0000000000..e2ea1d50a0 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/opentracks/TrackPointsBySegments.java @@ -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 . */ +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> 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); + } +}