fitpub/INDOOR_DETECTION_IMPLEMENTATION.md

8.1 KiB

Indoor Activity Detection - Implementation Summary

Overview

Implemented multi-format indoor activity detection with retroactive migration support. Indoor activities (trainer rides, treadmill runs, virtual activities) are now:

  • Visible in timeline with full GPS visualization
  • Excluded from heatmap to prevent pollution of outdoor activity maps
  • Automatically detected from FIT SubSport field or GPS heuristics

Database Changes

Migration V20 (Already Applied)

  • Added indoor boolean column (defaults to FALSE)
  • Created index idx_activity_indoor for efficient heatmap queries

Migration V21 (New - Just Applied)

  • Added sub_sport VARCHAR(50) - SubSport from FIT files
  • Added indoor_detection_method VARCHAR(20) - How indoor flag was determined

Schema Columns

Column Type Purpose
indoor BOOLEAN NOT NULL Main flag - exclude from heatmap if TRUE
sub_sport VARCHAR(50) NULL FIT SubSport field (e.g., INDOOR_CYCLING, ROAD, TRAIL)
indoor_detection_method VARCHAR(20) NULL Detection method enum value

Detection Methods

1. FIT Files - SubSport Field (Most Accurate)

Method: FIT_SUBSPORT

Reads the SubSport field from FIT file session message and detects:

  • INDOOR_CYCLING → Indoor
  • TREADMILL → Indoor
  • VIRTUAL_ACTIVITY → Indoor (Zwift, RGT, etc.)
  • TRAINER → Indoor
  • ROAD, MOUNTAIN, TRAIL → Outdoor

Implementation: FitParser.extractSessionData() (lines 332-348)

2. FIT Files - No GPS Data

Method: HEURISTIC_NO_GPS

If FIT file has no GPS track points → marked as indoor.

Implementation: FitParser.parse() (lines 111-120)

3. GPX Files - Stationary GPS

Method: HEURISTIC_STATIONARY

If all GPS points are within 50 meters of first point → marked as indoor. Detects trainer rides or treadmill runs with GPS enabled (phone/tablet).

Implementation: GpxParser.detectIndoorActivity() + isStationaryGps() (lines 627-673)

4. GPX Files - No GPS Data

Method: HEURISTIC_NO_GPS

If GPX file has no GPS track points → marked as indoor.

Implementation: GpxParser.detectIndoorActivity() (lines 630-634)

5. Manual Override (Future)

Method: MANUAL

User can manually flag activity as indoor (UI not yet implemented).


How It Works

For NEW Uploads (After This Feature)

FIT File Upload:

  1. User uploads FIT file
  2. FitParser.extractSessionData() reads SubSport field
  3. Sets parsedData.setSubSport("INDOOR_CYCLING") (example)
  4. Detects indoor keywords → sets parsedData.setIndoor(true)
  5. Sets parsedData.setIndoorDetectionMethod(FIT_SUBSPORT)
  6. ActivityFileService saves to database with all fields populated
  7. Done - no re-parsing needed

GPX File Upload:

  1. User uploads GPX file
  2. GpxParser.detectIndoorActivity() analyzes GPS points
  3. Checks if all points within 50m radius
  4. Sets parsedData.setIndoor(true) if stationary
  5. Sets parsedData.setIndoorDetectionMethod(HEURISTIC_STATIONARY)
  6. ActivityFileService saves to database
  7. Done - no re-parsing needed

For Timeline Loading

Fast database query - no file parsing:

SELECT id, activity_type, title, indoor, sub_sport,
       indoor_detection_method, ...
FROM activities
WHERE ...

For Heatmap Generation

Automatically excludes indoor activities:

Updated queries in UserHeatmapGridRepository:

-- Single activity update
WHERE a.id = :activityId
  AND a.track_points_json IS NOT NULL
  AND a.indoor = FALSE  -- NEW

-- Full user recalculation
WHERE a.user_id = :userId
  AND a.track_points_json IS NOT NULL
  AND a.indoor = FALSE  -- NEW

Retroactive Migration

For Activities Uploaded BEFORE This Feature

Run once to populate indoor flags for existing data:

POST /api/admin/migrate-indoor-flags
Authorization: Bearer <your-jwt-token>

This endpoint:

  1. Fetches all FIT activities with stored raw files
  2. Re-parses FIT files to extract SubSport
  3. Updates indoor, sub_sport, and indoor_detection_method
  4. Only saves if values changed (idempotent)

Response:

{
  "message": "Indoor activity flag migration complete",
  "activitiesUpdated": 15
}

How to Run Migration

Option 1: Using Browser DevTools

  1. Login to FitPub
  2. Open DevTools (F12) → Application → Local Storage
  3. Copy jwt_token value
  4. Use browser fetch or Postman:
fetch('http://localhost:8080/api/admin/migrate-indoor-flags', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer YOUR_TOKEN_HERE'
  }
}).then(r => r.json()).then(console.log);

Option 2: Using curl

curl -X POST \
  -H "Authorization: Bearer YOUR_TOKEN_HERE" \
  http://localhost:8080/api/admin/migrate-indoor-flags

Code Structure

Key Files Modified

  1. Database:

    • V20__add_indoor_flag_to_activities.sql - Indoor flag column
    • V21__add_indoor_detection_metadata.sql - SubSport & detection method columns
  2. Entity:

    • Activity.java - Added subSport, indoorDetectionMethod fields
    • Activity.IndoorDetectionMethod - New enum for detection methods
  3. Parsers:

    • FitParser.java - Extract SubSport, detect indoor from FIT files
    • GpxParser.java - Heuristic detection with GPS analysis (Haversine distance)
    • ParsedActivityData.java - Added fields for parsed data transfer
  4. Service:

    • ActivityFileService.java - Save SubSport & detection method to database
    • IndoorActivityMigrationService.java - Retroactive migration logic
  5. Repository:

    • UserHeatmapGridRepository.java - Exclude indoor activities from heatmap queries
    • ActivityRepository.java - Added query method for migration
  6. Controller:

    • AdminController.java - Migration endpoint
    • SecurityConfig.java - Added /api/admin/** route (authenticated)

Testing

Test Indoor Detection

1. Upload Zwift FIT File

Expected:

  • indoor = TRUE
  • sub_sport = "VIRTUAL_ACTIVITY"
  • indoor_detection_method = "FIT_SUBSPORT"
  • Visible in timeline
  • Not in heatmap

2. Upload Treadmill FIT File

Expected:

  • indoor = TRUE
  • sub_sport = "TREADMILL"
  • indoor_detection_method = "FIT_SUBSPORT"

3. Upload GPX with Stationary GPS

Expected:

  • indoor = TRUE
  • sub_sport = NULL (GPX doesn't have SubSport)
  • indoor_detection_method = "HEURISTIC_STATIONARY"

4. Upload Outdoor Ride FIT

Expected:

  • indoor = FALSE
  • sub_sport = "ROAD" or "MOUNTAIN"
  • indoor_detection_method = NULL (not indoor)

Performance

  • New uploads: SubSport extracted during normal parsing (no overhead)
  • Timeline loading: Simple column read (instant)
  • Heatmap queries: Added AND indoor = FALSE filter (uses index)
  • Migration: One-time operation, only re-parses FIT files with raw data

Future Enhancements

Phase 2 (Optional):

  1. GPX Extension Parsing: Read Garmin/Strava custom XML extensions
  2. Manual Override UI: Allow users to manually mark activities as indoor
  3. Activity Edit: Update indoor flag via activity edit form
  4. Statistics: Show "X indoor activities excluded from heatmap" message

Database Query Examples

Find All Indoor Activities

SELECT id, title, activity_type, sub_sport, indoor_detection_method
FROM activities
WHERE indoor = TRUE;

Find FIT Activities with SubSport

SELECT id, title, sub_sport
FROM activities
WHERE sub_sport IS NOT NULL
ORDER BY created_at DESC;

Count Detection Methods

SELECT indoor_detection_method, COUNT(*) as count
FROM activities
WHERE indoor = TRUE
GROUP BY indoor_detection_method;

Summary

Fully implemented multi-format indoor detection Backward compatible - existing activities default to outdoor Retroactive migration endpoint for old data Heatmap exclusion automatic via SQL filters Timeline display includes all activities Works for FIT and GPX files with different detection strategies Type-safe detection method enum Well documented with inline comments

The solution is production-ready and scales to all supported file formats!