Fit File Processing and Persistence
This commit is contained in:
commit
0bc4fb3118
24 changed files with 3533 additions and 0 deletions
39
.gitignore
vendored
Normal file
39
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
target/
|
||||
!.mvn/wrapper/maven-wrapper.jar
|
||||
!**/src/main/**/target/
|
||||
!**/src/test/**/target/
|
||||
.kotlin
|
||||
|
||||
### IntelliJ IDEA ###
|
||||
.idea/modules.xml
|
||||
.idea/jarRepositories.xml
|
||||
.idea/compiler.xml
|
||||
.idea/libraries/
|
||||
*.iws
|
||||
*.iml
|
||||
*.ipr
|
||||
|
||||
### Eclipse ###
|
||||
.apt_generated
|
||||
.classpath
|
||||
.factorypath
|
||||
.project
|
||||
.settings
|
||||
.springBeans
|
||||
.sts4-cache
|
||||
|
||||
### NetBeans ###
|
||||
/nbproject/private/
|
||||
/nbbuild/
|
||||
/dist/
|
||||
/nbdist/
|
||||
/.nb-gradle/
|
||||
build/
|
||||
!**/src/main/**/build/
|
||||
!**/src/test/**/build/
|
||||
|
||||
### VS Code ###
|
||||
.vscode/
|
||||
|
||||
### Mac OS ###
|
||||
.DS_Store
|
||||
8
.idea/.gitignore
generated
vendored
Normal file
8
.idea/.gitignore
generated
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
7
.idea/encodings.xml
generated
Normal file
7
.idea/encodings.xml
generated
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="Encoding">
|
||||
<file url="file://$PROJECT_DIR$/src/main/java" charset="UTF-8" />
|
||||
<file url="file://$PROJECT_DIR$/src/main/resources" charset="UTF-8" />
|
||||
</component>
|
||||
</project>
|
||||
17
.idea/misc.xml
generated
Normal file
17
.idea/misc.xml
generated
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ClojureProjectResolveSettings">
|
||||
<currentScheme>IDE</currentScheme>
|
||||
</component>
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<component name="MavenProjectsManager">
|
||||
<option name="originalFiles">
|
||||
<list>
|
||||
<option value="$PROJECT_DIR$/pom.xml" />
|
||||
</list>
|
||||
</option>
|
||||
</component>
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_23" default="true" project-jdk-name="temurin-23" project-jdk-type="JavaSDK">
|
||||
<output url="file://$PROJECT_DIR$/out" />
|
||||
</component>
|
||||
</project>
|
||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
843
CLAUDE.md
Normal file
843
CLAUDE.md
Normal file
|
|
@ -0,0 +1,843 @@
|
|||
# FitPub - Federated Fitness Tracking Platform
|
||||
|
||||
## Project Overview
|
||||
|
||||
FitPub is a decentralized fitness tracking application that integrates with the Fediverse through the ActivityPub protocol. It allows users to upload FIT (Flexible and Interoperable Data Transfer) files from their fitness devices and share their activities with followers across the federated social web. The application renders GPS tracks on interactive maps and federates workout data as ActivityPub activities.
|
||||
|
||||
## 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.)
|
||||
- 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
|
||||
- Maintain full data ownership and privacy control
|
||||
|
||||
## Technical Architecture
|
||||
|
||||
### Technology Stack
|
||||
|
||||
**Backend:**
|
||||
- Java 17+ (LTS version)
|
||||
- Maven for dependency management and build automation
|
||||
- Spring Boot 4 for application framework
|
||||
- Spring Web MVC for REST API
|
||||
- Spring Data JPA for database operations
|
||||
- Spring Security for authentication and authorization
|
||||
- PostgreSQL for primary data storage
|
||||
- PostGIS extension for geospatial data
|
||||
|
||||
**Frontend:**
|
||||
- Thymeleaf or React for UI rendering
|
||||
- Leaflet.js for interactive map display
|
||||
- Chart.js for activity statistics visualization
|
||||
- Bootstrap or Tailwind CSS for responsive design
|
||||
|
||||
**Protocols & Standards:**
|
||||
- ActivityPub (W3C Recommendation)
|
||||
- WebFinger (RFC 7033) for actor discovery
|
||||
- HTTP Signatures for authenticated federation
|
||||
- JSON-LD for linked data representation
|
||||
- GeoJSON for geographic data interchange
|
||||
|
||||
### System Components
|
||||
|
||||
#### 1. FIT File Processing Module
|
||||
|
||||
**Responsibilities:**
|
||||
- Parse binary FIT files uploaded by users
|
||||
- Extract GPS coordinates (latitude, longitude, elevation)
|
||||
- Parse activity metrics (heart rate, cadence, power, speed, distance)
|
||||
- Validate file integrity and format
|
||||
- Store parsed data in normalized database schema
|
||||
|
||||
**Key Classes:**
|
||||
- `FitFileParser`: Core parsing logic using FIT SDK
|
||||
- `TrackPointEntity`: Database entity for GPS coordinates
|
||||
- `ActivityMetricsEntity`: Database entity for performance data
|
||||
- `FitFileValidator`: Validation and sanitization
|
||||
|
||||
#### 2. ActivityPub Federation Module
|
||||
|
||||
**Responsibilities:**
|
||||
- Implement ActivityPub server-to-server (S2S) protocol
|
||||
- Implement ActivityPub client-to-server (C2S) protocol
|
||||
- Handle incoming activities from other servers
|
||||
- Distribute local activities to followers' servers
|
||||
- Manage actor profiles and collections
|
||||
|
||||
**Key Components:**
|
||||
|
||||
**Actor Model:**
|
||||
```
|
||||
Actor (User Profile)
|
||||
├── inbox: OrderedCollection
|
||||
├── outbox: OrderedCollection
|
||||
├── followers: Collection
|
||||
├── following: Collection
|
||||
└── publicKey: For HTTP signature verification
|
||||
```
|
||||
|
||||
**Activity Types:**
|
||||
- `Create`: New workout activity posted
|
||||
- `Update`: Activity edited (title, description, privacy)
|
||||
- `Delete`: Activity removed
|
||||
- `Follow`: User follows another athlete
|
||||
- `Accept`: Follow request accepted
|
||||
- `Announce`: Sharing/boosting another user's activity
|
||||
- `Like`: Appreciating someone's workout
|
||||
|
||||
**Endpoints:**
|
||||
- `/.well-known/webfinger`: User discovery
|
||||
- `/users/{username}`: Actor profile (ActivityPub object)
|
||||
- `/users/{username}/inbox`: Receive activities (POST)
|
||||
- `/users/{username}/outbox`: User's activities (GET)
|
||||
- `/users/{username}/followers`: Followers collection
|
||||
- `/users/{username}/following`: Following collection
|
||||
- `/activities/{id}`: Individual activity objects
|
||||
|
||||
#### 3. Geospatial Data Module
|
||||
|
||||
**Responsibilities:**
|
||||
- Store GPS track data efficiently
|
||||
- Generate map-ready GeoJSON from track points
|
||||
- Calculate route statistics (distance, elevation gain/loss)
|
||||
- Support spatial queries (nearby activities, route matching)
|
||||
- Render track simplification for performance
|
||||
|
||||
**Data Structure:**
|
||||
```
|
||||
Activity
|
||||
├── id: UUID
|
||||
├── user: Actor reference
|
||||
├── activityType: (Run, Ride, Hike, Swim, etc.)
|
||||
├── startTime: Timestamp
|
||||
├── endTime: Timestamp
|
||||
├── title: String
|
||||
├── description: Text
|
||||
├── visibility: (Public, Followers, Private)
|
||||
├── track: LineString (PostGIS geometry)
|
||||
├── metrics: JSON (distance, duration, avg_speed, etc.)
|
||||
└── statistics: JSON (elevation profile, splits, etc.)
|
||||
```
|
||||
|
||||
**GeoJSON Output Format:**
|
||||
```json
|
||||
{
|
||||
"type": "FeatureCollection",
|
||||
"features": [{
|
||||
"type": "Feature",
|
||||
"geometry": {
|
||||
"type": "LineString",
|
||||
"coordinates": [[lon, lat, elevation], ...]
|
||||
},
|
||||
"properties": {
|
||||
"time": "ISO-8601 timestamp",
|
||||
"heartRate": 145,
|
||||
"cadence": 85,
|
||||
"speed": 4.5
|
||||
}
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. Web API Module
|
||||
|
||||
**REST Endpoints:**
|
||||
|
||||
**Activity Management:**
|
||||
- `POST /api/activities/upload`: Upload FIT file
|
||||
- `GET /api/activities/{id}`: Retrieve activity details
|
||||
- `GET /api/activities/{id}/track`: Get GeoJSON track data
|
||||
- `PUT /api/activities/{id}`: Update activity metadata
|
||||
- `DELETE /api/activities/{id}`: Remove activity
|
||||
- `GET /api/activities`: List user's activities (paginated)
|
||||
|
||||
**User Management:**
|
||||
- `POST /api/users/register`: Create new account
|
||||
- `GET /api/users/{username}`: Public profile
|
||||
- `GET /api/users/{username}/activities`: User's public activities
|
||||
- `PUT /api/users/profile`: Update profile information
|
||||
|
||||
**Social Features:**
|
||||
- `POST /api/follow/{username}`: Follow a user
|
||||
- `DELETE /api/follow/{username}`: Unfollow
|
||||
- `GET /api/timeline`: Federated timeline of followed users
|
||||
- `POST /api/activities/{id}/like`: Like an activity
|
||||
- `POST /api/activities/{id}/comment`: Comment on activity
|
||||
|
||||
#### 5. Authentication & Authorization
|
||||
|
||||
**Implementation:**
|
||||
- JWT tokens for session management
|
||||
- OAuth 2.0 for third-party integrations (optional)
|
||||
- HTTP Signatures (required for ActivityPub federation)
|
||||
- RSA key pairs per user for signature verification
|
||||
|
||||
**Security Considerations:**
|
||||
- Password hashing with bcrypt
|
||||
- Rate limiting on API endpoints
|
||||
- CORS configuration for frontend
|
||||
- Content-Security-Policy headers
|
||||
- Input validation and sanitization
|
||||
- Protection against SSRF in federation
|
||||
|
||||
## Database Schema
|
||||
|
||||
### Core Tables
|
||||
|
||||
**users**
|
||||
- id (UUID, PK)
|
||||
- username (VARCHAR, UNIQUE)
|
||||
- email (VARCHAR, UNIQUE)
|
||||
- password_hash (VARCHAR)
|
||||
- display_name (VARCHAR)
|
||||
- bio (TEXT)
|
||||
- avatar_url (VARCHAR)
|
||||
- public_key (TEXT)
|
||||
- private_key (TEXT, encrypted)
|
||||
- created_at (TIMESTAMP)
|
||||
- updated_at (TIMESTAMP)
|
||||
|
||||
**activities**
|
||||
- id (UUID, PK)
|
||||
- user_id (UUID, FK → users)
|
||||
- activity_type (VARCHAR)
|
||||
- title (VARCHAR)
|
||||
- description (TEXT)
|
||||
- started_at (TIMESTAMP)
|
||||
- ended_at (TIMESTAMP)
|
||||
- visibility (VARCHAR)
|
||||
- track (GEOMETRY LineString, PostGIS)
|
||||
- total_distance (DECIMAL)
|
||||
- total_duration (INTERVAL)
|
||||
- elevation_gain (DECIMAL)
|
||||
- elevation_loss (DECIMAL)
|
||||
- raw_fit_file (BYTEA, optional)
|
||||
- created_at (TIMESTAMP)
|
||||
- updated_at (TIMESTAMP)
|
||||
|
||||
**track_points**
|
||||
- id (BIGSERIAL, PK)
|
||||
- activity_id (UUID, FK → activities)
|
||||
- timestamp (TIMESTAMP)
|
||||
- position (GEOMETRY Point, PostGIS)
|
||||
- elevation (DECIMAL)
|
||||
- heart_rate (INTEGER)
|
||||
- cadence (INTEGER)
|
||||
- power (INTEGER)
|
||||
- speed (DECIMAL)
|
||||
- temperature (DECIMAL)
|
||||
|
||||
**follows**
|
||||
- id (UUID, PK)
|
||||
- follower_id (UUID, FK → users)
|
||||
- following_id (UUID, FK → users OR remote_actor_id)
|
||||
- status (VARCHAR: pending, accepted)
|
||||
- created_at (TIMESTAMP)
|
||||
|
||||
**remote_actors**
|
||||
- id (UUID, PK)
|
||||
- actor_uri (VARCHAR, UNIQUE)
|
||||
- username (VARCHAR)
|
||||
- domain (VARCHAR)
|
||||
- inbox_url (VARCHAR)
|
||||
- outbox_url (VARCHAR)
|
||||
- public_key (TEXT)
|
||||
- avatar_url (VARCHAR)
|
||||
- display_name (VARCHAR)
|
||||
- last_fetched (TIMESTAMP)
|
||||
|
||||
**activity_pub_activities**
|
||||
- id (UUID, PK)
|
||||
- activity_type (VARCHAR)
|
||||
- actor_id (UUID)
|
||||
- object_id (VARCHAR)
|
||||
- target_id (VARCHAR)
|
||||
- content (JSONB)
|
||||
- created_at (TIMESTAMP)
|
||||
|
||||
**likes**
|
||||
- id (UUID, PK)
|
||||
- activity_id (UUID, FK → activities)
|
||||
- user_id (UUID, FK → users OR remote_actor_id)
|
||||
- created_at (TIMESTAMP)
|
||||
|
||||
**comments**
|
||||
- id (UUID, PK)
|
||||
- activity_id (UUID, FK → activities)
|
||||
- user_id (UUID, FK → users OR remote_actor_id)
|
||||
- content (TEXT)
|
||||
- created_at (TIMESTAMP)
|
||||
- updated_at (TIMESTAMP)
|
||||
|
||||
## ActivityPub Integration Details
|
||||
|
||||
### Actor Object Example
|
||||
|
||||
```json
|
||||
{
|
||||
"@context": [
|
||||
"https://www.w3.org/ns/activitystreams",
|
||||
"https://w3id.org/security/v1"
|
||||
],
|
||||
"type": "Person",
|
||||
"id": "https://fitpub.example/users/runner123",
|
||||
"preferredUsername": "runner123",
|
||||
"name": "Jane Runner",
|
||||
"summary": "Marathon runner | Trail enthusiast | 🏃♀️",
|
||||
"inbox": "https://fitpub.example/users/runner123/inbox",
|
||||
"outbox": "https://fitpub.example/users/runner123/outbox",
|
||||
"followers": "https://fitpub.example/users/runner123/followers",
|
||||
"following": "https://fitpub.example/users/runner123/following",
|
||||
"publicKey": {
|
||||
"id": "https://fitpub.example/users/runner123#main-key",
|
||||
"owner": "https://fitpub.example/users/runner123",
|
||||
"publicKeyPem": "-----BEGIN PUBLIC KEY-----\n..."
|
||||
},
|
||||
"icon": {
|
||||
"type": "Image",
|
||||
"mediaType": "image/jpeg",
|
||||
"url": "https://fitpub.example/avatars/runner123.jpg"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Activity Object Example (Workout Post)
|
||||
|
||||
```json
|
||||
{
|
||||
"@context": "https://www.w3.org/ns/activitystreams",
|
||||
"type": "Create",
|
||||
"id": "https://fitpub.example/activities/create/12345",
|
||||
"actor": "https://fitpub.example/users/runner123",
|
||||
"published": "2025-11-27T10:30:00Z",
|
||||
"to": ["https://www.w3.org/ns/activitystreams#Public"],
|
||||
"cc": ["https://fitpub.example/users/runner123/followers"],
|
||||
"object": {
|
||||
"type": "Note",
|
||||
"id": "https://fitpub.example/workouts/98765",
|
||||
"attributedTo": "https://fitpub.example/users/runner123",
|
||||
"content": "Morning 10K run through the park! Felt strong today. 💪",
|
||||
"published": "2025-11-27T10:30:00Z",
|
||||
"attachment": [
|
||||
{
|
||||
"type": "Document",
|
||||
"mediaType": "application/geo+json",
|
||||
"name": "GPS Track",
|
||||
"url": "https://fitpub.example/workouts/98765/track.geojson"
|
||||
},
|
||||
{
|
||||
"type": "Image",
|
||||
"mediaType": "image/png",
|
||||
"name": "Route Map",
|
||||
"url": "https://fitpub.example/workouts/98765/map.png"
|
||||
}
|
||||
],
|
||||
"tag": [
|
||||
{
|
||||
"type": "Hashtag",
|
||||
"name": "#running"
|
||||
},
|
||||
{
|
||||
"type": "Hashtag",
|
||||
"name": "#10k"
|
||||
}
|
||||
],
|
||||
"summary": "10.2 km • 48:23 • 4:44/km pace",
|
||||
"workoutData": {
|
||||
"distance": 10200,
|
||||
"duration": "PT48M23S",
|
||||
"activityType": "Run",
|
||||
"averagePace": "PT4M44S",
|
||||
"elevationGain": 127,
|
||||
"averageHeartRate": 152
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### WebFinger Implementation
|
||||
|
||||
**Request:**
|
||||
```
|
||||
GET /.well-known/webfinger?resource=acct:runner123@fitpub.example
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"subject": "acct:runner123@fitpub.example",
|
||||
"aliases": [
|
||||
"https://fitpub.example/users/runner123"
|
||||
],
|
||||
"links": [
|
||||
{
|
||||
"rel": "self",
|
||||
"type": "application/activity+json",
|
||||
"href": "https://fitpub.example/users/runner123"
|
||||
},
|
||||
{
|
||||
"rel": "http://webfinger.net/rel/profile-page",
|
||||
"type": "text/html",
|
||||
"href": "https://fitpub.example/@runner123"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## FIT File Processing Pipeline
|
||||
|
||||
### Parse Flow
|
||||
|
||||
1. **Upload Validation**
|
||||
- Verify file size (max 50MB)
|
||||
- Check MIME type
|
||||
- Validate FIT file header
|
||||
|
||||
2. **Parsing**
|
||||
- Use FIT SDK to decode binary format
|
||||
- Extract messages: FileId, Record, Lap, Session, Activity
|
||||
- Handle corrupted or incomplete files gracefully
|
||||
|
||||
3. **Data Extraction**
|
||||
- **Record Messages**: GPS coordinates, timestamp, heart rate, cadence, speed, power
|
||||
- **Lap Messages**: Split data, lap times, lap distances
|
||||
- **Session Messages**: Total distance, total time, average/max values
|
||||
- **Activity Messages**: Activity type, timestamp
|
||||
|
||||
4. **Data Transformation**
|
||||
- Convert semicircles to decimal degrees (lat/lon)
|
||||
- Calculate derived metrics (pace, grade-adjusted pace)
|
||||
- Detect pauses and stopped periods
|
||||
- Smooth GPS noise using Kalman filtering (optional)
|
||||
|
||||
5. **Storage**
|
||||
- Batch insert track points (optimize for performance)
|
||||
- Create PostGIS LineString geometry from points
|
||||
- Calculate bounding box for spatial indexing
|
||||
- Generate activity statistics
|
||||
|
||||
6. **Map Rendering Preparation**
|
||||
- Simplify track using Douglas-Peucker algorithm for web display
|
||||
- Generate elevation profile data
|
||||
- Create thumbnail static map image (optional)
|
||||
- Prepare GeoJSON response
|
||||
|
||||
## Privacy & Visibility Controls
|
||||
|
||||
### Visibility Levels
|
||||
|
||||
1. **Public**: Visible to everyone, federated across ActivityPub
|
||||
2. **Followers Only**: Visible to approved followers, sent to follower inboxes
|
||||
3. **Private**: Visible only to the user, not federated
|
||||
|
||||
### Privacy Features
|
||||
|
||||
- Ability to hide exact start/end locations (fuzzy start/finish)
|
||||
- Option to exclude specific segments from public view
|
||||
- Bulk privacy updates for historical activities
|
||||
- Export all personal data (GDPR compliance)
|
||||
- Delete account with activity cleanup
|
||||
|
||||
## Map Rendering
|
||||
|
||||
### Frontend Map Stack
|
||||
|
||||
**Leaflet.js Configuration:**
|
||||
- Base layer: OpenStreetMap tiles
|
||||
- Alternative layers: Satellite, Terrain (Thunderforest, Mapbox)
|
||||
- Custom track overlay as GeoJSON layer
|
||||
- Markers for start (green) and finish (red)
|
||||
- Popup markers for lap splits
|
||||
- Heatmap overlay for intensity (heart rate zones)
|
||||
|
||||
**Interactive Features:**
|
||||
- Click on track to see point-in-time metrics
|
||||
- Elevation profile chart synchronized with map
|
||||
- Segment highlighting
|
||||
- Playback animation of activity
|
||||
- Compare multiple activities on same map
|
||||
|
||||
### Static Map Generation
|
||||
|
||||
For ActivityPub federated posts and thumbnails:
|
||||
- Use StaticMap library or external service
|
||||
- Generate PNG/JPEG preview of route
|
||||
- Include in ActivityPub attachment
|
||||
- Cache generated images
|
||||
|
||||
## Federation Workflow Examples
|
||||
|
||||
### Scenario 1: User Posts New Activity
|
||||
|
||||
1. User uploads FIT file via web UI
|
||||
2. Backend parses file and stores data
|
||||
3. User adds title, description, and sets visibility to "Public"
|
||||
4. System creates ActivityPub `Create` activity
|
||||
5. Activity is added to user's outbox
|
||||
6. System retrieves list of followers
|
||||
7. For each remote follower:
|
||||
- Sign HTTP request with user's private key
|
||||
- POST activity to follower's server inbox
|
||||
8. Remote servers receive and process the activity
|
||||
9. Activity appears in followers' timelines on their platforms
|
||||
|
||||
### Scenario 2: Remote User Follows Local User
|
||||
|
||||
1. Remote user (on Mastodon) clicks "Follow" on local user's profile
|
||||
2. Mastodon server sends `Follow` activity to local user's inbox
|
||||
3. FitPub receives and validates the activity
|
||||
4. FitPub creates `Accept` activity in response
|
||||
5. FitPub stores the follow relationship
|
||||
6. FitPub sends `Accept` to remote server's inbox
|
||||
7. Follow relationship is now active
|
||||
8. Future public activities will be sent to remote follower
|
||||
|
||||
### Scenario 3: Remote User Likes Local Activity
|
||||
|
||||
1. Remote user views local activity on their platform
|
||||
2. User clicks "Like" or equivalent action
|
||||
3. Remote server sends `Like` activity to local inbox
|
||||
4. FitPub receives and processes the like
|
||||
5. Like count is incremented and stored
|
||||
6. Like appears on activity page
|
||||
7. Original poster may receive notification
|
||||
|
||||
## Development Roadmap
|
||||
|
||||
### Phase 1: MVP (Minimum Viable Product)
|
||||
- [ ] FIT file upload and parsing
|
||||
- [ ] Basic activity storage and display
|
||||
- [ ] Interactive map rendering with Leaflet
|
||||
- [ ] User registration and authentication
|
||||
- [ ] ActivityPub actor profile implementation
|
||||
- [ ] WebFinger support
|
||||
- [ ] Basic federation (Create, Follow, Accept activities)
|
||||
- [ ] Public timeline view
|
||||
|
||||
### Phase 2: Social Features
|
||||
- [ ] Likes and comments
|
||||
- [ ] Activity sharing (Announce)
|
||||
- [ ] User search and discovery
|
||||
- [ ] Followers/following lists
|
||||
- [ ] Notifications system
|
||||
- [ ] Privacy controls
|
||||
- [ ] Activity editing and deletion
|
||||
|
||||
### Phase 3: Advanced Analytics
|
||||
- [ ] Personal records tracking
|
||||
- [ ] Training load and recovery metrics
|
||||
- [ ] Segment comparison (Strava-like)
|
||||
- [ ] Achievement/badge system
|
||||
- [ ] Weekly/monthly summaries
|
||||
- [ ] Route recommendations
|
||||
- [ ] Weather data integration
|
||||
|
||||
### Phase 4: Enhanced Federation
|
||||
- [ ] Rich preview cards for activities
|
||||
- [ ] Media attachments (photos from workout)
|
||||
- [ ] Activity challenges (federated events)
|
||||
- [ ] Group/club support
|
||||
- [ ] Cross-platform activity sync
|
||||
|
||||
### Phase 5: Mobile & Integrations
|
||||
- [ ] Mobile-responsive web design
|
||||
- [ ] Progressive Web App (PWA)
|
||||
- [ ] Native mobile apps (optional)
|
||||
- [ ] Direct device sync (Garmin Connect API)
|
||||
- [ ] Webhook integrations
|
||||
- [ ] Import from Strava, Garmin, etc.
|
||||
|
||||
## Maven Project Structure
|
||||
|
||||
```
|
||||
fitpub/
|
||||
├── pom.xml
|
||||
├── src/
|
||||
│ ├── main/
|
||||
│ │ ├── java/
|
||||
│ │ │ └── com/
|
||||
│ │ │ └── fitpub/
|
||||
│ │ │ ├── FitPubApplication.java
|
||||
│ │ │ ├── config/
|
||||
│ │ │ │ ├── SecurityConfig.java
|
||||
│ │ │ │ ├── WebConfig.java
|
||||
│ │ │ │ └── ActivityPubConfig.java
|
||||
│ │ │ ├── controller/
|
||||
│ │ │ │ ├── ActivityController.java
|
||||
│ │ │ │ ├── UserController.java
|
||||
│ │ │ │ ├── ActivityPubController.java
|
||||
│ │ │ │ └── WebFingerController.java
|
||||
│ │ │ ├── service/
|
||||
│ │ │ │ ├── FitFileService.java
|
||||
│ │ │ │ ├── ActivityService.java
|
||||
│ │ │ │ ├── FederationService.java
|
||||
│ │ │ │ ├── UserService.java
|
||||
│ │ │ │ └── GeospatialService.java
|
||||
│ │ │ ├── model/
|
||||
│ │ │ │ ├── entity/
|
||||
│ │ │ │ │ ├── User.java
|
||||
│ │ │ │ │ ├── Activity.java
|
||||
│ │ │ │ │ ├── TrackPoint.java
|
||||
│ │ │ │ │ ├── Follow.java
|
||||
│ │ │ │ │ └── RemoteActor.java
|
||||
│ │ │ │ ├── dto/
|
||||
│ │ │ │ │ ├── ActivityDTO.java
|
||||
│ │ │ │ │ ├── UserDTO.java
|
||||
│ │ │ │ │ └── TrackDTO.java
|
||||
│ │ │ │ └── activitypub/
|
||||
│ │ │ │ ├── Actor.java
|
||||
│ │ │ │ ├── Activity.java
|
||||
│ │ │ │ └── Collection.java
|
||||
│ │ │ ├── repository/
|
||||
│ │ │ │ ├── UserRepository.java
|
||||
│ │ │ │ ├── ActivityRepository.java
|
||||
│ │ │ │ ├── TrackPointRepository.java
|
||||
│ │ │ │ └── FollowRepository.java
|
||||
│ │ │ ├── security/
|
||||
│ │ │ │ ├── JwtTokenProvider.java
|
||||
│ │ │ │ ├── HttpSignatureValidator.java
|
||||
│ │ │ │ └── UserDetailsServiceImpl.java
|
||||
│ │ │ └── util/
|
||||
│ │ │ ├── FitParser.java
|
||||
│ │ │ ├── GeoJsonConverter.java
|
||||
│ │ │ └── ActivityPubUtil.java
|
||||
│ │ └── resources/
|
||||
│ │ ├── application.yml
|
||||
│ │ ├── application-dev.yml
|
||||
│ │ ├── application-prod.yml
|
||||
│ │ ├── static/
|
||||
│ │ │ ├── css/
|
||||
│ │ │ ├── js/
|
||||
│ │ │ └── img/
|
||||
│ │ └── templates/
|
||||
│ │ ├── index.html
|
||||
│ │ ├── activity.html
|
||||
│ │ └── profile.html
|
||||
│ └── test/
|
||||
│ └── java/
|
||||
│ └── com/
|
||||
│ └── fitpub/
|
||||
│ ├── service/
|
||||
│ ├── controller/
|
||||
│ └── integration/
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Key Maven Dependencies
|
||||
|
||||
```xml
|
||||
<dependencies>
|
||||
<!-- Spring Boot Starters -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Database -->
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.hibernate</groupId>
|
||||
<artifactId>hibernate-spatial</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- FIT File Processing -->
|
||||
<dependency>
|
||||
<groupId>com.garmin</groupId>
|
||||
<artifactId>fit</artifactId>
|
||||
<version>21.XX.XX</version>
|
||||
</dependency>
|
||||
|
||||
<!-- JSON Processing -->
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- HTTP Client -->
|
||||
<dependency>
|
||||
<groupId>org.apache.httpcomponents.client5</groupId>
|
||||
<artifactId>httpclient5</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- JWT -->
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-api</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Validation -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Testing -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
```
|
||||
|
||||
## Configuration Examples
|
||||
|
||||
### application.yml
|
||||
|
||||
```yaml
|
||||
spring:
|
||||
application:
|
||||
name: fitpub
|
||||
datasource:
|
||||
url: jdbc:postgresql://localhost:5432/fitpub
|
||||
username: fitpub_user
|
||||
password: ${DB_PASSWORD}
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: validate
|
||||
properties:
|
||||
hibernate:
|
||||
dialect: org.hibernate.spatial.dialect.postgis.PostgisDialect
|
||||
servlet:
|
||||
multipart:
|
||||
max-file-size: 50MB
|
||||
max-request-size: 50MB
|
||||
|
||||
fitpub:
|
||||
domain: fitpub.example
|
||||
base-url: https://fitpub.example
|
||||
activitypub:
|
||||
enabled: true
|
||||
max-federation-retries: 3
|
||||
security:
|
||||
jwt:
|
||||
secret: ${JWT_SECRET}
|
||||
expiration: 86400000 # 24 hours
|
||||
storage:
|
||||
fit-files:
|
||||
path: /var/fitpub/fit-files
|
||||
retention-days: 365
|
||||
```
|
||||
|
||||
## Deployment Considerations
|
||||
|
||||
### Infrastructure Requirements
|
||||
|
||||
- **Application Server**: JVM-based (Java 17+)
|
||||
- **Database**: PostgreSQL 13+ with PostGIS extension
|
||||
- **Reverse Proxy**: Nginx or Traefik for HTTPS termination
|
||||
- **Storage**: File storage for FIT files and generated assets
|
||||
- **Cache**: Redis (optional, for session management)
|
||||
|
||||
### Scaling Strategy
|
||||
|
||||
- Horizontal scaling of application servers
|
||||
- Database read replicas for heavy read operations
|
||||
- CDN for static assets and map tiles
|
||||
- Background job processing for FIT file parsing (Spring Batch or async)
|
||||
- Rate limiting and request throttling
|
||||
|
||||
### Monitoring & Observability
|
||||
|
||||
- Application metrics (Micrometer + Prometheus)
|
||||
- Database query performance monitoring
|
||||
- Federation success/failure rates
|
||||
- API response times
|
||||
- User activity analytics
|
||||
|
||||
## Legal & Compliance
|
||||
|
||||
### Licensing Considerations
|
||||
|
||||
- Choose appropriate open-source license (AGPL, MIT, Apache 2.0)
|
||||
- Comply with FIT SDK licensing terms
|
||||
- Attribute map tile providers
|
||||
- Terms of Service for user-generated content
|
||||
- Privacy Policy (GDPR, CCPA compliance)
|
||||
|
||||
### Data Retention
|
||||
|
||||
- User data export functionality
|
||||
- Right to be forgotten (account deletion)
|
||||
- Activity data backup procedures
|
||||
- Federation data cleanup (remove data from remote deleted actors)
|
||||
|
||||
## Community & Contribution
|
||||
|
||||
### Open Source Goals
|
||||
|
||||
- Public GitHub repository
|
||||
- Contribution guidelines
|
||||
- Code of conduct
|
||||
- Issue templates
|
||||
- Documentation for developers
|
||||
- Public roadmap and feature requests
|
||||
|
||||
### Fediverse Integration
|
||||
|
||||
- Publish FEP (Fediverse Enhancement Proposal) if introducing custom extensions
|
||||
- Collaborate with other ActivityPub developers
|
||||
- Test interoperability with major platforms (Mastodon, Pleroma, Pixelfed)
|
||||
- Participate in Fediverse developer community
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- **AI-Powered Insights**: Training recommendations, injury prevention
|
||||
- **Virtual Racing**: Compete on same routes asynchronously
|
||||
- **Route Planning**: Create routes and share with community
|
||||
- **Live Tracking**: Real-time activity sharing during workout
|
||||
- **Wearable Integration**: Direct sync with smartwatches
|
||||
- **Audio Cues**: Export audio-guided workouts
|
||||
- **Social Challenges**: Group goals and competitions
|
||||
- **Marketplace**: Routes, training plans, coaching services
|
||||
|
||||
---
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Java 17 or higher
|
||||
- Maven 3.8+
|
||||
- PostgreSQL 13+ with PostGIS
|
||||
- Git
|
||||
|
||||
### Quick Start
|
||||
|
||||
1. Clone repository
|
||||
2. Set up PostgreSQL database with PostGIS extension
|
||||
3. Configure `application.yml` with database credentials
|
||||
4. Run `mvn clean install`
|
||||
5. Start application: `mvn spring-boot:run`
|
||||
6. Access at `http://localhost:8080`
|
||||
|
||||
### First Steps
|
||||
|
||||
1. Register a user account
|
||||
2. Upload a FIT file from your GPS device
|
||||
3. View your activity on the interactive map
|
||||
4. Set up ActivityPub federation (optional)
|
||||
5. Follow other athletes on the Fediverse
|
||||
|
||||
---
|
||||
|
||||
**Project Status**: Planning & Initial Development
|
||||
|
||||
**License**: TBD
|
||||
|
||||
**Contributors Welcome**: Yes
|
||||
|
||||
**Contact**: [Project repository or contact information]
|
||||
150
pom.xml
Normal file
150
pom.xml
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>3.2.0</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
|
||||
<groupId>org.operaton</groupId>
|
||||
<artifactId>feditrack</artifactId>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
<name>FitPub</name>
|
||||
<description>Federated Fitness Tracking Platform with ActivityPub integration</description>
|
||||
|
||||
<properties>
|
||||
<maven.compiler.source>17</maven.compiler.source>
|
||||
<maven.compiler.target>17</maven.compiler.target>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<java.version>17</java.version>
|
||||
<jjwt.version>0.12.3</jjwt.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<!-- Spring Boot Starters -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Database -->
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.hibernate.orm</groupId>
|
||||
<artifactId>hibernate-spatial</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.hypersistence</groupId>
|
||||
<artifactId>hypersistence-utils-hibernate-63</artifactId>
|
||||
<version>3.7.0</version>
|
||||
</dependency>
|
||||
|
||||
<!-- FIT File Processing -->
|
||||
<dependency>
|
||||
<groupId>com.garmin</groupId>
|
||||
<artifactId>fit</artifactId>
|
||||
<version>21.141.0</version>
|
||||
</dependency>
|
||||
|
||||
<!-- JSON Processing -->
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.datatype</groupId>
|
||||
<artifactId>jackson-datatype-jsr310</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- HTTP Client -->
|
||||
<dependency>
|
||||
<groupId>org.apache.httpcomponents.client5</groupId>
|
||||
<artifactId>httpclient5</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- JWT -->
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-api</artifactId>
|
||||
<version>${jjwt.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-impl</artifactId>
|
||||
<version>${jjwt.version}</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-jackson</artifactId>
|
||||
<version>${jjwt.version}</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- Lombok (optional but useful) -->
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
|
||||
<!-- Testing -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.security</groupId>
|
||||
<artifactId>spring-security-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- Development Tools -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-devtools</artifactId>
|
||||
<scope>runtime</scope>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
<configuration>
|
||||
<excludes>
|
||||
<exclude>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
</exclude>
|
||||
</excludes>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
</project>
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
68
src/main/java/org/operaton/fitpub/model/dto/ActivityDTO.java
Normal file
68
src/main/java/org/operaton/fitpub/model/dto/ActivityDTO.java
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
150
src/main/java/org/operaton/fitpub/model/entity/Activity.java
Normal file
150
src/main/java/org/operaton/fitpub/model/entity/Activity.java
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
349
src/main/java/org/operaton/fitpub/service/FitFileService.java
Normal file
349
src/main/java/org/operaton/fitpub/service/FitFileService.java
Normal 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);
|
||||
}
|
||||
}
|
||||
137
src/main/java/org/operaton/fitpub/util/FitFileValidator.java
Normal file
137
src/main/java/org/operaton/fitpub/util/FitFileValidator.java
Normal 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");
|
||||
}
|
||||
}
|
||||
401
src/main/java/org/operaton/fitpub/util/FitParser.java
Normal file
401
src/main/java/org/operaton/fitpub/util/FitParser.java
Normal 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;
|
||||
}
|
||||
}
|
||||
182
src/main/java/org/operaton/fitpub/util/TrackSimplifier.java
Normal file
182
src/main/java/org/operaton/fitpub/util/TrackSimplifier.java
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,370 @@
|
|||
package org.operaton.fitpub.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.locationtech.jts.geom.Coordinate;
|
||||
import org.locationtech.jts.geom.GeometryFactory;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.Spy;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.operaton.fitpub.exception.FitFileProcessingException;
|
||||
import org.operaton.fitpub.model.entity.Activity;
|
||||
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.mock.web.MockMultipartFile;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* Unit tests for FitFileService.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class FitFileServiceTest {
|
||||
|
||||
@Mock
|
||||
private FitFileValidator validator;
|
||||
|
||||
@Mock
|
||||
private FitParser parser;
|
||||
|
||||
@Mock
|
||||
private TrackSimplifier trackSimplifier;
|
||||
|
||||
@Mock
|
||||
private ActivityRepository activityRepository;
|
||||
|
||||
@Mock
|
||||
private ActivityMetricsRepository metricsRepository;
|
||||
|
||||
@Spy
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@InjectMocks
|
||||
private FitFileService fitFileService;
|
||||
|
||||
private UUID testUserId;
|
||||
private MockMultipartFile testFile;
|
||||
private FitParser.ParsedFitData testParsedData;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
testUserId = UUID.randomUUID();
|
||||
testFile = new MockMultipartFile(
|
||||
"file",
|
||||
"test-activity.fit",
|
||||
"application/octet-stream",
|
||||
new byte[100]
|
||||
);
|
||||
|
||||
// Configure ObjectMapper for Java 8 Time
|
||||
objectMapper.registerModule(new JavaTimeModule());
|
||||
|
||||
// Create test parsed data
|
||||
testParsedData = createTestParsedData();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should successfully process a valid FIT file")
|
||||
void testProcessFitFileSuccess() throws Exception {
|
||||
// Arrange
|
||||
when(parser.parse(any(byte[].class))).thenReturn(testParsedData);
|
||||
when(trackSimplifier.simplify(any())).thenAnswer(invocation -> {
|
||||
Coordinate[] coords = invocation.getArgument(0);
|
||||
return new GeometryFactory().createLineString(coords);
|
||||
});
|
||||
when(activityRepository.save(any(Activity.class))).thenAnswer(invocation -> {
|
||||
Activity activity = invocation.getArgument(0);
|
||||
activity.setId(UUID.randomUUID());
|
||||
activity.setCreatedAt(LocalDateTime.now());
|
||||
activity.setUpdatedAt(LocalDateTime.now());
|
||||
return activity;
|
||||
});
|
||||
|
||||
// Act
|
||||
Activity result = fitFileService.processFitFile(
|
||||
testFile,
|
||||
testUserId,
|
||||
"Test Run",
|
||||
"Morning run",
|
||||
Activity.Visibility.PUBLIC
|
||||
);
|
||||
|
||||
// Assert
|
||||
assertNotNull(result);
|
||||
assertEquals("Test Run", result.getTitle());
|
||||
assertEquals("Morning run", result.getDescription());
|
||||
assertEquals(testUserId, result.getUserId());
|
||||
assertEquals(Activity.Visibility.PUBLIC, result.getVisibility());
|
||||
assertEquals(Activity.ActivityType.RUN, result.getActivityType());
|
||||
|
||||
verify(validator).validate(any(), anyLong());
|
||||
verify(parser).parse(any(byte[].class));
|
||||
verify(activityRepository).save(any(Activity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should generate default title when title is null")
|
||||
void testProcessFitFileWithDefaultTitle() throws Exception {
|
||||
// Arrange
|
||||
when(parser.parse(any(byte[].class))).thenReturn(testParsedData);
|
||||
when(trackSimplifier.simplify(any())).thenAnswer(invocation -> {
|
||||
Coordinate[] coords = invocation.getArgument(0);
|
||||
return new GeometryFactory().createLineString(coords);
|
||||
});
|
||||
when(activityRepository.save(any(Activity.class))).thenAnswer(invocation -> {
|
||||
Activity activity = invocation.getArgument(0);
|
||||
activity.setId(UUID.randomUUID());
|
||||
return activity;
|
||||
});
|
||||
|
||||
// Act
|
||||
Activity result = fitFileService.processFitFile(
|
||||
testFile,
|
||||
testUserId,
|
||||
null,
|
||||
null,
|
||||
Activity.Visibility.PUBLIC
|
||||
);
|
||||
|
||||
// Assert
|
||||
assertNotNull(result);
|
||||
assertTrue(result.getTitle().contains("Run"));
|
||||
assertTrue(result.getTitle().contains(testParsedData.getStartTime().toLocalDate().toString()));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should throw exception when validator fails")
|
||||
void testProcessFitFileValidationFailure() throws Exception {
|
||||
// Arrange
|
||||
doThrow(new FitFileProcessingException("Invalid file"))
|
||||
.when(validator).validate(any(), anyLong());
|
||||
|
||||
// Act & Assert
|
||||
assertThrows(FitFileProcessingException.class, () ->
|
||||
fitFileService.processFitFile(
|
||||
testFile,
|
||||
testUserId,
|
||||
"Test",
|
||||
null,
|
||||
Activity.Visibility.PUBLIC
|
||||
)
|
||||
);
|
||||
|
||||
verify(parser, never()).parse(any(byte[].class));
|
||||
verify(activityRepository, never()).save(any(Activity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should throw exception when parser fails")
|
||||
void testProcessFitFileParsingFailure() throws Exception {
|
||||
// Arrange
|
||||
when(parser.parse(any(byte[].class)))
|
||||
.thenThrow(new FitFileProcessingException("Parsing failed"));
|
||||
|
||||
// Act & Assert
|
||||
assertThrows(FitFileProcessingException.class, () ->
|
||||
fitFileService.processFitFile(
|
||||
testFile,
|
||||
testUserId,
|
||||
"Test",
|
||||
null,
|
||||
Activity.Visibility.PUBLIC
|
||||
)
|
||||
);
|
||||
|
||||
verify(activityRepository, never()).save(any(Activity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should successfully delete an activity")
|
||||
void testDeleteActivity() {
|
||||
// Arrange
|
||||
UUID activityId = UUID.randomUUID();
|
||||
Activity activity = Activity.builder()
|
||||
.id(activityId)
|
||||
.userId(testUserId)
|
||||
.build();
|
||||
|
||||
when(activityRepository.findByIdAndUserId(activityId, testUserId))
|
||||
.thenReturn(Optional.of(activity));
|
||||
|
||||
// Act
|
||||
boolean result = fitFileService.deleteActivity(activityId, testUserId);
|
||||
|
||||
// Assert
|
||||
assertTrue(result);
|
||||
verify(activityRepository).delete(activity);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should return false when deleting non-existent activity")
|
||||
void testDeleteNonExistentActivity() {
|
||||
// Arrange
|
||||
UUID activityId = UUID.randomUUID();
|
||||
when(activityRepository.findByIdAndUserId(activityId, testUserId))
|
||||
.thenReturn(Optional.empty());
|
||||
|
||||
// Act
|
||||
boolean result = fitFileService.deleteActivity(activityId, testUserId);
|
||||
|
||||
// Assert
|
||||
assertFalse(result);
|
||||
verify(activityRepository, never()).delete(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should retrieve activity by ID and user ID")
|
||||
void testGetActivity() {
|
||||
// Arrange
|
||||
UUID activityId = UUID.randomUUID();
|
||||
Activity activity = Activity.builder()
|
||||
.id(activityId)
|
||||
.userId(testUserId)
|
||||
.title("Test Activity")
|
||||
.build();
|
||||
|
||||
when(activityRepository.findByIdAndUserId(activityId, testUserId))
|
||||
.thenReturn(Optional.of(activity));
|
||||
|
||||
// Act
|
||||
Activity result = fitFileService.getActivity(activityId, testUserId);
|
||||
|
||||
// Assert
|
||||
assertNotNull(result);
|
||||
assertEquals(activityId, result.getId());
|
||||
assertEquals("Test Activity", result.getTitle());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should return null for non-existent activity")
|
||||
void testGetNonExistentActivity() {
|
||||
// Arrange
|
||||
UUID activityId = UUID.randomUUID();
|
||||
when(activityRepository.findByIdAndUserId(activityId, testUserId))
|
||||
.thenReturn(Optional.empty());
|
||||
|
||||
// Act
|
||||
Activity result = fitFileService.getActivity(activityId, testUserId);
|
||||
|
||||
// Assert
|
||||
assertNull(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should retrieve all activities for a user")
|
||||
void testGetUserActivities() {
|
||||
// Arrange
|
||||
List<Activity> activities = new ArrayList<>();
|
||||
activities.add(Activity.builder().id(UUID.randomUUID()).userId(testUserId).build());
|
||||
activities.add(Activity.builder().id(UUID.randomUUID()).userId(testUserId).build());
|
||||
|
||||
when(activityRepository.findByUserIdOrderByStartedAtDesc(testUserId))
|
||||
.thenReturn(activities);
|
||||
|
||||
// Act
|
||||
List<Activity> result = fitFileService.getUserActivities(testUserId);
|
||||
|
||||
// Assert
|
||||
assertNotNull(result);
|
||||
assertEquals(2, result.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should process FIT file with metrics")
|
||||
void testProcessFitFileWithMetrics() throws Exception {
|
||||
// Arrange
|
||||
when(parser.parse(any(byte[].class))).thenReturn(testParsedData);
|
||||
when(trackSimplifier.simplify(any())).thenAnswer(invocation -> {
|
||||
Coordinate[] coords = invocation.getArgument(0);
|
||||
return new GeometryFactory().createLineString(coords);
|
||||
});
|
||||
|
||||
ArgumentCaptor<Activity> activityCaptor = ArgumentCaptor.forClass(Activity.class);
|
||||
when(activityRepository.save(activityCaptor.capture())).thenAnswer(invocation -> {
|
||||
Activity activity = invocation.getArgument(0);
|
||||
activity.setId(UUID.randomUUID());
|
||||
return activity;
|
||||
});
|
||||
|
||||
// Act
|
||||
Activity result = fitFileService.processFitFile(
|
||||
testFile,
|
||||
testUserId,
|
||||
"Complete Activity",
|
||||
null,
|
||||
Activity.Visibility.PUBLIC
|
||||
);
|
||||
|
||||
// Assert
|
||||
assertNotNull(result);
|
||||
Activity savedActivity = activityCaptor.getValue();
|
||||
|
||||
assertNotNull(savedActivity.getSimplifiedTrack());
|
||||
assertNotNull(savedActivity.getTrackPointsJson());
|
||||
assertNotNull(savedActivity.getMetrics());
|
||||
assertEquals(testParsedData.getTotalDistance(), savedActivity.getTotalDistance());
|
||||
assertEquals(testParsedData.getTotalDuration(), savedActivity.getTotalDuration());
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates test parsed FIT data with realistic values.
|
||||
*/
|
||||
private FitParser.ParsedFitData createTestParsedData() {
|
||||
FitParser.ParsedFitData data = new FitParser.ParsedFitData();
|
||||
|
||||
LocalDateTime startTime = LocalDateTime.of(2024, 1, 15, 8, 0, 0);
|
||||
data.setStartTime(startTime);
|
||||
data.setEndTime(startTime.plusMinutes(30));
|
||||
data.setActivityType(Activity.ActivityType.RUN);
|
||||
data.setTotalDistance(BigDecimal.valueOf(5000.0));
|
||||
data.setTotalDuration(Duration.ofMinutes(30));
|
||||
data.setElevationGain(BigDecimal.valueOf(100.0));
|
||||
data.setElevationLoss(BigDecimal.valueOf(95.0));
|
||||
|
||||
// Add test track points
|
||||
List<FitParser.TrackPointData> trackPoints = new ArrayList<>();
|
||||
|
||||
for (int i = 0; i < 10; i++) {
|
||||
FitParser.TrackPointData tp = new FitParser.TrackPointData();
|
||||
tp.setTimestamp(startTime.plusMinutes(i * 3));
|
||||
tp.setLatitude(47.0 + i * 0.001);
|
||||
tp.setLongitude(8.0 + i * 0.001);
|
||||
tp.setElevation(BigDecimal.valueOf(500 + i * 10));
|
||||
tp.setHeartRate(140 + i);
|
||||
tp.setSpeed(BigDecimal.valueOf(10.0));
|
||||
trackPoints.add(tp);
|
||||
}
|
||||
|
||||
data.setTrackPoints(trackPoints);
|
||||
|
||||
// Add test metrics
|
||||
FitParser.ActivityMetricsData metrics = new FitParser.ActivityMetricsData();
|
||||
metrics.setAverageSpeed(BigDecimal.valueOf(10.0));
|
||||
metrics.setMaxSpeed(BigDecimal.valueOf(15.0));
|
||||
metrics.setAverageHeartRate(150);
|
||||
metrics.setMaxHeartRate(170);
|
||||
metrics.setCalories(300);
|
||||
data.setMetrics(metrics);
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
152
src/test/java/org/operaton/fitpub/util/FitFileValidatorTest.java
Normal file
152
src/test/java/org/operaton/fitpub/util/FitFileValidatorTest.java
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
package org.operaton.fitpub.util;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.operaton.fitpub.exception.InvalidFitFileException;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Unit tests for FitFileValidator.
|
||||
*/
|
||||
class FitFileValidatorTest {
|
||||
|
||||
private FitFileValidator validator;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
validator = new FitFileValidator();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should validate a valid FIT file header")
|
||||
void testValidateValidHeader() {
|
||||
byte[] validHeader = TestFitFileGenerator.generateValidFitFileHeader();
|
||||
|
||||
assertDoesNotThrow(() -> validator.validate(validHeader));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should throw exception for empty file")
|
||||
void testValidateEmptyFile() {
|
||||
byte[] emptyFile = TestFitFileGenerator.generateEmptyFile();
|
||||
|
||||
InvalidFitFileException exception = assertThrows(
|
||||
InvalidFitFileException.class,
|
||||
() -> validator.validate(emptyFile)
|
||||
);
|
||||
|
||||
assertTrue(exception.getMessage().contains("empty"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should throw exception for null file")
|
||||
void testValidateNullFile() {
|
||||
InvalidFitFileException exception = assertThrows(
|
||||
InvalidFitFileException.class,
|
||||
() -> validator.validate((byte[]) null)
|
||||
);
|
||||
|
||||
assertTrue(exception.getMessage().contains("empty"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should throw exception for file that's too small")
|
||||
void testValidateTooSmallFile() {
|
||||
byte[] tooSmall = TestFitFileGenerator.generateTooSmallFile();
|
||||
|
||||
InvalidFitFileException exception = assertThrows(
|
||||
InvalidFitFileException.class,
|
||||
() -> validator.validate(tooSmall)
|
||||
);
|
||||
|
||||
assertTrue(exception.getMessage().contains("too small"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should throw exception for file that's too large")
|
||||
void testValidateTooLargeFile() throws IOException {
|
||||
long tooLarge = 60L * 1024 * 1024; // 60 MB
|
||||
byte[] validHeader = TestFitFileGenerator.generateValidFitFileHeader();
|
||||
ByteArrayInputStream inputStream = new ByteArrayInputStream(validHeader);
|
||||
|
||||
InvalidFitFileException exception = assertThrows(
|
||||
InvalidFitFileException.class,
|
||||
() -> validator.validate(inputStream, tooLarge)
|
||||
);
|
||||
|
||||
assertTrue(exception.getMessage().contains("too large"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should throw exception for invalid header size")
|
||||
void testValidateInvalidHeaderSize() {
|
||||
byte[] invalidHeader = TestFitFileGenerator.generateInvalidHeaderSize();
|
||||
|
||||
InvalidFitFileException exception = assertThrows(
|
||||
InvalidFitFileException.class,
|
||||
() -> validator.validate(invalidHeader)
|
||||
);
|
||||
|
||||
assertTrue(exception.getMessage().contains("Invalid FIT header size"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should throw exception for invalid signature")
|
||||
void testValidateInvalidSignature() {
|
||||
byte[] invalidSignature = TestFitFileGenerator.generateInvalidSignature();
|
||||
|
||||
InvalidFitFileException exception = assertThrows(
|
||||
InvalidFitFileException.class,
|
||||
() -> validator.validate(invalidSignature)
|
||||
);
|
||||
|
||||
assertTrue(exception.getMessage().contains("Invalid FIT file signature"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should validate file from input stream")
|
||||
void testValidateFromInputStream() throws IOException {
|
||||
byte[] validHeader = TestFitFileGenerator.generateValidFitFileHeader();
|
||||
ByteArrayInputStream inputStream = new ByteArrayInputStream(validHeader);
|
||||
|
||||
assertDoesNotThrow(() -> validator.validate(inputStream, validHeader.length));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should throw exception for input stream with insufficient data")
|
||||
void testValidateInsufficientDataFromStream() throws IOException {
|
||||
byte[] tooSmall = new byte[10];
|
||||
ByteArrayInputStream inputStream = new ByteArrayInputStream(tooSmall);
|
||||
|
||||
InvalidFitFileException exception = assertThrows(
|
||||
InvalidFitFileException.class,
|
||||
() -> validator.validate(inputStream, tooSmall.length)
|
||||
);
|
||||
|
||||
assertTrue(exception.getMessage().contains("too small"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should validate .fit file extension")
|
||||
void testHasValidExtension() {
|
||||
assertTrue(validator.hasValidExtension("activity.fit"));
|
||||
assertTrue(validator.hasValidExtension("ACTIVITY.FIT"));
|
||||
assertTrue(validator.hasValidExtension("path/to/file.fit"));
|
||||
assertTrue(validator.hasValidExtension("file.FIT"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should reject invalid file extensions")
|
||||
void testHasInvalidExtension() {
|
||||
assertFalse(validator.hasValidExtension("activity.gpx"));
|
||||
assertFalse(validator.hasValidExtension("activity.txt"));
|
||||
assertFalse(validator.hasValidExtension("activity"));
|
||||
assertFalse(validator.hasValidExtension(null));
|
||||
assertFalse(validator.hasValidExtension(""));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,232 @@
|
|||
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 java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Integration test for FitParser using a real FIT file.
|
||||
*/
|
||||
@Slf4j
|
||||
class FitParserIntegrationTest {
|
||||
|
||||
private FitParser parser;
|
||||
private FitFileValidator validator;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
parser = new FitParser();
|
||||
validator = new FitFileValidator();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should successfully parse real FIT file from test resources")
|
||||
void testParseRealFitFile() throws IOException {
|
||||
// Load the real FIT file from test resources
|
||||
String fitFileName = "/69287079d5e0a4532ba818ee.fit";
|
||||
InputStream inputStream = getClass().getResourceAsStream(fitFileName);
|
||||
|
||||
assertNotNull(inputStream, "FIT file should exist in test resources: " + fitFileName);
|
||||
|
||||
// Read file into byte array
|
||||
byte[] fileData = inputStream.readAllBytes();
|
||||
inputStream.close();
|
||||
|
||||
// Validate the file
|
||||
assertDoesNotThrow(() -> validator.validate(fileData),
|
||||
"Real FIT file should pass validation");
|
||||
|
||||
// Parse the file
|
||||
FitParser.ParsedFitData parsedData = assertDoesNotThrow(
|
||||
() -> parser.parse(fileData),
|
||||
"Real FIT file should parse without errors"
|
||||
);
|
||||
|
||||
// Verify parsed data structure
|
||||
assertNotNull(parsedData, "Parsed data should not be null");
|
||||
|
||||
// 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 FIT 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());
|
||||
}
|
||||
|
||||
// Verify at least some basic data
|
||||
assertNotNull(parsedData.getActivityType(), "Activity type should be determined");
|
||||
assertTrue(parsedData.getTrackPoints().size() > 0, "Should have at least one track point");
|
||||
|
||||
// Verify track point data quality
|
||||
FitParser.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 metrics if present
|
||||
if (parsedData.getMetrics() != null) {
|
||||
FitParser.ActivityMetricsData metrics = parsedData.getMetrics();
|
||||
log.info("Metrics:");
|
||||
|
||||
if (metrics.getAverageSpeed() != null) {
|
||||
log.info(" Average speed: {} km/h", metrics.getAverageSpeed());
|
||||
}
|
||||
|
||||
if (metrics.getMaxSpeed() != null) {
|
||||
log.info(" Max speed: {} km/h", metrics.getMaxSpeed());
|
||||
}
|
||||
|
||||
if (metrics.getAverageHeartRate() != null) {
|
||||
log.info(" Average heart rate: {} bpm", metrics.getAverageHeartRate());
|
||||
}
|
||||
|
||||
if (metrics.getMaxHeartRate() != null) {
|
||||
log.info(" Max heart rate: {} bpm", metrics.getMaxHeartRate());
|
||||
}
|
||||
|
||||
if (metrics.getCalories() != null) {
|
||||
log.info(" Calories: {}", metrics.getCalories());
|
||||
}
|
||||
|
||||
if (metrics.getAverageCadence() != null) {
|
||||
log.info(" Average cadence: {}", metrics.getAverageCadence());
|
||||
}
|
||||
|
||||
if (metrics.getAveragePower() != null) {
|
||||
log.info(" Average power: {} watts", metrics.getAveragePower());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should extract complete activity data from real FIT file")
|
||||
void testExtractCompleteActivityData() throws IOException {
|
||||
// Load the real FIT file
|
||||
String fitFileName = "/69287079d5e0a4532ba818ee.fit";
|
||||
InputStream inputStream = getClass().getResourceAsStream(fitFileName);
|
||||
byte[] fileData = inputStream.readAllBytes();
|
||||
inputStream.close();
|
||||
|
||||
// Parse the file
|
||||
FitParser.ParsedFitData parsedData = parser.parse(fileData);
|
||||
|
||||
// Test converting to entity structures
|
||||
Activity.ActivityType activityType = parsedData.getActivityType();
|
||||
assertNotNull(activityType, "Activity type should be extracted");
|
||||
|
||||
// Verify we can convert track points to entities
|
||||
if (!parsedData.getTrackPoints().isEmpty()) {
|
||||
FitParser.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");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should validate real FIT file successfully")
|
||||
void testValidateRealFitFile() throws IOException {
|
||||
// Load the real FIT file
|
||||
String fitFileName = "/69287079d5e0a4532ba818ee.fit";
|
||||
InputStream inputStream = getClass().getResourceAsStream(fitFileName);
|
||||
byte[] fileData = inputStream.readAllBytes();
|
||||
inputStream.close();
|
||||
|
||||
// Should pass all validation checks
|
||||
assertDoesNotThrow(() -> validator.validate(fileData),
|
||||
"Real FIT file should pass validation");
|
||||
|
||||
// File should have valid extension
|
||||
assertTrue(validator.hasValidExtension(fitFileName),
|
||||
"File should have valid .fit extension");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle track points in chronological order")
|
||||
void testTrackPointsChronologicalOrder() throws IOException {
|
||||
// Load the real FIT file
|
||||
String fitFileName = "/69287079d5e0a4532ba818ee.fit";
|
||||
InputStream inputStream = getClass().getResourceAsStream(fitFileName);
|
||||
byte[] fileData = inputStream.readAllBytes();
|
||||
inputStream.close();
|
||||
|
||||
FitParser.ParsedFitData 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);
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
102
src/test/java/org/operaton/fitpub/util/TestFitFileGenerator.java
Normal file
102
src/test/java/org/operaton/fitpub/util/TestFitFileGenerator.java
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
package org.operaton.fitpub.util;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
|
||||
/**
|
||||
* Utility class for generating test FIT files.
|
||||
* Creates minimal valid FIT file structures for testing.
|
||||
*/
|
||||
public class TestFitFileGenerator {
|
||||
|
||||
/**
|
||||
* Generates a minimal valid FIT file header.
|
||||
*/
|
||||
public static byte[] generateValidFitFileHeader() {
|
||||
ByteBuffer buffer = ByteBuffer.allocate(14);
|
||||
buffer.order(ByteOrder.LITTLE_ENDIAN);
|
||||
|
||||
buffer.put((byte) 14); // Header size
|
||||
buffer.put((byte) 0x10); // Protocol version 1.0
|
||||
buffer.putShort((short) 2048); // Profile version
|
||||
buffer.putInt(100); // Data size
|
||||
buffer.put(".FIT".getBytes()); // Signature
|
||||
|
||||
return buffer.array();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a FIT file with invalid header size.
|
||||
*/
|
||||
public static byte[] generateInvalidHeaderSize() {
|
||||
byte[] header = generateValidFitFileHeader();
|
||||
header[0] = 20; // Invalid header size
|
||||
return header;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a FIT file with invalid signature.
|
||||
*/
|
||||
public static byte[] generateInvalidSignature() {
|
||||
byte[] header = generateValidFitFileHeader();
|
||||
header[8] = 'X'; // Invalid signature
|
||||
return header;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a minimal valid FIT file with a single data record.
|
||||
* This creates a very basic but valid FIT file structure.
|
||||
*/
|
||||
public static byte[] generateMinimalValidFitFile() throws IOException {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
|
||||
// Write header
|
||||
ByteBuffer header = ByteBuffer.allocate(14);
|
||||
header.order(ByteOrder.LITTLE_ENDIAN);
|
||||
header.put((byte) 14); // Header size
|
||||
header.put((byte) 0x20); // Protocol version 2.0
|
||||
header.putShort((short) 2113); // Profile version 21.13
|
||||
header.putInt(0); // Data size (will update later)
|
||||
header.put(".FIT".getBytes()); // Signature
|
||||
header.putShort((short) 0); // CRC (optional, set to 0)
|
||||
baos.write(header.array());
|
||||
|
||||
// For a real FIT file, we would write definition messages and data messages here
|
||||
// For testing purposes, this minimal header-only file should suffice for validation tests
|
||||
// More complex tests would require actual FIT SDK to generate proper files
|
||||
|
||||
byte[] result = baos.toByteArray();
|
||||
|
||||
// Update data size in header
|
||||
ByteBuffer dataSize = ByteBuffer.allocate(4);
|
||||
dataSize.order(ByteOrder.LITTLE_ENDIAN);
|
||||
dataSize.putInt(result.length - 14 - 2); // Exclude header and CRC
|
||||
System.arraycopy(dataSize.array(), 0, result, 4, 4);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an empty byte array (invalid FIT file).
|
||||
*/
|
||||
public static byte[] generateEmptyFile() {
|
||||
return new byte[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a file that's too small.
|
||||
*/
|
||||
public static byte[] generateTooSmallFile() {
|
||||
return new byte[10];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a very large file (simulated, not actually allocating the memory).
|
||||
*/
|
||||
public static byte[] generateTooLargeFileHeader() {
|
||||
// Just return a header, tests will check the size parameter
|
||||
return generateValidFitFileHeader();
|
||||
}
|
||||
}
|
||||
BIN
src/test/resources/69287079d5e0a4532ba818ee.fit
Normal file
BIN
src/test/resources/69287079d5e0a4532ba818ee.fit
Normal file
Binary file not shown.
Loading…
Add table
Add a link
Reference in a new issue