Add GPX file support for activity imports

This commit adds comprehensive GPX file support alongside existing FIT file support, enabling users to import activities from Strava, Komoot, and other GPS apps.

## Key Features

### Core Components
- **GpxParser**: Full GPX 1.1 parsing with Garmin TrackPointExtension support
- **GpxFileValidator**: Validation for GPX file format and structure
- **ActivityFileService**: Unified service with automatic format detection (FIT/GPX)
- **ParsedActivityData**: Common data structure for both FIT and GPX files

### GPX Parsing Capabilities
- GPS track point extraction (latitude, longitude, elevation, timestamp)
- Garmin extension data (heart rate, cadence, temperature)
- Activity type detection from GPX metadata
- Distance calculation using Haversine formula
- Elevation gain/loss calculation
- Speed calculation from consecutive GPS points
- Speed smoothing to remove GPS artifacts
- Timezone detection from GPS coordinates
- Moving time vs. stopped time analysis

### Database Changes
- Migration V15: Renamed raw_fit_file → raw_activity_file
- Added source_file_format column (FIT/GPX) with constraint
- Index on source_file_format for performance
- Updated Activity entity with new fields

### Controller & UI Updates
- ActivityController: Now handles both FIT and GPX uploads
- Upload form: Updated to accept .fit and .gpx files
- Help text: Clarified both formats are supported

### Testing
- GpxParserIntegrationTest: 9 comprehensive tests with real GPX file
- Tests cover: parsing, validation, heart rate extraction, distance calculation,
  elevation metrics, speed calculation, chronological ordering, smoothing
- Fixed TrainingLoadServiceTest date issue (testDate outside 30-day window)
- All 97 unit tests passing (integration tests require Docker)

### Technical Details
- Supports GPX 1.0 and 1.1 specifications
- Handles multiple track segments
- Processes Garmin TrackPointExtension v1 and v2
- Same track simplification as FIT (Douglas-Peucker algorithm)
- Consistent JSONB storage format for track points
- Compatible with existing analytics, heatmaps, and image generation

## Testing Summary
-  9/9 GpxParserIntegrationTest tests passing
-  4/4 FitParserIntegrationTest tests passing
-  14/14 FitFileServiceTest tests passing
-  97/97 total unit tests passing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Tim Zöller 2026-01-02 13:31:05 +01:00
parent 66b14ebf7f
commit f4be439002
21 changed files with 7466 additions and 160 deletions

View file

@ -9,6 +9,7 @@ import org.operaton.fitpub.model.dto.ActivityUploadRequest;
import org.operaton.fitpub.model.entity.Activity;
import org.operaton.fitpub.model.entity.User;
import org.operaton.fitpub.repository.UserRepository;
import org.operaton.fitpub.service.ActivityFileService;
import org.operaton.fitpub.service.ActivityImageService;
import org.operaton.fitpub.service.FederationService;
import org.operaton.fitpub.service.FitFileService;
@ -31,7 +32,7 @@ import java.util.stream.Collectors;
/**
* REST controller for activity management.
* Handles FIT file uploads, activity retrieval, updates, and deletion.
* Handles activity file uploads (FIT, GPX), activity retrieval, updates, and deletion.
*/
@RestController
@RequestMapping("/api/activities")
@ -39,6 +40,7 @@ import java.util.stream.Collectors;
@Slf4j
public class ActivityController {
private final ActivityFileService activityFileService;
private final FitFileService fitFileService;
private final UserRepository userRepository;
private final FederationService federationService;
@ -62,9 +64,9 @@ public class ActivityController {
}
/**
* Uploads a FIT file and creates a new activity.
* Uploads an activity file (FIT or GPX) and creates a new activity.
*
* @param file the FIT file
* @param file the activity file (FIT or GPX)
* @param request the upload request with metadata
* @param userDetails the authenticated user
* @return the created activity
@ -75,12 +77,12 @@ public class ActivityController {
@Valid @ModelAttribute ActivityUploadRequest request,
@AuthenticationPrincipal UserDetails userDetails
) {
log.info("User {} uploading FIT file: {}", userDetails.getUsername(), file.getOriginalFilename());
log.info("User {} uploading activity file: {}", userDetails.getUsername(), file.getOriginalFilename());
User user = userRepository.findByUsername(userDetails.getUsername())
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
Activity activity = fitFileService.processFitFile(
Activity activity = activityFileService.processActivityFile(
file,
user.getId(),
request.getTitle(),

View file

@ -0,0 +1,16 @@
package org.operaton.fitpub.exception;
/**
* Base exception for GPX file processing errors.
* Thrown when an error occurs during GPX file parsing or processing.
*/
public class GpxFileProcessingException extends RuntimeException {
public GpxFileProcessingException(String message) {
super(message);
}
public GpxFileProcessingException(String message, Throwable cause) {
super(message, cause);
}
}

View file

@ -0,0 +1,16 @@
package org.operaton.fitpub.exception;
/**
* Exception thrown when a GPX file fails validation.
* This includes malformed XML, missing required elements, or invalid GPX structure.
*/
public class InvalidGpxFileException extends GpxFileProcessingException {
public InvalidGpxFileException(String message) {
super(message);
}
public InvalidGpxFileException(String message, Throwable cause) {
super(message, cause);
}
}

View file

@ -0,0 +1,16 @@
package org.operaton.fitpub.exception;
/**
* Exception thrown when an uploaded file format is not supported.
* Currently supported formats: FIT, GPX.
*/
public class UnsupportedFileFormatException extends RuntimeException {
public UnsupportedFileFormatException(String message) {
super(message);
}
public UnsupportedFileFormatException(String message, Throwable cause) {
super(message, cause);
}
}

View file

@ -94,12 +94,18 @@ public class Activity {
private BigDecimal elevationLoss;
/**
* Original FIT file for re-processing if needed.
* Original activity file (FIT or GPX) for re-processing if needed.
* Allows us to re-parse with updated algorithms.
*/
@Lob
@Column(name = "raw_fit_file")
private byte[] rawFitFile;
@Column(name = "raw_activity_file")
private byte[] rawActivityFile;
/**
* Source file format: "FIT" (Garmin/Wahoo devices) or "GPX" (GPS Exchange Format).
*/
@Column(name = "source_file_format", nullable = false, length = 10)
private String sourceFileFormat;
@OneToOne(mappedBy = "activity", cascade = CascadeType.ALL, orphanRemoval = true)
private ActivityMetrics metrics;

View file

@ -0,0 +1,310 @@
package org.operaton.fitpub.service;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.LineString;
import org.locationtech.jts.geom.PrecisionModel;
import org.operaton.fitpub.exception.FitFileProcessingException;
import org.operaton.fitpub.exception.GpxFileProcessingException;
import org.operaton.fitpub.exception.UnsupportedFileFormatException;
import org.operaton.fitpub.model.entity.Activity;
import org.operaton.fitpub.model.entity.ActivityMetrics;
import org.operaton.fitpub.repository.ActivityRepository;
import org.operaton.fitpub.util.*;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.UUID;
/**
* Unified service for processing activity files (FIT, GPX, etc.) and creating activities.
* Automatically detects file format and routes to the appropriate parser.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class ActivityFileService {
private static final int WGS84_SRID = 4326;
private static final GeometryFactory GEOMETRY_FACTORY =
new GeometryFactory(new PrecisionModel(), WGS84_SRID);
private final FitFileValidator fitValidator;
private final GpxFileValidator gpxValidator;
private final FitParser fitParser;
private final GpxParser gpxParser;
private final TrackSimplifier trackSimplifier;
private final ActivityRepository activityRepository;
private final ObjectMapper objectMapper;
private final PersonalRecordService personalRecordService;
private final AchievementService achievementService;
private final TrainingLoadService trainingLoadService;
private final ActivitySummaryService activitySummaryService;
private final WeatherService weatherService;
private final HeatmapGridService heatmapGridService;
/**
* Processes an uploaded activity file (FIT or GPX) and creates an activity.
*
* @param file the uploaded file
* @param userId the user ID
* @param title optional custom title (will be auto-generated if null)
* @param description optional description
* @param visibility visibility level
* @return the created activity
* @throws FitFileProcessingException if FIT processing fails
* @throws GpxFileProcessingException if GPX processing fails
* @throws UnsupportedFileFormatException if file format is unknown
*/
@Transactional
public Activity processActivityFile(
MultipartFile file,
UUID userId,
String title,
String description,
Activity.Visibility visibility
) {
try {
byte[] fileData = file.getBytes();
String filename = file.getOriginalFilename();
log.info("Processing activity file: {}, size: {} bytes", filename, file.getSize());
// Detect file format
FileFormat format = detectFileFormat(fileData, filename);
log.debug("Detected file format: {}", format);
// Parse based on format
ParsedActivityData parsedData;
if (format == FileFormat.FIT) {
fitValidator.validate(fileData);
parsedData = fitParser.parse(fileData);
parsedData.setSourceFormat("FIT");
} else if (format == FileFormat.GPX) {
gpxValidator.validate(fileData);
parsedData = gpxParser.parse(fileData);
parsedData.setSourceFormat("GPX");
} else {
throw new UnsupportedFileFormatException("Unsupported file format: " + filename);
}
// Common processing (same for both formats)
return createActivityFromParsedData(parsedData, userId, title, description, visibility, fileData);
} catch (IOException e) {
throw new RuntimeException("Failed to read activity file", e);
}
}
/**
* Detects file format from content and filename.
* Priority: magic bytes > XML header > file extension
*/
private FileFormat detectFileFormat(byte[] fileData, String filename) {
// Primary: Check magic bytes for FIT file signature at offset 8: ".FIT"
if (fileData.length >= 12) {
if (fileData[8] == '.' && fileData[9] == 'F' &&
fileData[10] == 'I' && fileData[11] == 'T') {
return FileFormat.FIT;
}
}
// Secondary: Check XML header for GPX
if (fileData.length >= 100) {
String header = new String(fileData, 0, Math.min(200, fileData.length), StandardCharsets.UTF_8);
if (header.contains("<?xml") && header.contains("<gpx")) {
return FileFormat.GPX;
}
}
// Fallback: File extension
if (filename != null && !filename.isEmpty()) {
String lowerFilename = filename.toLowerCase();
if (lowerFilename.endsWith(".fit")) {
return FileFormat.FIT;
}
if (lowerFilename.endsWith(".gpx")) {
return FileFormat.GPX;
}
}
throw new UnsupportedFileFormatException("Unable to detect file format from content or filename");
}
/**
* Creates an activity from parsed data (internal method).
* This method contains all the common logic for creating activities from any format.
*/
private Activity createActivityFromParsedData(
ParsedActivityData parsedData,
UUID userId,
String title,
String description,
Activity.Visibility visibility,
byte[] rawFile
) {
// Generate title if not provided
String activityTitle = title != null && !title.isBlank()
? title
: ActivityFormatter.generateActivityTitle(parsedData.getStartTime(), parsedData.getActivityType());
// Default to PUBLIC if visibility not specified
Activity.Visibility activityVisibility = visibility != null ? visibility : Activity.Visibility.PUBLIC;
// Create activity entity
Activity activity = Activity.builder()
.userId(userId)
.activityType(parsedData.getActivityType())
.title(activityTitle)
.description(description)
.startedAt(parsedData.getStartTime())
.endedAt(parsedData.getEndTime())
.timezone(parsedData.getTimezone())
.visibility(activityVisibility)
.totalDistance(parsedData.getTotalDistance())
.totalDurationSeconds(parsedData.getTotalDuration() != null ? parsedData.getTotalDuration().getSeconds() : null)
.elevationGain(parsedData.getElevationGain())
.elevationLoss(parsedData.getElevationLoss())
.rawActivityFile(rawFile)
.sourceFileFormat(parsedData.getSourceFormat())
.build();
// Convert track points to JSONB
String trackPointsJson = convertTrackPointsToJson(parsedData.getTrackPoints());
activity.setTrackPointsJson(trackPointsJson);
// Create full LineString from all points
LineString fullTrack = createLineStringFromTrackPoints(parsedData.getTrackPoints());
// Simplify track for map rendering
LineString simplifiedTrack = trackSimplifier.simplify(fullTrack.getCoordinates());
activity.setSimplifiedTrack(simplifiedTrack);
// Create metrics
if (parsedData.getMetrics() != null) {
ActivityMetrics metrics = parsedData.getMetrics().toEntity(activity);
calculateAdditionalMetrics(metrics, parsedData.getTrackPoints());
activity.setMetrics(metrics);
}
// Save activity (single INSERT instead of 855!)
Activity savedActivity = activityRepository.save(activity);
log.info("Successfully created {} activity {} with {} track points (simplified to {} for map)",
parsedData.getSourceFormat(),
savedActivity.getId(),
parsedData.getTrackPoints().size(),
simplifiedTrack.getNumPoints());
// Check for personal records and achievements
personalRecordService.checkAndUpdatePersonalRecords(savedActivity);
achievementService.checkAndAwardAchievements(savedActivity);
// Update heatmap grid
heatmapGridService.updateHeatmapForActivity(savedActivity);
// Update training load and summaries (async)
trainingLoadService.updateTrainingLoad(savedActivity);
activitySummaryService.updateSummariesForActivity(savedActivity);
// Fetch weather data (async, non-blocking)
try {
weatherService.fetchWeatherForActivity(savedActivity);
} catch (Exception e) {
log.warn("Failed to fetch weather data for activity {}: {}", savedActivity.getId(), e.getMessage());
// Don't fail the activity creation if weather fetching fails
}
return savedActivity;
}
/**
* Converts track points to JSON string for JSONB storage.
*/
private String convertTrackPointsToJson(List<ParsedActivityData.TrackPointData> trackPoints) {
try {
return objectMapper.writeValueAsString(trackPoints);
} catch (JsonProcessingException e) {
throw new RuntimeException("Failed to serialize track points to JSON", e);
}
}
/**
* Creates a PostGIS LineString from track points.
*/
private LineString createLineStringFromTrackPoints(List<ParsedActivityData.TrackPointData> trackPoints) {
Coordinate[] coordinates = trackPoints.stream()
.map(tp -> new Coordinate(tp.getLongitude(), tp.getLatitude()))
.toArray(Coordinate[]::new);
return GEOMETRY_FACTORY.createLineString(coordinates);
}
/**
* Calculates additional metrics that might not be in parsed data.
* For GPX files, most metrics are already calculated in GpxParser.
* For FIT files, some additional metrics like min/max elevation need calculation.
*/
private void calculateAdditionalMetrics(
ActivityMetrics metrics,
List<ParsedActivityData.TrackPointData> trackPoints
) {
if (trackPoints.isEmpty()) {
return;
}
// Calculate min/max elevation if not already set
if (metrics.getMinElevation() == null || metrics.getMaxElevation() == null) {
BigDecimal minElevation = null;
BigDecimal maxElevation = null;
for (ParsedActivityData.TrackPointData tp : trackPoints) {
if (tp.getElevation() != null) {
if (minElevation == null || tp.getElevation().compareTo(minElevation) < 0) {
minElevation = tp.getElevation();
}
if (maxElevation == null || tp.getElevation().compareTo(maxElevation) > 0) {
maxElevation = tp.getElevation();
}
}
}
if (metrics.getMinElevation() == null) metrics.setMinElevation(minElevation);
if (metrics.getMaxElevation() == null) metrics.setMaxElevation(maxElevation);
}
// Calculate average temperature if not already set
if (metrics.getAverageTemperature() == null) {
BigDecimal tempSum = BigDecimal.ZERO;
int tempCount = 0;
for (ParsedActivityData.TrackPointData tp : trackPoints) {
if (tp.getTemperature() != null) {
tempSum = tempSum.add(tp.getTemperature());
tempCount++;
}
}
if (tempCount > 0) {
metrics.setAverageTemperature(
tempSum.divide(BigDecimal.valueOf(tempCount), 2, BigDecimal.ROUND_HALF_UP)
);
}
}
}
/**
* Enum for supported file formats.
*/
private enum FileFormat {
FIT, GPX
}
}

View file

@ -16,6 +16,7 @@ import org.operaton.fitpub.repository.ActivityRepository;
import org.operaton.fitpub.util.ActivityFormatter;
import org.operaton.fitpub.util.FitFileValidator;
import org.operaton.fitpub.util.FitParser;
import org.operaton.fitpub.util.ParsedActivityData;
import org.operaton.fitpub.util.TrackSimplifier;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@ -78,7 +79,7 @@ public class FitFileService {
// Parse FIT file
byte[] fileData = file.getBytes();
FitParser.ParsedFitData parsedData = parser.parse(fileData);
ParsedActivityData parsedData = parser.parse(fileData);
// Create activity entity
Activity activity = createActivity(parsedData, userId, title, description, visibility, fileData);
@ -146,7 +147,7 @@ public class FitFileService {
@Transactional
public Activity processFitFile(byte[] fileData, UUID userId, Activity.Visibility visibility) {
validator.validate(fileData);
FitParser.ParsedFitData parsedData = parser.parse(fileData);
ParsedActivityData parsedData = parser.parse(fileData);
return createActivityFromParsedData(parsedData, userId, null, null, visibility, fileData);
}
@ -154,7 +155,7 @@ public class FitFileService {
* Creates an activity entity from parsed FIT data.
*/
private Activity createActivity(
FitParser.ParsedFitData parsedData,
ParsedActivityData parsedData,
UUID userId,
String title,
String description,
@ -181,7 +182,8 @@ public class FitFileService {
.totalDurationSeconds(parsedData.getTotalDuration() != null ? parsedData.getTotalDuration().getSeconds() : null)
.elevationGain(parsedData.getElevationGain())
.elevationLoss(parsedData.getElevationLoss())
.rawFitFile(rawFile)
.rawActivityFile(rawFile)
.sourceFileFormat("FIT")
.build();
}
@ -189,7 +191,7 @@ public class FitFileService {
* Creates an activity from parsed data (internal method).
*/
private Activity createActivityFromParsedData(
FitParser.ParsedFitData parsedData,
ParsedActivityData parsedData,
UUID userId,
String title,
String description,
@ -228,7 +230,7 @@ public class FitFileService {
/**
* Converts track points to JSON string for JSONB storage.
*/
private String convertTrackPointsToJson(List<FitParser.TrackPointData> trackPoints) {
private String convertTrackPointsToJson(List<ParsedActivityData.TrackPointData> trackPoints) {
try {
return objectMapper.writeValueAsString(trackPoints);
} catch (JsonProcessingException e) {
@ -239,7 +241,7 @@ public class FitFileService {
/**
* Creates a PostGIS LineString from track points.
*/
private LineString createLineStringFromTrackPoints(List<FitParser.TrackPointData> trackPoints) {
private LineString createLineStringFromTrackPoints(List<ParsedActivityData.TrackPointData> trackPoints) {
Coordinate[] coordinates = trackPoints.stream()
.map(tp -> new Coordinate(tp.getLongitude(), tp.getLatitude()))
.toArray(Coordinate[]::new);
@ -251,7 +253,7 @@ public class FitFileService {
* Generates a default title for an activity based on time of day.
* Examples: "Morning Run", "Evening Ride", "Night Walk"
*/
private String generateTitle(FitParser.ParsedFitData parsedData) {
private String generateTitle(ParsedActivityData parsedData) {
return ActivityFormatter.generateActivityTitle(
parsedData.getStartTime(),
parsedData.getActivityType()
@ -263,7 +265,7 @@ public class FitFileService {
*/
private void calculateAdditionalMetrics(
ActivityMetrics metrics,
List<FitParser.TrackPointData> trackPoints
List<ParsedActivityData.TrackPointData> trackPoints
) {
if (trackPoints.isEmpty()) {
return;
@ -273,7 +275,7 @@ public class FitFileService {
BigDecimal minElevation = null;
BigDecimal maxElevation = null;
for (FitParser.TrackPointData tp : trackPoints) {
for (ParsedActivityData.TrackPointData tp : trackPoints) {
if (tp.getElevation() != null) {
if (minElevation == null || tp.getElevation().compareTo(minElevation) < 0) {
minElevation = tp.getElevation();
@ -291,7 +293,7 @@ public class FitFileService {
BigDecimal tempSum = BigDecimal.ZERO;
int tempCount = 0;
for (FitParser.TrackPointData tp : trackPoints) {
for (ParsedActivityData.TrackPointData tp : trackPoints) {
if (tp.getTemperature() != null) {
tempSum = tempSum.add(tp.getTemperature());
tempCount++;

View file

@ -10,6 +10,8 @@ import org.locationtech.jts.geom.PrecisionModel;
import org.operaton.fitpub.exception.FitFileProcessingException;
import org.operaton.fitpub.model.entity.Activity;
import org.operaton.fitpub.model.entity.ActivityMetrics;
import org.operaton.fitpub.util.ParsedActivityData.ActivityMetricsData;
import org.operaton.fitpub.util.ParsedActivityData.TrackPointData;
import org.springframework.stereotype.Component;
import java.io.ByteArrayInputStream;
@ -52,10 +54,10 @@ public class FitParser {
* Parses a FIT file and returns the extracted data.
*
* @param fileData the FIT file data
* @return ParsedFitData containing activity information
* @return ParsedActivityData containing activity information
* @throws FitFileProcessingException if parsing fails
*/
public ParsedFitData parse(byte[] fileData) {
public ParsedActivityData parse(byte[] fileData) {
try (InputStream inputStream = new ByteArrayInputStream(fileData)) {
return parse(inputStream);
} catch (Exception e) {
@ -67,11 +69,11 @@ public class FitParser {
* Parses a FIT file from an input stream.
*
* @param inputStream the input stream
* @return ParsedFitData containing activity information
* @return ParsedActivityData containing activity information
* @throws FitFileProcessingException if parsing fails
*/
public ParsedFitData parse(InputStream inputStream) {
ParsedFitData parsedData = new ParsedFitData();
public ParsedActivityData parse(InputStream inputStream) {
ParsedActivityData parsedData = new ParsedActivityData();
Decode decode = new Decode();
MesgBroadcaster broadcaster = new MesgBroadcaster(decode);
@ -189,7 +191,7 @@ public class FitParser {
/**
* Extracts session data from a session message.
*/
private void extractSessionData(SessionMesg session, ParsedFitData parsedData) {
private void extractSessionData(SessionMesg session, ParsedActivityData parsedData) {
if (session.getStartTime() != null) {
parsedData.setStartTime(convertDateTime(session.getStartTime()));
}
@ -287,7 +289,7 @@ public class FitParser {
/**
* Extracts activity data from an activity message.
*/
private void extractActivityData(ActivityMesg activity, ParsedFitData parsedData) {
private void extractActivityData(ActivityMesg activity, ParsedActivityData parsedData) {
if (activity.getTimestamp() != null) {
parsedData.setActivityTimestamp(convertDateTime(activity.getTimestamp()));
}
@ -301,7 +303,7 @@ public class FitParser {
* Applies speed smoothing to track points and updates max speed in metrics.
* Removes unrealistic GPS speed spikes and recalculates max speed.
*/
private void smoothSpeedData(ParsedFitData parsedData) {
private void smoothSpeedData(ParsedActivityData parsedData) {
if (parsedData.getTrackPoints().isEmpty() || parsedData.getMetrics() == null) {
return;
}
@ -328,7 +330,7 @@ public class FitParser {
* Determines the timezone based on the first GPS coordinate.
* Uses TimeZoneEngine library for accurate timezone lookup from coordinates.
*/
private void determineTimezone(ParsedFitData parsedData) {
private void determineTimezone(ParsedActivityData parsedData) {
if (parsedData.getTrackPoints().isEmpty()) {
parsedData.setTimezone("UTC");
return;
@ -396,94 +398,4 @@ public class FitParser {
return Activity.ActivityType.OTHER;
}
}
/**
* Data class for track point information.
*/
@lombok.Data
public static class TrackPointData {
private LocalDateTime timestamp;
private double latitude;
private double longitude;
private BigDecimal elevation;
private Integer heartRate;
private Integer cadence;
private Integer power;
private BigDecimal speed;
private BigDecimal temperature;
private BigDecimal distance;
public Point toGeometry() {
return GEOMETRY_FACTORY.createPoint(new Coordinate(longitude, latitude));
}
}
/**
* Data class for activity metrics.
*/
@lombok.Data
public static class ActivityMetricsData {
private BigDecimal averageSpeed;
private BigDecimal maxSpeed;
private Duration averagePace;
private Integer averageHeartRate;
private Integer maxHeartRate;
private Integer averageCadence;
private Integer maxCadence;
private Integer averagePower;
private Integer maxPower;
private Integer normalizedPower;
private Integer calories;
private BigDecimal averageTemperature;
private BigDecimal maxElevation;
private BigDecimal minElevation;
private BigDecimal totalAscent;
private BigDecimal totalDescent;
private Duration movingTime;
private Duration stoppedTime;
private Integer totalSteps;
public ActivityMetrics toEntity(Activity activity) {
return ActivityMetrics.builder()
.activity(activity)
.averageSpeed(averageSpeed)
.maxSpeed(maxSpeed)
.averagePaceSeconds(averagePace != null ? averagePace.getSeconds() : null)
.averageHeartRate(averageHeartRate)
.maxHeartRate(maxHeartRate)
.averageCadence(averageCadence)
.maxCadence(maxCadence)
.averagePower(averagePower)
.maxPower(maxPower)
.normalizedPower(normalizedPower)
.calories(calories)
.averageTemperature(averageTemperature)
.maxElevation(maxElevation)
.minElevation(minElevation)
.totalAscent(totalAscent)
.totalDescent(totalDescent)
.movingTimeSeconds(movingTime != null ? movingTime.getSeconds() : null)
.stoppedTimeSeconds(stoppedTime != null ? stoppedTime.getSeconds() : null)
.totalSteps(totalSteps)
.build();
}
}
/**
* Data class holding all parsed FIT file data.
*/
@lombok.Data
public static class ParsedFitData {
private List<TrackPointData> trackPoints = new ArrayList<>();
private LocalDateTime startTime;
private LocalDateTime endTime;
private LocalDateTime activityTimestamp;
private String timezone; // IANA timezone ID (e.g., "Europe/Berlin")
private BigDecimal totalDistance;
private Duration totalDuration;
private BigDecimal elevationGain;
private BigDecimal elevationLoss;
private Activity.ActivityType activityType = Activity.ActivityType.OTHER;
private ActivityMetricsData metrics;
}
}

View file

@ -0,0 +1,116 @@
package org.operaton.fitpub.util;
import lombok.extern.slf4j.Slf4j;
import org.operaton.fitpub.exception.InvalidGpxFileException;
import org.springframework.stereotype.Component;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NodeList;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import java.io.ByteArrayInputStream;
/**
* Validates GPX files before processing.
* Checks file size, XML well-formedness, and GPX structure.
*/
@Component
@Slf4j
public class GpxFileValidator {
private static final long MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
private static final int MIN_FILE_SIZE = 100; // Minimum XML file size
/**
* Validates a GPX file from byte array.
*
* @param fileData the GPX file data
* @throws InvalidGpxFileException if the file is invalid
*/
public void validate(byte[] fileData) {
if (fileData == null || fileData.length == 0) {
throw new InvalidGpxFileException("GPX file is empty");
}
validateFileSize(fileData.length);
validateGpxStructure(fileData);
}
/**
* Validates the file size.
*
* @param size the file size in bytes
* @throws InvalidGpxFileException if the size is invalid
*/
private void validateFileSize(long size) {
if (size < MIN_FILE_SIZE) {
throw new InvalidGpxFileException(
String.format("GPX file is too small. Size: %d bytes, minimum: %d bytes", size, MIN_FILE_SIZE)
);
}
if (size > MAX_FILE_SIZE) {
throw new InvalidGpxFileException(
String.format("GPX file is too large. Size: %d bytes, maximum: %d bytes", size, MAX_FILE_SIZE)
);
}
}
/**
* Validates GPX XML structure.
*
* @param fileData the GPX file data
* @throws InvalidGpxFileException if the structure is invalid
*/
private void validateGpxStructure(byte[] fileData) {
try {
// Parse XML to check well-formedness
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setNamespaceAware(true);
DocumentBuilder builder = factory.newDocumentBuilder();
Document doc = builder.parse(new ByteArrayInputStream(fileData));
// Check root element is <gpx>
Element root = doc.getDocumentElement();
if (!"gpx".equals(root.getLocalName()) && !"gpx".equals(root.getNodeName())) {
throw new InvalidGpxFileException("Root element must be <gpx>, found: <" + root.getNodeName() + ">");
}
// Check for at least one <trk> or <rte> element
NodeList tracks = doc.getElementsByTagName("trk");
NodeList routes = doc.getElementsByTagName("rte");
// Also check for namespace-qualified names
if (tracks.getLength() == 0) {
tracks = doc.getElementsByTagNameNS("*", "trk");
}
if (routes.getLength() == 0) {
routes = doc.getElementsByTagNameNS("*", "rte");
}
if (tracks.getLength() == 0 && routes.getLength() == 0) {
throw new InvalidGpxFileException("GPX file must contain at least one <trk> or <rte> element");
}
log.debug("GPX validation successful. Tracks: {}, Routes: {}", tracks.getLength(), routes.getLength());
} catch (InvalidGpxFileException e) {
throw e; // Re-throw our custom exceptions
} catch (Exception e) {
throw new InvalidGpxFileException("Invalid GPX XML structure: " + e.getMessage(), e);
}
}
/**
* Checks if a file appears to be a valid GPX file based on extension.
*
* @param filename the filename
* @return true if the filename has a .gpx extension
*/
public boolean hasValidExtension(String filename) {
if (filename == null || filename.isEmpty()) {
return false;
}
return filename.toLowerCase().endsWith(".gpx");
}
}

View file

@ -0,0 +1,603 @@
package org.operaton.fitpub.util;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import net.iakovlev.timeshape.TimeZoneEngine;
import org.operaton.fitpub.exception.GpxFileProcessingException;
import org.operaton.fitpub.model.entity.Activity;
import org.operaton.fitpub.util.ParsedActivityData.ActivityMetricsData;
import org.operaton.fitpub.util.ParsedActivityData.TrackPointData;
import org.springframework.stereotype.Component;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import java.io.ByteArrayInputStream;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Duration;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
/**
* Parser for GPX (GPS Exchange Format) files.
* Extracts GPS coordinates, activity metrics from track points.
* Since GPX files lack session summaries, metrics are calculated from track points.
*/
@Component
@RequiredArgsConstructor
@Slf4j
public class GpxParser {
private static final String GPX_NS = "http://www.topografix.com/GPX/1/1";
private static final String GPXTPX_NS = "http://www.garmin.com/xmlschemas/TrackPointExtension/v1";
private static final double ELEVATION_NOISE_THRESHOLD = 2.0; // Ignore elevation changes < 2m
private static final double STOPPED_SPEED_THRESHOLD = 0.5; // km/h - below this is considered stopped
private static final long STOPPED_TIME_THRESHOLD = 30; // seconds - must be stopped this long to count
// Lazy-loaded timezone engine (expensive to initialize)
private static TimeZoneEngine timezoneEngine = null;
private final SpeedSmoother speedSmoother;
/**
* Parses a GPX file and returns the extracted data.
*
* @param fileData the GPX file data
* @return ParsedActivityData containing activity information
* @throws GpxFileProcessingException if parsing fails
*/
public ParsedActivityData parse(byte[] fileData) {
try {
ParsedActivityData parsedData = new ParsedActivityData();
parsedData.setSourceFormat("GPX");
// Parse XML
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setNamespaceAware(true);
DocumentBuilder builder = factory.newDocumentBuilder();
Document doc = builder.parse(new ByteArrayInputStream(fileData));
// Extract track points
extractTrackPoints(doc, parsedData);
if (parsedData.getTrackPoints().isEmpty()) {
throw new GpxFileProcessingException("No GPS track points found in GPX file");
}
// Set start and end times from first and last track points
TrackPointData firstPoint = parsedData.getTrackPoints().get(0);
TrackPointData lastPoint = parsedData.getTrackPoints().get(parsedData.getTrackPoints().size() - 1);
parsedData.setStartTime(firstPoint.getTimestamp());
parsedData.setEndTime(lastPoint.getTimestamp());
// Calculate duration
parsedData.setTotalDuration(Duration.between(firstPoint.getTimestamp(), lastPoint.getTimestamp()));
// Extract activity type from metadata
extractActivityType(doc, parsedData);
// Determine timezone from first GPS coordinate
determineTimezone(parsedData);
// Calculate metrics from track points
calculateMetrics(parsedData);
// Apply speed smoothing
smoothSpeedData(parsedData);
log.info("Successfully parsed GPX file: {} track points, activity type: {}, timezone: {}",
parsedData.getTrackPoints().size(), parsedData.getActivityType(), parsedData.getTimezone());
return parsedData;
} catch (GpxFileProcessingException e) {
throw e;
} catch (Exception e) {
throw new GpxFileProcessingException("Failed to parse GPX file", e);
}
}
/**
* Extracts track points from GPX document.
*/
private void extractTrackPoints(Document doc, ParsedActivityData parsedData) {
NodeList tracks = doc.getElementsByTagName("trk");
if (tracks.getLength() == 0) {
tracks = doc.getElementsByTagNameNS("*", "trk");
}
for (int i = 0; i < tracks.getLength(); i++) {
Element track = (Element) tracks.item(i);
// Get track segments
NodeList segments = track.getElementsByTagName("trkseg");
if (segments.getLength() == 0) {
segments = track.getElementsByTagNameNS("*", "trkseg");
}
for (int j = 0; j < segments.getLength(); j++) {
Element segment = (Element) segments.item(j);
// Get track points
NodeList trkpts = segment.getElementsByTagName("trkpt");
if (trkpts.getLength() == 0) {
trkpts = segment.getElementsByTagNameNS("*", "trkpt");
}
for (int k = 0; k < trkpts.getLength(); k++) {
Element trkptElement = (Element) trkpts.item(k);
TrackPointData trackPoint = extractTrackPoint(trkptElement);
if (trackPoint != null) {
parsedData.getTrackPoints().add(trackPoint);
}
}
}
}
}
/**
* Extracts a single track point from a <trkpt> element.
*/
private TrackPointData extractTrackPoint(Element trkptElement) {
try {
TrackPointData point = new TrackPointData();
// Extract latitude and longitude from attributes
String latStr = trkptElement.getAttribute("lat");
String lonStr = trkptElement.getAttribute("lon");
if (latStr.isEmpty() || lonStr.isEmpty()) {
log.warn("Track point missing lat/lon attributes");
return null;
}
point.setLatitude(Double.parseDouble(latStr));
point.setLongitude(Double.parseDouble(lonStr));
// Extract elevation
String elevation = getElementText(trkptElement, "ele");
if (elevation != null) {
point.setElevation(new BigDecimal(elevation).setScale(2, RoundingMode.HALF_UP));
}
// Extract time
String time = getElementText(trkptElement, "time");
if (time != null) {
point.setTimestamp(parseIso8601DateTime(time));
}
// Extract extensions (heart rate, cadence, power, temperature)
extractExtensions(trkptElement, point);
return point;
} catch (Exception e) {
log.warn("Failed to extract track point: {}", e.getMessage());
return null;
}
}
/**
* Extracts Garmin/TrainingPeaks extensions from track point.
*/
private void extractExtensions(Element trkptElement, TrackPointData point) {
NodeList extensions = trkptElement.getElementsByTagName("extensions");
if (extensions.getLength() == 0) {
extensions = trkptElement.getElementsByTagNameNS("*", "extensions");
}
if (extensions.getLength() == 0) {
return; // No extensions
}
Element extensionsElement = (Element) extensions.item(0);
// Try Garmin TrackPointExtension namespace
NodeList tpx = extensionsElement.getElementsByTagNameNS(GPXTPX_NS, "TrackPointExtension");
if (tpx.getLength() == 0) {
// Try without namespace
tpx = extensionsElement.getElementsByTagName("TrackPointExtension");
}
if (tpx.getLength() > 0) {
Element tpxElement = (Element) tpx.item(0);
// Heart rate
String hr = getElementTextNS(tpxElement, GPXTPX_NS, "hr");
if (hr == null) hr = getElementText(tpxElement, "hr");
if (hr != null) {
point.setHeartRate(Integer.parseInt(hr));
}
// Cadence
String cad = getElementTextNS(tpxElement, GPXTPX_NS, "cad");
if (cad == null) cad = getElementText(tpxElement, "cad");
if (cad != null) {
point.setCadence(Integer.parseInt(cad));
}
// Temperature
String atemp = getElementTextNS(tpxElement, GPXTPX_NS, "atemp");
if (atemp == null) atemp = getElementText(tpxElement, "atemp");
if (atemp != null) {
point.setTemperature(new BigDecimal(atemp).setScale(2, RoundingMode.HALF_UP));
}
}
// Check for power extension (different namespace)
String power = getElementTextNS(extensionsElement, "*", "power");
if (power == null) power = getElementText(extensionsElement, "power");
if (power != null) {
point.setPower(Integer.parseInt(power));
}
}
/**
* Extracts activity type from GPX metadata.
*/
private void extractActivityType(Document doc, ParsedActivityData parsedData) {
NodeList tracks = doc.getElementsByTagName("trk");
if (tracks.getLength() == 0) {
tracks = doc.getElementsByTagNameNS("*", "trk");
}
if (tracks.getLength() > 0) {
Element track = (Element) tracks.item(0);
String type = getElementText(track, "type");
if (type != null) {
parsedData.setActivityType(mapGpxTypeToActivityType(type));
}
}
}
/**
* Maps GPX activity type string to ActivityType enum.
* GPX type field is not standardized, so we support common values.
*/
private Activity.ActivityType mapGpxTypeToActivityType(String gpxType) {
if (gpxType == null || gpxType.isEmpty()) {
return Activity.ActivityType.OTHER;
}
String type = gpxType.toLowerCase().trim();
// Running variations
if (type.contains("run") || type.equals("9")) {
return Activity.ActivityType.RUN;
}
// Cycling variations
if (type.contains("cycl") || type.contains("bik") || type.equals("1")) {
return Activity.ActivityType.RIDE;
}
// Hiking
if (type.contains("hik")) {
return Activity.ActivityType.HIKE;
}
// Walking
if (type.contains("walk")) {
return Activity.ActivityType.WALK;
}
// Swimming
if (type.contains("swim")) {
return Activity.ActivityType.SWIM;
}
// Rowing
if (type.contains("row")) {
return Activity.ActivityType.ROWING;
}
log.debug("Unknown GPX activity type '{}', defaulting to OTHER", gpxType);
return Activity.ActivityType.OTHER;
}
/**
* Calculates all metrics from track points.
* GPX files don't include session summaries, so we must calculate everything.
*/
private void calculateMetrics(ParsedActivityData parsedData) {
List<TrackPointData> points = parsedData.getTrackPoints();
if (points.isEmpty()) {
return;
}
ActivityMetricsData metrics = new ActivityMetricsData();
double totalDistance = 0;
double elevationGain = 0;
double elevationLoss = 0;
BigDecimal previousElevation = points.get(0).getElevation();
BigDecimal maxElevation = previousElevation;
BigDecimal minElevation = previousElevation;
List<BigDecimal> speeds = new ArrayList<>();
List<Integer> heartRates = new ArrayList<>();
List<Integer> cadences = new ArrayList<>();
List<Integer> powers = new ArrayList<>();
List<BigDecimal> temperatures = new ArrayList<>();
LocalDateTime lastStoppedTime = null;
Duration stoppedTime = Duration.ZERO;
Duration movingTime = Duration.ZERO;
// Iterate through consecutive pairs of points
for (int i = 1; i < points.size(); i++) {
TrackPointData prev = points.get(i - 1);
TrackPointData curr = points.get(i);
// Calculate distance between points (Haversine formula)
double distance = calculateDistance(prev, curr);
totalDistance += distance;
// Calculate speed (km/h)
if (prev.getTimestamp() != null && curr.getTimestamp() != null) {
Duration timeDelta = Duration.between(prev.getTimestamp(), curr.getTimestamp());
long seconds = timeDelta.getSeconds();
if (seconds > 0) {
double speedKmh = (distance / 1000.0) / (seconds / 3600.0);
BigDecimal speed = BigDecimal.valueOf(speedKmh).setScale(2, RoundingMode.HALF_UP);
curr.setSpeed(speed);
speeds.add(speed);
// Track moving vs stopped time
if (speedKmh < STOPPED_SPEED_THRESHOLD) {
if (lastStoppedTime == null) {
lastStoppedTime = prev.getTimestamp();
}
Duration currentStopDuration = Duration.between(lastStoppedTime, curr.getTimestamp());
if (currentStopDuration.getSeconds() > STOPPED_TIME_THRESHOLD) {
stoppedTime = stoppedTime.plus(timeDelta);
}
} else {
lastStoppedTime = null;
movingTime = movingTime.plus(timeDelta);
}
}
}
// Set cumulative distance
curr.setDistance(BigDecimal.valueOf(totalDistance).setScale(2, RoundingMode.HALF_UP));
// Calculate elevation gain/loss
if (curr.getElevation() != null && previousElevation != null) {
double elevDelta = curr.getElevation().subtract(previousElevation).doubleValue();
// Ignore noise (GPS elevation is noisy)
if (Math.abs(elevDelta) > ELEVATION_NOISE_THRESHOLD) {
if (elevDelta > 0) {
elevationGain += elevDelta;
} else {
elevationLoss += Math.abs(elevDelta);
}
}
// Track min/max elevation
if (maxElevation == null || curr.getElevation().compareTo(maxElevation) > 0) {
maxElevation = curr.getElevation();
}
if (minElevation == null || curr.getElevation().compareTo(minElevation) < 0) {
minElevation = curr.getElevation();
}
previousElevation = curr.getElevation();
}
// Collect sensor data
if (curr.getHeartRate() != null) {
heartRates.add(curr.getHeartRate());
}
if (curr.getCadence() != null) {
cadences.add(curr.getCadence());
}
if (curr.getPower() != null) {
powers.add(curr.getPower());
}
if (curr.getTemperature() != null) {
temperatures.add(curr.getTemperature());
}
}
// Set calculated values in parsedData
parsedData.setTotalDistance(BigDecimal.valueOf(totalDistance).setScale(2, RoundingMode.HALF_UP));
parsedData.setElevationGain(BigDecimal.valueOf(elevationGain).setScale(2, RoundingMode.HALF_UP));
parsedData.setElevationLoss(BigDecimal.valueOf(elevationLoss).setScale(2, RoundingMode.HALF_UP));
// Calculate average and max values
if (!speeds.isEmpty()) {
metrics.setAverageSpeed(calculateAverage(speeds));
metrics.setMaxSpeed(speeds.stream().max(BigDecimal::compareTo).orElse(null));
}
if (!heartRates.isEmpty()) {
metrics.setAverageHeartRate(calculateAverageInt(heartRates));
metrics.setMaxHeartRate(heartRates.stream().max(Integer::compareTo).orElse(null));
}
if (!cadences.isEmpty()) {
metrics.setAverageCadence(calculateAverageInt(cadences));
metrics.setMaxCadence(cadences.stream().max(Integer::compareTo).orElse(null));
}
if (!powers.isEmpty()) {
metrics.setAveragePower(calculateAverageInt(powers));
metrics.setMaxPower(powers.stream().max(Integer::compareTo).orElse(null));
}
if (!temperatures.isEmpty()) {
metrics.setAverageTemperature(calculateAverage(temperatures));
}
metrics.setMaxElevation(maxElevation);
metrics.setMinElevation(minElevation);
metrics.setTotalAscent(BigDecimal.valueOf(elevationGain).setScale(2, RoundingMode.HALF_UP));
metrics.setTotalDescent(BigDecimal.valueOf(elevationLoss).setScale(2, RoundingMode.HALF_UP));
metrics.setMovingTime(movingTime);
metrics.setStoppedTime(stoppedTime);
parsedData.setMetrics(metrics);
}
/**
* Calculates distance between two GPS points using Haversine formula.
* Returns distance in meters.
*/
private double calculateDistance(TrackPointData p1, TrackPointData p2) {
final double EARTH_RADIUS = 6371000; // meters
double lat1 = Math.toRadians(p1.getLatitude());
double lat2 = Math.toRadians(p2.getLatitude());
double deltaLat = Math.toRadians(p2.getLatitude() - p1.getLatitude());
double deltaLon = Math.toRadians(p2.getLongitude() - p1.getLongitude());
double a = Math.sin(deltaLat / 2) * Math.sin(deltaLat / 2) +
Math.cos(lat1) * Math.cos(lat2) *
Math.sin(deltaLon / 2) * Math.sin(deltaLon / 2);
double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return EARTH_RADIUS * c;
}
/**
* Determines the timezone based on the first GPS coordinate.
*/
private void determineTimezone(ParsedActivityData parsedData) {
if (parsedData.getTrackPoints().isEmpty()) {
parsedData.setTimezone("UTC");
return;
}
TrackPointData firstPoint = parsedData.getTrackPoints().get(0);
double latitude = firstPoint.getLatitude();
double longitude = firstPoint.getLongitude();
try {
// Lazy-load timezone engine (expensive initialization ~200ms first time)
if (timezoneEngine == null) {
log.info("Initializing TimeZoneEngine for timezone lookup...");
timezoneEngine = TimeZoneEngine.initialize();
}
Optional<ZoneId> zoneId = timezoneEngine.query(latitude, longitude);
if (zoneId.isPresent()) {
parsedData.setTimezone(zoneId.get().getId());
log.debug("Determined timezone: {} from coordinates ({}, {})",
zoneId.get().getId(), latitude, longitude);
} else {
log.warn("Could not determine timezone for coordinates ({}, {}), defaulting to UTC",
latitude, longitude);
parsedData.setTimezone("UTC");
}
} catch (Exception e) {
log.error("Error determining timezone, defaulting to UTC", e);
parsedData.setTimezone("UTC");
}
}
/**
* Applies speed smoothing to track points and updates max speed in metrics.
*/
private void smoothSpeedData(ParsedActivityData parsedData) {
if (parsedData.getTrackPoints().isEmpty() || parsedData.getMetrics() == null) {
return;
}
// Smooth speed data and get recalculated max speed
BigDecimal smoothedMaxSpeed = speedSmoother.smoothAndCalculateMaxSpeed(
parsedData.getTrackPoints(),
parsedData.getActivityType()
);
// Update max speed in metrics if we got a valid smoothed value
if (smoothedMaxSpeed != null) {
BigDecimal originalMaxSpeed = parsedData.getMetrics().getMaxSpeed();
parsedData.getMetrics().setMaxSpeed(smoothedMaxSpeed);
if (originalMaxSpeed != null && smoothedMaxSpeed.compareTo(originalMaxSpeed) < 0) {
log.info("Smoothed max speed from {} km/h to {} km/h (removed GPS artifacts)",
originalMaxSpeed, smoothedMaxSpeed);
}
}
}
/**
* Parses ISO 8601 datetime string (GPX standard).
*/
private LocalDateTime parseIso8601DateTime(String dateTimeStr) {
try {
return LocalDateTime.parse(dateTimeStr, DateTimeFormatter.ISO_DATE_TIME);
} catch (Exception e) {
log.warn("Failed to parse datetime: {}", dateTimeStr);
return null;
}
}
/**
* Gets text content of child element by tag name.
*/
private String getElementText(Element parent, String tagName) {
NodeList nodes = parent.getElementsByTagName(tagName);
if (nodes.getLength() == 0) {
nodes = parent.getElementsByTagNameNS("*", tagName);
}
if (nodes.getLength() > 0) {
Node node = nodes.item(0);
String text = node.getTextContent();
return (text != null && !text.trim().isEmpty()) ? text.trim() : null;
}
return null;
}
/**
* Gets text content of child element by namespace and tag name.
*/
private String getElementTextNS(Element parent, String namespace, String tagName) {
NodeList nodes = "*".equals(namespace)
? parent.getElementsByTagNameNS("*", tagName)
: parent.getElementsByTagNameNS(namespace, tagName);
if (nodes.getLength() > 0) {
Node node = nodes.item(0);
String text = node.getTextContent();
return (text != null && !text.trim().isEmpty()) ? text.trim() : null;
}
return null;
}
/**
* Calculates average of BigDecimal list.
*/
private BigDecimal calculateAverage(List<BigDecimal> values) {
if (values.isEmpty()) {
return null;
}
BigDecimal sum = values.stream()
.reduce(BigDecimal.ZERO, BigDecimal::add);
return sum.divide(BigDecimal.valueOf(values.size()), 2, RoundingMode.HALF_UP);
}
/**
* Calculates average of Integer list.
*/
private Integer calculateAverageInt(List<Integer> values) {
if (values.isEmpty()) {
return null;
}
double sum = values.stream()
.mapToInt(Integer::intValue)
.average()
.orElse(0);
return (int) Math.round(sum);
}
}

View file

@ -0,0 +1,111 @@
package org.operaton.fitpub.util;
import lombok.Data;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.Point;
import org.locationtech.jts.geom.PrecisionModel;
import org.operaton.fitpub.model.entity.Activity;
import org.operaton.fitpub.model.entity.ActivityMetrics;
import java.math.BigDecimal;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* Common data structure for parsed activity files (FIT, GPX, etc.).
* Contains track points, metrics, and metadata extracted from the file.
*/
@Data
public class ParsedActivityData {
private List<TrackPointData> trackPoints = new ArrayList<>();
private LocalDateTime startTime;
private LocalDateTime endTime;
private LocalDateTime activityTimestamp;
private String timezone; // IANA timezone ID (e.g., "Europe/Berlin")
private BigDecimal totalDistance;
private Duration totalDuration;
private BigDecimal elevationGain;
private BigDecimal elevationLoss;
private Activity.ActivityType activityType = Activity.ActivityType.OTHER;
private ActivityMetricsData metrics;
private String sourceFormat; // "FIT" or "GPX"
/**
* Data class for track point information.
*/
@Data
public static class TrackPointData {
private static final int WGS84_SRID = 4326;
private static final GeometryFactory GEOMETRY_FACTORY =
new GeometryFactory(new PrecisionModel(), WGS84_SRID);
private LocalDateTime timestamp;
private double latitude;
private double longitude;
private BigDecimal elevation;
private Integer heartRate;
private Integer cadence;
private Integer power;
private BigDecimal speed;
private BigDecimal temperature;
private BigDecimal distance;
public Point toGeometry() {
return GEOMETRY_FACTORY.createPoint(new Coordinate(longitude, latitude));
}
}
/**
* Data class for activity metrics.
*/
@Data
public static class ActivityMetricsData {
private BigDecimal averageSpeed;
private BigDecimal maxSpeed;
private Duration averagePace;
private Integer averageHeartRate;
private Integer maxHeartRate;
private Integer averageCadence;
private Integer maxCadence;
private Integer averagePower;
private Integer maxPower;
private Integer normalizedPower;
private Integer calories;
private BigDecimal averageTemperature;
private BigDecimal maxElevation;
private BigDecimal minElevation;
private BigDecimal totalAscent;
private BigDecimal totalDescent;
private Duration movingTime;
private Duration stoppedTime;
private Integer totalSteps;
public ActivityMetrics toEntity(Activity activity) {
return ActivityMetrics.builder()
.activity(activity)
.averageSpeed(averageSpeed)
.maxSpeed(maxSpeed)
.averagePaceSeconds(averagePace != null ? averagePace.getSeconds() : null)
.averageHeartRate(averageHeartRate)
.maxHeartRate(maxHeartRate)
.averageCadence(averageCadence)
.maxCadence(maxCadence)
.averagePower(averagePower)
.maxPower(maxPower)
.normalizedPower(normalizedPower)
.calories(calories)
.averageTemperature(averageTemperature)
.maxElevation(maxElevation)
.minElevation(minElevation)
.totalAscent(totalAscent)
.totalDescent(totalDescent)
.movingTimeSeconds(movingTime != null ? movingTime.getSeconds() : null)
.stoppedTimeSeconds(stoppedTime != null ? stoppedTime.getSeconds() : null)
.totalSteps(totalSteps)
.build();
}
}
}

View file

@ -43,7 +43,7 @@ public class SpeedSmoother {
* @return smoothed maximum speed in km/h, or null if no valid speeds
*/
public BigDecimal smoothAndCalculateMaxSpeed(
List<FitParser.TrackPointData> trackPoints,
List<ParsedActivityData.TrackPointData> trackPoints,
Activity.ActivityType activityType
) {
if (trackPoints == null || trackPoints.isEmpty()) {
@ -110,10 +110,10 @@ public class SpeedSmoother {
* Applies speed threshold to remove obvious outliers.
* Replaces speeds exceeding threshold with null.
*/
private void applySpeedThreshold(List<FitParser.TrackPointData> trackPoints, double maxSpeedMps) {
private void applySpeedThreshold(List<ParsedActivityData.TrackPointData> trackPoints, double maxSpeedMps) {
int outlierCount = 0;
for (FitParser.TrackPointData point : trackPoints) {
for (ParsedActivityData.TrackPointData point : trackPoints) {
if (point.getSpeed() != null) {
// Convert km/h to m/s for comparison
double speedMps = point.getSpeed().doubleValue() / 3.6;
@ -136,14 +136,14 @@ public class SpeedSmoother {
* Removes points with unrealistic acceleration changes.
*/
private void applyAccelerationFilter(
List<FitParser.TrackPointData> trackPoints,
List<ParsedActivityData.TrackPointData> trackPoints,
double maxAcceleration
) {
int outlierCount = 0;
for (int i = 1; i < trackPoints.size(); i++) {
FitParser.TrackPointData prev = trackPoints.get(i - 1);
FitParser.TrackPointData curr = trackPoints.get(i);
ParsedActivityData.TrackPointData prev = trackPoints.get(i - 1);
ParsedActivityData.TrackPointData curr = trackPoints.get(i);
if (prev.getSpeed() == null || curr.getSpeed() == null ||
prev.getTimestamp() == null || curr.getTimestamp() == null) {
@ -180,7 +180,7 @@ public class SpeedSmoother {
* Applies moving median filter to smooth speed data.
* Fills in nulls with interpolated values where possible.
*/
private void applyMedianFilter(List<FitParser.TrackPointData> trackPoints) {
private void applyMedianFilter(List<ParsedActivityData.TrackPointData> trackPoints) {
int windowSize = Math.min(MEDIAN_WINDOW_SIZE, trackPoints.size());
if (windowSize < 3) {
return; // Not enough points for meaningful smoothing
@ -230,9 +230,9 @@ public class SpeedSmoother {
/**
* Calculates maximum speed from smoothed track points.
*/
private BigDecimal calculateMaxSpeed(List<FitParser.TrackPointData> trackPoints) {
private BigDecimal calculateMaxSpeed(List<ParsedActivityData.TrackPointData> trackPoints) {
return trackPoints.stream()
.map(FitParser.TrackPointData::getSpeed)
.map(ParsedActivityData.TrackPointData::getSpeed)
.filter(speed -> speed != null)
.max(BigDecimal::compareTo)
.orElse(null);

View file

@ -0,0 +1,25 @@
-- Migration to support GPX files in addition to FIT files
-- Renames raw_fit_file column and adds source_file_format tracking
-- Rename raw_fit_file column to raw_activity_file (more generic name)
ALTER TABLE activities RENAME COLUMN raw_fit_file TO raw_activity_file;
-- Add source_file_format column to track the original file format
ALTER TABLE activities ADD COLUMN source_file_format VARCHAR(10) DEFAULT 'FIT';
-- Backfill existing records (all existing activities are from FIT files)
UPDATE activities SET source_file_format = 'FIT' WHERE source_file_format IS NULL;
-- Make source_file_format NOT NULL now that all records are backfilled
ALTER TABLE activities ALTER COLUMN source_file_format SET NOT NULL;
-- Add check constraint to ensure only valid formats are stored
ALTER TABLE activities ADD CONSTRAINT chk_source_file_format
CHECK (source_file_format IN ('FIT', 'GPX'));
-- Add index for faster filtering by format (optional but helpful for analytics)
CREATE INDEX idx_activities_source_format ON activities(source_file_format);
-- Add comment for documentation
COMMENT ON COLUMN activities.source_file_format IS 'Original file format: FIT (Garmin/Wahoo devices) or GPX (GPS Exchange Format)';
COMMENT ON COLUMN activities.raw_activity_file IS 'Raw activity file bytes for re-processing with updated algorithms';

View file

@ -37,27 +37,27 @@
<!-- File Upload Area -->
<div class="mb-4">
<label class="form-label fw-bold">
FIT File <span class="text-danger">*</span>
Activity File <span class="text-danger">*</span>
</label>
<div class="file-upload-area" id="fileUploadArea">
<input type="file"
id="fitFile"
name="file"
accept=".fit"
accept=".fit,.gpx"
class="d-none"
required>
<div class="file-upload-icon">
<i class="bi bi-cloud-arrow-up"></i>
</div>
<p class="mb-2"><strong>Drop your FIT file here</strong></p>
<p class="mb-2"><strong>Drop your FIT or GPX file here</strong></p>
<p class="text-muted mb-2">or click to browse</p>
<p class="file-upload-label text-primary fw-bold" id="fileLabel">
No file selected
</p>
<small class="text-muted">Supported: .fit files from Garmin, Wahoo, etc. (Max 50MB)</small>
<small class="text-muted">Supported: .fit, .gpx files (Max 50MB)</small>
</div>
<div class="invalid-feedback">
Please select a FIT file to upload.
Please select a FIT or GPX file to upload.
</div>
</div>
@ -164,7 +164,8 @@
<div class="card-body">
<h6><i class="bi bi-lightbulb text-warning"></i> Upload Tips</h6>
<ul class="mb-0 small">
<li>FIT files can be exported from Garmin Connect, Strava, Wahoo, and most GPS devices</li>
<li>FIT files from Garmin, Wahoo, and most GPS devices</li>
<li>GPX files exported from Strava, Komoot, or other apps</li>
<li>The activity will be processed to extract GPS tracks, metrics, and statistics</li>
<li>You can add a title and description after uploading</li>
<li>Public activities will appear in your followers' timelines on the Fediverse</li>