fitpub/src/main/java/net/javahippie/fitpub/service/ActivityFileService.java
Niklas 102d515b42
Display activity date in local time (using the time zone that is stored with the activity), not in UTC (#4)
* Display timestamps using the timezone that is stored at the activity (fix 'new Date()' invocation)

* Display timestamps using the timezone that is stored at the activity (relative date in timeline views)

* Use correct timezone for auto-generated activity title

---------

Co-authored-by: Niklas Deutschmann <sonstharmlos@noreply.codeberg.org>
2026-04-27 22:01:08 +02:00

575 lines
24 KiB
Java

package net.javahippie.fitpub.service;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import net.javahippie.fitpub.repository.ReverseGeolocationRepository;
import net.javahippie.fitpub.util.ActivityFormatter;
import net.javahippie.fitpub.util.FitFileValidator;
import net.javahippie.fitpub.util.FitParser;
import net.javahippie.fitpub.util.GpxFileValidator;
import net.javahippie.fitpub.util.GpxParser;
import net.javahippie.fitpub.util.ParsedActivityData;
import net.javahippie.fitpub.util.TrackSimplifier;
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 net.javahippie.fitpub.exception.FitFileProcessingException;
import net.javahippie.fitpub.exception.GpxFileProcessingException;
import net.javahippie.fitpub.exception.UnsupportedFileFormatException;
import net.javahippie.fitpub.model.entity.Activity;
import net.javahippie.fitpub.model.entity.ActivityMetrics;
import net.javahippie.fitpub.repository.ActivityRepository;
import net.javahippie.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);
/**
* Processing options to control which side effects are executed after activity creation.
* Used to skip expensive operations during batch imports and re-execute them later as a batch.
*/
@lombok.Getter
@lombok.Builder
public static class ProcessingOptions {
@lombok.Builder.Default
private final boolean skipPersonalRecords = false;
@lombok.Builder.Default
private final boolean skipAchievements = false;
@lombok.Builder.Default
private final boolean skipHeatmap = false;
@lombok.Builder.Default
private final boolean skipTrainingLoad = false;
@lombok.Builder.Default
private final boolean skipSummaries = false;
@lombok.Builder.Default
private final boolean skipWeather = false;
/**
* Creates options for batch import mode - skips all side effects.
* Analytics and social features are recalculated in a batch after import completes.
*
* @return processing options with all side effects skipped
*/
public static ProcessingOptions batchImportMode() {
return ProcessingOptions.builder()
.skipPersonalRecords(true)
.skipAchievements(true)
.skipHeatmap(true)
.skipTrainingLoad(true)
.skipSummaries(true)
.skipWeather(true)
.build();
}
/**
* Creates options for normal mode - executes all side effects.
* This is the default behavior for single activity uploads.
*
* @return processing options with no side effects skipped
*/
public static ProcessingOptions normalMode() {
return ProcessingOptions.builder().build();
}
}
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;
// Async operations moved to ActivityPostProcessingService:
// - PersonalRecordService (async)
// - HeatmapGridService (async)
// - WeatherService (async)
// Synchronous operations remain here:
private final AchievementService achievementService;
private final TrainingLoadService trainingLoadService;
private final ActivitySummaryService activitySummaryService;
private final ReverseGeolocationRepository reverseGeolocationRepository;
/**
* Processes an uploaded activity file (FIT or GPX) and creates an activity.
* Uses normal processing mode with all side effects enabled.
*
* @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
) {
return processActivityFile(file, userId, title, description, visibility, ProcessingOptions.normalMode());
}
/**
* Processes an uploaded activity file (FIT or GPX) and creates an activity with custom processing options.
* Allows selective skipping of side effects for batch import scenarios.
*
* @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
* @param options processing options to control side effects
* @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,
ProcessingOptions options
) {
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, options);
} catch (IOException e) {
throw new RuntimeException("Failed to read activity file", e);
}
}
/**
* Reprocess elevation for a GPX activity from its stored raw file.
* Only works for GPX files — FIT files use their session summary.
*
* @param activity the activity to reprocess
* @return true if elevation was updated
*/
@Transactional
public boolean reprocessGpxElevation(Activity activity) {
if (!"GPX".equals(activity.getSourceFileFormat())) {
return false;
}
byte[] rawFile = activity.getRawActivityFile();
if (rawFile == null || rawFile.length == 0) {
log.debug("No raw file stored for activity {}, skipping", activity.getId());
return false;
}
try {
ParsedActivityData parsedData = gpxParser.parse(rawFile);
BigDecimal oldGain = activity.getElevationGain();
activity.setElevationGain(parsedData.getElevationGain());
activity.setElevationLoss(parsedData.getElevationLoss());
activityRepository.save(activity);
log.info("Reprocessed GPX elevation for activity {}: {}m -> {}m",
activity.getId(), oldGain, parsedData.getElevationGain());
return true;
} catch (Exception e) {
log.warn("Failed to reprocess elevation for activity {}: {}", activity.getId(), e.getMessage());
return false;
}
}
/**
* Reprocess elevation for a FIT activity that currently has no per-point
* elevation profile. Re-parses the stored raw FIT bytes with the current parser
* (which reads {@code enhanced_altitude} in addition to the legacy {@code altitude}
* field) and replaces the activity's track-points JSON. Also fills in
* {@code elevationGain}/{@code elevationLoss} if they were missing.
*
* <p>Returns {@code false} (and does not touch the activity) if the activity:
* <ul>
* <li>is not a FIT file,</li>
* <li>has no raw file stored (e.g. uploaded before raw file persistence was added),</li>
* <li>already has a non-null elevation value on at least one track point,</li>
* <li>or fails to re-parse for any reason.</li>
* </ul>
*
* @param activity the activity to reprocess
* @return true if the track points JSON was updated
*/
@Transactional
public boolean reprocessFitElevation(Activity activity) {
if (!"FIT".equals(activity.getSourceFileFormat())) {
return false;
}
byte[] rawFile = activity.getRawActivityFile();
if (rawFile == null || rawFile.length == 0) {
log.debug("No raw file stored for FIT activity {}, skipping", activity.getId());
return false;
}
// Skip activities that already have per-point elevation. This makes the
// backfill idempotent — running it twice in a row is safe and the second
// run is a no-op for already-fixed activities.
if (trackJsonAlreadyHasElevation(activity.getTrackPointsJson())) {
return false;
}
try {
ParsedActivityData parsedData = fitParser.parse(rawFile);
// Replace the entire track points blob. The new JSON will contain
// elevation values from enhanced_altitude (or legacy altitude as
// fallback) thanks to the FitParser fix.
String newJson = convertTrackPointsToJson(parsedData.getTrackPoints());
activity.setTrackPointsJson(newJson);
// Backfill session totals if they were missing. We don't overwrite
// existing values — those came from session.getTotalAscent() at upload
// time and the device's own calculation is likely more accurate than
// anything we'd recompute from the track points.
if (activity.getElevationGain() == null && parsedData.getElevationGain() != null) {
activity.setElevationGain(parsedData.getElevationGain());
}
if (activity.getElevationLoss() == null && parsedData.getElevationLoss() != null) {
activity.setElevationLoss(parsedData.getElevationLoss());
}
activityRepository.save(activity);
log.info("Reprocessed FIT elevation profile for activity {} ({} track points)",
activity.getId(), parsedData.getTrackPoints().size());
return true;
} catch (Exception e) {
log.warn("Failed to reprocess FIT elevation for activity {}: {}", activity.getId(), e.getMessage());
return false;
}
}
/**
* Returns true if the given track points JSON already contains at least one
* non-null {@code elevation} value. Used by the FIT elevation backfill to
* skip activities that don't need to be touched.
*/
private boolean trackJsonAlreadyHasElevation(String trackPointsJson) {
if (trackPointsJson == null || trackPointsJson.isEmpty()) {
return false;
}
try {
com.fasterxml.jackson.databind.JsonNode root = objectMapper.readTree(trackPointsJson);
if (!root.isArray()) {
return false;
}
for (com.fasterxml.jackson.databind.JsonNode point : root) {
com.fasterxml.jackson.databind.JsonNode elevation = point.get("elevation");
if (elevation != null && !elevation.isNull()) {
return true;
}
}
return false;
} catch (Exception e) {
// Malformed JSON shouldn't happen for stored activities, but if it does
// err on the side of "no elevation" so the backfill picks it up.
log.warn("Could not parse track points JSON to check for elevation: {}", e.getMessage());
return false;
}
}
/**
* 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.
*
* @param options processing options to control which side effects are executed
*/
private Activity createActivityFromParsedData(
ParsedActivityData parsedData,
UUID userId,
String title,
String description,
Activity.Visibility visibility,
byte[] rawFile,
ProcessingOptions options
) throws JsonProcessingException {
String activityTitle;
if (title != null && !title.isBlank()) {
activityTitle = title;
} else if (parsedData.getTitle() != null) {
// Try to use title from input file
activityTitle = parsedData.getTitle();
} else {
// Generate title if not provided
activityTitle = ActivityFormatter.generateActivityTitle(parsedData.getStartTime(), parsedData.getTimezone(),
parsedData.getActivityType());
}
// Default to PUBLIC if visibility not specified
Activity.Visibility activityVisibility = visibility != null ? visibility : Activity.Visibility.PRIVATE;
// 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())
.indoor(parsedData.getIndoor() != null ? parsedData.getIndoor() : false)
.subSport(parsedData.getSubSport())
.indoorDetectionMethod(parsedData.getIndoorDetectionMethod() != null ?
parsedData.getIndoorDetectionMethod().name() : null)
.build();
// Convert track points to JSONB
String trackPointsJson = convertTrackPointsToJson(parsedData.getTrackPoints());
activity.setTrackPointsJson(trackPointsJson);
// Create and simplify track only if GPS data is present
if (!parsedData.getTrackPoints().isEmpty()) {
// 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);
} else {
// No GPS track for indoor activities
activity.setSimplifiedTrack(null);
log.info("Activity has no GPS track (indoor activity)");
}
// Create metrics
if (parsedData.getMetrics() != null) {
ActivityMetrics metrics = parsedData.getMetrics().toEntity(activity);
calculateAdditionalMetrics(metrics, parsedData.getTrackPoints());
activity.setMetrics(metrics);
}
activity.findFirstTrackpoint()
.map(tp -> reverseGeolocationRepository.findForLocation(tp.lon(), tp.lat()))
.ifPresent(reverseGeolocation -> activity.setActivityLocation(reverseGeolocation.formatWithHighestResolution()));
// Save activity (single INSERT instead of 855!)
Activity savedActivity = activityRepository.save(activity);
if (savedActivity.getSimplifiedTrack() != null) {
log.info("Successfully created {} activity {} with {} track points (simplified to {} for map)",
parsedData.getSourceFormat(),
savedActivity.getId(),
parsedData.getTrackPoints().size(),
savedActivity.getSimplifiedTrack().getNumPoints());
} else {
log.info("Successfully created {} activity {} (indoor activity without GPS track)",
parsedData.getSourceFormat(),
savedActivity.getId());
}
// Execute synchronous side effects based on processing options
// Personal Records, Heatmap, and Weather are now handled asynchronously by caller (ActivityController)
// In batch import mode, even synchronous operations are skipped and executed later as a batch
if (!options.isSkipAchievements()) {
log.debug("Checking achievements for activity {}", savedActivity.getId());
achievementService.checkAndAwardAchievements(savedActivity);
} else {
log.debug("Skipping achievements check for activity {} (batch mode)", savedActivity.getId());
}
if (!options.isSkipTrainingLoad()) {
log.debug("Updating training load for activity {}", savedActivity.getId());
trainingLoadService.updateTrainingLoad(savedActivity);
} else {
log.debug("Skipping training load update for activity {} (batch mode)", savedActivity.getId());
}
if (!options.isSkipSummaries()) {
log.debug("Updating summaries for activity {}", savedActivity.getId());
activitySummaryService.updateSummariesForActivity(savedActivity);
} else {
log.debug("Skipping summaries update for activity {} (batch mode)", savedActivity.getId());
}
// Note: Async post-processing (Personal Records, Heatmap, Weather, Federation)
// is triggered by the caller (ActivityController) via ActivityPostProcessingService
// This keeps ActivityFileService focused on file parsing and initial activity save
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
}
}