Fit File Processing and Persistence

This commit is contained in:
Tim Zöller 2025-11-27 22:59:45 +01:00
commit 0bc4fb3118
24 changed files with 3533 additions and 0 deletions

View file

@ -0,0 +1,15 @@
package org.operaton.fitpub.exception;
/**
* Exception thrown when FIT file processing fails.
*/
public class FitFileProcessingException extends RuntimeException {
public FitFileProcessingException(String message) {
super(message);
}
public FitFileProcessingException(String message, Throwable cause) {
super(message, cause);
}
}

View file

@ -0,0 +1,15 @@
package org.operaton.fitpub.exception;
/**
* Exception thrown when a FIT file is invalid or corrupted.
*/
public class InvalidFitFileException extends FitFileProcessingException {
public InvalidFitFileException(String message) {
super(message);
}
public InvalidFitFileException(String message, Throwable cause) {
super(message, cause);
}
}

View file

@ -0,0 +1,68 @@
package org.operaton.fitpub.model.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.operaton.fitpub.model.entity.Activity;
import java.math.BigDecimal;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* DTO for Activity data transfer.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ActivityDTO {
private UUID id;
private UUID userId;
private String activityType;
private String title;
private String description;
private LocalDateTime startedAt;
private LocalDateTime endedAt;
private String visibility;
private BigDecimal totalDistance;
private Long totalDurationSeconds;
private BigDecimal elevationGain;
private BigDecimal elevationLoss;
private ActivityMetricsDTO metrics;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
/**
* Creates a DTO from an Activity entity.
*/
public static ActivityDTO fromEntity(Activity activity) {
ActivityDTOBuilder builder = ActivityDTO.builder()
.id(activity.getId())
.userId(activity.getUserId())
.activityType(activity.getActivityType().name())
.title(activity.getTitle())
.description(activity.getDescription())
.startedAt(activity.getStartedAt())
.endedAt(activity.getEndedAt())
.visibility(activity.getVisibility().name())
.totalDistance(activity.getTotalDistance())
.elevationGain(activity.getElevationGain())
.elevationLoss(activity.getElevationLoss())
.createdAt(activity.getCreatedAt())
.updatedAt(activity.getUpdatedAt());
if (activity.getTotalDuration() != null) {
builder.totalDurationSeconds(activity.getTotalDuration().getSeconds());
}
if (activity.getMetrics() != null) {
builder.metrics(ActivityMetricsDTO.fromEntity(activity.getMetrics()));
}
return builder.build();
}
}

View file

@ -0,0 +1,78 @@
package org.operaton.fitpub.model.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.operaton.fitpub.model.entity.ActivityMetrics;
import java.math.BigDecimal;
/**
* DTO for ActivityMetrics data transfer.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ActivityMetricsDTO {
private BigDecimal averageSpeed;
private BigDecimal maxSpeed;
private Long averagePaceSeconds;
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 Long movingTimeSeconds;
private Long stoppedTimeSeconds;
private Integer totalSteps;
private BigDecimal trainingStressScore;
/**
* Creates a DTO from an ActivityMetrics entity.
*/
public static ActivityMetricsDTO fromEntity(ActivityMetrics metrics) {
ActivityMetricsDTOBuilder builder = ActivityMetricsDTO.builder()
.averageSpeed(metrics.getAverageSpeed())
.maxSpeed(metrics.getMaxSpeed())
.averageHeartRate(metrics.getAverageHeartRate())
.maxHeartRate(metrics.getMaxHeartRate())
.averageCadence(metrics.getAverageCadence())
.maxCadence(metrics.getMaxCadence())
.averagePower(metrics.getAveragePower())
.maxPower(metrics.getMaxPower())
.normalizedPower(metrics.getNormalizedPower())
.calories(metrics.getCalories())
.averageTemperature(metrics.getAverageTemperature())
.maxElevation(metrics.getMaxElevation())
.minElevation(metrics.getMinElevation())
.totalAscent(metrics.getTotalAscent())
.totalDescent(metrics.getTotalDescent())
.totalSteps(metrics.getTotalSteps())
.trainingStressScore(metrics.getTrainingStressScore());
if (metrics.getAveragePace() != null) {
builder.averagePaceSeconds(metrics.getAveragePace().getSeconds());
}
if (metrics.getMovingTime() != null) {
builder.movingTimeSeconds(metrics.getMovingTime().getSeconds());
}
if (metrics.getStoppedTime() != null) {
builder.stoppedTimeSeconds(metrics.getStoppedTime().getSeconds());
}
return builder.build();
}
}

View file

@ -0,0 +1,150 @@
package org.operaton.fitpub.model.entity;
import io.hypersistence.utils.hibernate.type.json.JsonBinaryType;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.Type;
import org.hibernate.annotations.UpdateTimestamp;
import org.locationtech.jts.geom.LineString;
import java.math.BigDecimal;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* Entity representing a fitness activity (workout).
* Stores metadata, simplified track for map rendering, and full track data as JSONB.
* This design optimizes for scalability by avoiding normalized track_points table.
*/
@Entity
@Table(name = "activities", indexes = {
@Index(name = "idx_activity_user_id", columnList = "user_id"),
@Index(name = "idx_activity_started_at", columnList = "started_at"),
@Index(name = "idx_activity_type", columnList = "activity_type")
})
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Activity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(name = "user_id", nullable = false)
private UUID userId;
@Column(name = "activity_type", nullable = false, length = 50)
@Enumerated(EnumType.STRING)
private ActivityType activityType;
@Column(nullable = false, length = 255)
private String title;
@Column(columnDefinition = "TEXT")
private String description;
@Column(name = "started_at", nullable = false)
private LocalDateTime startedAt;
@Column(name = "ended_at", nullable = false)
private LocalDateTime endedAt;
@Column(nullable = false, length = 20)
@Enumerated(EnumType.STRING)
private Visibility visibility;
/**
* Simplified track for map rendering (50-200 points).
* Uses Douglas-Peucker algorithm to reduce point count while maintaining shape.
*/
@Column(name = "simplified_track", columnDefinition = "geometry(LineString, 4326)")
private LineString simplifiedTrack;
/**
* Full track data stored as JSONB for detail view.
* Contains all original track points with sensor data.
* Much more efficient than normalized track_points table.
*/
@Type(JsonBinaryType.class)
@Column(name = "track_points_json", columnDefinition = "jsonb")
private String trackPointsJson;
@Column(name = "total_distance", precision = 10, scale = 2)
private BigDecimal totalDistance;
@Column(name = "total_duration")
private Duration totalDuration;
@Column(name = "elevation_gain", precision = 8, scale = 2)
private BigDecimal elevationGain;
@Column(name = "elevation_loss", precision = 8, scale = 2)
private BigDecimal elevationLoss;
/**
* Original FIT file for re-processing if needed.
* Allows us to re-parse with updated algorithms.
*/
@Column(name = "raw_fit_file")
@Lob
private byte[] rawFitFile;
@OneToOne(mappedBy = "activity", cascade = CascadeType.ALL, orphanRemoval = true)
private ActivityMetrics metrics;
@CreationTimestamp
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@UpdateTimestamp
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
/**
* Helper method to set metrics for this activity
*/
public void setMetrics(ActivityMetrics metrics) {
this.metrics = metrics;
if (metrics != null) {
metrics.setActivity(this);
}
}
/**
* Activity types supported by the platform
*/
public enum ActivityType {
RUN,
RIDE,
HIKE,
WALK,
SWIM,
ALPINE_SKI,
BACKCOUNTRY_SKI,
NORDIC_SKI,
SNOWBOARD,
ROWING,
KAYAKING,
CANOEING,
INLINE_SKATING,
ROCK_CLIMBING,
MOUNTAINEERING,
YOGA,
WORKOUT,
OTHER
}
/**
* Visibility levels for activities
*/
public enum Visibility {
PUBLIC,
FOLLOWERS,
PRIVATE
}
}

View file

@ -0,0 +1,90 @@
package org.operaton.fitpub.model.entity;
import jakarta.persistence.*;
import lombok.*;
import java.math.BigDecimal;
import java.time.Duration;
import java.util.UUID;
/**
* Entity storing calculated metrics and statistics for an activity.
* Includes average/max values, pace information, and other derived data.
*/
@Entity
@Table(name = "activity_metrics")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ActivityMetrics {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@OneToOne
@JoinColumn(name = "activity_id", nullable = false)
private Activity activity;
@Column(name = "average_speed", precision = 8, scale = 2)
private BigDecimal averageSpeed;
@Column(name = "max_speed", precision = 8, scale = 2)
private BigDecimal maxSpeed;
@Column(name = "average_pace")
private Duration averagePace;
@Column(name = "average_heart_rate")
private Integer averageHeartRate;
@Column(name = "max_heart_rate")
private Integer maxHeartRate;
@Column(name = "average_cadence")
private Integer averageCadence;
@Column(name = "max_cadence")
private Integer maxCadence;
@Column(name = "average_power")
private Integer averagePower;
@Column(name = "max_power")
private Integer maxPower;
@Column(name = "normalized_power")
private Integer normalizedPower;
@Column(name = "calories")
private Integer calories;
@Column(name = "average_temperature", precision = 5, scale = 2)
private BigDecimal averageTemperature;
@Column(name = "max_elevation", precision = 8, scale = 2)
private BigDecimal maxElevation;
@Column(name = "min_elevation", precision = 8, scale = 2)
private BigDecimal minElevation;
@Column(name = "total_ascent", precision = 8, scale = 2)
private BigDecimal totalAscent;
@Column(name = "total_descent", precision = 8, scale = 2)
private BigDecimal totalDescent;
@Column(name = "moving_time")
private Duration movingTime;
@Column(name = "stopped_time")
private Duration stoppedTime;
@Column(name = "total_steps")
private Integer totalSteps;
@Column(name = "training_stress_score")
private BigDecimal trainingStressScore;
}

View file

@ -0,0 +1,34 @@
package org.operaton.fitpub.repository;
import org.operaton.fitpub.model.entity.ActivityMetrics;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.Optional;
import java.util.UUID;
/**
* Repository for ActivityMetrics entities.
*/
@Repository
public interface ActivityMetricsRepository extends JpaRepository<ActivityMetrics, UUID> {
/**
* Find metrics for a specific activity.
*
* @param activityId the activity ID
* @return optional metrics
*/
@Query("SELECT am FROM ActivityMetrics am WHERE am.activity.id = :activityId")
Optional<ActivityMetrics> findByActivityId(@Param("activityId") UUID activityId);
/**
* Delete metrics for a specific activity.
*
* @param activityId the activity ID
*/
@Query("DELETE FROM ActivityMetrics am WHERE am.activity.id = :activityId")
void deleteByActivityId(@Param("activityId") UUID activityId);
}

View file

@ -0,0 +1,88 @@
package org.operaton.fitpub.repository;
import org.operaton.fitpub.model.entity.Activity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
/**
* Repository for Activity entities.
*/
@Repository
public interface ActivityRepository extends JpaRepository<Activity, UUID> {
/**
* Find all activities for a specific user.
*
* @param userId the user ID
* @return list of activities
*/
List<Activity> findByUserIdOrderByStartedAtDesc(UUID userId);
/**
* Find all activities for a user within a date range.
*
* @param userId the user ID
* @param startDate the start date
* @param endDate the end date
* @return list of activities
*/
List<Activity> findByUserIdAndStartedAtBetweenOrderByStartedAtDesc(
UUID userId,
LocalDateTime startDate,
LocalDateTime endDate
);
/**
* Find all public activities for a user.
*
* @param userId the user ID
* @return list of activities
*/
List<Activity> findByUserIdAndVisibilityOrderByStartedAtDesc(
UUID userId,
Activity.Visibility visibility
);
/**
* Find activities by type for a user.
*
* @param userId the user ID
* @param activityType the activity type
* @return list of activities
*/
List<Activity> findByUserIdAndActivityTypeOrderByStartedAtDesc(
UUID userId,
Activity.ActivityType activityType
);
/**
* Count activities for a user.
*
* @param userId the user ID
* @return count of activities
*/
long countByUserId(UUID userId);
/**
* Find an activity by ID and user ID.
*
* @param id the activity ID
* @param userId the user ID
* @return optional activity
*/
Optional<Activity> findByIdAndUserId(UUID id, UUID userId);
/**
* Delete all activities for a user.
*
* @param userId the user ID
*/
void deleteByUserId(UUID userId);
}

View file

@ -0,0 +1,349 @@
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.model.entity.Activity;
import org.operaton.fitpub.model.entity.ActivityMetrics;
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.TrackSimplifier;
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.util.List;
import java.util.UUID;
/**
* Service for processing FIT files and creating activities.
* Uses JSONB for track points and simplified LineString for map rendering.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class FitFileService {
private static final int WGS84_SRID = 4326;
private static final GeometryFactory GEOMETRY_FACTORY =
new GeometryFactory(new PrecisionModel(), WGS84_SRID);
private final FitFileValidator validator;
private final FitParser parser;
private final TrackSimplifier trackSimplifier;
private final ActivityRepository activityRepository;
private final ActivityMetricsRepository metricsRepository;
private final ObjectMapper objectMapper;
/**
* Processes an uploaded FIT file and creates an activity.
*
* @param file the uploaded FIT 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 processing fails
*/
@Transactional
public Activity processFitFile(
MultipartFile file,
UUID userId,
String title,
String description,
Activity.Visibility visibility
) {
try {
// Validate file
log.info("Processing FIT file: {}, size: {} bytes", file.getOriginalFilename(), file.getSize());
validator.validate(file.getInputStream(), file.getSize());
// Parse FIT file
byte[] fileData = file.getBytes();
FitParser.ParsedFitData parsedData = parser.parse(fileData);
// Create activity entity
Activity activity = createActivity(parsedData, userId, title, description, visibility, fileData);
// 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
Coordinate[] coordinates = fullTrack.getCoordinates();
LineString simplifiedTrack = trackSimplifier.simplify(coordinates);
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)",
savedActivity.getId(),
parsedData.getTrackPoints().size(),
simplifiedTrack.getNumPoints());
return savedActivity;
} catch (IOException e) {
throw new FitFileProcessingException("Failed to read FIT file", e);
}
}
/**
* Processes FIT file data directly (for testing or non-upload scenarios).
*
* @param fileData the FIT file bytes
* @param userId the user ID
* @param visibility visibility level
* @return the created activity
*/
@Transactional
public Activity processFitFile(byte[] fileData, UUID userId, Activity.Visibility visibility) {
validator.validate(fileData);
FitParser.ParsedFitData parsedData = parser.parse(fileData);
return createActivityFromParsedData(parsedData, userId, null, null, visibility, fileData);
}
/**
* Creates an activity entity from parsed FIT data.
*/
private Activity createActivity(
FitParser.ParsedFitData parsedData,
UUID userId,
String title,
String description,
Activity.Visibility visibility,
byte[] rawFile
) {
String activityTitle = title != null && !title.isBlank()
? title
: generateTitle(parsedData);
return Activity.builder()
.userId(userId)
.activityType(parsedData.getActivityType())
.title(activityTitle)
.description(description)
.startedAt(parsedData.getStartTime())
.endedAt(parsedData.getEndTime())
.visibility(visibility)
.totalDistance(parsedData.getTotalDistance())
.totalDuration(parsedData.getTotalDuration())
.elevationGain(parsedData.getElevationGain())
.elevationLoss(parsedData.getElevationLoss())
.rawFitFile(rawFile)
.build();
}
/**
* Creates an activity from parsed data (internal method).
*/
private Activity createActivityFromParsedData(
FitParser.ParsedFitData parsedData,
UUID userId,
String title,
String description,
Activity.Visibility visibility,
byte[] rawFile
) {
Activity activity = createActivity(parsedData, userId, title, description, visibility, rawFile);
String trackPointsJson = convertTrackPointsToJson(parsedData.getTrackPoints());
activity.setTrackPointsJson(trackPointsJson);
LineString fullTrack = createLineStringFromTrackPoints(parsedData.getTrackPoints());
LineString simplifiedTrack = trackSimplifier.simplify(fullTrack.getCoordinates());
activity.setSimplifiedTrack(simplifiedTrack);
if (parsedData.getMetrics() != null) {
ActivityMetrics metrics = parsedData.getMetrics().toEntity(activity);
calculateAdditionalMetrics(metrics, parsedData.getTrackPoints());
activity.setMetrics(metrics);
}
return activityRepository.save(activity);
}
/**
* Converts track points to JSON string for JSONB storage.
*/
private String convertTrackPointsToJson(List<FitParser.TrackPointData> trackPoints) {
try {
return objectMapper.writeValueAsString(trackPoints);
} catch (JsonProcessingException e) {
throw new FitFileProcessingException("Failed to serialize track points to JSON", e);
}
}
/**
* Creates a PostGIS LineString from track points.
*/
private LineString createLineStringFromTrackPoints(List<FitParser.TrackPointData> trackPoints) {
Coordinate[] coordinates = trackPoints.stream()
.map(tp -> new Coordinate(tp.getLongitude(), tp.getLatitude()))
.toArray(Coordinate[]::new);
return GEOMETRY_FACTORY.createLineString(coordinates);
}
/**
* Generates a default title for an activity.
*/
private String generateTitle(FitParser.ParsedFitData parsedData) {
String activityType = formatActivityType(parsedData.getActivityType());
String date = parsedData.getStartTime().toLocalDate().toString();
return String.format("%s - %s", activityType, date);
}
/**
* Formats activity type for display.
*/
private String formatActivityType(Activity.ActivityType type) {
switch (type) {
case RUN:
return "Run";
case RIDE:
return "Ride";
case HIKE:
return "Hike";
case WALK:
return "Walk";
case SWIM:
return "Swim";
case ALPINE_SKI:
return "Alpine Ski";
case BACKCOUNTRY_SKI:
return "Backcountry Ski";
case NORDIC_SKI:
return "Nordic Ski";
case SNOWBOARD:
return "Snowboard";
case ROWING:
return "Rowing";
case KAYAKING:
return "Kayaking";
case CANOEING:
return "Canoeing";
case INLINE_SKATING:
return "Inline Skating";
case ROCK_CLIMBING:
return "Rock Climbing";
case MOUNTAINEERING:
return "Mountaineering";
case YOGA:
return "Yoga";
case WORKOUT:
return "Workout";
default:
return "Activity";
}
}
/**
* Calculates additional metrics not provided by the FIT file.
*/
private void calculateAdditionalMetrics(
ActivityMetrics metrics,
List<FitParser.TrackPointData> trackPoints
) {
if (trackPoints.isEmpty()) {
return;
}
// Calculate min/max elevation
BigDecimal minElevation = null;
BigDecimal maxElevation = null;
for (FitParser.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();
}
}
}
metrics.setMinElevation(minElevation);
metrics.setMaxElevation(maxElevation);
// Calculate average temperature
BigDecimal tempSum = BigDecimal.ZERO;
int tempCount = 0;
for (FitParser.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)
);
}
}
/**
* Deletes an activity and all associated data.
*
* @param activityId the activity ID
* @param userId the user ID (for authorization)
* @return true if deleted, false if not found or unauthorized
*/
@Transactional
public boolean deleteActivity(UUID activityId, UUID userId) {
return activityRepository.findByIdAndUserId(activityId, userId)
.map(activity -> {
activityRepository.delete(activity);
log.info("Deleted activity {} for user {}", activityId, userId);
return true;
})
.orElse(false);
}
/**
* Retrieves an activity by ID.
*
* @param activityId the activity ID
* @param userId the user ID (for authorization)
* @return the activity or null if not found
*/
@Transactional(readOnly = true)
public Activity getActivity(UUID activityId, UUID userId) {
return activityRepository.findByIdAndUserId(activityId, userId).orElse(null);
}
/**
* Retrieves all activities for a user.
*
* @param userId the user ID
* @return list of activities
*/
@Transactional(readOnly = true)
public List<Activity> getUserActivities(UUID userId) {
return activityRepository.findByUserIdOrderByStartedAtDesc(userId);
}
}

View file

@ -0,0 +1,137 @@
package org.operaton.fitpub.util;
import lombok.extern.slf4j.Slf4j;
import org.operaton.fitpub.exception.InvalidFitFileException;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.io.InputStream;
/**
* Validates FIT files before processing.
* Checks file size, header, and basic integrity.
*/
@Component
@Slf4j
public class FitFileValidator {
private static final long MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
private static final int MIN_FILE_SIZE = 14; // Minimum FIT file header size
private static final byte[] FIT_HEADER_SIGNATURE = {'.', 'F', 'I', 'T'};
private static final int HEADER_SIZE_OFFSET = 0;
private static final int PROTOCOL_VERSION_OFFSET = 1;
private static final int SIGNATURE_OFFSET = 8;
/**
* Validates a FIT file from byte array.
*
* @param fileData the FIT file data
* @throws InvalidFitFileException if the file is invalid
*/
public void validate(byte[] fileData) {
if (fileData == null || fileData.length == 0) {
throw new InvalidFitFileException("FIT file is empty");
}
validateFileSize(fileData.length);
validateFitHeader(fileData);
}
/**
* Validates a FIT file from input stream.
*
* @param inputStream the input stream
* @param contentLength the content length
* @throws InvalidFitFileException if the file is invalid
*/
public void validate(InputStream inputStream, long contentLength) throws IOException {
validateFileSize(contentLength);
byte[] header = new byte[14];
int bytesRead = inputStream.read(header);
if (bytesRead < MIN_FILE_SIZE) {
throw new InvalidFitFileException("FIT file is too small. Minimum size is " + MIN_FILE_SIZE + " bytes");
}
validateFitHeader(header);
}
/**
* Validates the file size.
*
* @param size the file size in bytes
* @throws InvalidFitFileException if the size is invalid
*/
private void validateFileSize(long size) {
if (size < MIN_FILE_SIZE) {
throw new InvalidFitFileException(
String.format("FIT file is too small. Size: %d bytes, minimum: %d bytes", size, MIN_FILE_SIZE)
);
}
if (size > MAX_FILE_SIZE) {
throw new InvalidFitFileException(
String.format("FIT file is too large. Size: %d bytes, maximum: %d bytes", size, MAX_FILE_SIZE)
);
}
}
/**
* Validates the FIT file header.
*
* @param data the file data (at least first 14 bytes)
* @throws InvalidFitFileException if the header is invalid
*/
private void validateFitHeader(byte[] data) {
if (data.length < MIN_FILE_SIZE) {
throw new InvalidFitFileException("Insufficient data to validate FIT header");
}
// Check header size
int headerSize = data[HEADER_SIZE_OFFSET] & 0xFF;
if (headerSize != 12 && headerSize != 14) {
throw new InvalidFitFileException(
String.format("Invalid FIT header size: %d. Expected 12 or 14", headerSize)
);
}
// Check protocol version
int protocolVersion = data[PROTOCOL_VERSION_OFFSET] & 0xFF;
int majorVersion = protocolVersion >> 4;
if (majorVersion == 0 || majorVersion > 20) {
log.warn("Unusual FIT protocol version: {}.{}", majorVersion, protocolVersion & 0x0F);
}
// Check signature
boolean signatureValid = true;
for (int i = 0; i < FIT_HEADER_SIGNATURE.length; i++) {
if (data[SIGNATURE_OFFSET + i] != FIT_HEADER_SIGNATURE[i]) {
signatureValid = false;
break;
}
}
if (!signatureValid) {
throw new InvalidFitFileException(
"Invalid FIT file signature. Expected '.FIT' at offset " + SIGNATURE_OFFSET
);
}
log.debug("FIT file header validated successfully. Header size: {}, Protocol version: {}.{}",
headerSize, majorVersion, protocolVersion & 0x0F);
}
/**
* Checks if a file appears to be a valid FIT file based on extension.
*
* @param filename the filename
* @return true if the filename has a .fit extension
*/
public boolean hasValidExtension(String filename) {
if (filename == null || filename.isEmpty()) {
return false;
}
return filename.toLowerCase().endsWith(".fit");
}
}

View file

@ -0,0 +1,401 @@
package org.operaton.fitpub.util;
import com.garmin.fit.*;
import lombok.extern.slf4j.Slf4j;
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.exception.FitFileProcessingException;
import org.operaton.fitpub.model.entity.Activity;
import org.operaton.fitpub.model.entity.ActivityMetrics;
import org.springframework.stereotype.Component;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.List;
/**
* Parser for Garmin FIT files.
* Extracts GPS coordinates, activity metrics, and sensor data.
*/
@Component
@Slf4j
public class FitParser {
private static final int WGS84_SRID = 4326;
private static final GeometryFactory GEOMETRY_FACTORY =
new GeometryFactory(new PrecisionModel(), WGS84_SRID);
private static final double SEMICIRCLES_TO_DEGREES = 180.0 / Math.pow(2, 31);
private static final double MPS_TO_KPH = 3.6;
/**
* Parses a FIT file and returns the extracted data.
*
* @param fileData the FIT file data
* @return ParsedFitData containing activity information
* @throws FitFileProcessingException if parsing fails
*/
public ParsedFitData parse(byte[] fileData) {
try (InputStream inputStream = new ByteArrayInputStream(fileData)) {
return parse(inputStream);
} catch (Exception e) {
throw new FitFileProcessingException("Failed to parse FIT file", e);
}
}
/**
* Parses a FIT file from an input stream.
*
* @param inputStream the input stream
* @return ParsedFitData containing activity information
* @throws FitFileProcessingException if parsing fails
*/
public ParsedFitData parse(InputStream inputStream) {
ParsedFitData parsedData = new ParsedFitData();
Decode decode = new Decode();
MesgBroadcaster broadcaster = new MesgBroadcaster(decode);
// Listen for record messages (GPS points)
broadcaster.addListener((RecordMesgListener) record -> {
TrackPointData trackPoint = extractTrackPoint(record);
if (trackPoint != null) {
parsedData.getTrackPoints().add(trackPoint);
}
});
// Listen for session messages (summary data)
broadcaster.addListener((SessionMesgListener) session -> {
extractSessionData(session, parsedData);
});
// Listen for activity messages
broadcaster.addListener((ActivityMesgListener) activity -> {
extractActivityData(activity, parsedData);
});
// Listen for lap messages
broadcaster.addListener((LapMesgListener) lap -> {
log.debug("Lap data: distance={}, time={}", lap.getTotalDistance(), lap.getTotalTimerTime());
});
try {
if (!decode.read(inputStream, broadcaster)) {
throw new FitFileProcessingException("Failed to decode FIT file");
}
if (parsedData.getTrackPoints().isEmpty()) {
throw new FitFileProcessingException("No GPS track points found in FIT file");
}
log.info("Successfully parsed FIT file: {} track points, activity type: {}",
parsedData.getTrackPoints().size(), parsedData.getActivityType());
return parsedData;
} catch (FitRuntimeException e) {
throw new FitFileProcessingException("Error decoding FIT file", e);
}
}
/**
* Extracts a track point from a record message.
*/
private TrackPointData extractTrackPoint(RecordMesg record) {
Integer positionLat = record.getPositionLat();
Integer positionLong = record.getPositionLong();
if (positionLat == null || positionLong == null) {
return null; // Skip points without GPS coordinates
}
TrackPointData point = new TrackPointData();
// Convert semicircles to degrees
double latitude = positionLat * SEMICIRCLES_TO_DEGREES;
double longitude = positionLong * SEMICIRCLES_TO_DEGREES;
point.setLatitude(latitude);
point.setLongitude(longitude);
// Extract timestamp
if (record.getTimestamp() != null) {
point.setTimestamp(convertDateTime(record.getTimestamp()));
}
// Extract elevation
if (record.getAltitude() != null) {
point.setElevation(BigDecimal.valueOf(record.getAltitude()).setScale(2, RoundingMode.HALF_UP));
}
// Extract heart rate
if (record.getHeartRate() != null) {
point.setHeartRate(record.getHeartRate().intValue());
}
// Extract cadence
if (record.getCadence() != null) {
point.setCadence(record.getCadence().intValue());
}
// Extract power
if (record.getPower() != null) {
point.setPower(record.getPower());
}
// Extract speed (convert m/s to km/h)
if (record.getSpeed() != null) {
point.setSpeed(BigDecimal.valueOf(record.getSpeed() * MPS_TO_KPH)
.setScale(2, RoundingMode.HALF_UP));
}
// Extract distance
if (record.getDistance() != null) {
point.setDistance(BigDecimal.valueOf(record.getDistance()).setScale(2, RoundingMode.HALF_UP));
}
// Extract temperature
if (record.getTemperature() != null) {
point.setTemperature(BigDecimal.valueOf(record.getTemperature()).setScale(2, RoundingMode.HALF_UP));
}
return point;
}
/**
* Extracts session data from a session message.
*/
private void extractSessionData(SessionMesg session, ParsedFitData parsedData) {
if (session.getStartTime() != null) {
parsedData.setStartTime(convertDateTime(session.getStartTime()));
}
if (session.getTotalElapsedTime() != null) {
long totalSeconds = session.getTotalElapsedTime().longValue();
parsedData.setTotalDuration(Duration.ofSeconds(totalSeconds));
if (parsedData.getStartTime() != null) {
parsedData.setEndTime(parsedData.getStartTime().plus(Duration.ofSeconds(totalSeconds)));
}
}
if (session.getTotalDistance() != null) {
parsedData.setTotalDistance(
BigDecimal.valueOf(session.getTotalDistance()).setScale(2, RoundingMode.HALF_UP)
);
}
if (session.getTotalAscent() != null) {
parsedData.setElevationGain(
BigDecimal.valueOf(session.getTotalAscent()).setScale(2, RoundingMode.HALF_UP)
);
}
if (session.getTotalDescent() != null) {
parsedData.setElevationLoss(
BigDecimal.valueOf(session.getTotalDescent()).setScale(2, RoundingMode.HALF_UP)
);
}
// Extract metrics
ActivityMetricsData metrics = new ActivityMetricsData();
if (session.getAvgSpeed() != null) {
metrics.setAverageSpeed(
BigDecimal.valueOf(session.getAvgSpeed() * MPS_TO_KPH).setScale(2, RoundingMode.HALF_UP)
);
}
if (session.getMaxSpeed() != null) {
metrics.setMaxSpeed(
BigDecimal.valueOf(session.getMaxSpeed() * MPS_TO_KPH).setScale(2, RoundingMode.HALF_UP)
);
}
if (session.getAvgHeartRate() != null) {
metrics.setAverageHeartRate(session.getAvgHeartRate().intValue());
}
if (session.getMaxHeartRate() != null) {
metrics.setMaxHeartRate(session.getMaxHeartRate().intValue());
}
if (session.getAvgCadence() != null) {
metrics.setAverageCadence(session.getAvgCadence().intValue());
}
if (session.getMaxCadence() != null) {
metrics.setMaxCadence(session.getMaxCadence().intValue());
}
if (session.getAvgPower() != null) {
metrics.setAveragePower(session.getAvgPower());
}
if (session.getMaxPower() != null) {
metrics.setMaxPower(session.getMaxPower());
}
if (session.getNormalizedPower() != null) {
metrics.setNormalizedPower(session.getNormalizedPower());
}
if (session.getTotalCalories() != null) {
metrics.setCalories(session.getTotalCalories());
}
if (session.getTotalMovingTime() != null) {
metrics.setMovingTime(Duration.ofSeconds(session.getTotalMovingTime().longValue()));
}
if (session.getTotalStrides() != null) {
metrics.setTotalSteps(session.getTotalStrides().intValue() * 2); // Strides to steps
}
parsedData.setMetrics(metrics);
// Determine activity type
if (session.getSport() != null) {
parsedData.setActivityType(mapSportToActivityType(session.getSport()));
}
}
/**
* Extracts activity data from an activity message.
*/
private void extractActivityData(ActivityMesg activity, ParsedFitData parsedData) {
if (activity.getTimestamp() != null) {
parsedData.setActivityTimestamp(convertDateTime(activity.getTimestamp()));
}
if (activity.getTotalTimerTime() != null) {
log.debug("Activity total timer time: {}", activity.getTotalTimerTime());
}
}
/**
* Converts FIT DateTime to LocalDateTime.
*/
private LocalDateTime convertDateTime(DateTime dateTime) {
long timestamp = dateTime.getTimestamp();
Instant instant = Instant.ofEpochSecond(timestamp);
return LocalDateTime.ofInstant(instant, ZoneId.systemDefault());
}
/**
* Maps FIT sport type to our activity type.
*/
private Activity.ActivityType mapSportToActivityType(Sport sport) {
if (sport == Sport.RUNNING) {
return Activity.ActivityType.RUN;
} else if (sport == Sport.CYCLING) {
return Activity.ActivityType.RIDE;
} else if (sport == Sport.HIKING) {
return Activity.ActivityType.HIKE;
} else if (sport == Sport.WALKING) {
return Activity.ActivityType.WALK;
} else if (sport == Sport.SWIMMING) {
return Activity.ActivityType.SWIM;
} else if (sport == Sport.ROWING) {
return Activity.ActivityType.ROWING;
} else {
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)
.averagePace(averagePace)
.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)
.movingTime(movingTime)
.stoppedTime(stoppedTime)
.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 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,182 @@
package org.operaton.fitpub.util;
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.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
/**
* Simplifies GPS tracks using the Douglas-Peucker algorithm.
* Reduces the number of points while maintaining the overall shape of the track.
* This is critical for scalability - we don't want to render 1000+ points on a map.
*/
@Component
@Slf4j
public class TrackSimplifier {
private static final int WGS84_SRID = 4326;
private static final GeometryFactory GEOMETRY_FACTORY =
new GeometryFactory(new PrecisionModel(), WGS84_SRID);
// Default epsilon: ~10 meters tolerance (in degrees, roughly 0.0001 degrees)
private static final double DEFAULT_EPSILON = 0.0001;
// Target: 50-200 points for map rendering
private static final int TARGET_POINTS_MIN = 50;
private static final int TARGET_POINTS_MAX = 200;
/**
* Simplifies a track to a target number of points.
* Automatically adjusts epsilon to achieve the desired point count.
*
* @param coordinates original track coordinates
* @return simplified LineString
*/
public LineString simplify(Coordinate[] coordinates) {
if (coordinates == null || coordinates.length == 0) {
return GEOMETRY_FACTORY.createLineString(new Coordinate[0]);
}
if (coordinates.length <= TARGET_POINTS_MAX) {
// Already small enough, no simplification needed
log.debug("Track has {} points, no simplification needed", coordinates.length);
return GEOMETRY_FACTORY.createLineString(coordinates);
}
// Try to find epsilon that gives us TARGET_POINTS_MIN to TARGET_POINTS_MAX points
double epsilon = DEFAULT_EPSILON;
List<Coordinate> simplified = douglasPeucker(coordinates, epsilon);
// Adjust epsilon if needed
int iterations = 0;
while (simplified.size() > TARGET_POINTS_MAX && iterations < 10) {
epsilon *= 1.5; // Increase tolerance
simplified = douglasPeucker(coordinates, epsilon);
iterations++;
}
while (simplified.size() < TARGET_POINTS_MIN && epsilon > 0.00001 && iterations < 10) {
epsilon *= 0.7; // Decrease tolerance
simplified = douglasPeucker(coordinates, epsilon);
iterations++;
}
log.info("Simplified track from {} to {} points (epsilon: {})",
coordinates.length, simplified.size(), epsilon);
return GEOMETRY_FACTORY.createLineString(simplified.toArray(new Coordinate[0]));
}
/**
* Douglas-Peucker algorithm implementation.
* Recursively removes points that deviate less than epsilon from the line.
*
* @param coordinates input coordinates
* @param epsilon tolerance (maximum distance from line)
* @return simplified list of coordinates
*/
private List<Coordinate> douglasPeucker(Coordinate[] coordinates, double epsilon) {
if (coordinates.length < 3) {
List<Coordinate> result = new ArrayList<>();
for (Coordinate coord : coordinates) {
result.add(coord);
}
return result;
}
// Find point with maximum distance from line between first and last point
double maxDistance = 0;
int maxIndex = 0;
Coordinate start = coordinates[0];
Coordinate end = coordinates[coordinates.length - 1];
for (int i = 1; i < coordinates.length - 1; i++) {
double distance = perpendicularDistance(coordinates[i], start, end);
if (distance > maxDistance) {
maxDistance = distance;
maxIndex = i;
}
}
List<Coordinate> result = new ArrayList<>();
// If max distance is greater than epsilon, recursively simplify
if (maxDistance > epsilon) {
// Recursive call for first part
Coordinate[] firstPart = new Coordinate[maxIndex + 1];
System.arraycopy(coordinates, 0, firstPart, 0, maxIndex + 1);
List<Coordinate> left = douglasPeucker(firstPart, epsilon);
// Recursive call for second part
Coordinate[] secondPart = new Coordinate[coordinates.length - maxIndex];
System.arraycopy(coordinates, maxIndex, secondPart, 0, coordinates.length - maxIndex);
List<Coordinate> right = douglasPeucker(secondPart, epsilon);
// Combine results (remove duplicate point at junction)
result.addAll(left.subList(0, left.size() - 1));
result.addAll(right);
} else {
// All points can be removed except endpoints
result.add(start);
result.add(end);
}
return result;
}
/**
* Calculates perpendicular distance from a point to a line segment.
*
* @param point the point
* @param lineStart start of line segment
* @param lineEnd end of line segment
* @return perpendicular distance
*/
private double perpendicularDistance(Coordinate point, Coordinate lineStart, Coordinate lineEnd) {
double x = point.x;
double y = point.y;
double x1 = lineStart.x;
double y1 = lineStart.y;
double x2 = lineEnd.x;
double y2 = lineEnd.y;
double A = x - x1;
double B = y - y1;
double C = x2 - x1;
double D = y2 - y1;
double dot = A * C + B * D;
double lenSq = C * C + D * D;
if (lenSq == 0) {
// Line segment is actually a point
return Math.sqrt(A * A + B * B);
}
double param = dot / lenSq;
double xx, yy;
if (param < 0) {
xx = x1;
yy = y1;
} else if (param > 1) {
xx = x2;
yy = y2;
} else {
xx = x1 + param * C;
yy = y1 + param * D;
}
double dx = x - xx;
double dy = y - yy;
return Math.sqrt(dx * dx + dy * dy);
}
}