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

@ -7,7 +7,7 @@ FitPub is a decentralized fitness tracking application that integrates with the
## Core Concept
The platform bridges the gap between fitness tracking and social networking by leveraging the open ActivityPub standard. Users can:
- Upload FIT files from GPS-enabled fitness devices (Garmin, Wahoo, etc.)
- Upload FIT files from GPS-enabled fitness devices (Garmin, Wahoo, etc.) or GPX files from apps (Strava, Komoot, etc.)
- View their tracks rendered on interactive maps
- Share activities with followers on Mastodon, Pleroma, and other Fediverse platforms
- Follow other athletes and see their public workouts
@ -508,14 +508,17 @@ For ActivityPub federated posts and thumbnails:
### Phase 1: MVP (Minimum Viable Product)
**System Component 1: FIT File Processing Module** ✅
**System Component 1: Activity File Processing Module** ✅
- [x] FIT file upload and parsing (FitParser)
- [x] FIT file validation (FitFileValidator)
- [x] GPX file upload and parsing (GpxParser)
- [x] GPX file validation (GpxFileValidator)
- [x] Unified ActivityFileService with automatic format detection
- [x] Activity entity with JSONB track points and simplified LineString
- [x] Activity metrics extraction and storage
- [x] Activity metrics extraction and storage (calculated from track points for GPX)
- [x] Track simplification using Douglas-Peucker algorithm
- [x] FIT file service with comprehensive tests
- [x] Integration test with real FIT file
- [x] File service with comprehensive tests
- [x] Integration tests with real FIT and GPX files
**User Management & Security** ✅
- [x] User entity with ActivityPub keys
@ -538,7 +541,7 @@ For ActivityPub federated posts and thumbnails:
- [x] Profile-specific configs (application-dev.yml, application-prod.yml)
**Activity REST API** ✅
- [x] POST /api/activities/upload - Upload FIT file
- [x] POST /api/activities/upload - Upload FIT or GPX file (automatic format detection)
- [x] GET /api/activities/{id} - Get activity details
- [x] GET /api/activities - List user's activities (paginated)
- [x] PUT /api/activities/{id} - Update activity metadata
@ -636,7 +639,7 @@ For ActivityPub federated posts and thumbnails:
- [x] Authenticated API fetch helper (authenticatedFetch in auth.js)
**Activity Upload & Management UI** ✅
- [x] FIT file upload form with drag-and-drop
- [x] Activity file upload form with drag-and-drop (FIT and GPX)
- [x] Upload progress indicator
- [x] Activity metadata form (title, description, visibility)
- [x] Activity list view (user's own activities)
@ -750,7 +753,7 @@ For ActivityPub federated posts and thumbnails:
## Phase 1 (MVP) - ✅ COMPLETE!
**All core features implemented and working:**
- ✅ FIT file upload and processing
- ✅ FIT and GPX file upload and processing (automatic format detection)
- ✅ GPS track visualization with Leaflet maps
- ✅ Activity management (CRUD operations)
- ✅ User authentication and profiles
@ -849,11 +852,12 @@ For ActivityPub federated posts and thumbnails:
- [x] Cross-platform activity sync
### Phase 5: Mobile & Integrations
- [x] GPX file support (completed - import from Strava, Komoot, etc. via GPX export)
- [ ] Progressive Web App (PWA)
- [ ] Native mobile apps (optional)
- [ ] Direct device sync (Garmin Connect API)
- [ ] Webhook integrations
- [ ] Import from Strava, Garmin, etc.
- [ ] TCX file support
- [ ] Avatar file upload (currently URL-based)
### Phase 6: Testing & Documentation

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>

View file

@ -10,6 +10,7 @@ import org.operaton.fitpub.model.entity.User;
import org.operaton.fitpub.repository.ActivityRepository;
import org.operaton.fitpub.repository.UserRepository;
import org.operaton.fitpub.util.FitParser;
import org.operaton.fitpub.util.ParsedActivityData;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
@ -67,7 +68,7 @@ class ActivityImageServiceTest {
assertTrue(fitFileData.length > 0, "FIT file should not be empty");
// Parse FIT file
FitParser.ParsedFitData parsedData = fitParser.parse(fitFileData);
ParsedActivityData parsedData = fitParser.parse(fitFileData);
assertNotNull(parsedData);
assertNotNull(parsedData.getStartTime());
assertNotNull(parsedData.getEndTime());
@ -182,7 +183,7 @@ class ActivityImageServiceTest {
/**
* Helper method to convert parsed track points to JSON format.
*/
private String convertTrackPointsToJson(FitParser.ParsedFitData parsedData) throws IOException {
private String convertTrackPointsToJson(ParsedActivityData parsedData) throws IOException {
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
mapper.registerModule(new com.fasterxml.jackson.datatype.jsr310.JavaTimeModule());

View file

@ -19,6 +19,7 @@ import org.operaton.fitpub.repository.ActivityMetricsRepository;
import org.operaton.fitpub.repository.ActivityRepository;
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.mock.web.MockMultipartFile;
@ -81,7 +82,7 @@ class FitFileServiceTest {
private UUID testUserId;
private MockMultipartFile testFile;
private FitParser.ParsedFitData testParsedData;
private ParsedActivityData testParsedData;
@BeforeEach
void setUp() {
@ -512,8 +513,8 @@ class FitFileServiceTest {
/**
* Creates test parsed FIT data with realistic values.
*/
private FitParser.ParsedFitData createTestParsedData() {
FitParser.ParsedFitData data = new FitParser.ParsedFitData();
private ParsedActivityData createTestParsedData() {
ParsedActivityData data = new ParsedActivityData();
LocalDateTime startTime = LocalDateTime.of(2024, 1, 15, 8, 0, 0);
data.setStartTime(startTime);
@ -525,10 +526,10 @@ class FitFileServiceTest {
data.setElevationLoss(BigDecimal.valueOf(95.0));
// Add test track points
List<FitParser.TrackPointData> trackPoints = new ArrayList<>();
List<ParsedActivityData.TrackPointData> trackPoints = new ArrayList<>();
for (int i = 0; i < 10; i++) {
FitParser.TrackPointData tp = new FitParser.TrackPointData();
ParsedActivityData.TrackPointData tp = new ParsedActivityData.TrackPointData();
tp.setTimestamp(startTime.plusMinutes(i * 3));
tp.setLatitude(47.0 + i * 0.001);
tp.setLongitude(8.0 + i * 0.001);
@ -541,7 +542,7 @@ class FitFileServiceTest {
data.setTrackPoints(trackPoints);
// Add test metrics
FitParser.ActivityMetricsData metrics = new FitParser.ActivityMetricsData();
ParsedActivityData.ActivityMetricsData metrics = new ParsedActivityData.ActivityMetricsData();
metrics.setAverageSpeed(BigDecimal.valueOf(10.0));
metrics.setMaxSpeed(BigDecimal.valueOf(15.0));
metrics.setAverageHeartRate(150);

View file

@ -280,8 +280,9 @@ class TrainingLoadServiceTest {
// Given
int days = 30;
LocalDate startDate = LocalDate.now().minusDays(days - 1);
LocalDate recentDate = LocalDate.now().minusDays(5); // Use a date within the last 30 days
List<TrainingLoad> existingLoad = List.of(
createTrainingLoad(userId, testDate, BigDecimal.valueOf(100.0))
createTrainingLoad(userId, recentDate, BigDecimal.valueOf(100.0))
);
when(trainingLoadRepository.findByUserIdSinceDate(userId, startDate))
@ -297,7 +298,7 @@ class TrainingLoadServiceTest {
// Verify that the existing load is included
assertTrue(result.stream().anyMatch(tl ->
tl.getDate().equals(testDate) &&
tl.getDate().equals(recentDate) &&
tl.getTrainingStressScore() != null &&
tl.getTrainingStressScore().compareTo(BigDecimal.valueOf(100.0)) == 0
));

View file

@ -46,7 +46,7 @@ class FitParserIntegrationTest {
"Real FIT file should pass validation");
// Parse the file
FitParser.ParsedFitData parsedData = assertDoesNotThrow(
ParsedActivityData parsedData = assertDoesNotThrow(
() -> parser.parse(fileData),
"Real FIT file should parse without errors"
);
@ -93,7 +93,7 @@ class FitParserIntegrationTest {
assertTrue(parsedData.getTrackPoints().size() > 0, "Should have at least one track point");
// Verify track point data quality
FitParser.TrackPointData firstPoint = parsedData.getTrackPoints().get(0);
ParsedActivityData.TrackPointData firstPoint = parsedData.getTrackPoints().get(0);
assertNotNull(firstPoint, "First track point should not be null");
assertNotEquals(0.0, firstPoint.getLatitude(), "Latitude should be set");
assertNotEquals(0.0, firstPoint.getLongitude(), "Longitude should be set");
@ -116,7 +116,7 @@ class FitParserIntegrationTest {
// Verify metrics if present
if (parsedData.getMetrics() != null) {
FitParser.ActivityMetricsData metrics = parsedData.getMetrics();
ParsedActivityData.ActivityMetricsData metrics = parsedData.getMetrics();
log.info("Metrics:");
if (metrics.getAverageSpeed() != null) {
@ -159,7 +159,7 @@ class FitParserIntegrationTest {
inputStream.close();
// Parse the file
FitParser.ParsedFitData parsedData = parser.parse(fileData);
ParsedActivityData parsedData = parser.parse(fileData);
// Test converting to entity structures
Activity.ActivityType activityType = parsedData.getActivityType();
@ -167,7 +167,7 @@ class FitParserIntegrationTest {
// Verify we can convert track points to entities
if (!parsedData.getTrackPoints().isEmpty()) {
FitParser.TrackPointData trackPointData = parsedData.getTrackPoints().get(0);
ParsedActivityData.TrackPointData trackPointData = parsedData.getTrackPoints().get(0);
// Test geometry creation
assertDoesNotThrow(() -> trackPointData.toGeometry(),
@ -209,13 +209,13 @@ class FitParserIntegrationTest {
byte[] fileData = inputStream.readAllBytes();
inputStream.close();
FitParser.ParsedFitData parsedData = parser.parse(fileData);
ParsedActivityData parsedData = parser.parse(fileData);
// Verify track points are in chronological order
if (parsedData.getTrackPoints().size() > 1) {
for (int i = 0; i < parsedData.getTrackPoints().size() - 1; i++) {
FitParser.TrackPointData current = parsedData.getTrackPoints().get(i);
FitParser.TrackPointData next = parsedData.getTrackPoints().get(i + 1);
ParsedActivityData.TrackPointData current = parsedData.getTrackPoints().get(i);
ParsedActivityData.TrackPointData next = parsedData.getTrackPoints().get(i + 1);
if (current.getTimestamp() != null && next.getTimestamp() != null) {
assertTrue(

View file

@ -0,0 +1,427 @@
package org.operaton.fitpub.util;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.DisplayName;
import org.operaton.fitpub.model.entity.Activity;
import org.operaton.fitpub.util.ParsedActivityData.TrackPointData;
import org.operaton.fitpub.util.ParsedActivityData.ActivityMetricsData;
import java.io.IOException;
import java.io.InputStream;
import java.math.BigDecimal;
import static org.junit.jupiter.api.Assertions.*;
/**
* Integration test for GpxParser using a real GPX file.
* Tests parsing of GPX files exported from Strava with Garmin extensions.
*/
@Slf4j
class GpxParserIntegrationTest {
private GpxParser parser;
private GpxFileValidator validator;
private SpeedSmoother speedSmoother;
@BeforeEach
void setUp() {
speedSmoother = new SpeedSmoother();
parser = new GpxParser(speedSmoother);
validator = new GpxFileValidator();
}
@Test
@DisplayName("Should successfully parse real GPX file from test resources")
void testParseRealGpxFile() throws IOException {
// Load the real GPX file from test resources
String gpxFileName = "/7410863774.gpx";
InputStream inputStream = getClass().getResourceAsStream(gpxFileName);
assertNotNull(inputStream, "GPX file should exist in test resources: " + gpxFileName);
// Read file into byte array
byte[] fileData = inputStream.readAllBytes();
inputStream.close();
// Validate the file
assertDoesNotThrow(() -> validator.validate(fileData),
"Real GPX file should pass validation");
// Parse the file
ParsedActivityData parsedData = assertDoesNotThrow(
() -> parser.parse(fileData),
"Real GPX file should parse without errors"
);
// Verify parsed data structure
assertNotNull(parsedData, "Parsed data should not be null");
assertEquals("GPX", parsedData.getSourceFormat(), "Source format should be GPX");
// Verify track points
assertNotNull(parsedData.getTrackPoints(), "Track points should not be null");
assertFalse(parsedData.getTrackPoints().isEmpty(), "Track points should not be empty");
log.info("Successfully parsed real GPX file:");
log.info(" Track points: {}", parsedData.getTrackPoints().size());
log.info(" Activity type: {}", parsedData.getActivityType());
if (parsedData.getStartTime() != null) {
log.info(" Start time: {}", parsedData.getStartTime());
}
if (parsedData.getEndTime() != null) {
log.info(" End time: {}", parsedData.getEndTime());
}
if (parsedData.getTotalDistance() != null) {
log.info(" Total distance: {} meters", parsedData.getTotalDistance());
}
if (parsedData.getTotalDuration() != null) {
long minutes = parsedData.getTotalDuration().toMinutes();
long seconds = parsedData.getTotalDuration().getSeconds() % 60;
log.info(" Total duration: {}m {}s", minutes, seconds);
}
if (parsedData.getElevationGain() != null) {
log.info(" Elevation gain: {} meters", parsedData.getElevationGain());
}
if (parsedData.getElevationLoss() != null) {
log.info(" Elevation loss: {} meters", parsedData.getElevationLoss());
}
if (parsedData.getTimezone() != null) {
log.info(" Timezone: {}", parsedData.getTimezone());
}
// Verify at least some basic data
assertNotNull(parsedData.getActivityType(), "Activity type should be determined");
assertEquals(Activity.ActivityType.RUN, parsedData.getActivityType(),
"Activity type should be RUN (from GPX <type>running</type>)");
assertTrue(parsedData.getTrackPoints().size() > 0, "Should have at least one track point");
// Verify track point data quality
TrackPointData firstPoint = parsedData.getTrackPoints().get(0);
assertNotNull(firstPoint, "First track point should not be null");
assertNotEquals(0.0, firstPoint.getLatitude(), "Latitude should be set");
assertNotEquals(0.0, firstPoint.getLongitude(), "Longitude should be set");
// Verify GPS coordinates are in valid range
assertTrue(firstPoint.getLatitude() >= -90 && firstPoint.getLatitude() <= 90,
"Latitude should be in valid range (-90 to 90)");
assertTrue(firstPoint.getLongitude() >= -180 && firstPoint.getLongitude() <= 180,
"Longitude should be in valid range (-180 to 180)");
log.info(" First point: lat={}, lon={}", firstPoint.getLatitude(), firstPoint.getLongitude());
if (firstPoint.getElevation() != null) {
log.info(" First point elevation: {} meters", firstPoint.getElevation());
}
if (firstPoint.getHeartRate() != null) {
log.info(" First point heart rate: {} bpm", firstPoint.getHeartRate());
}
// Verify calculated metrics (GPX doesn't have session summaries, so we calculate them)
if (parsedData.getMetrics() != null) {
ActivityMetricsData metrics = parsedData.getMetrics();
log.info("Calculated Metrics:");
if (metrics.getAverageSpeed() != null) {
log.info(" Average speed: {} km/h", metrics.getAverageSpeed());
assertTrue(metrics.getAverageSpeed().compareTo(BigDecimal.ZERO) > 0,
"Average speed should be positive");
}
if (metrics.getMaxSpeed() != null) {
log.info(" Max speed: {} km/h", metrics.getMaxSpeed());
assertTrue(metrics.getMaxSpeed().compareTo(BigDecimal.ZERO) > 0,
"Max speed should be positive");
}
if (metrics.getAverageHeartRate() != null) {
log.info(" Average heart rate: {} bpm", metrics.getAverageHeartRate());
assertTrue(metrics.getAverageHeartRate() > 0 && metrics.getAverageHeartRate() < 220,
"Average heart rate should be in reasonable range");
}
if (metrics.getMaxHeartRate() != null) {
log.info(" Max heart rate: {} bpm", metrics.getMaxHeartRate());
assertTrue(metrics.getMaxHeartRate() > 0 && metrics.getMaxHeartRate() < 220,
"Max heart rate should be in reasonable range");
}
if (metrics.getMinElevation() != null) {
log.info(" Min elevation: {} meters", metrics.getMinElevation());
}
if (metrics.getMaxElevation() != null) {
log.info(" Max elevation: {} meters", metrics.getMaxElevation());
}
if (metrics.getMovingTime() != null) {
log.info(" Moving time: {} seconds", metrics.getMovingTime().getSeconds());
}
if (metrics.getStoppedTime() != null) {
log.info(" Stopped time: {} seconds", metrics.getStoppedTime().getSeconds());
}
}
}
@Test
@DisplayName("Should extract heart rate data from Garmin extensions")
void testExtractHeartRateFromExtensions() throws IOException {
// Load the real GPX file
String gpxFileName = "/7410863774.gpx";
InputStream inputStream = getClass().getResourceAsStream(gpxFileName);
byte[] fileData = inputStream.readAllBytes();
inputStream.close();
// Parse the file
ParsedActivityData parsedData = parser.parse(fileData);
// Verify heart rate data is extracted from extensions
long pointsWithHeartRate = parsedData.getTrackPoints().stream()
.filter(tp -> tp.getHeartRate() != null)
.count();
assertTrue(pointsWithHeartRate > 0,
"Should have extracted heart rate data from Garmin TrackPointExtension");
log.info("Points with heart rate data: {} out of {}",
pointsWithHeartRate, parsedData.getTrackPoints().size());
// Verify first point has heart rate
TrackPointData firstPoint = parsedData.getTrackPoints().get(0);
assertNotNull(firstPoint.getHeartRate(),
"First point should have heart rate from extension");
assertTrue(firstPoint.getHeartRate() > 0 && firstPoint.getHeartRate() < 220,
"Heart rate should be in reasonable range (0-220 bpm)");
log.info("First point heart rate: {} bpm", firstPoint.getHeartRate());
}
@Test
@DisplayName("Should calculate distance from GPS coordinates using Haversine formula")
void testCalculateDistanceFromCoordinates() throws IOException {
// Load the real GPX file
String gpxFileName = "/7410863774.gpx";
InputStream inputStream = getClass().getResourceAsStream(gpxFileName);
byte[] fileData = inputStream.readAllBytes();
inputStream.close();
ParsedActivityData parsedData = parser.parse(fileData);
// Verify total distance was calculated
assertNotNull(parsedData.getTotalDistance(), "Total distance should be calculated");
assertTrue(parsedData.getTotalDistance().compareTo(BigDecimal.ZERO) > 0,
"Total distance should be positive");
log.info("Calculated total distance: {} meters", parsedData.getTotalDistance());
// Verify distance is reasonable for a running activity
// (GPX files don't have session summaries, so we calculate from track points)
double distanceKm = parsedData.getTotalDistance().doubleValue() / 1000.0;
assertTrue(distanceKm > 0 && distanceKm < 100,
"Distance should be reasonable for a running activity");
log.info("Distance in km: {}", distanceKm);
}
@Test
@DisplayName("Should calculate elevation gain and loss from track points")
void testCalculateElevationMetrics() throws IOException {
// Load the real GPX file
String gpxFileName = "/7410863774.gpx";
InputStream inputStream = getClass().getResourceAsStream(gpxFileName);
byte[] fileData = inputStream.readAllBytes();
inputStream.close();
ParsedActivityData parsedData = parser.parse(fileData);
// Verify elevation metrics were calculated
assertNotNull(parsedData.getElevationGain(), "Elevation gain should be calculated");
assertNotNull(parsedData.getElevationLoss(), "Elevation loss should be calculated");
// Elevation gain/loss should be non-negative
assertTrue(parsedData.getElevationGain().compareTo(BigDecimal.ZERO) >= 0,
"Elevation gain should be non-negative");
assertTrue(parsedData.getElevationLoss().compareTo(BigDecimal.ZERO) >= 0,
"Elevation loss should be non-negative");
log.info("Calculated elevation gain: {} meters", parsedData.getElevationGain());
log.info("Calculated elevation loss: {} meters", parsedData.getElevationLoss());
// Verify min/max elevation in metrics
if (parsedData.getMetrics() != null) {
BigDecimal minElev = parsedData.getMetrics().getMinElevation();
BigDecimal maxElev = parsedData.getMetrics().getMaxElevation();
if (minElev != null && maxElev != null) {
assertTrue(maxElev.compareTo(minElev) >= 0,
"Max elevation should be >= min elevation");
log.info("Elevation range: {} - {} meters", minElev, maxElev);
}
}
}
@Test
@DisplayName("Should validate real GPX file successfully")
void testValidateRealGpxFile() throws IOException {
// Load the real GPX file
String gpxFileName = "/7410863774.gpx";
InputStream inputStream = getClass().getResourceAsStream(gpxFileName);
byte[] fileData = inputStream.readAllBytes();
inputStream.close();
// Should pass all validation checks
assertDoesNotThrow(() -> validator.validate(fileData),
"Real GPX file should pass validation");
// File should have valid extension
assertTrue(validator.hasValidExtension(gpxFileName),
"File should have valid .gpx extension");
}
@Test
@DisplayName("Should handle track points in chronological order")
void testTrackPointsChronologicalOrder() throws IOException {
// Load the real GPX file
String gpxFileName = "/7410863774.gpx";
InputStream inputStream = getClass().getResourceAsStream(gpxFileName);
byte[] fileData = inputStream.readAllBytes();
inputStream.close();
ParsedActivityData parsedData = parser.parse(fileData);
// Verify track points are in chronological order
if (parsedData.getTrackPoints().size() > 1) {
for (int i = 0; i < parsedData.getTrackPoints().size() - 1; i++) {
TrackPointData current = parsedData.getTrackPoints().get(i);
TrackPointData next = parsedData.getTrackPoints().get(i + 1);
if (current.getTimestamp() != null && next.getTimestamp() != null) {
assertTrue(
!current.getTimestamp().isAfter(next.getTimestamp()),
"Track points should be in chronological order at index " + i
);
}
}
log.info("Track points are in chronological order");
log.info(" First timestamp: {}", parsedData.getTrackPoints().get(0).getTimestamp());
log.info(" Last timestamp: {}",
parsedData.getTrackPoints().get(parsedData.getTrackPoints().size() - 1).getTimestamp());
}
}
@Test
@DisplayName("Should calculate speed from consecutive GPS points")
void testSpeedCalculation() throws IOException {
// Load the real GPX file
String gpxFileName = "/7410863774.gpx";
InputStream inputStream = getClass().getResourceAsStream(gpxFileName);
byte[] fileData = inputStream.readAllBytes();
inputStream.close();
ParsedActivityData parsedData = parser.parse(fileData);
// Verify speed was calculated for track points
long pointsWithSpeed = parsedData.getTrackPoints().stream()
.filter(tp -> tp.getSpeed() != null)
.count();
assertTrue(pointsWithSpeed > 0,
"Should have calculated speed for track points");
log.info("Points with calculated speed: {} out of {}",
pointsWithSpeed, parsedData.getTrackPoints().size());
// Verify speeds are reasonable for running
for (TrackPointData point : parsedData.getTrackPoints()) {
if (point.getSpeed() != null) {
double speedKmh = point.getSpeed().doubleValue();
assertTrue(speedKmh >= 0 && speedKmh < 50,
"Running speed should be reasonable (0-50 km/h)");
}
}
}
@Test
@DisplayName("Should extract complete activity data from real GPX file")
void testExtractCompleteActivityData() throws IOException {
// Load the real GPX file
String gpxFileName = "/7410863774.gpx";
InputStream inputStream = getClass().getResourceAsStream(gpxFileName);
byte[] fileData = inputStream.readAllBytes();
inputStream.close();
// Parse the file
ParsedActivityData parsedData = parser.parse(fileData);
// Test converting to entity structures
Activity.ActivityType activityType = parsedData.getActivityType();
assertNotNull(activityType, "Activity type should be extracted");
assertEquals(Activity.ActivityType.RUN, activityType,
"Activity should be detected as RUN from GPX <type>running</type>");
// Verify we can convert track points to entities
if (!parsedData.getTrackPoints().isEmpty()) {
TrackPointData trackPointData = parsedData.getTrackPoints().get(0);
// Test geometry creation
assertDoesNotThrow(() -> trackPointData.toGeometry(),
"Should be able to create Point geometry from track point");
var point = trackPointData.toGeometry();
assertNotNull(point, "Point geometry should not be null");
assertEquals(trackPointData.getLongitude(), point.getX(), 0.0001,
"Point X coordinate should match longitude");
assertEquals(trackPointData.getLatitude(), point.getY(), 0.0001,
"Point Y coordinate should match latitude");
}
// Verify timezone was determined from GPS coordinates
assertNotNull(parsedData.getTimezone(), "Timezone should be determined from GPS");
assertFalse(parsedData.getTimezone().isEmpty(), "Timezone should not be empty");
log.info("Determined timezone: {}", parsedData.getTimezone());
// Verify start and end times
assertNotNull(parsedData.getStartTime(), "Start time should be set");
assertNotNull(parsedData.getEndTime(), "End time should be set");
assertTrue(!parsedData.getStartTime().isAfter(parsedData.getEndTime()),
"Start time should be before or equal to end time");
// Verify total duration
assertNotNull(parsedData.getTotalDuration(), "Total duration should be calculated");
assertTrue(parsedData.getTotalDuration().getSeconds() > 0,
"Total duration should be positive");
}
@Test
@DisplayName("Should apply speed smoothing to remove GPS artifacts")
void testSpeedSmoothing() throws IOException {
// Load the real GPX file
String gpxFileName = "/7410863774.gpx";
InputStream inputStream = getClass().getResourceAsStream(gpxFileName);
byte[] fileData = inputStream.readAllBytes();
inputStream.close();
ParsedActivityData parsedData = parser.parse(fileData);
// Verify speed smoothing was applied (max speed should be reasonable after smoothing)
if (parsedData.getMetrics() != null && parsedData.getMetrics().getMaxSpeed() != null) {
double maxSpeedKmh = parsedData.getMetrics().getMaxSpeed().doubleValue();
// For running, max speed should be reasonable after smoothing (typically < 30 km/h)
assertTrue(maxSpeedKmh > 0 && maxSpeedKmh < 30,
"Max speed should be reasonable for running after smoothing: " + maxSpeedKmh);
log.info("Max speed after smoothing: {} km/h", maxSpeedKmh);
}
}
}

File diff suppressed because it is too large Load diff