* 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>
575 lines
24 KiB
Java
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
|
|
}
|
|
}
|