From 835884ab16ad02b2d63a59469d1fdbfe30859960 Mon Sep 17 00:00:00 2001 From: DanyPM Date: Sun, 21 Jun 2026 23:35:20 +0200 Subject: [PATCH] Add device-agnostic FIT activity export (#6268) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds FIT file export for activity tracks, alongside the existing GPX export: - **`FitExporter`** — builds a valid FIT activity (file_id / session / lap / record, plus length / split / set when present) from an `ActivityTrack` + `ActivitySummaryData`. - **`AutoFitExporter`** + **`AutoExportFitSettingsActivity`** — opt-in auto-export on sync, mirroring the GPX auto-export setting (per-device or all-devices). - **"Share FIT File"** action in the workout detail menu. Export is fed device-agnostically: `GarminWorkoutParser` and the shared model (`ActivityTrack` / `ActivityPoint` / `ActivitySummaryEntries` / `ActivityKind`) carry laps, segments, per-length swim, splits/sets, running dynamics and HR zones. The Garmin FIT codec (`messages`, `baseTypes`, `NativeFITMessage`, `RecordData`) is extended to encode them. `AutoFitExporter.doExport(...)` is wired beside `AutoGpxExporter` in the Garmin, CMF, Huawei, BangleJS, Huami and Xiaomi activity paths; `FitImporter` also honours the FIT auto-export toggle. ## Testing `:app:testMainlineDebugUnitTest` — FIT/export subset green: - `FitExporterReadmeRoundTripTest` — round-trips the curated README sample **and** the full `Activity//` corpus for years ≥ 2015 from https://github.com/ThomasKuehne/FIT-test-files (3482 files): parse → export → re-parse, asserting GPS/altitude/HR/speed/cadence/distance/power/temp and session aggregates survive. - `FitExporterPaceTest`, `BaseTypeEncodeRoundTripTest`, `GarminSportTest`, `GPXExporterTest`, `FitImporterTest`, `GpxRouteFileConverterTest`, `FitWeatherTest`. Thanks a lot to @ThomasKuehne for the initial review Co-authored-by: Dany Mestas <94061157+DanyPM@users.noreply.github.com> Reviewed-on: https://codeberg.org/Freeyourgadget/Gadgetbridge/pulls/6268 --- app/build.gradle | 9 + app/src/main/AndroidManifest.xml | 4 + .../AutoExportFitSettingsActivity.kt | 122 ++ .../workouts/WorkoutDetailsFragment.kt | 168 +- .../workouts/charts/DefaultWorkoutCharts.java | 196 ++ .../devices/garmin/GarminWorkoutParser.java | 79 +- .../gadgetbridge/export/AutoFitExporter.java | 148 ++ .../gadgetbridge/export/FitExporter.java | 1791 +++++++++++++++++ .../gadgetbridge/model/ActivityKind.java | 2 + .../gadgetbridge/model/ActivityPoint.java | 124 +- .../model/ActivitySummaryEntries.java | 9 + .../gadgetbridge/model/ActivityTrack.java | 262 ++- .../model/FitActivityTrackProvider.java | 55 + .../banglejs/BangleJSActivityTrack.java | 3 + .../devices/cmfwatchpro/CmfActivitySync.java | 4 +- .../devices/garmin/fit/FitImporter.java | 6 +- .../devices/garmin/fit/RecordData.java | 16 +- .../garmin/fit/baseTypes/BaseType.java | 13 +- .../garmin/fit/baseTypes/BaseTypeByte.java | 4 +- .../garmin/fit/baseTypes/BaseTypeInt.java | 5 +- .../garmin/fit/baseTypes/BaseTypeShort.java | 6 +- .../devices/garmin/fit/enums/GarminSport.java | 54 +- .../garmin/fit/messages/FitRecord.java | 7 + .../fetch/FetchSportsDetailsOperation.java | 2 + .../devices/huawei/HuaweiSupportProvider.java | 2 + .../activity/impl/WorkoutGpsParser.java | 2 + .../gadgetbridge/util/GBPrefs.java | 6 + .../menu/activity_take_screenshot_menu.xml | 6 + app/src/main/res/values/strings.xml | 11 + .../main/res/xml/auto_export_fit_settings.xml | 49 + app/src/main/res/xml/automations_settings.xml | 8 + .../export/FitExporterPaceTest.java | 318 +++ .../FitExporterReadmeRoundTripTest.java | 758 +++++++ .../BaseTypeEncodeRoundTripTest.java | 146 ++ .../garmin/fit/enums/GarminSportTest.java | 30 +- .../test/resources/TestGpxImport.course.fit | Bin 437 -> 437 bytes .../gpx-exporter-test-SampleTrack.course.fit | Bin 531 -> 531 bytes ...x-parser-test-multiple-segments.course.fit | Bin 341 -> 341 bytes 38 files changed, 4338 insertions(+), 87 deletions(-) create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/automations/AutoExportFitSettingsActivity.kt create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/export/AutoFitExporter.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/export/FitExporter.java create mode 100644 app/src/main/res/xml/auto_export_fit_settings.xml create mode 100644 app/src/test/java/nodomain/freeyourgadget/gadgetbridge/export/FitExporterPaceTest.java create mode 100644 app/src/test/java/nodomain/freeyourgadget/gadgetbridge/export/FitExporterReadmeRoundTripTest.java create mode 100644 app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/baseTypes/BaseTypeEncodeRoundTripTest.java diff --git a/app/build.gradle b/app/build.gradle index 16b4e36bb2..0dcb21705b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -168,6 +168,15 @@ android { java.srcDirs += "build/generated/sources/gbdao" res.srcDirs += "build/generated/res/changelog" } + test { + // The optional FIT round-trip corpus (FitExporterReadmeRoundTripTest) is read + // by filesystem path, not the classpath, and is not committed. Exclude it from + // the test resources so Gradle does not copy tens of thousands of files into + // the unit-test runtime on every build. + resources { + exclude "FIT-test-files-main/**" + } + } } buildTypes { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 234d48ff6d..3930cf9a1e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -234,6 +234,10 @@ android:name=".activities.automations.AutoExportGpxSettingsActivity" android:label="@string/pref_header_auto_export_gpx" android:parentActivityName=".activities.SettingsActivity" /> + . */ +package nodomain.freeyourgadget.gadgetbridge.activities.automations + +import android.content.Intent +import android.os.Bundle +import androidx.activity.result.ActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.edit +import androidx.preference.MultiSelectListPreference +import androidx.preference.Preference +import androidx.preference.PreferenceFragmentCompat +import nodomain.freeyourgadget.gadgetbridge.GBApplication +import nodomain.freeyourgadget.gadgetbridge.R +import nodomain.freeyourgadget.gadgetbridge.activities.AbstractPreferenceFragment +import nodomain.freeyourgadget.gadgetbridge.activities.AbstractSettingsActivityV2 +import nodomain.freeyourgadget.gadgetbridge.util.GBPrefs +import org.slf4j.LoggerFactory + +class AutoExportFitSettingsActivity : AbstractSettingsActivityV2() { + override fun newFragment(): PreferenceFragmentCompat { + return AutoExportFitSettingsFragment() + } + + companion object { + class AutoExportFitSettingsFragment : AbstractPreferenceFragment() { + companion object { + private val LOG = LoggerFactory.getLogger(AutoExportFitSettingsFragment::class.java) + } + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + setPreferencesFromResource(R.xml.auto_export_fit_settings, rootKey) + + setupCustomDirectoryPreference() + setupDeviceSelection() + } + + private fun setupCustomDirectoryPreference() { + val gbPrefs = GBApplication.getPrefs() + + val customDirectoryPicker = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result: ActivityResult? -> + if (result?.resultCode != RESULT_OK) { + return@registerForActivityResult + } + val uri = result.data?.data + LOG.info("Got {} for fit export directory", uri) + if (uri == null) { + return@registerForActivityResult + } + + requireContext().contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) + + gbPrefs.preferences.edit { + putString(GBPrefs.AUTO_EXPORT_FIT_DIRECTORY, uri.toString()) + } + + updateCustomDirectorySummary(uri.toString()) + } + + val customDirPref = findPreference(GBPrefs.AUTO_EXPORT_FIT_DIRECTORY) + customDirPref?.setOnPreferenceClickListener { + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) + intent.addFlags( + Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or + Intent.FLAG_GRANT_READ_URI_PERMISSION or + Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) + customDirectoryPicker.launch(intent) + true + } + + val currentUri = gbPrefs.getString(GBPrefs.AUTO_EXPORT_FIT_DIRECTORY, "") + updateCustomDirectorySummary(currentUri) + } + + private fun updateCustomDirectorySummary(uriString: String) { + val customDirPref = findPreference(GBPrefs.AUTO_EXPORT_FIT_DIRECTORY) + if (uriString.isEmpty()) { + customDirPref?.summary = getString(R.string.not_set) + } else { + customDirPref?.summary = AbstractAutoExportSettingsFragment.resolveLocationSummary( + requireContext(), + uriString + ) + } + } + + private fun setupDeviceSelection() { + val selectedDevicesPref = + findPreference(GBPrefs.AUTO_EXPORT_FIT_SELECTED_DEVICES) + + // Populate device list + val devices = GBApplication.app().deviceManager.devices + .filter { it.deviceCoordinator.supportsRecordedActivities(it) } + val deviceAddresses = devices.map { it.address } + val deviceNames = devices.map { it.aliasOrName } + + selectedDevicesPref?.entryValues = deviceAddresses.toTypedArray() + selectedDevicesPref?.entries = deviceNames.toTypedArray() + } + } + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/workouts/WorkoutDetailsFragment.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/workouts/WorkoutDetailsFragment.kt index 4e327e4616..ce80838144 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/workouts/WorkoutDetailsFragment.kt +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/workouts/WorkoutDetailsFragment.kt @@ -72,6 +72,7 @@ import nodomain.freeyourgadget.gadgetbridge.activities.workouts.entries.Activity import nodomain.freeyourgadget.gadgetbridge.databinding.FragmentWorkoutDetailsBinding import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary import nodomain.freeyourgadget.gadgetbridge.entities.Device +import nodomain.freeyourgadget.gadgetbridge.export.FitExporter import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryData @@ -581,6 +582,11 @@ class WorkoutDetailsFragment : Fragment(), MenuProvider { true } + R.id.activity_action_share_fit -> { + exportFit(workout) + true + } + R.id.activity_summary_detail_action_edit_name -> { currentWorkout?.let { workoutEditor.editWorkoutName(it, object : WorkoutEditor.Callback { @@ -643,10 +649,13 @@ class WorkoutDetailsFragment : Fragment(), MenuProvider { devToolsMenu?.isVisible = devToolsSubMenu != null && devToolsSubMenu.hasVisibleItems() } + // Endurain accepts FIT (built from the summary alone if needed), so it is offered + // for any workout. Wanderer only supports GPX uploads, so it requires a GPS track. val endurainVm: EndurainSetupViewModel by viewModels() - val server = GBApplication.getPrefs().preferences.getString("endurain_server", null) - overflowMenu?.findItem(R.id.activity_action_upload_to_endurain)?.isVisible = hasGpx && server != null && endurainVm.endurainTokenManager.isLoggedIn() - overflowMenu?.findItem(R.id.activity_action_upload_to_wanderer)?.isVisible = hasGpx && server != null && WandererTokenManager(requireContext()).isLoggedIn() + val endurainServer = GBApplication.getPrefs().preferences.getString("endurain_server", null) + val wandererServer = GBApplication.getPrefs().preferences.getString("wanderer_server", null) + overflowMenu?.findItem(R.id.activity_action_upload_to_endurain)?.isVisible = endurainServer != null && endurainVm.endurainTokenManager.isLoggedIn() + overflowMenu?.findItem(R.id.activity_action_upload_to_wanderer)?.isVisible = hasGpx && wandererServer != null && WandererTokenManager(requireContext()).isLoggedIn() } private fun takeSharedScreenshot() { @@ -755,70 +764,67 @@ class WorkoutDetailsFragment : Fragment(), MenuProvider { private fun uploadToEndurain() { val workout = currentWorkout ?: return - val workoutName = currentWorkout!!.summary.name - val activityKind = ActivityKind.fromCode(currentWorkout!!.summary.activityKind) - val activityTrackProvider = gbDevice.deviceCoordinator.getActivityTrackProvider(gbDevice, requireContext()) + val workoutName = workout.summary.name + val activityKind = ActivityKind.fromCode(workout.summary.activityKind) - val activityFile = if (workout.summary.rawDetailsPath?.endsWith(".fit") == true) { - FileUtils.tryFixPath(workout.summary.rawDetailsPath) - } else { - ActivitySummaryUtils.getShareableGpxFile(activityTrackProvider, workout.summary) - } + lifecycleScope.launch { + val activityFile = try { + buildFitFile(workout) + } catch (e: Exception) { + LOG.error("Failed to build FIT for Endurain upload", e) + GB.toast( + getString(R.string.endurain_unable_to_upload_gpx_file_toast, e.localizedMessage), + Toast.LENGTH_LONG, + GB.ERROR, + e + ) + return@launch + } - if (activityFile == null) { - GB.toast(getString(R.string.no_activity_track_in_activity_toast), Toast.LENGTH_LONG, GB.INFO) - return - } - - try { - val endurainVm: EndurainSetupViewModel by viewModels() - val serverUrl = GBApplication.getPrefs().preferences.getString("endurain_server", null) - val apiClient = EndurainApiClient(serverUrl!!, endurainVm.endurainTokenManager) - endurainVm.endurainTokenManager.performTokenRefresh(serverUrl) { - LOG.info("Uploading workout '{}' (type {}) to Endurain", workoutName, activityKind) - apiClient.uploadActivity(activityFile) { newId -> - if (newId != null) { - // Update activity type on the server - apiClient.editActivity(newId, activityKind, workoutName) - } - activity?.runOnUiThread { - if (newId != null) - GB.toast( - getString(R.string.endurain_successfully_uploaded_toast), - Toast.LENGTH_SHORT, - GB.INFO - ) - else - GB.toast( - getString(R.string.endurain_error_while_uploading_toast), - Toast.LENGTH_SHORT, - GB.INFO - ) + try { + val endurainVm: EndurainSetupViewModel by viewModels() + val serverUrl = GBApplication.getPrefs().preferences.getString("endurain_server", null) + val apiClient = EndurainApiClient(serverUrl!!, endurainVm.endurainTokenManager) + endurainVm.endurainTokenManager.performTokenRefresh(serverUrl) { + LOG.info("Uploading workout '{}' (type {}) to Endurain", workoutName, activityKind) + apiClient.uploadActivity(activityFile) { newId -> + if (newId != null) { + // Update activity type on the server + apiClient.editActivity(newId, activityKind, workoutName) + } + activity?.runOnUiThread { + if (newId != null) + GB.toast( + getString(R.string.endurain_successfully_uploaded_toast), + Toast.LENGTH_SHORT, + GB.INFO + ) + else + GB.toast( + getString(R.string.endurain_error_while_uploading_toast), + Toast.LENGTH_SHORT, + GB.INFO + ) + } } } + } catch (e: Exception) { + GB.toast( + getString(R.string.endurain_unable_to_upload_gpx_file_toast, e.localizedMessage), + Toast.LENGTH_LONG, + GB.ERROR, + e + ) } - } catch (e: Exception) { - GB.toast( - getString(R.string.endurain_unable_to_upload_gpx_file_toast, e.localizedMessage), - Toast.LENGTH_LONG, - GB.ERROR, - e - ) } } private fun uploadToWanderer() { val workout = currentWorkout ?: return - val workoutName = currentWorkout!!.summary.name - val activityKind = ActivityKind.fromCode(currentWorkout!!.summary.activityKind) val activityTrackProvider = gbDevice.deviceCoordinator.getActivityTrackProvider(gbDevice, requireContext()) - val activityFile = if (workout.summary.rawDetailsPath?.endsWith(".fit") == true) { - FileUtils.tryFixPath(workout.summary.rawDetailsPath) - } else { - ActivitySummaryUtils.getShareableGpxFile(activityTrackProvider, workout.summary) - } - + // Wanderer only supports GPX uploads, so always send GPX (never a FIT file). + val activityFile = ActivitySummaryUtils.getShareableGpxFile(activityTrackProvider, workout.summary) if (activityFile == null) { GB.toast(getString(R.string.no_activity_track_in_activity_toast), Toast.LENGTH_LONG, GB.INFO) return @@ -930,6 +936,58 @@ class WorkoutDetailsFragment : Fragment(), MenuProvider { } } + /** + * Builds a FIT file for the given workout in the cache directory. + * + * FIT-native devices (Garmin, iGPSPORT) keep the original .fit at rawDetailsPath — + * it is copied verbatim. For any other device the FIT is synthesized from the + * summary (and the activity track, if one is available). + */ + private suspend fun buildFitFile(workout: Workout): File = withContext(Dispatchers.IO) { + val kindLabel = ActivityKind.fromCode(workout.summary.activityKind) + .getLabel(requireContext()).lowercase() + val fileName = FileUtils.makeValidFileName( + "Workout-${kindLabel}-${DateTimeUtils.formatIso8601(workout.summary.startTime)}.fit" + ) + val cacheSubDir = File(requireContext().cacheDir, "raw") + cacheSubDir.mkdirs() + val outFile = File(cacheSubDir, fileName) + + val rawFit = FitExporter.resolveRawFitFile(workout.summary) + if (rawFit != null) { + rawFit.copyTo(outFile, overwrite = true) + } else { + val activityTrackProvider = gbDevice.deviceCoordinator + .getActivityTrackProvider(gbDevice, requireContext()) + val track = try { + activityTrackProvider?.getActivityTrack(workout.summary) + } catch (e: Exception) { + LOG.warn("Failed to load activity track for FIT export", e) + null + } + FitExporter().performExport(track, workout.summary, workout.data, outFile) + } + outFile + } + + private fun exportFit(workout: Workout) { + lifecycleScope.launch { + try { + val targetFile = buildFitFile(workout) + AndroidUtils.shareFile(requireContext(), targetFile, "application/octet-stream") + } catch (e: Exception) { + LOG.error("Failed to export FIT file", e) + GB.toast( + requireContext(), + getString(R.string.activity_detail_export_fit_failed), + Toast.LENGTH_LONG, + GB.ERROR, + e + ) + } + } + } + private fun getGBDevice(device: Device): GBDevice { return device.let { findDevice -> GBApplication.app().deviceManager.devices diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/workouts/charts/DefaultWorkoutCharts.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/workouts/charts/DefaultWorkoutCharts.java index 992c63f1d0..b7c1dd7230 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/workouts/charts/DefaultWorkoutCharts.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/workouts/charts/DefaultWorkoutCharts.java @@ -22,6 +22,7 @@ import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries. import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_KMPH; import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_METERS; import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_METERS_PER_SECOND; +import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_MILLISECONDS; import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_MINUTES_PER_100_METERS; import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_MINUTES_PER_KM; import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_MM; @@ -81,6 +82,12 @@ public class DefaultWorkoutCharts { final List stepLengthPoints = new ArrayList<>(initialCapacity); final List n2LoadPoints = new ArrayList<>(initialCapacity); final List cnsToxicityPoints = new ArrayList<>(initialCapacity); + final List verticalOscillationPoints = new ArrayList<>(initialCapacity); + final List stanceTimePercentPoints = new ArrayList<>(initialCapacity); + final List stanceTimePoints = new ArrayList<>(initialCapacity); + final List verticalRatioPoints = new ArrayList<>(initialCapacity); + final List stanceTimeBalancePoints = new ArrayList<>(initialCapacity); + final List performanceConditionPoints = new ArrayList<>(initialCapacity); // some activities / devices provide all points with zero values boolean hasSpeedValues = false; @@ -96,6 +103,12 @@ public class DefaultWorkoutCharts { boolean hasStepLengthValues = false; boolean hasN2LoadValues = false; boolean hasCnsToxicityValues = false; + boolean hasVerticalOscillationValues = false; + boolean hasStanceTimePercentValues = false; + boolean hasStanceTimeValues = false; + boolean hasVerticalRatioValues = false; + boolean hasStanceTimeBalanceValues = false; + boolean hasPerformanceConditionValues = false; final Accumulator cadenceAccumulator = new Accumulator(); final Accumulator temperatureAccumulator = new Accumulator(); @@ -200,6 +213,45 @@ public class DefaultWorkoutCharts { n2LoadPoints.add(new Entry(tsShorten, n2Load)); hasN2LoadValues = hasN2LoadValues || (n2Load > 0.0f); } + + // Running dynamics + final float verticalOscillation = point.getVerticalOscillation(); + if (!Float.isNaN(verticalOscillation)) { + verticalOscillationPoints.add(new Entry(tsShorten, verticalOscillation)); + hasVerticalOscillationValues = hasVerticalOscillationValues || (verticalOscillation > 0.0f); + } + + final float stanceTimePercent = point.getStanceTimePercent(); + if (!Float.isNaN(stanceTimePercent)) { + stanceTimePercentPoints.add(new Entry(tsShorten, stanceTimePercent)); + hasStanceTimePercentValues = hasStanceTimePercentValues || (stanceTimePercent > 0.0f); + } + + final float stanceTime = point.getStanceTime(); + if (!Float.isNaN(stanceTime)) { + stanceTimePoints.add(new Entry(tsShorten, stanceTime)); + hasStanceTimeValues = hasStanceTimeValues || (stanceTime > 0.0f); + } + + final float verticalRatio = point.getVerticalRatio(); + if (!Float.isNaN(verticalRatio)) { + verticalRatioPoints.add(new Entry(tsShorten, verticalRatio)); + hasVerticalRatioValues = hasVerticalRatioValues || (verticalRatio > 0.0f); + } + + final float stanceTimeBalance = point.getStanceTimeBalance(); + if (!Float.isNaN(stanceTimeBalance)) { + stanceTimeBalancePoints.add(new Entry(tsShorten, stanceTimeBalance)); + hasStanceTimeBalanceValues = hasStanceTimeBalanceValues || (stanceTimeBalance > 0.0f); + } + + final int performanceCondition = point.getPerformanceCondition(); + if (performanceCondition > Integer.MIN_VALUE) { + // Integer.MIN_VALUE is the no-data sentinel; any other value is a valid + // reading, including negative ones (performance condition can be < 0). + performanceConditionPoints.add(new Entry(tsShorten, performanceCondition)); + hasPerformanceConditionValues = true; + } } if (!heartRateDataPoints.isEmpty()) { @@ -258,6 +310,30 @@ public class DefaultWorkoutCharts { charts.add(createN2LoadChart(context, n2LoadPoints)); } + if (hasVerticalOscillationValues && !verticalOscillationPoints.isEmpty()) { + charts.add(createVerticalOscillationChart(context, verticalOscillationPoints)); + } + + if (hasStanceTimePercentValues && !stanceTimePercentPoints.isEmpty()) { + charts.add(createStanceTimePercentChart(context, stanceTimePercentPoints)); + } + + if (hasStanceTimeValues && !stanceTimePoints.isEmpty()) { + charts.add(createStanceTimeChart(context, stanceTimePoints)); + } + + if (hasVerticalRatioValues && !verticalRatioPoints.isEmpty()) { + charts.add(createVerticalRatioChart(context, verticalRatioPoints)); + } + + if (hasStanceTimeBalanceValues && !stanceTimeBalancePoints.isEmpty()) { + charts.add(createStanceTimeBalanceChart(context, stanceTimeBalancePoints)); + } + + if (hasPerformanceConditionValues && !performanceConditionPoints.isEmpty()) { + charts.add(createPerformanceConditionChart(context, performanceConditionPoints)); + } + return charts; } @@ -573,6 +649,126 @@ public class DefaultWorkoutCharts { ); } + private static WorkoutChart createVerticalOscillationChart(final Context context, + final List verticalOscillationPoints) { + final String label = String.format("%s(%s)", context.getString(R.string.vertical_oscillation), getUnitString(context, UNIT_MM)); + final LineDataSet dataset = createLineDataSet(context, verticalOscillationPoints, label, ContextCompat.getColor(context, R.color.chart_line_stride)); + final ValueFormatter valueFormatter = new ValueFormatter() { + @Override + public String getFormattedValue(float value) { + return String.valueOf((int) value); + } + }; + return new WorkoutChart( + "chart_vertical_oscillation", + context.getString(R.string.vertical_oscillation), + ActivitySummaryEntries.GROUP_RUNNING_FORM, + new LineData(dataset), + valueFormatter, + getUnitString(context, UNIT_MM) + ); + } + + private static WorkoutChart createStanceTimePercentChart(final Context context, + final List stanceTimePercentPoints) { + final String label = String.format("%s(%s)", context.getString(R.string.stance_time_percent), getUnitString(context, UNIT_PERCENTAGE)); + final LineDataSet dataset = createLineDataSet(context, stanceTimePercentPoints, label, ContextCompat.getColor(context, R.color.chart_line_swolf)); + final ValueFormatter valueFormatter = new ValueFormatter() { + @Override + public String getFormattedValue(float value) { + return String.format(java.util.Locale.ROOT, "%.1f", value); + } + }; + return new WorkoutChart( + "chart_stance_time_percent", + context.getString(R.string.stance_time_percent), + ActivitySummaryEntries.GROUP_RUNNING_FORM, + new LineData(dataset), + valueFormatter, + getUnitString(context, UNIT_PERCENTAGE) + ); + } + + private static WorkoutChart createStanceTimeChart(final Context context, + final List stanceTimePoints) { + final String label = String.format("%s(%s)", context.getString(R.string.ground_contact_time), getUnitString(context, UNIT_MILLISECONDS)); + final LineDataSet dataset = createLineDataSet(context, stanceTimePoints, label, ContextCompat.getColor(context, R.color.chart_line_step_length)); + final ValueFormatter valueFormatter = new ValueFormatter() { + @Override + public String getFormattedValue(float value) { + return String.valueOf((int) value); + } + }; + return new WorkoutChart( + "chart_stance_time", + context.getString(R.string.ground_contact_time), + ActivitySummaryEntries.GROUP_RUNNING_FORM, + new LineData(dataset), + valueFormatter, + getUnitString(context, UNIT_MILLISECONDS) + ); + } + + private static WorkoutChart createVerticalRatioChart(final Context context, + final List verticalRatioPoints) { + final String label = String.format("%s(%s)", context.getString(R.string.vertical_ratio), getUnitString(context, UNIT_PERCENTAGE)); + final LineDataSet dataset = createLineDataSet(context, verticalRatioPoints, label, ContextCompat.getColor(context, R.color.chart_line_stamina)); + final ValueFormatter valueFormatter = new ValueFormatter() { + @Override + public String getFormattedValue(float value) { + return String.format(java.util.Locale.ROOT, "%.1f", value); + } + }; + return new WorkoutChart( + "chart_vertical_ratio", + context.getString(R.string.vertical_ratio), + ActivitySummaryEntries.GROUP_RUNNING_FORM, + new LineData(dataset), + valueFormatter, + getUnitString(context, UNIT_PERCENTAGE) + ); + } + + private static WorkoutChart createStanceTimeBalanceChart(final Context context, + final List stanceTimeBalancePoints) { + final String label = String.format("%s(%s)", context.getString(R.string.ground_contact_time_balance), getUnitString(context, UNIT_PERCENTAGE)); + final LineDataSet dataset = createLineDataSet(context, stanceTimeBalancePoints, label, ContextCompat.getColor(context, R.color.chart_line_body_energy)); + final ValueFormatter valueFormatter = new ValueFormatter() { + @Override + public String getFormattedValue(float value) { + return String.format(java.util.Locale.ROOT, "%.1f", value); + } + }; + return new WorkoutChart( + "chart_stance_time_balance", + context.getString(R.string.ground_contact_time_balance), + ActivitySummaryEntries.GROUP_RUNNING_FORM, + new LineData(dataset), + valueFormatter, + getUnitString(context, UNIT_PERCENTAGE) + ); + } + + private static WorkoutChart createPerformanceConditionChart(final Context context, + final List performanceConditionPoints) { + final String label = context.getString(R.string.performance_condition); + final LineDataSet dataset = createLineDataSet(context, performanceConditionPoints, label, ContextCompat.getColor(context, R.color.chart_line_elevation)); + final ValueFormatter valueFormatter = new ValueFormatter() { + @Override + public String getFormattedValue(float value) { + return String.valueOf((int) value); + } + }; + return new WorkoutChart( + "chart_performance_condition", + context.getString(R.string.performance_condition), + ActivitySummaryEntries.GROUP_RUNNING_FORM, + new LineData(dataset), + valueFormatter, + "" + ); + } + public static String getUnitString(final Context context, final String unit) { final int resId = context.getResources().getIdentifier(unit, "string", context.getPackageName()); if (resId != 0) { diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminWorkoutParser.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminWorkoutParser.java index 1a2c8ef690..f7bae60943 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminWorkoutParser.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminWorkoutParser.java @@ -62,6 +62,8 @@ import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefi import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions.FieldDefinitionMeasurementSystem; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions.FieldDefinitionWaterType; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitDeviceInfo; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitFileCreator; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitFileId; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitDeviceStatus; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitDiveGas; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitDiveSettings; @@ -88,8 +90,14 @@ public class GarminWorkoutParser implements ActivitySummaryParser { private final List timesInZone = new ArrayList<>(); private final List activityPoints = new ArrayList<>(); private List sessionActivityPoints; + // Multi-session FIT files (triathlons, brick workouts) carry one FitSession per + // sport. Primary == first session — current import path produces a single + // BaseActivitySummary, so secondary sessions are recorded for surfacing in the + // summary table but do not produce additional summaries. Callers that need + // per-session records should iterate {@link #getAllSessions()}. @Nullable private FitSession session = null; + private final List allSessions = new ArrayList<>(); @Nullable private FitSport sport = null; @Nullable @@ -119,6 +127,10 @@ public class GarminWorkoutParser implements ActivitySummaryParser { @Nullable private FitWorkout workout = null; private final List genericMetricSamples = new ArrayList<>(); + @Nullable + private FitFileId fileId = null; + @Nullable + private FitFileCreator fileCreator = null; public GarminWorkoutParser(final Context context) { this.context = context; @@ -198,10 +210,15 @@ public class GarminWorkoutParser implements ActivitySummaryParser { ); } + public List getAllSessions() { + return allSessions; + } + public void reset() { timesInZone.clear(); activityPoints.clear(); session = null; + allSessions.clear(); sport = null; userMetrics = null; userProfile = null; @@ -219,6 +236,8 @@ public class GarminWorkoutParser implements ActivitySummaryParser { ebikeBatteryStart = null; ebikeBatteryEnd = null; workout = null; + fileId = null; + fileCreator = null; } public boolean handleRecord(final RecordData record) { @@ -234,12 +253,16 @@ public class GarminWorkoutParser implements ActivitySummaryParser { } } else if (record instanceof FitSession fitSession) { LOG.debug("Session: {}", fitSession); - if (session != null) { - LOG.warn("Got multiple sessions - NOT SUPPORTED: {}", fitSession); - } else { - // We only support 1 session + allSessions.add(fitSession); + if (session == null) { + // Primary session drives the BaseActivitySummary; subsequent sessions + // (multi-sport / brick workouts) are kept in allSessions for the + // summary table only — current single-summary import path cannot + // split them into separate activity entries. session = fitSession; sessionActivityPoints = (session.toActivityPoints()); + } else { + LOG.info("Multi-session FIT — primary preserved, secondary kept in allSessions for table render"); } } else if (record instanceof FitPhysiologicalMetrics fitPhysiologicalMetrics) { LOG.debug("Physiological Metrics: {}", fitPhysiologicalMetrics); @@ -352,6 +375,14 @@ public class GarminWorkoutParser implements ActivitySummaryParser { || (used != null && used != 0)) { diveTanks.add(fitTankSummary); } + } else if (record instanceof FitFileId fitFileId) { + // Captured for the manufacturer=255 (development) fallback in updateSummary. + // Real Garmin/Suunto/Wahoo devices populate device_info messages; smartwatches + // running 3rd-party FIT writers (Watch5/Watch6 generic recorders) often only + // populate file_id and rely on the importer to display product_name. + fileId = fitFileId; + } else if (record instanceof FitFileCreator fitFileCreator) { + fileCreator = fitFileCreator; } else { return false; } @@ -948,13 +979,13 @@ public class GarminWorkoutParser implements ActivitySummaryParser { summaryData.add(HR_USER_MAX, userMetrics.getMaxHr(), UNIT_BPM); } - if (!deviceInfos.isEmpty()) { - final ActivitySummaryTableBuilder tableBuilder = new ActivitySummaryTableBuilder(GROUP_GEAR_INFO, "gear_info_header", Arrays.asList( - "device", - "battery_status", - "battery_level" - )); + final ActivitySummaryTableBuilder gearTableBuilder = new ActivitySummaryTableBuilder(GROUP_GEAR_INFO, "gear_info_header", Arrays.asList( + "device", + "battery_status", + "battery_level" + )); + if (!deviceInfos.isEmpty()) { for (final Map.Entry entry : deviceInfos.entrySet()) { final Integer deviceIndex = entry.getKey(); final FitDeviceInfo deviceInfo = entry.getValue(); @@ -975,7 +1006,7 @@ public class GarminWorkoutParser implements ActivitySummaryParser { level_uom = UNIT_VOLT; } - tableBuilder.addRow( + gearTableBuilder.addRow( "device_info_" + deviceIndex, Arrays.asList( new ActivitySummaryValue(device, UNIT_RAW_STRING), @@ -984,12 +1015,32 @@ public class GarminWorkoutParser implements ActivitySummaryParser { ) ); } - - if (tableBuilder.hasRows()) { - tableBuilder.addToSummaryData(summaryData); + } else if (fileId != null) { + // Manufacturer=255 (development) fallback. Devices that only populate file_id + // (Watch5/Watch6, third-party FIT recorders) get a synthetic gear row built + // from product_name + software_version so the activity detail view shows + // *something* rather than no device at all. + final String productName = fileId.getProductName(); + if (productName != null && !productName.isEmpty()) { + final StringBuilder label = new StringBuilder(productName); + if (fileCreator != null && fileCreator.getSoftwareVersion() != null) { + label.append(" (sw ").append(fileCreator.getSoftwareVersion()).append(')'); + } + gearTableBuilder.addRow( + "device_info_file_id", + Arrays.asList( + new ActivitySummaryValue(label.toString(), UNIT_RAW_STRING), + new ActivitySummaryValue((String) null), + new ActivitySummaryValue((Number) null, UNIT_PERCENTAGE) + ) + ); } } + if (gearTableBuilder.hasRows()) { + gearTableBuilder.addToSummaryData(summaryData); + } + if (deviceStatusStart != null) { Number batteryLevel = deviceStatusStart.getBatteryLevel(); String batteryUom = UNIT_PERCENTAGE; diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/export/AutoFitExporter.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/export/AutoFitExporter.java new file mode 100644 index 0000000000..4949126b59 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/export/AutoFitExporter.java @@ -0,0 +1,148 @@ +/* Copyright (C) 2026 Dany Mestas + + This file is part of Gadgetbridge. + + Gadgetbridge is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Gadgetbridge is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . */ +package nodomain.freeyourgadget.gadgetbridge.export; + +import android.content.Context; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.documentfile.provider.DocumentFile; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.util.Collections; +import java.util.Locale; +import java.util.Set; + +import nodomain.freeyourgadget.gadgetbridge.GBApplication; +import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind; +import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryData; +import nodomain.freeyourgadget.gadgetbridge.model.ActivityTrack; +import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils; +import nodomain.freeyourgadget.gadgetbridge.util.FileUtils; +import nodomain.freeyourgadget.gadgetbridge.util.GBPrefs; + +public class AutoFitExporter { + private static final Logger LOG = LoggerFactory.getLogger(AutoFitExporter.class); + + public static boolean isExportEnabled(@NonNull final GBDevice gbDevice) { + return getExportDirectory(gbDevice) != null; + } + + @Nullable + public static String getExportDirectory(@NonNull final GBDevice gbDevice) { + final GBPrefs prefs = GBApplication.getPrefs(); + final boolean enabled = prefs.getBoolean(GBPrefs.AUTO_EXPORT_FIT_ENABLED, false); + if (!enabled) { + LOG.debug("Auto fit export is disabled"); + return null; + } + + final Set selectedDevices = prefs.getStringSet(GBPrefs.AUTO_EXPORT_FIT_SELECTED_DEVICES, Collections.emptySet()); + final boolean allDevices = prefs.getBoolean(GBPrefs.AUTO_EXPORT_FIT_ALL_DEVICES, true); + if (!allDevices && !selectedDevices.contains(gbDevice.getAddress())) { + LOG.debug("Auto fit export is not enabled for {}", gbDevice); + return null; + } + + final String directory = prefs.getString(GBPrefs.AUTO_EXPORT_FIT_DIRECTORY, ""); + if (directory.isBlank()) { + LOG.warn("No auto fit export directory specified"); + return null; + } + + return directory; + } + + public static void doExport(final Context context, + final GBDevice gbDevice, + @NonNull final BaseActivitySummary summary, + @Nullable final ActivityTrack activityTrack) { + final String directory = getExportDirectory(gbDevice); + if (directory == null) { + return; + } + + final String kindLabel = context.getString( + ActivityKind.fromCode(summary.getActivityKind()).getLabel()).toLowerCase(Locale.ROOT); + final String isoDate = DateTimeUtils.formatIso8601(summary.getStartTime()); + final String fileName = FileUtils.makeValidFileName(isoDate + "-" + kindLabel + ".fit"); + + final ActivitySummaryData summaryData; + final String summaryJson = summary.getSummaryData(); + if (summaryJson != null) { + summaryData = ActivitySummaryData.fromJson(summaryJson); + } else { + summaryData = null; + } + + try { + final Uri directoryUri = Uri.parse(directory); + final DocumentFile documentDir = DocumentFile.fromTreeUri(context, directoryUri); + if (documentDir == null || !documentDir.exists() || !documentDir.canWrite()) { + LOG.error("Cannot write to directory: {}", directory); + return; + } + + final DocumentFile existingFile = documentDir.findFile(fileName); + if (existingFile != null) { + LOG.debug("File already exists, will not overwrite: {}", fileName); + return; + } + + final DocumentFile targetFile = documentDir.createFile("application/octet-stream", fileName); + if (targetFile == null) { + LOG.error("Failed to create file: {}", fileName); + return; + } + + // FIT-native devices (Garmin, iGPSPORT) keep the original .fit at + // rawDetailsPath — export it verbatim instead of regenerating. + final File rawFit = FitExporter.resolveRawFitFile(summary); + try (OutputStream out = context.getContentResolver().openOutputStream(targetFile.getUri())) { + if (out == null) { + LOG.error("Failed to open output stream for {}", targetFile.getUri()); + return; + } + if (rawFit != null) { + LOG.debug("Auto-export: using original FIT {}", rawFit); + try (FileInputStream in = new FileInputStream(rawFit)) { + final byte[] buf = new byte[8192]; + int n; + while ((n = in.read(buf)) > 0) { + out.write(buf, 0, n); + } + } + } else { + new FitExporter().performExport(activityTrack, summary, summaryData, out); + } + } + + LOG.info("Auto-exported FIT to: {}", targetFile.getUri()); + } catch (final Exception e) { + LOG.error("Failed to auto-export FIT", e); + } + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/export/FitExporter.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/export/FitExporter.java new file mode 100644 index 0000000000..7cb234fff8 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/export/FitExporter.java @@ -0,0 +1,1791 @@ +/* Copyright (C) 2026 Dany Mestas + + This file is part of Gadgetbridge. + + Gadgetbridge is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Gadgetbridge is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . */ +package nodomain.freeyourgadget.gadgetbridge.export; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import nodomain.freeyourgadget.gadgetbridge.BuildConfig; +import nodomain.freeyourgadget.gadgetbridge.activities.workouts.entries.ActivitySummaryEntry; +import nodomain.freeyourgadget.gadgetbridge.activities.workouts.entries.ActivitySummarySimpleEntry; +import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary; +import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind; +import nodomain.freeyourgadget.gadgetbridge.model.ActivityPoint; +import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryData; +import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries; +import nodomain.freeyourgadget.gadgetbridge.model.ActivityTrack; +import nodomain.freeyourgadget.gadgetbridge.model.GPSCoordinate; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.FileType; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.GarminTimeUtils; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.FitFile; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.RecordData; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.enums.GarminSport; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitActivity; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitEvent; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitFileCreator; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitFileId; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitLap; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitRecord; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitLength; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitSession; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitSet; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitSplit; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitWorkout; + +/** + * Exports an arbitrary workout (summary + optional ActivityTrack) as a FIT ACTIVITY file + * by reusing the auto-generated Garmin FIT message builders. Manufacturer is set to + * "development" (255) since the file is synthesized by Gadgetbridge rather than emitted + * by a Garmin device. The exporter is device-agnostic: it consumes the generic + * BaseActivitySummary / ActivitySummaryData / ActivityTrack types and works for any + * device whose parser populates those objects. + */ +public class FitExporter { + private static final Logger LOG = LoggerFactory.getLogger(FitExporter.class); + + // FIT manufacturer ID. 255 = "development" (anyone can use; some strict importers + // such as old Strava clients reject this — switch to a registered ID if needed). + public static final int DEFAULT_MANUFACTURER_ID = 255; + + private final int manufacturerId; + + // FIT event enum values (subset) + private static final int EVENT_TIMER = 0; + private static final int EVENT_ACTIVITY = 26; + private static final int EVENT_TYPE_START = 0; + private static final int EVENT_TYPE_STOP = 1; + private static final int EVENT_TYPE_STOP_ALL = 9; + private static final int ACTIVITY_TYPE_MANUAL = 0; + + // Local message type slots (per-file message numbering) + private static final int LMT_FILE_ID = 0; + private static final int LMT_FILE_CREATOR = 1; + private static final int LMT_EVENT = 2; + private static final int LMT_RECORD = 3; + private static final int LMT_LAP = 4; + private static final int LMT_SESSION = 5; + private static final int LMT_ACTIVITY = 6; + private static final int LMT_WORKOUT = 7; + private static final int LMT_LENGTH = 8; + private static final int LMT_SPLIT = 9; + private static final int LMT_SET = 10; + + // FIT lap intensity codes (from intensity_t in the FIT spec) + private static final int FIT_INTENSITY_ACTIVE = 0; + private static final int FIT_INTENSITY_REST = 1; + + // Tiny-segment threshold: ActivityTrack segments smaller than this are dropped + // from the lap stream (their records still emit in the FIT record stream — only + // the lap boundary is suppressed). Avoids 1-record "rest blip" noise from parsers + // that signal a transient pause as its own segment. + private static final int TINY_SEGMENT_MIN_RECORDS = 5; + private static final long TINY_SEGMENT_MIN_SECONDS = 10L; + + // Default per-stroke distance (m) used to synthesize total_distance for rowing + // workouts where the watch records strokes but no measured distance — see + // deriveRowingDistanceMeters. Indoor erg averages 6-8 m/stroke at recreational + // 24-32 spm (Concept2 published averages); on-water sculling runs longer per stroke + // (8-10 m). TODO: expose as a user preference once a settings host is identified. + static final double DEFAULT_INDOOR_ROWING_STROKE_LENGTH_M = 6.0; + static final double DEFAULT_OUTDOOR_ROWING_STROKE_LENGTH_M = 9.0; + + public FitExporter() { + this(DEFAULT_MANUFACTURER_ID); + } + + public FitExporter(final int manufacturerId) { + this.manufacturerId = manufacturerId; + } + + /** + * If the summary points to an original FIT file (e.g. Garmin / iGPSPORT, where + * {@code FitImporter} stored the device's own .fit at {@code rawDetailsPath}), + * returns that File so callers can export it verbatim — preserving full fidelity — + * instead of regenerating one. Returns null when there is no usable raw FIT, in + * which case the caller should fall back to {@link #performExport}. + */ + @Nullable + public static File resolveRawFitFile(@Nullable final BaseActivitySummary summary) { + if (summary == null) { + return null; + } + final String rawPath = summary.getRawDetailsPath(); + if (rawPath == null || rawPath.isEmpty()) { + return null; + } + final File file = new File(rawPath); + if (!file.isFile() || !file.canRead()) { + return null; + } + return isFitFile(file) ? file : null; + } + + /** True when the file carries the ".FIT" magic at header bytes 8-11 (FIT spec). */ + private static boolean isFitFile(@NonNull final File file) { + try (FileInputStream in = new FileInputStream(file)) { + final byte[] header = new byte[12]; + if (in.read(header) < header.length) { + return false; + } + return header[8] == '.' && header[9] == 'F' && header[10] == 'I' && header[11] == 'T'; + } catch (final IOException e) { + LOG.warn("Could not read FIT header of {}", file, e); + return false; + } + } + + private static int mapIntensity(final ActivityTrack.SegmentIntensity intensity) { + if (intensity == ActivityTrack.SegmentIntensity.REST) return FIT_INTENSITY_REST; + // ACTIVE and UNKNOWN both map to ACTIVE — UNKNOWN defaults to active so that + // importers that filter by intensity still surface the lap. + return FIT_INTENSITY_ACTIVE; + } + + /// Detects FIT sport+sub_sport pairs that represent rowing of any kind. Used to gate + /// the rowing-only stroke→distance fallback. Covers ROWING (15,*), ROW_INDOOR (15,14), + /// and the alternate FITNESS_EQUIPMENT-class indoor rowing (4,14). + private static boolean isRowingSport(final int sport, final int subSport) { + return sport == 15 || (sport == 4 && subSport == 14); + } + + /// Sports whose Lap/Session messages may carry the num_lengths field. Lap swimming + /// (5,17) plus TRACK_RUN (1,4) and INDOOR_TRACK (1,45) where a "length" maps to a + /// track loop. Currently a no-op for the running variants until FitLength records are + /// emitted for them. + private static boolean sportSupportsNumLengths(final int sport, final int subSport) { + return (sport == 5 && subSport == 17) + || (sport == 1 && (subSport == 4 || subSport == 45)); + } + + /// True when the activity's distance-over-time has a meaningful "speed" interpretation. + /// Used to gate the avg_speed = distance/elapsed pace fallback in buildLap/buildSession — + /// for stationary or non-locomotion sports (yoga, breathing, strength, stopwatch, + /// meditation) the fallback would invent a bogus speed. + private static boolean isLocomotionSport(final int sport, final int subSport) { + // Sport-level non-locomotion families. + if (sport == 52) return false; // stopwatch + if (sport == 60) return false; // health snapshot + if (sport == 67) return false; // meditation + // Generic family — only breathing(62) is non-locomotion among the common subsports. + if (sport == 0 && subSport == 62) return false; + // Training family — strength/yoga/stretching/breathing/cardio do not have a + // meaningful speed even when distance is logged. + if (sport == 10) { + return subSport != 19 // stretching + && subSport != 20 // strength + && subSport != 43 // yoga + && subSport != 62; // breathing + } + // Fitness equipment family — strength/yoga/pilates do not have meaningful speed. + if (sport == 4) { + return subSport != 20 // calisthenics/strength + && subSport != 43 // yoga equipment + && subSport != 44; // pilates + } + return true; + } + + /// Picks the per-stroke distance default for the given rowing sport/sub-sport pair. + /// Indoor erg (sub_sport=14) uses a shorter stroke; on-water rowing uses a longer one. + /// Caller is expected to gate with isRowingSport first. + private static double rowingStrokeLengthMeters(final int sport, final int subSport) { + return subSport == 14 + ? DEFAULT_INDOOR_ROWING_STROKE_LENGTH_M + : DEFAULT_OUTDOOR_ROWING_STROKE_LENGTH_M; + } + + /// Synthesizes a total-distance value for stroke-based workouts (rowing) where the + /// watch only reports strokes. Returns null for non-positive strokes/length so callers + /// can skip emitting the field entirely. + @Nullable + private static Double deriveRowingDistanceMeters(@Nullable final Long strokes, + final double strokeLengthM) { + if (strokes == null || strokes <= 0L || strokeLengthM <= 0.0) return null; + return strokes * strokeLengthM; + } + + public void performExport(@Nullable final ActivityTrack track, + @NonNull final BaseActivitySummary summary, + @Nullable final ActivitySummaryData summaryData, + @NonNull final File targetFile) throws IOException { + try (FileOutputStream fos = new FileOutputStream(targetFile)) { + performExport(track, summary, summaryData, fos); + } + } + + public void performExport(@Nullable final ActivityTrack track, + @NonNull final BaseActivitySummary summary, + @Nullable final ActivitySummaryData summaryData, + @NonNull final OutputStream outputStream) throws IOException { + final long startMs = summary.getStartTime().getTime(); + final long endMs = summary.getEndTime() != null ? summary.getEndTime().getTime() : startMs; + final long startSeconds = startMs / 1000L; + final long endSeconds = Math.max(startSeconds, endMs / 1000L); + final long elapsedSeconds = endSeconds - startSeconds; + + if (track == null) { + LOG.warn("performExport: track is null for summary {} — emitting fallback single-lap shell file", + summary.getId()); + } else { + int totalPoints = 0; + for (final List seg : track.getSegments()) totalPoints += seg.size(); + LOG.info("performExport: summary {} track has {} segments / {} points", + summary.getId(), track.getSegments().size(), totalPoints); + } + + final List> segments = (track != null && !track.getSegments().isEmpty()) + ? track.getSegments() + : List.of(new ArrayList<>()); + final List segmentInfos = (track != null && !track.getSegmentInfos().isEmpty()) + ? track.getSegmentInfos() + : List.of(new ActivityTrack.SegmentInfo()); + + // Count non-empty segments — used to decide whether per-lap aggregates can + // safely come from the session-level summaryData (only when there is exactly + // one lap covering the whole session). + int nonEmptySegments = 0; + for (final List seg : segments) { + if (!seg.isEmpty()) nonEmptySegments++; + } + final boolean singleSegment = nonEmptySegments <= 1; + + final ActivityKind kind = ActivityKind.fromCode(summary.getActivityKind()); + final Optional garminSport = GarminSport.fromActivityKind(kind); + final int sport = garminSport.map(GarminSport::getType).orElse(GarminSport.GENERIC.getType()); + final int subSport = garminSport.map(GarminSport::getSubtype).orElse(GarminSport.GENERIC.getSubtype()); + + // Sensor-presence pre-pass: when a track-wide cadence or power stream is all + // zero, the source has no cadence/power sensor — emitting "0" per record + // pollutes downstream graphs in Strava/Garmin Connect/Endurain. Detect once + // up front and pass the flags to buildRecord so it can suppress those fields + // on every record. HR is already gated on >0 inside buildRecord. + boolean trackHasCadence = false; + boolean trackHasPower = false; + for (final List seg : segments) { + for (final ActivityPoint p : seg) { + if (!trackHasCadence && p.getCadence() > 0) trackHasCadence = true; + if (!trackHasPower && Float.isFinite(p.getPower()) && p.getPower() > 0f) trackHasPower = true; + if (trackHasCadence && trackHasPower) break; + } + if (trackHasCadence && trackHasPower) break; + } + + final List records = new ArrayList<>(); + records.add(buildFileId(startSeconds)); + records.add(buildFileCreator()); + // wkt_name lets importers (Endurain) display the user-facing activity name + // instead of falling back to a generic "Workout" label. + final String workoutName = summary.getName(); + if (workoutName != null && !workoutName.isEmpty()) { + records.add(buildWorkout(workoutName, sport, subSport)); + } + records.add(buildEvent(startSeconds, EVENT_TYPE_START)); + + final PointAggregates totalAgg = new PointAggregates(); + final List lapDescriptors = new ArrayList<>(); + // Content-aware dedup: drop a record only when its timestamp AND every + // value-bearing field exactly matches the previously emitted point. Sources + // that emit multiple records per second with DIFFERENT field values (Strava + // E-Bike, Wattbike trainers) keep both — the strict 1s timestamp dedup the + // earlier code did discarded ~50% of those. + long lastEventTs = startSeconds; + long lastEmittedSig = 0L; + boolean haveLastSig = false; + + for (int s = 0; s < segments.size(); s++) { + final List seg = segments.get(s); + if (seg.isEmpty()) continue; + + final ActivityTrack.SegmentInfo info = s < segmentInfos.size() + ? segmentInfos.get(s) + : new ActivityTrack.SegmentInfo(); + + long segStartTs = Long.MAX_VALUE; + long segEndTs = Long.MIN_VALUE; + final PointAggregates segAgg = new PointAggregates(); + + for (final ActivityPoint p : seg) { + segAgg.accumulate(p); + totalAgg.accumulate(p); + if (p.getTime() == null) continue; + final long ts = p.getTime().getTime() / 1000L; + if (ts < segStartTs) segStartTs = ts; + if (ts > segEndTs) segEndTs = ts; + // Pause / segment-break markers — most non-Garmin parsers signal these via + // ActivityPoint.description. Emit a TIMER STOP_ALL event so importers that + // honour pauses (Strava, Garmin Connect) see them. Skip if a STOP/START + // event was already emitted at this same second. + if (p.getDescription() != null && !p.getDescription().isEmpty() && ts != lastEventTs) { + records.add(buildEvent(ts, EVENT_TYPE_STOP_ALL)); + lastEventTs = ts; + } + // Skip only when this point is byte-identical to the previously emitted one + // (same ts, same fields). Keeps multi-record-per-second sources intact. + final long sig = pointSignature(p); + if (haveLastSig && sig == lastEmittedSig) continue; + final RecordData rec = buildRecord(p, trackHasCadence, trackHasPower); + if (rec != null) { + records.add(rec); + lastEmittedSig = sig; + haveLastSig = true; + } + } + + if (segStartTs == Long.MAX_VALUE) continue; // no timestamped points in segment + final long segElapsed = Math.max(0L, segEndTs - segStartTs); + // Tiny-segment skip: keeps the lap stream meaningful while preserving every + // record. The first segment always emits a lap so importers that require a + // lap before the session see one. + final boolean tiny = (seg.size() < TINY_SEGMENT_MIN_RECORDS || segElapsed < TINY_SEGMENT_MIN_SECONDS) + && !lapDescriptors.isEmpty(); + if (tiny) continue; + + lapDescriptors.add(new LapDescriptor(segStartTs, segElapsed, segAgg, info)); + } + + // Per-lap totals come ONLY from per-segment binary fields parsed by the source + // (SegmentInfo.distanceMeters, SegmentInfo.strokes). When the source does not + // encode a per-segment metric, the corresponding FIT lap field is omitted — + // we never derive or distribute session-level aggregates across laps. + final List lapRecords = new ArrayList<>(); + // Lap-window length count for swim laps. Build once: for each lap descriptor, + // count FitLength records whose startTime is within [start, start+elapsed). + // For the no-descriptor fallback path the whole-track length count is used. + final int totalLengths = (track != null) ? track.getLengths().size() : 0; + if (lapDescriptors.isEmpty()) { + // Edge case: no segments yielded a lap (e.g. all empty / no track). Emit one + // fallback lap covering the whole session so importers always see numLaps >= 1. + lapRecords.add(buildLap(summaryData, totalAgg, sport, subSport, startSeconds, elapsedSeconds, + 0, ActivityTrack.SegmentIntensity.UNKNOWN, true, LapTotals.EMPTY, + totalLengths > 0 ? totalLengths : null)); + } else { + for (int i = 0; i < lapDescriptors.size(); i++) { + final LapDescriptor d = lapDescriptors.get(i); + final LapTotals overrides = singleSegment + ? LapTotals.EMPTY + : new LapTotals( + d.info.getDistanceMeters() != null + ? d.info.getDistanceMeters().doubleValue() : null, + d.info.getStrokes() != null + ? d.info.getStrokes().longValue() : null); + Integer lapLengthCount = null; + if (track != null && !track.getLengths().isEmpty()) { + int c = 0; + final long lapEnd = d.startTs + d.elapsed; + for (final ActivityTrack.LengthInfo li : track.getLengths()) { + if (li.startTimeSec >= d.startTs && li.startTimeSec < lapEnd) c++; + } + if (c > 0) lapLengthCount = c; + } + lapRecords.add(buildLap(summaryData, d.agg, sport, subSport, d.startTs, d.elapsed, + i, d.info.getIntensity(), singleSegment, overrides, lapLengthCount)); + } + } + final int emittedLaps = Math.max(1, lapDescriptors.size()); + + // Final stop event — skip if a pause STOP_ALL already lands on endSeconds. + if (endSeconds != lastEventTs) { + records.add(buildEvent(endSeconds, EVENT_TYPE_STOP_ALL)); + } + records.addAll(lapRecords); + // Per-length swim records — only emitted for lap-swimming workouts (sport=5, + // sub_sport=17). Strava + Endurain render per-length stats in their swim + // detail views; without these the lap-swimming activity is reduced to a + // single distance bar. + if (sport == 5 && subSport == 17 && track != null && !track.getLengths().isEmpty()) { + int lengthIdx = 0; + for (final ActivityTrack.LengthInfo li : track.getLengths()) { + records.add(buildLength(li, lengthIdx++)); + } + } + // Per-split records — Garmin Edge/Forerunner emit auto-splits (per km/mile). + // Strava surfaces them on the lap chart, Endurain reads them for split summary. + if (track != null && !track.getSplits().isEmpty()) { + int splitIdx = 0; + for (final ActivityTrack.SplitInfo si : track.getSplits()) { + records.add(buildSplit(si, splitIdx++)); + } + } + // Per-set records — strength training. Endurain renders these in its workout + // set table; Garmin Connect uses them to drive the strength workout view. + if (track != null && !track.getSets().isEmpty()) { + for (final ActivityTrack.SetInfo si : track.getSets()) { + records.add(buildSet(si)); + } + } + // Sum per-segment strokes across all laps as a fallback when summaryData lacks + // STROKES — used by buildSession to synthesize rowing distance. + Long sumLapStrokes = null; + for (final LapDescriptor d : lapDescriptors) { + final Integer s = d.info.getStrokes(); + if (s == null) continue; + if (sumLapStrokes == null) sumLapStrokes = 0L; + sumLapStrokes += s.longValue(); + } + records.add(buildSession(summaryData, totalAgg, sport, subSport, startSeconds, elapsedSeconds, emittedLaps, sumLapStrokes, + track != null ? track.getLengths().size() : 0, summary.getName())); + records.add(buildActivity(endSeconds, elapsedSeconds)); + + final FitFile fitFile = new FitFile(records); + final byte[] bytes = fitFile.getOutgoingMessage(); + + outputStream.write(bytes); + + LOG.info("Exported FIT activity ({} bytes, {} laps) for summary {}", + bytes.length, emittedLaps, summary.getId()); + } + + /** Per-lap state collected during the segment walk; used in pass 2 to emit laps. */ + private static final class LapDescriptor { + final long startTs; + final long elapsed; + final PointAggregates agg; + final ActivityTrack.SegmentInfo info; + + LapDescriptor(final long startTs, final long elapsed, + final PointAggregates agg, + final ActivityTrack.SegmentInfo info) { + this.startTs = startTs; + this.elapsed = elapsed; + this.agg = agg; + this.info = info; + } + } + + /** Per-lap totals taken DIRECTLY from per-segment binary fields parsed by the + * source (not derived from session aggregates). When the source does not encode + * a per-segment metric, the corresponding field stays null and buildLap omits + * the FIT lap field rather than fabricating a value. + * {@link #EMPTY} is used for the single-lap path (lap == session, all aggregates + * pulled from summaryData). */ + private static final class LapTotals { + static final LapTotals EMPTY = new LapTotals(null, null); + + @Nullable final Double distance; + @Nullable final Long strokes; + + LapTotals(@Nullable final Double distance, + @Nullable final Long strokes) { + this.distance = distance; + this.strokes = strokes; + } + } + + private RecordData buildFileId(final long startSeconds) { + return new FitFileId.Builder() + .setType(FileType.FILETYPE.ACTIVITY) + .setManufacturer(manufacturerId) + .setProduct(0) + .setSerialNumber(1L) + .setTimeCreated(startSeconds) + .setNumber(0) + .setProductName("Gadgetbridge") + .build(LMT_FILE_ID); + } + + private RecordData buildFileCreator() { + return new FitFileCreator.Builder() + .setSoftwareVersion(BuildConfig.VERSION_CODE) + .setHardwareVersion(0) + .build(LMT_FILE_CREATOR); + } + + private RecordData buildWorkout(@NonNull final String name, final int sport, final int subSport) { + return new FitWorkout.Builder() + .setName(name) + .setSport(sport) + .setSubSport(subSport) + .setNumValidSteps(0) + .build(LMT_WORKOUT); + } + + private RecordData buildSplit(@NonNull final ActivityTrack.SplitInfo info, + final int messageIndex) { + final FitSplit.Builder b = new FitSplit.Builder(); + b.setMessageIndex(messageIndex); + b.setStartTime(info.startTimeSec); + if (info.endTimeSec != null) b.setEndTime(info.endTimeSec); + if (info.splitType != null) b.setSplitType(info.splitType); + if (info.totalElapsedTimeSec != null) b.setTotalElapsedTime(info.totalElapsedTimeSec); + if (info.totalTimerTimeSec != null) b.setTotalTimerTime(info.totalTimerTimeSec); + if (info.totalDistanceMeters != null) b.setTotalDistance(info.totalDistanceMeters); + if (info.avgSpeedMps != null) b.setAvgSpeed(info.avgSpeedMps); + if (info.maxSpeedMps != null) b.setMaxSpeed(info.maxSpeedMps); + if (info.totalAscentMeters != null) b.setTotalAscent(info.totalAscentMeters); + if (info.totalDescentMeters != null) b.setTotalDescent(info.totalDescentMeters); + if (info.totalCalories != null) b.setTotalCalories(info.totalCalories); + if (info.startElevationMeters != null) b.setStartElevation(info.startElevationMeters); + if (info.startPositionLat != null) b.setStartPositionLat(info.startPositionLat); + if (info.startPositionLong != null) b.setStartPositionLong(info.startPositionLong); + if (info.endPositionLat != null) b.setEndPositionLat(info.endPositionLat); + if (info.endPositionLong != null) b.setEndPositionLong(info.endPositionLong); + return b.build(LMT_SPLIT); + } + + private RecordData buildSet(@NonNull final ActivityTrack.SetInfo info) { + final FitSet.Builder b = new FitSet.Builder(); + if (info.messageIndex != null) b.setMessageIndex(info.messageIndex); + b.setStartTime(info.startTimeSec); + b.setTimestamp(info.durationSec != null + ? info.startTimeSec + Math.round(info.durationSec) + : info.startTimeSec); + if (info.durationSec != null) b.setDuration(info.durationSec); + if (info.repetitions != null) b.setRepetitions(info.repetitions); + if (info.weightKg != null) b.setWeight(info.weightKg); + if (info.setType != null) b.setSetType(info.setType); + if (info.weightDisplayUnit != null) b.setWeightDisplayUnit(info.weightDisplayUnit); + return b.build(LMT_SET); + } + + private RecordData buildLength(@NonNull final ActivityTrack.LengthInfo info, + final int messageIndex) { + final FitLength.Builder b = new FitLength.Builder(); + b.setMessageIndex(messageIndex); + b.setStartTime(info.startTimeSec); + b.setTimestamp(info.startTimeSec + (long) Math.round(info.totalElapsedTimeSec)); + b.setEvent(EVENT_TIMER); + b.setEventType(EVENT_TYPE_STOP); + b.setTotalElapsedTime(info.totalElapsedTimeSec); + b.setTotalTimerTime(info.totalTimerTimeSec); + if (info.totalStrokes != null) b.setTotalStrokes(info.totalStrokes); + if (info.avgSpeed != null) b.setAvgSpeed(info.avgSpeed); + if (info.swimStroke != null) b.setSwimStroke(info.swimStroke); + if (info.lengthType != null) b.setLengthType(info.lengthType); + if (info.avgSwimmingCadence != null) b.setAvgSwimmingCadence(info.avgSwimmingCadence); + return b.build(LMT_LENGTH); + } + + private RecordData buildEvent(final long timestampSeconds, final int eventType) { + return new FitEvent.Builder() + .setTimestamp(timestampSeconds) + .setEvent(EVENT_TIMER) + .setEventGroup(0) + .setEventType(eventType) + .build(LMT_EVENT); + } + + @Nullable + private RecordData buildRecord(@NonNull final ActivityPoint p, + final boolean trackHasCadence, + final boolean trackHasPower) { + if (p.getTime() == null) { + return null; + } + final FitRecord.Builder b = new FitRecord.Builder(); + b.setTimestamp(Math.round(p.getTime().getTime() / 1000.0)); + + final GPSCoordinate loc = p.getLocation(); + if (loc != null) { + b.setLatitude(loc.getLatitude()); + b.setLongitude(loc.getLongitude()); + } + + final double altitude = p.getAltitude(); + if (altitude > GPSCoordinate.UNKNOWN_ALTITUDE) { + b.setEnhancedAltitude(altitude); + } + + if (p.getHeartRate() > 0) { + b.setHeartRate(p.getHeartRate()); + } + + // Cadence: emit only when the track has at least one non-zero sample; a + // track-wide stream of zeros means the device has no cadence sensor and the + // zeros are sentinels, not measurements. + if (trackHasCadence && p.getCadence() >= 0) { + b.setCadence(p.getCadence()); + } + + final float speed = p.getSpeed(); + if (Float.isFinite(speed) && speed >= 0f) { + b.setEnhancedSpeed((double) speed); + } + + final double distance = p.getDistance(); + if (distance >= 0.0) { + b.setDistance(distance); + } + + // Same sensor-presence gate as cadence — see comment above. + final float power = p.getPower(); + if (trackHasPower && Float.isFinite(power) && power >= 0f) { + b.setPower((int) power); + } + + final double temperature = p.getTemperature(); + // ActivityPoint default temperature is -273 (i.e. unset) + if (temperature > -273.0 && Double.isFinite(temperature)) { + b.setTemperature((int) temperature); + } + + final double depth = p.getDepth(); + if (depth >= 0.0 && Double.isFinite(depth)) { + b.setDepth(depth); + } + + // step length: disabled until source semantics are confirmed. + // ActivityPoint exposes both stride (foot-to-same-foot, mm) and stepLength + // (foot-to-opposite-foot, mm). FIT record.step_length wants foot-to-opposite (mm). + // Some non-Garmin parsers populate `stride` only, others populate `stepLength`, + // and a few may use cm. Until each source parser is audited, do not emit — the + // session-level STEP_LENGTH_AVG aggregate (which is unit-converted via + // readMillimetersFromInt) is enough. + + final float respirationRate = p.getRespiratoryRate(); + if (Float.isFinite(respirationRate) && respirationRate > 0f) { + b.setEnhancedRespirationRate(respirationRate); + } + + final float bodyEnergy = p.getBodyEnergy(); + if (Float.isFinite(bodyEnergy) && bodyEnergy >= 0f) { + b.setBodyBattery((int) bodyEnergy); + } + + final float stamina = p.getStamina(); + if (Float.isFinite(stamina) && stamina >= 0f) { + b.setStamina((int) stamina); + } + + final float cnsToxicity = p.getCnsToxicity(); + if (Float.isFinite(cnsToxicity) && cnsToxicity >= 0f) { + b.setCnsLoad((int) cnsToxicity); + } + + final float n2Load = p.getN2Load(); + if (Float.isFinite(n2Load) && n2Load >= 0f) { + b.setN2Load((int) n2Load); + } + + // Running dynamics — only emitted when the source carried them. Non-running + // sports leave these unset on ActivityPoint, so the gates skip the field. + final float verticalOscillation = p.getVerticalOscillation(); + if (Float.isFinite(verticalOscillation) && verticalOscillation >= 0f) { + b.setOscillation(verticalOscillation); + } + final float stanceTimePercent = p.getStanceTimePercent(); + if (Float.isFinite(stanceTimePercent) && stanceTimePercent >= 0f) { + b.setStanceTimePercent(stanceTimePercent); + } + final float stanceTime = p.getStanceTime(); + if (Float.isFinite(stanceTime) && stanceTime >= 0f) { + b.setStanceTime(stanceTime); + } + final float verticalRatio = p.getVerticalRatio(); + if (Float.isFinite(verticalRatio) && verticalRatio >= 0f) { + b.setVerticalRatio(verticalRatio); + } + final float stanceTimeBalance = p.getStanceTimeBalance(); + if (Float.isFinite(stanceTimeBalance) && stanceTimeBalance >= 0f) { + b.setStanceTimeBalance(stanceTimeBalance); + } + final int performanceCondition = p.getPerformanceCondition(); + if (performanceCondition >= 0) { + b.setPerformanceCondition(performanceCondition); + } + + return b.build(LMT_RECORD); + } + + private RecordData buildLap(@Nullable final ActivitySummaryData summaryData, + @NonNull final PointAggregates agg, + final int sport, + final int subSport, + final long startSeconds, + final long elapsedSeconds, + final int messageIndex, + @NonNull final ActivityTrack.SegmentIntensity intensity, + final boolean applySummaryAggregates, + @NonNull final LapTotals overrides, + @Nullable final Integer numLengths) { + // summaryData is a session-level aggregate. For multi-lap exports we must NOT + // splat it onto each lap (would falsely claim each lap has the full-session + // calorie/ascent/etc.). When applySummaryAggregates is false, the aliased + // local `data` is null so every readX(data, ...) helper falls back to the + // per-lap PointAggregates value. The pre-computed `overrides` then supplies + // the distributable session totals (distance, calories, ascent, descent, work). + final ActivitySummaryData data = applySummaryAggregates ? summaryData : null; + + final FitLap.Builder b = new FitLap.Builder(); + b.setMessageIndex(messageIndex); + b.setTimestamp(startSeconds + elapsedSeconds); + b.setStartTime(startSeconds); + b.setEvent(EVENT_TIMER); + b.setEventType(EVENT_TYPE_STOP); + b.setSport(sport); + b.setLapTrigger(0); // manual + b.setIntensity(mapIntensity(intensity)); + b.setTotalElapsedTime((double) elapsedSeconds); + b.setTotalTimerTime((double) elapsedSeconds); + // num_lengths — gate via sportSupportsNumLengths. Caller pre-computes the count + // of FitLength records whose startTime falls in the lap's time window. + if (sportSupportsNumLengths(sport, subSport) && numLengths != null && numLengths > 0) { + b.setNumLengths(numLengths); + } + + final GPSCoordinate first = agg.getFirstLocation(); + final GPSCoordinate last = agg.getLastLocation(); + if (first != null) { + b.setStartLat(first.getLatitude()); + b.setStartLong(first.getLongitude()); + } + if (last != null) { + b.setEndLat(last.getLatitude()); + b.setEndLong(last.getLongitude()); + } + // Bounding box — drives map preview in Strava / Garmin Connect / Endurain. + // Note: codegen currently maps setNecLat to FIT field 25 and setNecLong to 26 + // (legacy event_group slot); reader uses field 26 for nec_long and 27/28 for + // swc_lat/long so round-trip is self-consistent. Fix codegen mapping later. + if (agg.hasBoundingBox()) { + b.setNecLat(agg.getMaxLat()); + b.setNecLong(agg.getMaxLong()); + b.setSwcLat(agg.getMinLat()); + b.setSwcLong(agg.getMinLong()); + } + + Double distance = first(overrides.distance, readDistanceMeters(data, agg.getLastDistance())); + if (overrides.strokes != null) { + // Per-segment stroke count parsed from device payload (e.g. Xiaomi rowing v4). + // FIT lap.total_cycles covers strokes for paddle/row sports. + b.setTotalCycles(overrides.strokes); + } + // Rowing-only synth: when no measured distance is available but per-lap strokes are, + // derive distance from strokes × default stroke length. avg_stroke_distance is set so + // any FIT consumer that inspects it can tell the value is stroke-derived rather than + // measured. + if (distance == null && isRowingSport(sport, subSport) && overrides.strokes != null) { + final double strokeLen = rowingStrokeLengthMeters(sport, subSport); + final Double derived = deriveRowingDistanceMeters(overrides.strokes, strokeLen); + if (derived != null) { + distance = derived; + b.setAvgStrokeDistance((int) Math.round(strokeLen * 100.0)); + } + } + if (distance != null) { + b.setTotalDistance(distance); + } + final Double ascent = readMeters(data, ActivitySummaryEntries.ASCENT_METERS); + if (ascent != null) { + b.setTotalAscent((int) Math.round(ascent)); + } + final Double descent = readMeters(data, ActivitySummaryEntries.DESCENT_METERS); + if (descent != null) { + b.setTotalDescent((int) Math.round(descent)); + } + final Integer calories = readInt(data, ActivitySummaryEntries.CALORIES_BURNT, null); + if (calories != null) { + b.setTotalCalories(calories); + } + final Integer avgHr = readInt(data, ActivitySummaryEntries.HR_AVG, agg.getAvgHr()); + if (avgHr != null) { + b.setAvgHeartRate(avgHr); + } + final Integer maxHr = readInt(data, ActivitySummaryEntries.HR_MAX, agg.getMaxHr()); + if (maxHr != null) { + b.setMaxHeartRate(maxHr); + } + Float avgSpeed = first(readMetersPerSecond(data, ActivitySummaryEntries.SPEED_AVG), agg.getAvgSpeed()); + // Pace fallback: when the source provided neither summary SPEED_AVG nor per-record + // speed, but distance and elapsed time are both known, the FIT importer's pace + // computation needs an explicit avg_speed (FIT has no per-lap "pace" field — pace is + // always derived from avg_speed). Skip when an explicit non-zero value is present. + // Also gated on isLocomotionSport so stopwatch/yoga/strength/breathing don't get a + // bogus speed invented from any distance the source happened to log. + if (isLocomotionSport(sport, subSport) + && (avgSpeed == null || avgSpeed == 0f) && distance != null && distance > 0.0 && elapsedSeconds > 0L) { + avgSpeed = (float) (distance / (double) elapsedSeconds); + } + if (avgSpeed != null) { + b.setAvgSpeed(avgSpeed); + b.setEnhancedAvgSpeed(avgSpeed.doubleValue()); + } + final Float maxSpeed = first(readMetersPerSecond(data, ActivitySummaryEntries.SPEED_MAX), agg.getMaxSpeed()); + if (maxSpeed != null) { + b.setMaxSpeed(maxSpeed); + b.setEnhancedMaxSpeed(maxSpeed.doubleValue()); + } + final Integer avgCadence = readInt(data, ActivitySummaryEntries.CADENCE_AVG, agg.getAvgCadence()); + if (avgCadence != null) { + b.setAvgCadence(avgCadence); + } + final Integer maxCadence = readInt(data, ActivitySummaryEntries.CADENCE_MAX, agg.getMaxCadence()); + if (maxCadence != null) { + b.setMaxCadence(maxCadence); + } + + final Integer minHr = readInt(data, ActivitySummaryEntries.HR_MIN, null); + if (minHr != null) { + b.setMinHeartRate(minHr); + } + // Temperature: prefer summary aggregates, fall back to per-segment point stream. + final Integer avgTemp = readInt(data, ActivitySummaryEntries.TEMPERATURE_AVG, agg.getAvgTemp()); + if (avgTemp != null) { + b.setAvgTemperature(avgTemp); + } + final Integer maxTemp = readInt(data, ActivitySummaryEntries.TEMPERATURE_MAX, agg.getMaxTemp()); + if (maxTemp != null) { + b.setMaxTemperature(maxTemp); + } + final Integer minTemp = readInt(data, ActivitySummaryEntries.TEMPERATURE_MIN, agg.getMinTemp()); + if (minTemp != null) { + b.setMinTemperature(minTemp); + } + final Double avgAlt = readMeters(data, ActivitySummaryEntries.ALTITUDE_AVG); + if (avgAlt != null) { + b.setEnhancedAvgAltitude(avgAlt); + } + final Double maxAlt = readMeters(data, ActivitySummaryEntries.ALTITUDE_MAX); + if (maxAlt != null) { + b.setEnhancedMaxAltitude(maxAlt); + } + final Double minAlt = readMeters(data, ActivitySummaryEntries.ALTITUDE_MIN); + if (minAlt != null) { + b.setEnhancedMinAltitude(minAlt); + } + // Power: same fallback pattern — derive from points when summary lacks it. + final Integer avgPower = readInt(data, ActivitySummaryEntries.AVG_POWER, agg.getAvgPower()); + if (avgPower != null) { + b.setAvgPower(avgPower); + } + final Integer maxPower = readInt(data, ActivitySummaryEntries.MAX_POWER, agg.getMaxPower()); + if (maxPower != null) { + b.setMaxPower(maxPower); + } + final Integer normalizedPower = readInt(data, ActivitySummaryEntries.NORMALIZED_POWER, null); + if (normalizedPower != null) { + b.setNormalizedPower(normalizedPower); + } + final Long totalWork = readLong(data, ActivitySummaryEntries.TOTAL_WORK, null); + if (totalWork != null) { + b.setTotalWork(totalWork); + } + final Float avgVerticalOscillation = readMillimeters(data, ActivitySummaryEntries.AVG_VERTICAL_OSCILLATION); + if (avgVerticalOscillation != null) { + b.setAvgVerticalOscillation(avgVerticalOscillation); + } + final Float avgVerticalRatio = readFloat(data, ActivitySummaryEntries.AVG_VERTICAL_RATIO, null); + if (avgVerticalRatio != null) { + b.setAvgVerticalRatio(avgVerticalRatio); + } + final Float avgStanceTime = readMilliseconds(data, ActivitySummaryEntries.AVG_GROUND_CONTACT_TIME); + if (avgStanceTime != null) { + b.setAvgStanceTime(avgStanceTime); + } + final Float avgStanceTimeBalance = readFloat(data, ActivitySummaryEntries.AVG_GROUND_CONTACT_TIME_BALANCE, null); + if (avgStanceTimeBalance != null) { + b.setAvgStanceTimeBalance(avgStanceTimeBalance); + } + final Float avgStepLength = readMillimeters(data, ActivitySummaryEntries.STEP_LENGTH_AVG); + if (avgStepLength != null) { + b.setAvgStepLength(avgStepLength); + } + final Float stepSpeedLossPercentage = readFloat(data, ActivitySummaryEntries.STEP_SPEED_LOSS_PERCENTAGE, null); + if (stepSpeedLossPercentage != null) { + b.setStepSpeedLossPercentage(stepSpeedLossPercentage); + } + final Float avgRespiration = readFloat(data, ActivitySummaryEntries.RESPIRATION_AVG, null); + if (avgRespiration != null) { + b.setEnhancedAvgRespirationRate(avgRespiration); + } + final Float maxRespiration = readFloat(data, ActivitySummaryEntries.RESPIRATION_MAX, null); + if (maxRespiration != null) { + b.setEnhancedMaxRespirationRate(maxRespiration); + } + // Diving — Lap-level depth aggregates + final Double avgDepth = readMeters(data, ActivitySummaryEntries.AVG_DEPTH); + if (avgDepth != null) { + b.setAvgDepth(avgDepth); + } + final Double maxDepth = readMeters(data, ActivitySummaryEntries.MAX_DEPTH); + if (maxDepth != null) { + b.setMaxDepth(maxDepth); + } + + return b.build(LMT_LAP); + } + + private RecordData buildSession(@Nullable final ActivitySummaryData data, + @NonNull final PointAggregates agg, + final int sport, + final int subSport, + final long startSeconds, + final long elapsedSeconds, + final int numLaps, + @Nullable final Long lapStrokesFallback, + final int numLengths, + @Nullable final String workoutName) { + final FitSession.Builder b = new FitSession.Builder(); + b.setMessageIndex(0); + b.setTimestamp(startSeconds + elapsedSeconds); + b.setStartTime(startSeconds); + b.setEvent(EVENT_TIMER); + b.setEventType(EVENT_TYPE_STOP); + b.setSport(sport); + b.setSubSport(subSport); + // SESSION field 7 (total_elapsed_time) is unscaled in NativeFITMessage and stored + // as-is in ms. Field 8 (total_timer_time) carries scale=1000 — the codec multiplies + // raw seconds by 1000 on encode, so pass seconds. + b.setTotalElapsedTime(elapsedSeconds * 1000L); + b.setTotalTimerTime((double) elapsedSeconds); + b.setNumLaps(numLaps); + b.setFirstLapIndex(0); + b.setTrigger(0); // activity_end + + final GPSCoordinate first = agg.getFirstLocation(); + final GPSCoordinate last = agg.getLastLocation(); + if (first != null) { + b.setStartLatitude(first.getLatitude()); + b.setStartLongitude(first.getLongitude()); + } + if (last != null) { + b.setEndLatitude(last.getLatitude()); + b.setEndLongitude(last.getLongitude()); + } + // Bounding box across the whole track. Codegen here is correct (29/30/31/32 per + // FIT spec). + if (agg.hasBoundingBox()) { + b.setNecLatitude(agg.getMaxLat()); + b.setNecLongitude(agg.getMaxLong()); + b.setSwcLatitude(agg.getMinLat()); + b.setSwcLongitude(agg.getMinLong()); + } + // num_lengths — gate via sportSupportsNumLengths. Endurain + Strava show this as + // the session's "lengths" headline number. + if (sportSupportsNumLengths(sport, subSport) && numLengths > 0) { + b.setNumLengths(numLengths); + } + // sport_profile_name — Strava uses this as workout title fallback when + // file_id.product_name is generic. Pass through the user-facing summary name. + if (workoutName != null && !workoutName.isEmpty()) { + b.setSportProfileName(workoutName); + } + + Double distance = readDistanceMeters(data, agg.getLastDistance()); + boolean totalCyclesSet = false; + // Rowing-only synth: when no measured distance is available, derive from total + // strokes × default stroke length. Strokes prefer summary STROKES (single + // authoritative figure parsed from device), fall back to sum of per-lap strokes. + // total_cycles + avg_stroke_distance let any FIT consumer reconstruct the + // derivation and tell it apart from a measured distance. + if (distance == null && isRowingSport(sport, subSport)) { + final Long totalStrokes = readLong(data, ActivitySummaryEntries.STROKES, lapStrokesFallback); + final double strokeLen = rowingStrokeLengthMeters(sport, subSport); + final Double derived = deriveRowingDistanceMeters(totalStrokes, strokeLen); + if (derived != null) { + distance = derived; + b.setTotalCycles(totalStrokes); + b.setAvgStrokeDistance((float) strokeLen); + totalCyclesSet = true; + } + } + if (distance != null) { + b.setTotalDistance(distance); + } + // total_cycles (FIT subfield = total_strides for running/walking/hiking, + // total_strokes for paddle/swim, total_cycles for cycling). Strava reads it + // as total_strides for running. Skip if rowing branch already set it. + if (!totalCyclesSet) { + final Long stepCount = readLong(data, ActivitySummaryEntries.STEPS, null); + if (stepCount != null) { + b.setTotalCycles(stepCount); + totalCyclesSet = true; + } + } + if (!totalCyclesSet) { + final Long strokeCount = readLong(data, ActivitySummaryEntries.STROKES, lapStrokesFallback); + if (strokeCount != null) { + b.setTotalCycles(strokeCount); + } + } + final Double ascent = readMeters(data, ActivitySummaryEntries.ASCENT_METERS); + if (ascent != null) { + b.setTotalAscent((int) Math.round(ascent)); + } + final Double descent = readMeters(data, ActivitySummaryEntries.DESCENT_METERS); + if (descent != null) { + b.setTotalDescent((int) Math.round(descent)); + } + final Integer calories = readInt(data, ActivitySummaryEntries.CALORIES_BURNT, null); + if (calories != null) { + b.setTotalCalories(calories); + } + final Integer avgHr = readInt(data, ActivitySummaryEntries.HR_AVG, agg.getAvgHr()); + if (avgHr != null) { + b.setAverageHeartRate(avgHr); + } + final Integer maxHr = readInt(data, ActivitySummaryEntries.HR_MAX, agg.getMaxHr()); + if (maxHr != null) { + b.setMaxHeartRate(maxHr); + } + Float avgSpeed = first(readMetersPerSecond(data, ActivitySummaryEntries.SPEED_AVG), agg.getAvgSpeed()); + // Same pace fallback as buildLap — see comment there. + if (isLocomotionSport(sport, subSport) + && (avgSpeed == null || avgSpeed == 0f) && distance != null && distance > 0.0 && elapsedSeconds > 0L) { + avgSpeed = (float) (distance / (double) elapsedSeconds); + } + if (avgSpeed != null) { + b.setAvgSpeed(avgSpeed); + b.setEnhancedAvgSpeed(avgSpeed.doubleValue()); + } + final Float maxSpeed = first(readMetersPerSecond(data, ActivitySummaryEntries.SPEED_MAX), agg.getMaxSpeed()); + if (maxSpeed != null) { + b.setMaxSpeed(maxSpeed); + b.setEnhancedMaxSpeed(maxSpeed.doubleValue()); + } + final Integer avgCadence = readInt(data, ActivitySummaryEntries.CADENCE_AVG, agg.getAvgCadence()); + if (avgCadence != null) { + b.setAvgCadence(avgCadence); + } + final Integer maxCadence = readInt(data, ActivitySummaryEntries.CADENCE_MAX, agg.getMaxCadence()); + if (maxCadence != null) { + b.setMaxCadence(maxCadence); + } + + // ---- Common extensions ---- + final Integer minHr = readInt(data, ActivitySummaryEntries.HR_MIN, null); + if (minHr != null) { + b.setMinHeartRate(minHr); + } + // Temperature aggregates: prefer summary, fall back to track-wide point stream. + final Integer avgTemp = readInt(data, ActivitySummaryEntries.TEMPERATURE_AVG, agg.getAvgTemp()); + if (avgTemp != null) { + b.setAvgTemperature(avgTemp); + } + final Integer maxTemp = readInt(data, ActivitySummaryEntries.TEMPERATURE_MAX, agg.getMaxTemp()); + if (maxTemp != null) { + b.setMaxTemperature(maxTemp); + } + final Integer minTemp = readInt(data, ActivitySummaryEntries.TEMPERATURE_MIN, agg.getMinTemp()); + if (minTemp != null) { + b.setMinTemperature(minTemp); + } + final Double avgAlt = readMeters(data, ActivitySummaryEntries.ALTITUDE_AVG); + if (avgAlt != null) { + b.setEnhancedAvgAltitude(avgAlt); + } + final Double maxAlt = readMeters(data, ActivitySummaryEntries.ALTITUDE_MAX); + if (maxAlt != null) { + b.setEnhancedMaxAltitude(maxAlt); + } + final Double minAlt = readMeters(data, ActivitySummaryEntries.ALTITUDE_MIN); + if (minAlt != null) { + b.setEnhancedMinAltitude(minAlt); + } + final Float avgRespiration = readFloat(data, ActivitySummaryEntries.RESPIRATION_AVG, null); + if (avgRespiration != null) { + b.setEnhancedAvgRespirationRate(avgRespiration); + } + final Float maxRespiration = readFloat(data, ActivitySummaryEntries.RESPIRATION_MAX, null); + if (maxRespiration != null) { + b.setEnhancedMaxRespirationRate(maxRespiration); + } + final Float minRespiration = readFloat(data, ActivitySummaryEntries.RESPIRATION_MIN, null); + if (minRespiration != null) { + b.setEnhancedMinRespirationRate(minRespiration); + } + // Power aggregates: prefer summary, fall back to point stream. + final Integer avgPower = readInt(data, ActivitySummaryEntries.AVG_POWER, agg.getAvgPower()); + if (avgPower != null) { + b.setAvgPower(avgPower); + } + final Integer maxPower = readInt(data, ActivitySummaryEntries.MAX_POWER, agg.getMaxPower()); + if (maxPower != null) { + b.setMaxPower(maxPower); + } + final Integer normalizedPower = readInt(data, ActivitySummaryEntries.NORMALIZED_POWER, null); + if (normalizedPower != null) { + b.setNormalizedPower(normalizedPower); + } + final Long totalWork = readLong(data, ActivitySummaryEntries.TOTAL_WORK, null); + if (totalWork != null) { + b.setTotalWork(totalWork); + } + // FIXME: scale unverified — Cmf parser divides by 100 (0-5 range), Xiaomi parser + // stores raw float of unknown range. FIT total_training_effect expects 0.0-5.0 + // (scale=10). Re-import test against Garmin Connect / Strava needed. + final Float trainingEffectAerobic = readFloat(data, ActivitySummaryEntries.TRAINING_EFFECT_AEROBIC, null); + if (trainingEffectAerobic != null) { + b.setTotalTrainingEffect(trainingEffectAerobic); + } + // FIXME: same scale uncertainty as TRAINING_EFFECT_AEROBIC above. + final Float trainingEffectAnaerobic = readFloat(data, ActivitySummaryEntries.TRAINING_EFFECT_ANAEROBIC, null); + if (trainingEffectAnaerobic != null) { + b.setTotalAnaerobicTrainingEffect(trainingEffectAnaerobic); + } + // FIXME: source range unverified (Garmin tags UNIT_NONE). FIT training_stress_score + // expects natural TSS (scale=10). + final Float trainingStressScore = readFloat(data, ActivitySummaryEntries.TRAINING_STRESS_SCORE, null); + if (trainingStressScore != null) { + b.setTrainingStressScore(trainingStressScore); + } + // Training-load score from the device summary → FIT session.training_load_peak + // (field 168). Stored on the Gadgetbridge side as a Number (typically captured via + // addShort(WORKOUT_LOAD, UNIT_NONE)); readFloat surfaces it as a Double. + final Float trainingLoadPeak = readFloat(data, ActivitySummaryEntries.WORKOUT_LOAD, null); + if (trainingLoadPeak != null) { + b.setTrainingLoadPeak(trainingLoadPeak.doubleValue()); + } + // Per-zone seconds → FIT session.time_in_hr_zone (5-element array, scale=1000 — codec + // multiplies on encode, so pass raw seconds). Mapping: warm-up → idx 0, fat-burn → + // idx 1 (the conventional "easy" zone), aerobic → idx 2, anaerobic → idx 3, + // extreme/threshold → idx 4. Emit only when at least one zone has a value; FIT array + // slots cannot be null individually, so missing zones encode as 0. + final Long zWarm = readSeconds(data, ActivitySummaryEntries.HR_ZONE_WARM_UP); + final Long zEasy = readSeconds(data, ActivitySummaryEntries.HR_ZONE_FAT_BURN); + final Long zAer = readSeconds(data, ActivitySummaryEntries.HR_ZONE_AEROBIC); + final Long zAnaer = readSeconds(data, ActivitySummaryEntries.HR_ZONE_ANAEROBIC); + final Long zMax = readSeconds(data, ActivitySummaryEntries.HR_ZONE_EXTREME); + if (zWarm != null || zEasy != null || zAer != null || zAnaer != null || zMax != null) { + b.setTimeInHrZone(new Number[]{ + nz(zWarm), nz(zEasy), nz(zAer), nz(zAnaer), nz(zMax) + }); + } + final Integer hrvSdrr = readInt(data, ActivitySummaryEntries.HRV_SDRR, null); + if (hrvSdrr != null) { + b.setHrvSdrr(hrvSdrr); + } + final Integer hrvRmssd = readInt(data, ActivitySummaryEntries.HRV_RMSSD, null); + if (hrvRmssd != null) { + b.setHrvRmssd(hrvRmssd); + } + final Integer recoveryHr = readInt(data, ActivitySummaryEntries.RECOVERY_HR, null); + if (recoveryHr != null) { + b.setRecoveryHeartRate(recoveryHr); + } + // FIXME: source range unverified — Garmin parser tags UNIT_PERCENTAGE (0-100). + // FIT workout_feel is 0-100 (scale=1) so likely OK, but other parsers may scale + // differently. + final Integer workoutFeel = readInt(data, ActivitySummaryEntries.WORKOUT_FEEL, null); + if (workoutFeel != null) { + b.setWorkoutFeel(workoutFeel); + } + // FIXME: source range unverified — Garmin tags UNIT_PERCENTAGE but FIT workout_rpe + // expects 0-10 (scale=10). If Garmin actually stores 0-100, we'd 10x-overshoot. + final Integer rpe = readInt(data, ActivitySummaryEntries.RATING_OF_PERCEIVED_EXERTION, null); + if (rpe != null) { + b.setWorkoutRpe(rpe); + } + final Integer avgSpo2 = readInt(data, ActivitySummaryEntries.SPO2_AVG, null); + if (avgSpo2 != null) { + b.setAvgSpo2(avgSpo2); + } + final Integer avgStress = readInt(data, ActivitySummaryEntries.STRESS_AVG, null); + if (avgStress != null) { + b.setAvgStress(avgStress); + } + final Integer beginningBodyBattery = readInt(data, ActivitySummaryEntries.BODY_ENERGY_AT_START, null); + if (beginningBodyBattery != null) { + b.setBeginningBodyBattery(beginningBodyBattery); + } + final Integer endingBodyBattery = readInt(data, ActivitySummaryEntries.BODY_ENERGY_AT_END, null); + if (endingBodyBattery != null) { + b.setEndingBodyBattery(endingBodyBattery); + } + // FIXME: unit ambiguous — Garmin parser tags UNIT_SECONDS but FIT battery_gain spec + // is unclear (some sources: percent change, others: minutes). Downstream + // interpretation varies. + final Long batteryGain = readLong(data, ActivitySummaryEntries.BATTERY_GAIN, null); + if (batteryGain != null) { + b.setBatteryGain(batteryGain); + } + // FIXME: FIT solar_intensity scale/unit unverified beyond Garmin parser's + // UNIT_PERCENTAGE assumption. + final Float solarIntensity = readFloat(data, ActivitySummaryEntries.SOLAR_INTENSITY, null); + if (solarIntensity != null) { + b.setSolarIntensity(solarIntensity); + } + final Integer estimatedSweatLoss = readInt(data, ActivitySummaryEntries.ESTIMATED_SWEAT_LOSS, null); + if (estimatedSweatLoss != null) { + b.setEstimatedSweatLoss(estimatedSweatLoss); + } + final Integer fluidConsumed = readInt(data, ActivitySummaryEntries.FLUID_CONSUMED, null); + if (fluidConsumed != null) { + b.setFluidConsumed(fluidConsumed); + } + final Integer caloriesConsumed = readInt(data, ActivitySummaryEntries.CALORIES_CONSUMED, null); + if (caloriesConsumed != null) { + b.setCaloriesConsumed(caloriesConsumed); + } + final Integer restingCalories = readInt(data, ActivitySummaryEntries.CALORIES_RESTING, null); + if (restingCalories != null) { + b.setRestingCalories(restingCalories); + } + + // ---- Running form ---- + final Float avgVerticalOscillation = readMillimeters(data, ActivitySummaryEntries.AVG_VERTICAL_OSCILLATION); + if (avgVerticalOscillation != null) { + b.setAvgVerticalOscillation(avgVerticalOscillation); + } + final Float avgVerticalRatio = readFloat(data, ActivitySummaryEntries.AVG_VERTICAL_RATIO, null); + if (avgVerticalRatio != null) { + b.setAvgVerticalRatio(avgVerticalRatio); + } + final Float avgStanceTime = readMilliseconds(data, ActivitySummaryEntries.AVG_GROUND_CONTACT_TIME); + if (avgStanceTime != null) { + b.setAvgStanceTime(avgStanceTime); + } + final Float avgStanceTimeBalance = readFloat(data, ActivitySummaryEntries.AVG_GROUND_CONTACT_TIME_BALANCE, null); + if (avgStanceTimeBalance != null) { + b.setAvgStanceTimeBalance(avgStanceTimeBalance); + } + final Float avgStepLength = readMillimeters(data, ActivitySummaryEntries.STEP_LENGTH_AVG); + if (avgStepLength != null) { + b.setAvgStepLength(avgStepLength); + } + final Float stepSpeedLossPercentage = readFloat(data, ActivitySummaryEntries.STEP_SPEED_LOSS_PERCENTAGE, null); + if (stepSpeedLossPercentage != null) { + b.setStepSpeedLossPercentage(stepSpeedLossPercentage); + } + final Float stepSpeedLoss = readFloat(data, ActivitySummaryEntries.STEP_SPEED_LOSS, null); + if (stepSpeedLoss != null) { + b.setStepSpeedLoss(stepSpeedLoss); + } + + // ---- Cycling power ---- + // FIXME: Garmin parser stores this as a STRING ("45-55%"), so readInt returns null + // and nothing emits. Also FIT left_right_balance uses bit-7 as a right-vs-left flag + // — non-Garmin sources passing a plain percent would need bit-7 set explicitly. + final Integer leftRightBalance = readInt(data, ActivitySummaryEntries.LEFT_RIGHT_BALANCE, null); + if (leftRightBalance != null) { + b.setLeftRightBalance(leftRightBalance); + } + // FIXME: Garmin parser stores this as a STRING (range_percentage), so readFloat + // silently returns null. Only useful for non-Garmin sources that store a Number. + final Float avgPedalSmoothness = readFloat(data, ActivitySummaryEntries.AVG_PEDAL_SMOOTHNESS, null); + if (avgPedalSmoothness != null) { + b.setAvgCombinedPedalSmoothness(avgPedalSmoothness); + } + // FIXME: Garmin parser stores this as a STRING — silent null from readFloat. Also + // we duplicate the value into both setAvgLeftTorqueEffectiveness and + // setAvgRightTorqueEffectiveness, which loses the L/R split when the source + // distinguishes them. + final Float avgTorqueEffectiveness = readFloat(data, ActivitySummaryEntries.AVG_TORQUE_EFFECTIVENESS, null); + if (avgTorqueEffectiveness != null) { + b.setAvgLeftTorqueEffectiveness(avgTorqueEffectiveness); + b.setAvgRightTorqueEffectiveness(avgTorqueEffectiveness); + } + // FIXME: source range unverified — assume natural ratio (0-1). FIT intensity_factor + // scale=1000. Encoder will multiply, so passing 0.85 stores 850 (correct). If a + // parser stores 85 (percent) we'd 100x-overshoot. + final Float intensityFactor = readFloat(data, ActivitySummaryEntries.INTENSITY_FACTOR, null); + if (intensityFactor != null) { + b.setIntensityFactor(intensityFactor); + } + final Integer frontShifts = readInt(data, ActivitySummaryEntries.FRONT_GEAR_SHIFTS, null); + if (frontShifts != null) { + b.setFrontShifts(frontShifts); + } + final Integer rearShifts = readInt(data, ActivitySummaryEntries.REAR_GEAR_SHIFTS, null); + if (rearShifts != null) { + b.setRearShifts(rearShifts); + } + final Long standTime = readSeconds(data, ActivitySummaryEntries.STANDING_TIME); + if (standTime != null) { + b.setStandTime((double) standTime); + } + final Integer standCount = readInt(data, ActivitySummaryEntries.STANDING_COUNT, null); + if (standCount != null) { + b.setStandCount(standCount); + } + final Integer avgLeftPco = readMillimetersFromInt(data, ActivitySummaryEntries.AVG_LEFT_PCO); + if (avgLeftPco != null) { + b.setAvgLeftPco(avgLeftPco); + } + final Integer avgRightPco = readMillimetersFromInt(data, ActivitySummaryEntries.AVG_RIGHT_PCO); + if (avgRightPco != null) { + b.setAvgRightPco(avgRightPco); + } + + // ---- Swimming ---- + final Double poolLength = readMeters(data, ActivitySummaryEntries.POOL_LENGTH); + if (poolLength != null) { + b.setPoolLength(poolLength.floatValue()); + } + final Integer swolfAvg = readInt(data, ActivitySummaryEntries.SWOLF_AVG, null); + if (swolfAvg != null) { + b.setAvgSwolf(swolfAvg); + } + // FIXME: source unit unverified — likely strokes/min. FIT avg_swim_cadence scale=1, + // unit=strokes/min. If a parser stores Hz (strokes/sec) we'd 60x-undershoot. + final Float strokeRateAvg = readFloat(data, ActivitySummaryEntries.STROKE_RATE_AVG, null); + if (strokeRateAvg != null) { + b.setAvgSwimCadence(strokeRateAvg); + } + // Predominant stroke style → FIT session.swim_stroke (enum). Source codes are + // device-specific; mapXiaomiSwimStyleToFit handles the convention used by Xiaomi + // band firmware (0=free,1=back,2=breast,3=fly,4=mixed). Unknown sources whose + // codes already match the FIT enum will pass through unchanged for 0-3. + final Integer swimStyleRaw = readInt(data, ActivitySummaryEntries.SWIM_STYLE, null); + final Integer swimStroke = mapXiaomiSwimStyleToFit(swimStyleRaw); + if (swimStroke != null) { + b.setSwimStroke(swimStroke); + } + + // ---- Diving ---- + final Double avgDepth = readMeters(data, ActivitySummaryEntries.AVG_DEPTH); + if (avgDepth != null) { + b.setAvgDepth(avgDepth); + } + final Double maxDepth = readMeters(data, ActivitySummaryEntries.MAX_DEPTH); + if (maxDepth != null) { + b.setMaxDepth(maxDepth); + } + final Long surfaceInterval = readSeconds(data, ActivitySummaryEntries.SURFACE_INTERVAL); + if (surfaceInterval != null) { + b.setSurfaceInterval(surfaceInterval); + } + final Long diveNumber = readLong(data, ActivitySummaryEntries.DIVE_NUMBER, null); + if (diveNumber != null) { + b.setDiveNumber(diveNumber); + } + final Integer startCns = readInt(data, ActivitySummaryEntries.START_CNS, null); + if (startCns != null) { + b.setStartCns(startCns); + } + final Integer endCns = readInt(data, ActivitySummaryEntries.END_CNS, null); + if (endCns != null) { + b.setEndCns(endCns); + } + final Integer startN2 = readInt(data, ActivitySummaryEntries.START_N2, null); + if (startN2 != null) { + b.setStartN2(startN2); + } + final Integer endN2 = readInt(data, ActivitySummaryEntries.END_N2, null); + if (endN2 != null) { + b.setEndN2(endN2); + } + final Integer o2Toxicity = readInt(data, ActivitySummaryEntries.OXYGEN_TOXICITY, null); + if (o2Toxicity != null) { + b.setO2Toxicity(o2Toxicity); + } + + // ---- MTB ---- + final Float totalGrit = readFloat(data, ActivitySummaryEntries.MOUNTAIN_BIKE_GRIT_SCORE, null); + if (totalGrit != null) { + b.setTotalGrit(totalGrit); + } + final Float totalFlow = readFloat(data, ActivitySummaryEntries.MOUNTAIN_BIKE_FLOW_SCORE, null); + if (totalFlow != null) { + b.setTotalFlow(totalFlow); + } + + // ---- Jumps / sets ---- + final Integer jumpCount = readInt(data, ActivitySummaryEntries.JUMPS, null); + if (jumpCount != null) { + b.setJumpCount(jumpCount); + } + final Integer totalSets = readInt(data, ActivitySummaryEntries.SETS, null); + if (totalSets != null) { + b.setTotalSets(totalSets); + } + + return b.build(LMT_SESSION); + } + + private RecordData buildActivity(final long endSeconds, final long elapsedSeconds) { + // NativeFITMessage.ACTIVITY field 0 (total_timer_time) is declared without scale — + // FIT spec is scale=1000 unit=s, so pre-multiply seconds → milliseconds. + // Field 5 (local_timestamp) likewise lacks the TIMESTAMP marker, so the encoder + // does not subtract the Garmin epoch — do it manually so importers read the + // right wall-clock time (we don't track timezone offset, so use endSeconds as-is). + return new FitActivity.Builder() + .setTimestamp(endSeconds) + .setLocalTimestamp(endSeconds - GarminTimeUtils.GARMIN_TIME_EPOCH) + .setTotalTimerTime(elapsedSeconds * 1000L) + .setNumSessions(1) + .setType(ACTIVITY_TYPE_MANUAL) + .setEvent(EVENT_ACTIVITY) + .setEventType(EVENT_TYPE_STOP) + .setEventGroup(0) + .build(LMT_ACTIVITY); + } + + /// Single-pass accumulator for per-point aggregates needed by Lap and Session. + /// Same gating rules as the previous standalone helpers; getters return {@code null} + /// when no valid samples were seen, matching the prior behaviour exactly. + private static final class PointAggregates { + private GPSCoordinate firstLocation; + private GPSCoordinate lastLocation; + private Double lastDistance; + private long hrSum; + private int hrCount; + private int hrMax; + private double speedSum; + private int speedCount; + private float speedMax = -1f; + private long cadSum; + private int cadCount; + private int cadMax; + // Bounding box across all GPS samples seen. NEC = north-east corner (max lat, + // max long); SWC = south-west corner (min lat, min long). FIT lap+session use + // these to drive the map preview in Garmin Connect / Strava. + private double minLat = Double.POSITIVE_INFINITY; + private double maxLat = Double.NEGATIVE_INFINITY; + private double minLong = Double.POSITIVE_INFINITY; + private double maxLong = Double.NEGATIVE_INFINITY; + // Temperature: skip the −273 sentinel ActivityPoint uses to mean "unset". + private double tempSum; + private int tempCount; + private int tempMax = Integer.MIN_VALUE; + private int tempMin = Integer.MAX_VALUE; + // Power. ActivityPoint defaults to −1; only count finite, non-negative samples. + private double powerSum; + private int powerCount; + private float powerMax = -1f; + + void accumulate(@NonNull final ActivityPoint p) { + final GPSCoordinate loc = p.getLocation(); + if (loc != null) { + if (firstLocation == null) firstLocation = loc; + lastLocation = loc; + final double lat = loc.getLatitude(); + final double lon = loc.getLongitude(); + if (lat < minLat) minLat = lat; + if (lat > maxLat) maxLat = lat; + if (lon < minLong) minLong = lon; + if (lon > maxLong) maxLong = lon; + } + + final double dist = p.getDistance(); + if (dist > 0.0) { + lastDistance = dist; + } + + final int hr = p.getHeartRate(); + if (hr > 0) { + hrSum += hr; + hrCount++; + if (hr > hrMax) hrMax = hr; + } + + final float speed = p.getSpeed(); + if (Float.isFinite(speed) && speed >= 0f) { + speedSum += speed; + speedCount++; + if (speed > speedMax) speedMax = speed; + } + + final int cad = p.getCadence(); + if (cad > 0) { + cadSum += cad; + cadCount++; + if (cad > cadMax) cadMax = cad; + } + + final double temp = p.getTemperature(); + if (Double.isFinite(temp) && temp > -273.0) { + final int t = (int) Math.round(temp); + tempSum += t; + tempCount++; + if (t > tempMax) tempMax = t; + if (t < tempMin) tempMin = t; + } + + final float power = p.getPower(); + if (Float.isFinite(power) && power >= 0f) { + powerSum += power; + powerCount++; + if (power > powerMax) powerMax = power; + } + } + + @Nullable GPSCoordinate getFirstLocation() { return firstLocation; } + @Nullable GPSCoordinate getLastLocation() { return lastLocation; } + @Nullable Double getLastDistance() { return lastDistance; } + @Nullable Integer getAvgHr() { return hrCount > 0 ? (int) (hrSum / hrCount) : null; } + @Nullable Integer getMaxHr() { return hrMax > 0 ? hrMax : null; } + @Nullable Float getAvgSpeed() { return speedCount > 0 ? (float) (speedSum / speedCount) : null; } + @Nullable Float getMaxSpeed() { return speedMax >= 0f ? speedMax : null; } + @Nullable Integer getAvgCadence() { return cadCount > 0 ? (int) (cadSum / cadCount) : null; } + @Nullable Integer getMaxCadence() { return cadMax > 0 ? cadMax : null; } + + boolean hasBoundingBox() { return Double.isFinite(minLat) && Double.isFinite(minLong); } + double getMaxLat() { return maxLat; } + double getMinLat() { return minLat; } + double getMaxLong() { return maxLong; } + double getMinLong() { return minLong; } + + @Nullable Integer getAvgTemp() { return tempCount > 0 ? (int) Math.round(tempSum / tempCount) : null; } + @Nullable Integer getMaxTemp() { return tempCount > 0 ? tempMax : null; } + @Nullable Integer getMinTemp() { return tempCount > 0 ? tempMin : null; } + + @Nullable Integer getAvgPower() { return powerCount > 0 ? (int) Math.round(powerSum / powerCount) : null; } + @Nullable Integer getMaxPower() { return powerCount > 0 ? (int) Math.round((double) powerMax) : null; } + } + + @Nullable + private static Integer readInt(@Nullable final ActivitySummaryData data, + @NonNull final String key, + @Nullable final Integer fallback) { + if (data != null) { + final Number n = data.getNumber(key, null); + if (n != null) return n.intValue(); + } + return fallback; + } + + @Nullable + private static Float readFloat(@Nullable final ActivitySummaryData data, + @NonNull final String key, + @Nullable final Float fallback) { + if (data != null) { + final Number n = data.getNumber(key, null); + if (n != null) return n.floatValue(); + } + return fallback; + } + + @Nullable + private static Double readDouble(@Nullable final ActivitySummaryData data, + @NonNull final String key, + @Nullable final Double fallback) { + if (data != null) { + final Number n = data.getNumber(key, null); + if (n != null) return n.doubleValue(); + } + return fallback; + } + + @Nullable + private static Long readLong(@Nullable final ActivitySummaryData data, + @NonNull final String key, + @Nullable final Long fallback) { + if (data != null) { + final Number n = data.getNumber(key, null); + if (n != null) return n.longValue(); + } + return fallback; + } + + /// Returns {@code a} if non-null, else {@code b}. Used to combine a unit-aware + /// summaryData read with a point-aggregate fallback. + @Nullable + private static T first(@Nullable final T a, @Nullable final T b) { + return a != null ? a : b; + } + + /// Coerces a nullable Long to a non-null Number (0 for null). Used by setTimeInHrZone + /// where individual array slots cannot be null but missing zones should encode as 0. + private static Number nz(@Nullable final Long v) { + return v != null ? v : 0L; + } + + /// Maps a Xiaomi-band SWIM_STYLE byte to the FIT swim_stroke enum. + /// Xiaomi convention (empirical): 0=freestyle, 1=backstroke, 2=breaststroke, + /// 3=butterfly, 4=mixed. FIT enum: 0=freestyle, 1=backstroke, 2=breaststroke, + /// 3=butterfly, 4=drill, 5=mixed, 6=IM. Returns null for unknown codes so callers + /// can omit the field entirely. + @Nullable + private static Integer mapXiaomiSwimStyleToFit(@Nullable final Integer raw) { + if (raw == null) return null; + switch (raw) { + case 0: return 0; // freestyle + case 1: return 1; // backstroke + case 2: return 2; // breaststroke + case 3: return 3; // butterfly + case 4: return 5; // mixed + default: return null; + } + } + + /// Hash-based fingerprint of an ActivityPoint's value-bearing fields. Used by + /// the content-aware dedup in performExport — two consecutive points sharing the + /// same fingerprint are considered identical and only the first is emitted. Two + /// points with different fingerprints are always emitted, even when their + /// timestamps collide (multi-record-per-second sources like Wattbike trainers). + private static long pointSignature(@NonNull final ActivityPoint p) { + final GPSCoordinate loc = p.getLocation(); + final long ts = p.getTime() != null ? p.getTime().getTime() : 0L; + return java.util.Objects.hash( + ts, + loc != null ? loc.getLatitude() : null, + loc != null ? loc.getLongitude() : null, + p.getAltitude(), + p.getHeartRate(), + p.getCadence(), + p.getSpeed(), + p.getDistance(), + p.getPower(), + p.getTemperature(), + p.getDepth(), + p.getRespiratoryRate(), + p.getBodyEnergy(), + p.getStamina(), + p.getCnsToxicity(), + p.getN2Load(), + p.getVerticalOscillation(), + p.getStanceTimePercent(), + p.getStanceTime(), + p.getVerticalRatio(), + p.getStanceTimeBalance(), + p.getPerformanceCondition()); + } + + /// Reads workout distance in meters, preferring DISTANCE_METERS_CALIBRATED (when + /// non-zero) over DISTANCE_METERS — per Xiaomi parser comment, the calibrated value + /// "displaces the raw DISTANCE_METERS for display" when calibration was applied. + /// Falls back to the point-aggregate last distance when no summary value is set. + @Nullable + private static Double readDistanceMeters(@Nullable final ActivitySummaryData data, + @Nullable final Double pointFallback) { + final Double calibrated = readMeters(data, ActivitySummaryEntries.DISTANCE_METERS_CALIBRATED); + if (calibrated != null && calibrated > 0.0) { + return calibrated; + } + return first(readMeters(data, ActivitySummaryEntries.DISTANCE_METERS), pointFallback); + } + + /// Reads (value, unit) for a numeric entry, or null if absent / non-numeric. + /// Returned array is {@code [Number value, String unit]}. + @Nullable + private static Object[] readEntry(@Nullable final ActivitySummaryData data, + @NonNull final String key) { + if (data == null) return null; + final ActivitySummaryEntry entry = data.get(key); + if (!(entry instanceof ActivitySummarySimpleEntry simple)) return null; + final Object value = simple.getValue(); + if (!(value instanceof Number)) return null; + return new Object[]{ value, simple.getUnit() }; + } + + /// Reads a length value and converts it to millimetres, regardless of the + /// source unit (mm, cm, m, km). Required for FIT fields like step_length and + /// avg_vertical_oscillation where the natural unit is mm but Xiaomi stores cm. + @Nullable + private static Float readMillimeters(@Nullable final ActivitySummaryData data, + @NonNull final String key) { + final Object[] entry = readEntry(data, key); + if (entry == null) return null; + final double v = ((Number) entry[0]).doubleValue(); + final String unit = (String) entry[1]; + if (unit == null) return (float) v; // assume already mm + return switch (unit) { + case ActivitySummaryEntries.UNIT_MM -> (float) v; + case ActivitySummaryEntries.UNIT_CM -> (float) (v * 10.0); + case ActivitySummaryEntries.UNIT_METERS -> (float) (v * 1000.0); + case ActivitySummaryEntries.UNIT_KILOMETERS -> (float) (v * 1_000_000.0); + default -> { + LOG.warn("Unknown length unit '{}' for key {}, assuming mm", unit, key); + yield (float) v; + } + }; + } + + /// Same as {@link #readMillimeters} but returns Integer (for FIT fields like + /// avg_left/right_pco where the builder takes Integer mm). + @Nullable + private static Integer readMillimetersFromInt(@Nullable final ActivitySummaryData data, + @NonNull final String key) { + final Float mm = readMillimeters(data, key); + return mm == null ? null : Math.round(mm); + } + + /// Reads a length and converts to metres (mm/cm/m/km accepted). + @Nullable + private static Double readMeters(@Nullable final ActivitySummaryData data, + @NonNull final String key) { + final Object[] entry = readEntry(data, key); + if (entry == null) return null; + final double v = ((Number) entry[0]).doubleValue(); + final String unit = (String) entry[1]; + if (unit == null) return v; + return switch (unit) { + case ActivitySummaryEntries.UNIT_METERS -> v; + case ActivitySummaryEntries.UNIT_KILOMETERS -> v * 1000.0; + case ActivitySummaryEntries.UNIT_CM -> v / 100.0; + case ActivitySummaryEntries.UNIT_MM -> v / 1000.0; + default -> { + LOG.warn("Unknown length unit '{}' for key {}, assuming m", unit, key); + yield v; + } + }; + } + + /// Reads a speed and converts to m/s (m/s, km/h, knots, cm/s accepted). + @Nullable + private static Float readMetersPerSecond(@Nullable final ActivitySummaryData data, + @NonNull final String key) { + final Object[] entry = readEntry(data, key); + if (entry == null) return null; + final double v = ((Number) entry[0]).doubleValue(); + final String unit = (String) entry[1]; + if (unit == null) return (float) v; + return switch (unit) { + case ActivitySummaryEntries.UNIT_METERS_PER_SECOND -> (float) v; + case ActivitySummaryEntries.UNIT_KMPH -> (float) (v / 3.6); + case ActivitySummaryEntries.UNIT_KNOTS -> (float) (v * 0.514444); + case ActivitySummaryEntries.UNIT_CENTIMETERS_PER_SECOND -> (float) (v / 100.0); + case ActivitySummaryEntries.UNIT_METERS_PER_HOUR -> (float) (v / 3600.0); + default -> { + LOG.warn("Unknown speed unit '{}' for key {}, assuming m/s", unit, key); + yield (float) v; + } + }; + } + + /// Reads a duration and converts to milliseconds (ms, s, min, h accepted). + @Nullable + private static Float readMilliseconds(@Nullable final ActivitySummaryData data, + @NonNull final String key) { + final Object[] entry = readEntry(data, key); + if (entry == null) return null; + final double v = ((Number) entry[0]).doubleValue(); + final String unit = (String) entry[1]; + if (unit == null) return (float) v; + return switch (unit) { + case ActivitySummaryEntries.UNIT_MILLISECONDS -> (float) v; + case ActivitySummaryEntries.UNIT_SECONDS -> (float) (v * 1000.0); + case ActivitySummaryEntries.UNIT_HOURS -> (float) (v * 3_600_000.0); + default -> { + LOG.warn("Unknown duration unit '{}' for key {}, assuming ms", unit, key); + yield (float) v; + } + }; + } + + /// Reads a duration and converts to seconds (ms, s, min, h accepted). + @Nullable + private static Long readSeconds(@Nullable final ActivitySummaryData data, + @NonNull final String key) { + final Object[] entry = readEntry(data, key); + if (entry == null) return null; + final double v = ((Number) entry[0]).doubleValue(); + final String unit = (String) entry[1]; + if (unit == null) return Math.round(v); + return switch (unit) { + case ActivitySummaryEntries.UNIT_SECONDS -> Math.round(v); + case ActivitySummaryEntries.UNIT_MILLISECONDS -> Math.round(v / 1000.0); + case ActivitySummaryEntries.UNIT_HOURS -> Math.round(v * 3600.0); + default -> { + LOG.warn("Unknown duration unit '{}' for key {}, assuming s", unit, key); + yield Math.round(v); + } + }; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivityKind.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivityKind.java index 10d0b7d542..d289317591 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivityKind.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivityKind.java @@ -296,6 +296,8 @@ public enum ActivityKind { TREKKING(0x040000f1, R.string.activity_type_trekking, R.drawable.ic_activity_hiking), TRAIL_RUN(0x040000f2, R.string.activity_type_trail_run, R.drawable.ic_activity_trail_run), UPPER_BODY(0x040000f3, R.string.activity_type_upper_body), + TRAIL_HIKE(0x040000f4, R.string.activity_type_trail_hike, R.drawable.ic_activity_hiking), + MMA_HIIT(0x040000f5, R.string.activity_type_mma_hiit, R.drawable.ic_activity_mma), LOWER_BODY(0x040000ff, R.string.activity_type_lower_body), BARBELL(0x04000100, R.string.activity_type_barbell, R.drawable.ic_activity_barbell), TRIATHLON(0x04000101, R.string.activity_type_triathlon), diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivityPoint.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivityPoint.java index abf8f0b3b6..4e917e7a12 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivityPoint.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivityPoint.java @@ -40,6 +40,15 @@ public class ActivityPoint { private float n2Load = -1.0f; private double altitude = GPSCoordinate.UNKNOWN_ALTITUDE; + // Running dynamics (Garmin running watches with HRM-Pro / RD-Pod). All Float so + // the unset sentinel is NaN; non-running sports leave these untouched. + private float verticalOscillation = Float.NaN; // mm + private float stanceTimePercent = Float.NaN; // % + private float stanceTime = Float.NaN; // ms + private float verticalRatio = Float.NaN; // % + private float stanceTimeBalance = Float.NaN; // % (left/right) + private int performanceCondition = Integer.MIN_VALUE; // 0–100, integer per FIT spec + // e.g. to describe a pause during the activity private @Nullable String description; @@ -198,11 +207,66 @@ public class ActivityPoint { this.depth = depth; } + /// vertical bounce of torso while running, in mm + public float getVerticalOscillation() { + return verticalOscillation; + } + + public void setVerticalOscillation(final float verticalOscillation) { + this.verticalOscillation = verticalOscillation; + } + + /// fraction of step cycle the foot is on the ground, in % + public float getStanceTimePercent() { + return stanceTimePercent; + } + + public void setStanceTimePercent(final float stanceTimePercent) { + this.stanceTimePercent = stanceTimePercent; + } + + /// foot ground-contact time, in ms + public float getStanceTime() { + return stanceTime; + } + + public void setStanceTime(final float stanceTime) { + this.stanceTime = stanceTime; + } + + /// vertical oscillation as % of step length + public float getVerticalRatio() { + return verticalRatio; + } + + public void setVerticalRatio(final float verticalRatio) { + this.verticalRatio = verticalRatio; + } + + /// left/right stance-time balance, in % + public float getStanceTimeBalance() { + return stanceTimeBalance; + } + + public void setStanceTimeBalance(final float stanceTimeBalance) { + this.stanceTimeBalance = stanceTimeBalance; + } + + /// performance condition score 0–100 (Garmin) + public int getPerformanceCondition() { + return performanceCondition; + } + + public void setPerformanceCondition(final int performanceCondition) { + this.performanceCondition = performanceCondition; + } + @Override public boolean equals(Object o) { if (!(o instanceof ActivityPoint that)) return false; return heartRate == that.heartRate && Float.compare(speed, that.speed) == 0 && + stepLength == that.stepLength && cadence == that.cadence && Float.compare(power, that.power) == 0 && Float.compare(respiratoryRate, that.respiratoryRate) == 0 && @@ -214,12 +278,20 @@ public class ActivityPoint { Double.compare(distance, that.distance) == 0 && Double.compare(altitude, that.altitude) == 0 && Float.compare(bodyEnergy, that.bodyEnergy) == 0 && - Float.compare(stamina, that.stamina) == 0; + Float.compare(stamina, that.stamina) == 0 && + Float.compare(cnsToxicity, that.cnsToxicity) == 0 && + Float.compare(n2Load, that.n2Load) == 0 && + Float.compare(verticalOscillation, that.verticalOscillation) == 0 && + Float.compare(stanceTimePercent, that.stanceTimePercent) == 0 && + Float.compare(stanceTime, that.stanceTime) == 0 && + Float.compare(verticalRatio, that.verticalRatio) == 0 && + Float.compare(stanceTimeBalance, that.stanceTimeBalance) == 0 && + performanceCondition == that.performanceCondition; } @Override public int hashCode() { - return Objects.hash(time, location, heartRate, speed, cadence, power, respiratoryRate, depth, temperature, description, distance, altitude, bodyEnergy, stamina); + return Objects.hash(time, location, heartRate, speed, stepLength, cadence, power, respiratoryRate, depth, temperature, description, distance, altitude, bodyEnergy, stamina, cnsToxicity, n2Load, verticalOscillation, stanceTimePercent, stanceTime, verticalRatio, stanceTimeBalance, performanceCondition); } public static class Builder { @@ -247,6 +319,12 @@ public class ActivityPoint { private float stamina = Float.NaN; private float cnsToxicity = Float.NaN; private float n2Load = Float.NaN; + private float verticalOscillation = Float.NaN; + private float stanceTimePercent = Float.NaN; + private float stanceTime = Float.NaN; + private float verticalRatio = Float.NaN; + private float stanceTimeBalance = Float.NaN; + private int performanceCondition = Integer.MIN_VALUE; public Builder() { } @@ -509,6 +587,30 @@ public class ActivityPoint { this.n2Load = n2Load; } + public void setVerticalOscillation(@Nullable Number verticalOscillation) { + this.verticalOscillation = (verticalOscillation == null) ? Float.NaN : verticalOscillation.floatValue(); + } + + public void setStanceTimePercent(@Nullable Number stanceTimePercent) { + this.stanceTimePercent = (stanceTimePercent == null) ? Float.NaN : stanceTimePercent.floatValue(); + } + + public void setStanceTime(@Nullable Number stanceTime) { + this.stanceTime = (stanceTime == null) ? Float.NaN : stanceTime.floatValue(); + } + + public void setVerticalRatio(@Nullable Number verticalRatio) { + this.verticalRatio = (verticalRatio == null) ? Float.NaN : verticalRatio.floatValue(); + } + + public void setStanceTimeBalance(@Nullable Number stanceTimeBalance) { + this.stanceTimeBalance = (stanceTimeBalance == null) ? Float.NaN : stanceTimeBalance.floatValue(); + } + + public void setPerformanceCondition(@Nullable Number performanceCondition) { + this.performanceCondition = (performanceCondition == null) ? Integer.MIN_VALUE : performanceCondition.intValue(); + } + public ActivityPoint build() { final ActivityPoint activityPoint = new ActivityPoint(date); @@ -571,6 +673,24 @@ public class ActivityPoint { if(!Float.isNaN(n2Load)){ activityPoint.n2Load = n2Load; } + if (!Float.isNaN(verticalOscillation)) { + activityPoint.verticalOscillation = verticalOscillation; + } + if (!Float.isNaN(stanceTimePercent)) { + activityPoint.stanceTimePercent = stanceTimePercent; + } + if (!Float.isNaN(stanceTime)) { + activityPoint.stanceTime = stanceTime; + } + if (!Float.isNaN(verticalRatio)) { + activityPoint.verticalRatio = verticalRatio; + } + if (!Float.isNaN(stanceTimeBalance)) { + activityPoint.stanceTimeBalance = stanceTimeBalance; + } + if (performanceCondition > Integer.MIN_VALUE) { + activityPoint.performanceCondition = performanceCondition; + } return activityPoint; } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivitySummaryEntries.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivitySummaryEntries.java index 892f1d263a..d7d3d27beb 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivitySummaryEntries.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivitySummaryEntries.java @@ -178,10 +178,18 @@ public class ActivitySummaryEntries { public static final String SWOLF_MIN = "swolfMin"; public static final String SWIM_AVG_CADENCE = "swim_avg_cadence"; + // Dietary intake kcal (food eaten), NOT energy expenditure. Sourced from Garmin FIT + // session.calories_consumed. Distinct from the three "expended" counters below. public static final String CALORIES_CONSUMED = "calories_consumed"; + // Active kcal expended by the workout, excluding basal metabolic rate. The headline + // number shown on the watch workout card on Xiaomi devices. public static final String CALORIES_BURNT = "active_calories"; + // Active + resting kcal over the workout window (CALORIES_BURNT + CALORIES_RESTING). + // Not emitted by every device or workout type. public static final String CALORIES_TOTAL = "caloriesBurnt"; + // Basal metabolic kcal accrued during the workout window (CALORIES_TOTAL - CALORIES_BURNT). public static final String CALORIES_RESTING = "restingCalories"; + public static final String TRAINING_EFFECT_AEROBIC = "aerobicTrainingEffect"; public static final String TRAINING_EFFECT_ANAEROBIC = "anaerobicTrainingEffect"; public static final String TRAINING_EFFECT_TOTAL = "training_effect_total"; @@ -189,6 +197,7 @@ public class ActivitySummaryEntries { public static final String MAXIMUM_OXYGEN_UPTAKE = "maximumOxygenUptake"; public static final String RECOVERY_TIME = "recoveryTime"; public static final String RECOVERY_HR = "recoveryHr"; + public static final String DISTANCE_METERS_CALIBRATED = "workout_distance_meters_calibrated"; public static final String FLUID_CONSUMED = "fluid_consumed"; public static final String ESTIMATED_SWEAT_LOSS = "estimatedSweatLoss"; public static final String LACTATE_THRESHOLD_HR = "lactateThresholdHeartRate"; diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivityTrack.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivityTrack.java index c636eaab97..f48515e87f 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivityTrack.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivityTrack.java @@ -1,4 +1,4 @@ -/* Copyright (C) 2017-2024 Carsten Pfeiffer, José Rebelo +/* Copyright (C) 2017-2024 Carsten Pfeiffer, José Rebelo, Dany Mestas This file is part of Gadgetbridge. @@ -18,6 +18,7 @@ package nodomain.freeyourgadget.gadgetbridge.model; import java.util.ArrayList; import java.util.Collection; +import java.util.Comparator; import java.util.Date; import java.util.List; import java.util.stream.Collectors; @@ -26,6 +27,177 @@ import nodomain.freeyourgadget.gadgetbridge.entities.Device; import nodomain.freeyourgadget.gadgetbridge.entities.User; public class ActivityTrack { + + /** Intensity tag for a workout segment / interval. ACTIVE = work phase, + * REST = recovery / pause. UNKNOWN if the source parser did not provide it. */ + public enum SegmentIntensity { + ACTIVE, REST, UNKNOWN + } + + /** Per-segment metadata. Populated by parsers that expose interval structure + * (e.g. Xiaomi rowing's active/rest phase byte). Length always equals + * {@link #segments} length. + * + * Optional per-segment metric fields ({@link #distanceMeters}, {@link #strokes}) + * carry values that are PARSED DIRECTLY from the device's per-segment binary + * payload — they are never derived or distributed from session-level aggregates. + * When the source does not encode a per-segment metric, the field stays null and + * the FIT exporter omits the corresponding lap field rather than fabricating it. */ + public static final class SegmentInfo { + private final SegmentIntensity intensity; + private final Integer distanceMeters; + private final Integer strokes; + + public SegmentInfo() { + this(SegmentIntensity.UNKNOWN, null, null); + } + + public SegmentInfo(final SegmentIntensity intensity) { + this(intensity, null, null); + } + + public SegmentInfo(final SegmentIntensity intensity, + final Integer distanceMeters, + final Integer strokes) { + this.intensity = intensity != null ? intensity : SegmentIntensity.UNKNOWN; + this.distanceMeters = distanceMeters; + this.strokes = strokes; + } + + public SegmentIntensity getIntensity() { + return intensity; + } + + public Integer getDistanceMeters() { + return distanceMeters; + } + + public Integer getStrokes() { + return strokes; + } + } + + /** Per-length swim metadata captured from FIT length messages (msg 101). Carried + * on the track so the exporter can re-emit length records for lap-swimming + * workouts (sport=5, sub_sport=17). All fields are nullable — the source + * parser populates whichever the device wrote. Length-level data is independent + * of segments: a single lap usually contains multiple lengths (one per pool + * length) which Strava + Endurain both render in their swim detail views. */ + /** Per-split metadata captured from FIT split messages (msg 312). Garmin + * Edge/Forerunner devices emit one split per auto-paused interval — Strava + * surfaces these in the lap chart, Endurain in its split summary. Carried on + * the track so the exporter can re-emit split records on round-trip. */ + public static final class SplitInfo { + public final long startTimeSec; + public final Long endTimeSec; + public final Integer splitType; + public final Double totalElapsedTimeSec; + public final Double totalTimerTimeSec; + public final Double totalDistanceMeters; + public final Double avgSpeedMps; + public final Double maxSpeedMps; + public final Integer totalAscentMeters; + public final Integer totalDescentMeters; + public final Long totalCalories; + public final Double startElevationMeters; + public final Double startPositionLat; + public final Double startPositionLong; + public final Double endPositionLat; + public final Double endPositionLong; + + public SplitInfo(final long startTimeSec, + final Long endTimeSec, + final Integer splitType, + final Double totalElapsedTimeSec, + final Double totalTimerTimeSec, + final Double totalDistanceMeters, + final Double avgSpeedMps, + final Double maxSpeedMps, + final Integer totalAscentMeters, + final Integer totalDescentMeters, + final Long totalCalories, + final Double startElevationMeters, + final Double startPositionLat, + final Double startPositionLong, + final Double endPositionLat, + final Double endPositionLong) { + this.startTimeSec = startTimeSec; + this.endTimeSec = endTimeSec; + this.splitType = splitType; + this.totalElapsedTimeSec = totalElapsedTimeSec; + this.totalTimerTimeSec = totalTimerTimeSec; + this.totalDistanceMeters = totalDistanceMeters; + this.avgSpeedMps = avgSpeedMps; + this.maxSpeedMps = maxSpeedMps; + this.totalAscentMeters = totalAscentMeters; + this.totalDescentMeters = totalDescentMeters; + this.totalCalories = totalCalories; + this.startElevationMeters = startElevationMeters; + this.startPositionLat = startPositionLat; + this.startPositionLong = startPositionLong; + this.endPositionLat = endPositionLat; + this.endPositionLong = endPositionLong; + } + } + + /** Per-set strength-training metadata from FIT set messages (msg 27). + * Endurain renders these in its workout-set table. Carried on the track so the + * exporter can re-emit set records for strength activities. */ + public static final class SetInfo { + public final long startTimeSec; + public final Double durationSec; + public final Integer repetitions; + public final Float weightKg; + public final Integer setType; // 0=rest, 1=active + public final Integer weightDisplayUnit; + public final Integer messageIndex; + + public SetInfo(final long startTimeSec, + final Double durationSec, + final Integer repetitions, + final Float weightKg, + final Integer setType, + final Integer weightDisplayUnit, + final Integer messageIndex) { + this.startTimeSec = startTimeSec; + this.durationSec = durationSec; + this.repetitions = repetitions; + this.weightKg = weightKg; + this.setType = setType; + this.weightDisplayUnit = weightDisplayUnit; + this.messageIndex = messageIndex; + } + } + + public static final class LengthInfo { + public final long startTimeSec; + public final double totalElapsedTimeSec; + public final double totalTimerTimeSec; + public final Integer totalStrokes; + public final Float avgSpeed; + public final Integer swimStroke; + public final Integer lengthType; + public final Integer avgSwimmingCadence; + + public LengthInfo(final long startTimeSec, + final double totalElapsedTimeSec, + final double totalTimerTimeSec, + final Integer totalStrokes, + final Float avgSpeed, + final Integer swimStroke, + final Integer lengthType, + final Integer avgSwimmingCadence) { + this.startTimeSec = startTimeSec; + this.totalElapsedTimeSec = totalElapsedTimeSec; + this.totalTimerTimeSec = totalTimerTimeSec; + this.totalStrokes = totalStrokes; + this.avgSpeed = avgSpeed; + this.swimStroke = swimStroke; + this.lengthType = lengthType; + this.avgSwimmingCadence = avgSwimmingCadence; + } + } + private Date baseTime; private Device device; private User user; @@ -34,6 +206,12 @@ public class ActivityTrack { private final List> segments = new ArrayList<>() {{ add(currentSegment); }}; + private final List segmentInfos = new ArrayList<>() {{ + add(new SegmentInfo()); + }}; + private final List lengths = new ArrayList<>(); + private final List splits = new ArrayList<>(); + private final List sets = new ArrayList<>(); public void setBaseTime(Date baseTime) { this.baseTime = baseTime; @@ -67,23 +245,103 @@ public class ActivityTrack { } public void startNewSegment() { - // Only really start a new segment if the current one is not empty + startNewSegment(new SegmentInfo()); + } + + /** Start a new segment with explicit per-segment metadata. Mirrors the no-arg + * startNewSegment() (only opens a new segment if the current one is non-empty) + * and keeps {@link #segments} / {@link #segmentInfos} length-synchronised. */ + public void startNewSegment(final SegmentInfo info) { if (!currentSegment.isEmpty()) { currentSegment = new ArrayList<>(); segments.add(currentSegment); + segmentInfos.add(info != null ? info : new SegmentInfo()); } } + /** Replace the SegmentInfo for the current (last) segment. Use when a parser + * knows the intensity of the very first segment before adding any further ones. */ + public void setCurrentSegmentInfo(final SegmentInfo info) { + final SegmentInfo nonNull = info != null ? info : new SegmentInfo(); + if (segmentInfos.isEmpty()) { + segmentInfos.add(nonNull); + } else { + segmentInfos.set(segmentInfos.size() - 1, nonNull); + } + } + + /** Replace all segments and per-segment metadata in one atomic step. Lengths must + * match. Used when a downstream parser knows segment boundaries that were not + * available when the track was first built (e.g. Xiaomi GPS+DETAILS merge, + * where segment ranges come from DETAILS but points come from the GPS file). + * Reseats {@link #currentSegment} to the trailing entry so subsequent + * {@link #addTrackPoint(ActivityPoint)} calls keep targeting the last segment. */ + public void replaceSegments(final List> newSegments, + final List newSegmentInfos) { + if (newSegments == null || newSegmentInfos == null + || newSegments.size() != newSegmentInfos.size() + || newSegments.isEmpty()) { + throw new IllegalArgumentException( + "replaceSegments requires non-empty length-matched lists"); + } + segments.clear(); + segmentInfos.clear(); + for (int i = 0; i < newSegments.size(); i++) { + final List seg = newSegments.get(i); + segments.add(seg != null ? seg : new ArrayList<>()); + final SegmentInfo info = newSegmentInfos.get(i); + segmentInfos.add(info != null ? info : new SegmentInfo()); + } + currentSegment = segments.get(segments.size() - 1); + } + public List> getSegments() { return segments; } + public List getSegmentInfos() { + return segmentInfos; + } + + public List getLengths() { + return lengths; + } + + public void addLength(final LengthInfo info) { + if (info != null) lengths.add(info); + } + + public List getSplits() { + return splits; + } + + public void addSplit(final SplitInfo info) { + if (info != null) splits.add(info); + } + + public List getSets() { + return sets; + } + + public void addSet(final SetInfo info) { + if (info != null) sets.add(info); + } + public List getAllPoints() { return getSegments().stream() .flatMap(Collection::stream) .collect(Collectors.toList()); } + /** Sort points within each segment by timestamp ascending. Use after a merge that + * may have appended points out of order, so the chart renders monotonically. */ + public void sortPointsByTime() { + final Comparator byTime = Comparator.comparing(ActivityPoint::getTime); + for (final List segment : segments) { + segment.sort(byTime); + } + } + public Date getBaseTime() { return baseTime; } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/FitActivityTrackProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/FitActivityTrackProvider.java index 19a0664d2e..89777efce1 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/FitActivityTrackProvider.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/FitActivityTrackProvider.java @@ -31,7 +31,10 @@ import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.FitFile; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.exception.FitParseException; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitLap; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitLength; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitRecord; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitSet; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitSplit; import nodomain.freeyourgadget.gadgetbridge.util.FileUtils; public class FitActivityTrackProvider implements ActivityTrackProvider { @@ -101,6 +104,58 @@ public class FitActivityTrackProvider implements ActivityTrackProvider { activityTrack.addTrackPoint(record.toActivityPoint()); } + // Per-length / per-split / per-set metadata — kept on the track so the + // exporter can re-emit them on round-trip. Garmin Connect, Strava and + // Endurain all surface these in their detail views. + for (final var rd : fitFile.getRecords()) { + if (rd instanceof FitLength len) { + final Long startTime = len.getStartTime(); + if (startTime == null) continue; + final Double elapsedSec = len.getTotalElapsedTime(); + final Double timerSec = len.getTotalTimerTime(); + activityTrack.addLength(new ActivityTrack.LengthInfo( + startTime, + elapsedSec != null ? elapsedSec : 0.0, + timerSec != null ? timerSec : 0.0, + len.getTotalStrokes(), + len.getAvgSpeed(), + len.getSwimStroke(), + len.getLengthType(), + len.getAvgSwimmingCadence())); + } else if (rd instanceof FitSplit sp) { + final Long startTime = sp.getStartTime(); + if (startTime == null) continue; + activityTrack.addSplit(new ActivityTrack.SplitInfo( + startTime, + sp.getEndTime(), + sp.getSplitType(), + sp.getTotalElapsedTime(), + sp.getTotalTimerTime(), + sp.getTotalDistance(), + sp.getAvgSpeed(), + sp.getMaxSpeed(), + sp.getTotalAscent(), + sp.getTotalDescent(), + sp.getTotalCalories(), + sp.getStartElevation(), + sp.getStartPositionLat(), + sp.getStartPositionLong(), + sp.getEndPositionLat(), + sp.getEndPositionLong())); + } else if (rd instanceof FitSet set) { + final Long startTime = set.getStartTime(); + if (startTime == null) continue; + activityTrack.addSet(new ActivityTrack.SetInfo( + startTime, + set.getDuration(), + set.getRepetitions(), + set.getWeight(), + set.getSetType(), + set.getWeightDisplayUnit(), + set.getMessageIndex())); + } + } + return activityTrack; } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/banglejs/BangleJSActivityTrack.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/banglejs/BangleJSActivityTrack.java index ff1936ac20..4203e2e219 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/banglejs/BangleJSActivityTrack.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/banglejs/BangleJSActivityTrack.java @@ -34,6 +34,7 @@ import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary; import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession; import nodomain.freeyourgadget.gadgetbridge.entities.Device; import nodomain.freeyourgadget.gadgetbridge.entities.User; +import nodomain.freeyourgadget.gadgetbridge.export.AutoFitExporter; import nodomain.freeyourgadget.gadgetbridge.export.AutoGpxExporter; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind; @@ -204,8 +205,10 @@ class BangleJSActivityTrack { } if (hasGPXReading /*|| hasHRMReading*/) { + // GPX needs at least one GPS-valid point; FIT can be emitted regardless. AutoGpxExporter.doExport(context, device, summary, track); } + AutoFitExporter.doExport(context, device, summary, track); //summary.setSummaryData(null); // remove json before saving to database, diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfActivitySync.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfActivitySync.java index 730400299b..6dc835bb89 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfActivitySync.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfActivitySync.java @@ -51,6 +51,7 @@ import nodomain.freeyourgadget.gadgetbridge.entities.CmfWorkoutGpsSample; import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession; import nodomain.freeyourgadget.gadgetbridge.entities.Device; import nodomain.freeyourgadget.gadgetbridge.entities.User; +import nodomain.freeyourgadget.gadgetbridge.export.AutoFitExporter; import nodomain.freeyourgadget.gadgetbridge.export.AutoGpxExporter; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind; @@ -502,9 +503,10 @@ public class CmfActivitySync { .anyMatch(p -> p.getLocation() != null); if (hasGps) { - // Save the gpx file + // GPX needs at least one GPS-valid point; FIT can be emitted regardless. AutoGpxExporter.doExport(getContext(), getDevice(), summary, activityTrack); } + AutoFitExporter.doExport(getContext(), getDevice(), summary, activityTrack); } private Context getContext() { diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/FitImporter.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/FitImporter.java index ed9183598a..3d5613b39a 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/FitImporter.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/FitImporter.java @@ -88,6 +88,7 @@ import nodomain.freeyourgadget.gadgetbridge.entities.GenericMetricSample; import nodomain.freeyourgadget.gadgetbridge.entities.GenericTrainingLoadAcuteSample; import nodomain.freeyourgadget.gadgetbridge.entities.GenericTrainingLoadChronicSample; import nodomain.freeyourgadget.gadgetbridge.entities.User; +import nodomain.freeyourgadget.gadgetbridge.export.AutoFitExporter; import nodomain.freeyourgadget.gadgetbridge.export.AutoGpxExporter; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind; @@ -717,12 +718,13 @@ public class FitImporter { GB.toast(context, "Error saving workout", Toast.LENGTH_LONG, GB.ERROR, e); } - // Export to gpx - if (AutoGpxExporter.isExportEnabled(gbDevice)) { + // Export to gpx / fit + if (AutoGpxExporter.isExportEnabled(gbDevice) || AutoFitExporter.isExportEnabled(gbDevice)) { final FitActivityTrackProvider activityTrackProvider = new FitActivityTrackProvider(); final ActivityTrack activityTrack = activityTrackProvider.getActivityTrack(summary, fitFile); if (activityTrack != null) { AutoGpxExporter.doExport(context, gbDevice, summary, activityTrack); + AutoFitExporter.doExport(context, gbDevice, summary, activityTrack); } } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/RecordData.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/RecordData.java index 41ad0ca5b6..7ab6f50942 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/RecordData.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/RecordData.java @@ -12,6 +12,7 @@ import java.util.Date; import java.util.List; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.GarminByteBufferReader; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.baseTypes.BaseType; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.MessageWriter; import nodomain.freeyourgadget.gadgetbridge.util.ArrayUtils; import nodomain.freeyourgadget.gadgetbridge.util.GBToStringBuilder; @@ -60,7 +61,20 @@ public class RecordData { if (recordDefinition.getDevFieldDefinitions() != null) { for (DevFieldDefinition fieldDef : recordDefinition.getDevFieldDefinitions()) { - FieldDefinition temp = new FieldDefinition(fieldDef.getFieldDefinitionNumber(), fieldDef.getSize(), fieldDef.getBaseType(), fieldDef.getName()); + // DevFieldDefinition.baseType is nullable — populated by populateDevFields + // only when a matching field_description message has been parsed before + // the dev field's first record. If absent (out-of-order spec, malformed + // file), fall back to opaque bytes so the codec can walk past the field + // using its declared size. + final BaseType devBaseType; + if (fieldDef.getBaseType() != null) { + devBaseType = fieldDef.getBaseType(); + } else { + LOG.warn("Dev field '{}' (#{}) has no base type — no matching field_description was parsed before its first record; falling back to opaque bytes", + fieldDef.getName(), fieldDef.getFieldDefinitionNumber()); + devBaseType = BaseType.BASE_TYPE_BYTE; + } + FieldDefinition temp = new FieldDefinition(fieldDef.getFieldDefinitionNumber(), fieldDef.getSize(), devBaseType, fieldDef.getName()); fieldDataList.add(new FieldData(temp, totalSize)); totalSize += fieldDef.getSize(); } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/baseTypes/BaseType.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/baseTypes/BaseType.java index f68c20d92a..fb90033cfb 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/baseTypes/BaseType.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/baseTypes/BaseType.java @@ -1,5 +1,8 @@ package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.baseTypes; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import java.nio.ByteBuffer; //see https://github.com/dtcooper/python-fitparse/blob/master/fitparse/records.py @@ -31,13 +34,21 @@ public enum BaseType { this.baseTypeInterface = byteBaseType; } + private static final Logger LOG = LoggerFactory.getLogger(BaseType.class); + public static BaseType fromIdentifier(int identifier) { for (final BaseType baseType : BaseType.values()) { if (baseType.getIdentifier() == identifier) { return baseType; } } - throw new IllegalArgumentException("Unknown type " + identifier); + // Unknown / future / developer-extended base type. Walk past as opaque bytes + // so the rest of the file still parses — the field's value is lost but the + // file structure is preserved. The caller's FieldDefinition still carries the + // declared byte size, so the codec can advance correctly. + LOG.warn("Unknown FIT base type 0x{} — falling back to opaque-byte handling", + Integer.toHexString(identifier & 0xFF)); + return BASE_TYPE_BYTE; } public int getSize() { diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/baseTypes/BaseTypeByte.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/baseTypes/BaseTypeByte.java index 7e71fa701f..47df5c1a21 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/baseTypes/BaseTypeByte.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/baseTypes/BaseTypeByte.java @@ -44,7 +44,9 @@ public class BaseTypeByte implements BaseTypeInterface { invalidate(byteBuffer); return; } - int i = (int) ((((Number) o).intValue() + offset) * scale); + // Use doubleValue() rather than intValue() so fractional Float/Double inputs are + // not truncated before the scale multiplication. Integer-typed inputs unchanged. + int i = (int) ((((Number) o).doubleValue() + offset) * scale); if (i < min || i > max) { invalidate(byteBuffer); return; diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/baseTypes/BaseTypeInt.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/baseTypes/BaseTypeInt.java index 4273f2e7be..961f1ff3bd 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/baseTypes/BaseTypeInt.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/baseTypes/BaseTypeInt.java @@ -42,7 +42,10 @@ public class BaseTypeInt implements BaseTypeInterface { invalidate(byteBuffer); return; } - long l = (long) ((((Number) o).longValue() + offset) * scale); + // Use doubleValue() rather than longValue() so that fractional inputs survive the + // multiply by scale instead of being truncated first. For Integer/Long inputs the + // result is identical. + long l = (long) ((((Number) o).doubleValue() + offset) * scale); if (l < min || l > max) { invalidate(byteBuffer); return; diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/baseTypes/BaseTypeShort.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/baseTypes/BaseTypeShort.java index 64d377365a..eea22002c0 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/baseTypes/BaseTypeShort.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/baseTypes/BaseTypeShort.java @@ -42,7 +42,11 @@ public class BaseTypeShort implements BaseTypeInterface { invalidate(byteBuffer); return; } - int i = (int) ((((Number) o).intValue() + offset) * scale); + // Use doubleValue() rather than intValue() so that fractional inputs (Float/Double + // physical values like 2.5 m/s on a scale=1000 field) survive the multiply by + // scale instead of being truncated to 2 first. For Integer/Long inputs the result + // is identical. + int i = (int) ((((Number) o).doubleValue() + offset) * scale); if (i < min || i > max) { invalidate(byteBuffer); return; diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/enums/GarminSport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/enums/GarminSport.java index 7ffa26c9cf..87823b7d2b 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/enums/GarminSport.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/enums/GarminSport.java @@ -31,8 +31,8 @@ public enum GarminSport { OBSTACLE_RACING(1, 59, ActivityKind.OBSTACLE_RACE), ULTRA_RUN(1, 67, ActivityKind.ULTRA_RUN), BIKE(2, 0, ActivityKind.CYCLING), + STREET_CYCLING(2, 2, ActivityKind.OUTDOOR_CYCLING), CYCLING_SPIN(2, 5, ActivityKind.SPINNING), - BIKE_INDOOR(2, 6, ActivityKind.INDOOR_CYCLING), ROAD_BIKE(2, 7, ActivityKind.ROAD_BIKE), MTB(2, 8, ActivityKind.MOUNTAIN_BIKE), CYCLING_DOWNHILL(2, 9, ActivityKind.CYCLING_DOWNHILL), @@ -45,6 +45,10 @@ public enum GarminSport { BIKE_COMMUTE(2, 48, ActivityKind.BIKE_COMMUTE), BIKE_TOUR(2, 49, ActivityKind.BIKE_TOUR), VIRTUAL_CYCLING(2, 58, ActivityKind.INDOOR_CYCLING), + // Declared after VIRTUAL_CYCLING so reverse-lookup of ActivityKind.INDOOR_CYCLING + // resolves to (2, 6) — the canonical indoor-bike code most Garmin devices use — + // rather than (2, 58) which is reserved for Zwift/virtual rides. + BIKE_INDOOR(2, 6, ActivityKind.INDOOR_CYCLING), HANDCYCLING_INDOOR(2, 88, ActivityKind.HANDCYCLING_INDOOR), TRANSITION(3, 0, ActivityKind.TRANSITION), FITNESS_EQUIPMENT(4, 0, ActivityKind.FITNESS_EQUIPMENT), @@ -71,11 +75,15 @@ public enum GarminSport { YOGA(10, 43, ActivityKind.YOGA), BREATHWORK(10, 62, ActivityKind.BREATHWORK), WALK(11, 0, ActivityKind.WALKING), + STREET_WALKING(11, 2, ActivityKind.OUTDOOR_WALKING), WALK_INDOOR(11, 27, ActivityKind.INDOOR_WALKING), XC_CLASSIC_SKI(12, 0, ActivityKind.XC_CLASSIC_SKI), XC_SKATE_SKI(12, 42, ActivityKind.XC_SKATE_SKI), SKI(13, 0, ActivityKind.SKIING), - TELEMARK_SKIING(13, 9, ActivityKind.XC_CLASSIC_SKI), + // TELEMARK_SKIING formerly aliased XC_CLASSIC_SKI which broke reverse-lookup of + // ActivityKind.XC_CLASSIC_SKI to (13, 9) instead of (12, 0). Map to SKIING — the + // closest canonical kind — and let dedicated XC entries own XC_CLASSIC_SKI. + TELEMARK_SKIING(13, 9, ActivityKind.SKIING), BACKCOUNTRY_SKI(13, 37, ActivityKind.BACKCOUNTRY_SKIING), SNOWBOARD(14, 0, ActivityKind.SNOWBOARDING), BACKCOUNTRY_SNOWBOARD(14, 37, ActivityKind.BACKCOUNTRY_SNOWBOARDING), @@ -84,6 +92,7 @@ public enum GarminSport { MOUNTAINEERING(16, 0, ActivityKind.MOUNTAINEERING), BACKCOUNTRY_MOUNTAINEERING(16, 37, ActivityKind.MOUNTAINEERING), HIKE(17, 0, ActivityKind.HIKING), + TRAIL_HIKE(17, 3, ActivityKind.TRAIL_HIKE), MULTISPORT(18, 0, ActivityKind.MULTISPORT), PADDLING(19, 0, ActivityKind.PADDLING), FLYING(20, 0, ActivityKind.FLYING), @@ -126,11 +135,15 @@ public enum GarminSport { SOFTBALL_SLOW_PITCH(51, 0, ActivityKind.SOFTBALL_SLOW_PITCH), STOPWATCH(52, 0, ActivityKind.STOP_WATCH), DIVING(53, 0, ActivityKind.DIVING), - SINGLE_GAS_DIVING(53, 53, ActivityKind.SCUBA_DIVING), MULTI_GAS_DIVING(53, 54, ActivityKind.SCUBA_DIVING), + // SINGLE_GAS_DIVING declared after MULTI_GAS so reverse-lookup of SCUBA_DIVING + // resolves to (53, 53) — the canonical Garmin code. Order matters here. + SINGLE_GAS_DIVING(53, 53, ActivityKind.SCUBA_DIVING), GAUGE_DIVING(53, 55, ActivityKind.DIVING), - APNEA_DIVING(53, 56, ActivityKind.FREE_DIVING), APNEA_HUNTING(53, 57, ActivityKind.FREE_DIVING), + // APNEA_DIVING declared after APNEA_HUNTING so reverse-lookup of FREE_DIVING + // resolves to (53, 56) — the canonical Garmin code. + APNEA_DIVING(53, 56, ActivityKind.FREE_DIVING), CCR_DIVING(53, 63, ActivityKind.CCR_DIVING), SHOOTING(56, 0, ActivityKind.SHOOTING), AUTO_RACING(57, 0, ActivityKind.AUTO_RACING), @@ -168,6 +181,7 @@ public enum GarminSport { WATER_SPORT(78, 0, ActivityKind.OTHER_WATER_SPORTS), ARCHERY(79, 0, ActivityKind.ARCHERY), MIXED_MARTIAL_ARTS(80, 0, ActivityKind.MIXED_MARTIAL_ARTS), // aka MMA + MMA_HIIT(80, 70, ActivityKind.MMA_HIIT), MOTOR_SPORT(81, 0, ActivityKind.MOTOR_SPORT), OVERLAND(81, 98, ActivityKind.OVERLANDING), SNORKELING(82, 0, ActivityKind.SNORKELING), @@ -211,4 +225,36 @@ public enum GarminSport { return Optional.empty(); } + + public static Optional fromActivityKind(final ActivityKind activityKind) { + if (activityKind == null) { + return Optional.empty(); + } + // Traverse last-to-first so more specific (later-declared) entries win over + // generic earlier ones — e.g. ELLIPTICAL(4, 15) wins over ELLIPTICAL_TRAINER(0, 15). + final GarminSport[] values = GarminSport.values(); + for (int i = values.length - 1; i >= 0; i--) { + if (values[i].getActivityKind() == activityKind) { + return Optional.of(values[i]); + } + } + final ActivityKind alias = aliasFor(activityKind); + if (alias != activityKind) { + return fromActivityKind(alias); + } + return Optional.empty(); + } + + // Maps ActivityKinds with no direct GarminSport entry onto a related kind that does. + // OUTDOOR_RUNNING is aliased rather than added as a duplicate FIT entry because + // STREET_RUN's decode-side ActivityKind must remain STREET_RUNNING. + // Unknown kinds fall back to ACTIVITY → GENERIC(0, 0) so exports stay valid. + private static ActivityKind aliasFor(final ActivityKind kind) { + switch (kind) { + case OUTDOOR_RUNNING: + return ActivityKind.STREET_RUNNING; + default: + return ActivityKind.ACTIVITY; + } + } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitRecord.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitRecord.java index a07e16a854..1cdd97372c 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitRecord.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitRecord.java @@ -1028,6 +1028,13 @@ public class FitRecord extends RecordData { builder.setStepLength(getStepLength()); builder.setTemperature(getTemperature()); + builder.setVerticalOscillation(getOscillation()); + builder.setStanceTimePercent(getStanceTimePercent()); + builder.setStanceTime(getStanceTime()); + builder.setVerticalRatio(getVerticalRatio()); + builder.setStanceTimeBalance(getStanceTimeBalance()); + builder.setPerformanceCondition(getPerformanceCondition()); + final Double enhancedAltitude = getEnhancedAltitude(); if (enhancedAltitude == null) { builder.setAltitude(getAltitude()); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/fetch/FetchSportsDetailsOperation.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/fetch/FetchSportsDetailsOperation.java index e4b9f895f4..1cfbef4fb6 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/fetch/FetchSportsDetailsOperation.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/fetch/FetchSportsDetailsOperation.java @@ -37,6 +37,7 @@ import nodomain.freeyourgadget.gadgetbridge.GBApplication; import nodomain.freeyourgadget.gadgetbridge.R; import nodomain.freeyourgadget.gadgetbridge.database.DBHandler; import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary; +import nodomain.freeyourgadget.gadgetbridge.export.AutoFitExporter; import nodomain.freeyourgadget.gadgetbridge.export.AutoGpxExporter; import nodomain.freeyourgadget.gadgetbridge.model.ActivityTrack; import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions; @@ -112,6 +113,7 @@ public class FetchSportsDetailsOperation extends AbstractFetchOperation { try { final ActivityTrack track = detailsParser.parse(buffer.toByteArray()); AutoGpxExporter.doExport(getContext(), getDevice(), summary, track); + AutoFitExporter.doExport(getContext(), getDevice(), summary, track); } catch (final Exception e) { GB.toast(getContext(), "Error saving activity details: " + e.getLocalizedMessage(), Toast.LENGTH_LONG, GB.ERROR, e); // #4549 - we do not return false here, since this might cause the same activity to be fetched over and over again diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/HuaweiSupportProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/HuaweiSupportProvider.java index 9a1af0a00b..f8c1b4296a 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/HuaweiSupportProvider.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/HuaweiSupportProvider.java @@ -100,6 +100,7 @@ import nodomain.freeyourgadget.gadgetbridge.entities.HuaweiSleepStageSample; import nodomain.freeyourgadget.gadgetbridge.entities.HuaweiSleepStatsSample; import nodomain.freeyourgadget.gadgetbridge.entities.HuaweiStressSample; import nodomain.freeyourgadget.gadgetbridge.entities.HuaweiWorkoutSummarySample; +import nodomain.freeyourgadget.gadgetbridge.export.AutoFitExporter; import nodomain.freeyourgadget.gadgetbridge.export.AutoGpxExporter; import nodomain.freeyourgadget.gadgetbridge.externalevents.gps.GBLocationProviderType; import nodomain.freeyourgadget.gadgetbridge.externalevents.gps.GBLocationService; @@ -2999,6 +3000,7 @@ public class HuaweiSupportProvider { } AutoGpxExporter.doExport(getContext(), getDevice(), null, track); + AutoFitExporter.doExport(getContext(), getDevice(), null, track); new HuaweiWorkoutGbParser(getDevice(), getContext()).parseWorkout(databaseId); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/activity/impl/WorkoutGpsParser.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/activity/impl/WorkoutGpsParser.java index 598e4741a0..6de2db3fc1 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/activity/impl/WorkoutGpsParser.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/activity/impl/WorkoutGpsParser.java @@ -36,6 +36,7 @@ import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary; import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession; import nodomain.freeyourgadget.gadgetbridge.entities.Device; import nodomain.freeyourgadget.gadgetbridge.entities.User; +import nodomain.freeyourgadget.gadgetbridge.export.AutoFitExporter; import nodomain.freeyourgadget.gadgetbridge.export.AutoGpxExporter; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind; @@ -155,6 +156,7 @@ public class WorkoutGpsParser extends XiaomiActivityParser { } AutoGpxExporter.doExport(context, gbDevice, summary, activityTrack); + AutoFitExporter.doExport(context, gbDevice, summary, activityTrack); return true; } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/GBPrefs.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/GBPrefs.java index 610d77f85e..0a9faf520f 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/GBPrefs.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/GBPrefs.java @@ -91,6 +91,12 @@ public class GBPrefs extends Prefs { public static final String AUTO_EXPORT_GPX_ALL_DEVICES = "gpx_auto_export_all_devices"; public static final String AUTO_EXPORT_GPX_SELECTED_DEVICES = "gpx_auto_export_selected_devices"; + // FIT export + public static final String AUTO_EXPORT_FIT_ENABLED = "fit_auto_export_enabled"; + public static final String AUTO_EXPORT_FIT_DIRECTORY = "fit_auto_export_directory"; + public static final String AUTO_EXPORT_FIT_ALL_DEVICES = "fit_auto_export_all_devices"; + public static final String AUTO_EXPORT_FIT_SELECTED_DEVICES = "fit_auto_export_selected_devices"; + // Intent API public static final String INTENT_API_BROADCAST_EXPORT_DB = "intent_api_broadcast_export"; public static final String INTENT_API_BROADCAST_EXPORT_ZIP = "intent_api_broadcast_zip_export"; diff --git a/app/src/main/res/menu/activity_take_screenshot_menu.xml b/app/src/main/res/menu/activity_take_screenshot_menu.xml index 955e114d41..a5ab3bd958 100644 --- a/app/src/main/res/menu/activity_take_screenshot_menu.xml +++ b/app/src/main/res/menu/activity_take_screenshot_menu.xml @@ -62,6 +62,12 @@ android:title="@string/activity_detail_upload_to_wanderer" app:showAsAction="never" /> + + Export GPX tracks from all devices Selected devices Choose which devices to export GPX tracks from + + Auto export FIT tracks + Automatically export FIT files when syncing activity tracks + Export FIT tracks from all devices + Choose which devices to export FIT tracks from Auto fetch Auto fetch activity data @@ -1661,6 +1666,8 @@ Other Trekking Trail run + Trail hike + MMA HIIT Wrestling Unknown activity Navigate @@ -2962,6 +2969,8 @@ Ground Contact Time Balance Step Speed Loss Step Speed Loss % + Stance Time % + Performance Condition Repetitions Revolutions Swings @@ -3003,6 +3012,8 @@ Duration Show GPS Track Share GPS Track + Share FIT File + Failed to export FIT file Inspect file Share Raw Summary Share Raw Details diff --git a/app/src/main/res/xml/auto_export_fit_settings.xml b/app/src/main/res/xml/auto_export_fit_settings.xml new file mode 100644 index 0000000000..f071926a47 --- /dev/null +++ b/app/src/main/res/xml/auto_export_fit_settings.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/xml/automations_settings.xml b/app/src/main/res/xml/automations_settings.xml index 128f4a66ea..18c001361c 100644 --- a/app/src/main/res/xml/automations_settings.xml +++ b/app/src/main/res/xml/automations_settings.xml @@ -28,6 +28,14 @@ android:targetPackage="@string/applicationId" /> + + + + diff --git a/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/export/FitExporterPaceTest.java b/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/export/FitExporterPaceTest.java new file mode 100644 index 0000000000..02b2d1c870 --- /dev/null +++ b/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/export/FitExporterPaceTest.java @@ -0,0 +1,318 @@ +/* Copyright (C) 2026 Dany Mestas + + This file is part of Gadgetbridge. + + Gadgetbridge is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Gadgetbridge is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . */ +package nodomain.freeyourgadget.gadgetbridge.export; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary; +import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind; +import nodomain.freeyourgadget.gadgetbridge.model.ActivityPoint; +import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryData; +import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries; +import nodomain.freeyourgadget.gadgetbridge.model.ActivityTrack; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.FitFile; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.RecordData; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitLap; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitSession; + +/** + * Covers the FitExporter Part A (avg_speed fallback) and Part B (rowing distance synth) + * paths added to address the Endurain rowing/running pace bug. + * + *

Tests build synthetic BaseActivitySummary + ActivityTrack inputs, run FitExporter + * against a temp file, then decode the FIT bytes via FitFile.parseIncoming and assert + * Session/Lap fields. No Android context, DB, or device is involved. + */ +public class FitExporterPaceTest { + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + /** Indoor rowing (erg) session: 9 segments (4 active 0x81 of ~256 strokes each, 5 rest + * 0x82 of ~5 strokes), totalling 1208 strokes / 2550 s — matches the user's 20-Apr + * workout. ActivityKind.ROWING_MACHINE → FIT (sport=15, sub_sport=14) → indoor + * 6.0 m/stroke default. */ + @Test + public void indoorRowingSession_synthesizesDistanceCyclesStrokeDistanceAvgSpeed() throws Exception { + final long start = 1776705018L; + final long elapsed = 2550L; + final BaseActivitySummary summary = newSummary(start, elapsed, ActivityKind.ROWING_MACHINE); + final ActivitySummaryData data = new ActivitySummaryData(); + // STROKES is normally populated by WorkoutSummaryParser.getRowingParser; mirror that. + data.add(ActivitySummaryEntries.STROKES, 1208, ActivitySummaryEntries.UNIT_STROKES); + data.add(ActivitySummaryEntries.HR_AVG, 142, ActivitySummaryEntries.UNIT_BPM); + data.add(ActivitySummaryEntries.CALORIES_BURNT, 214, ActivitySummaryEntries.UNIT_KCAL); + + final ActivityTrack track = newRowingTrack(start); + + final File out = tmp.newFile("rowing-indoor.fit"); + new FitExporter().performExport(track, summary, data, out); + + final FitFile fit = FitFile.parseIncoming(out); + final FitSession session = onlySession(fit); + final List laps = laps(fit); + + // ---- Session expectations ---- + assertNotNull("session.totalDistance", session.getTotalDistance()); + // 1208 strokes * 6.0 m/stroke = 7248 m. Getter returns raw meters (codec applies scale=100). + assertEquals(7248.0, session.getTotalDistance(), 0.5); + assertEquals(Long.valueOf(1208), session.getTotalCycles()); + assertNotNull("session.avgStrokeDistance", session.getAvgStrokeDistance()); + assertEquals(6.0f, session.getAvgStrokeDistance(), 0.01f); + assertNotNull("session.avgSpeed", session.getAvgSpeed()); + // 7248 / 2550 ≈ 2.842 m/s + assertEquals(2.842f, session.getAvgSpeed(), 0.005f); + // ActivityKind.ROWING_MACHINE → reverse-lookup picks ROW_INDOOR(15,14) — the + // modern Garmin code — over the legacy FITNESS_EQUIPMENT/INDOOR_ROWING(4,14). + // FitExporter.isRowingSport accepts both forms for the stroke→distance synth. + assertEquals(Integer.valueOf(15), session.getSport()); + assertEquals(Integer.valueOf(14), session.getSubSport()); + + // ---- Lap expectations: every active rowing lap has distance/cycles/speed set ---- + // Both FitLap.getTotalDistance() and FitSession.getTotalDistance() return raw + // meters (codec applies scale=100 on decode). + double sumLapDistanceMeters = 0; + for (final FitLap lap : laps) { + assertNotNull("lap distance", lap.getTotalDistance()); + sumLapDistanceMeters += lap.getTotalDistance(); + assertNotNull("lap cycles", lap.getTotalCycles()); + assertNotNull("lap avgStrokeDistance", lap.getAvgStrokeDistance()); + // FIT lap.avg_stroke_distance is Integer cm-scaled (600 = 6.0 m) + assertEquals(600, lap.getAvgStrokeDistance().intValue()); + // avg_speed must be set on every lap including rest laps (≈0 there). + assertNotNull("lap avgSpeed", lap.getAvgSpeed()); + } + // Sum of lap distances should equal session distance. + assertEquals(7248.0, sumLapDistanceMeters, 0.5); + } + + /** Outdoor (on-water) rowing uses a longer 9.0 m/stroke default. Same 1208/2550 input + * pinned to the ROWING (15, 0) FIT mapping to lock the indoor-vs-outdoor split. */ + @Test + public void outdoorRowingSession_usesLongerStrokeLengthDefault() throws Exception { + final long start = 1776705018L; + final long elapsed = 2550L; + final BaseActivitySummary summary = newSummary(start, elapsed, ActivityKind.ROWING); + final ActivitySummaryData data = new ActivitySummaryData(); + data.add(ActivitySummaryEntries.STROKES, 1208, ActivitySummaryEntries.UNIT_STROKES); + + final ActivityTrack track = newRowingTrack(start); + + final File out = tmp.newFile("rowing-outdoor.fit"); + new FitExporter().performExport(track, summary, data, out); + + final FitFile fit = FitFile.parseIncoming(out); + final FitSession session = onlySession(fit); + final List laps = laps(fit); + + // 1208 * 9.0 = 10872 m. Getter returns raw meters. + assertNotNull("session.totalDistance", session.getTotalDistance()); + assertEquals(10872.0, session.getTotalDistance(), 0.5); + assertEquals(9.0f, session.getAvgStrokeDistance(), 0.01f); + // 10872 / 2550 ≈ 4.264 m/s + assertEquals(4.264f, session.getAvgSpeed(), 0.005f); + assertEquals(Integer.valueOf(15), session.getSport()); + assertEquals(Integer.valueOf(0), session.getSubSport()); + + for (final FitLap lap : laps) { + assertNotNull("lap avgStrokeDistance", lap.getAvgStrokeDistance()); + assertEquals(900, lap.getAvgStrokeDistance().intValue()); + } + } + + /** + * Outdoor running with measured distance but no per-record speed (recorded summary + * SPEED_AVG=0). After fix, both lap and session avg_speed are derived from + * distance/elapsed so importers can show pace. + */ + @Test + public void outdoorRunningSession_derivesAvgSpeedFromDistanceAndTime() throws Exception { + final long start = 1777747544L; + final long elapsed = 496L; + final BaseActivitySummary summary = newSummary(start, elapsed, ActivityKind.OUTDOOR_RUNNING); + final ActivitySummaryData data = new ActivitySummaryData(); + data.add(ActivitySummaryEntries.DISTANCE_METERS, 386, ActivitySummaryEntries.UNIT_METERS); + // Watch reported SPEED_AVG=0 for the interval session — fallback should kick in. + data.add(ActivitySummaryEntries.SPEED_AVG, 0, ActivitySummaryEntries.UNIT_KMPH); + + final ActivityTrack track = singleSegmentTrack(start, elapsed); + + final File out = tmp.newFile("running.fit"); + new FitExporter().performExport(track, summary, data, out); + + final FitFile fit = FitFile.parseIncoming(out); + final FitSession session = onlySession(fit); + final List laps = laps(fit); + + // 386 / 496 ≈ 0.7782 m/s + assertNotNull("session.avgSpeed", session.getAvgSpeed()); + assertEquals(0.778f, session.getAvgSpeed(), 0.005f); + // Sport remapped via the OUTDOOR_RUNNING alias added earlier to GarminSport. + assertEquals(Integer.valueOf(1), session.getSport()); + + // Single-lap fallback path → exactly one lap with the same derivation. + assertEquals(1, laps.size()); + final FitLap lap = laps.get(0); + assertNotNull("lap.avgSpeed", lap.getAvgSpeed()); + assertEquals(0.778f, lap.getAvgSpeed(), 0.005f); + } + + /** + * Treadmill session that already has a non-zero summary SPEED_AVG (per-record speed + * existed): the fallback must NOT overwrite it. + */ + @Test + public void treadmillSession_keepsExistingNonZeroAvgSpeed() throws Exception { + final long start = 1777656743L; + final long elapsed = 2004L; + final BaseActivitySummary summary = newSummary(start, elapsed, ActivityKind.TREADMILL); + final ActivitySummaryData data = new ActivitySummaryData(); + data.add(ActivitySummaryEntries.DISTANCE_METERS, 5000, ActivitySummaryEntries.UNIT_METERS); + // 9 km/h — matches the 30-Apr treadmill workout's per-record speed avg. + data.add(ActivitySummaryEntries.SPEED_AVG, 9.0, ActivitySummaryEntries.UNIT_KMPH); + + final ActivityTrack track = singleSegmentTrack(start, elapsed); + + final File out = tmp.newFile("treadmill.fit"); + new FitExporter().performExport(track, summary, data, out); + + final FitFile fit = FitFile.parseIncoming(out); + final FitSession session = onlySession(fit); + + assertNotNull("session.avgSpeed", session.getAvgSpeed()); + // Existing 2.5 m/s (= 9 km/h) preserved, NOT replaced by 5000/2004 ≈ 2.495. + assertEquals(2.5f, session.getAvgSpeed(), 0.005f); + } + + /** + * Non-rowing distanceless workout (HIIT) — no synthesis should occur even though + * strokes/distance are absent. + */ + @Test + public void hiitSession_doesNotSynthesizeDistance() throws Exception { + final long start = 1777656743L; + final long elapsed = 1200L; + final BaseActivitySummary summary = newSummary(start, elapsed, ActivityKind.HIIT); + final ActivitySummaryData data = new ActivitySummaryData(); + data.add(ActivitySummaryEntries.HR_AVG, 150, ActivitySummaryEntries.UNIT_BPM); + + final File out = tmp.newFile("hiit.fit"); + new FitExporter().performExport(null, summary, data, out); + + final FitFile fit = FitFile.parseIncoming(out); + final FitSession session = onlySession(fit); + // No distance and no avg_speed should be emitted — HIIT genuinely has none. + assertNull("session.totalDistance", session.getTotalDistance()); + assertNull("session.avgSpeed", session.getAvgSpeed()); + } + + // ---------- helpers ---------- + + private static BaseActivitySummary newSummary(final long startSec, + final long elapsedSec, + final ActivityKind kind) { + final BaseActivitySummary s = new BaseActivitySummary(); + s.setStartTime(new Date(startSec * 1000L)); + s.setEndTime(new Date((startSec + elapsedSec) * 1000L)); + s.setActivityKind(kind.getCode()); + return s; + } + + /** Build a 9-segment rowing track matching the 20-Apr workout structure (4 active + 5 rest) + * with the same per-segment strokes (151+4+256+6+261+9+257+1+263 = 1208 total). */ + private static ActivityTrack newRowingTrack(final long startSec) { + final ActivityTrack track = new ActivityTrack(); + + final int[][] segs = { + // {durationSec, strokes, isActive(0x81 == 1)} + {372, 151, 1}, {16, 4, 0}, {461, 256, 1}, {114, 6, 0}, + {461, 261, 1}, {111, 9, 0}, {455, 257, 1}, {118, 1, 0}, {442, 263, 1}, + }; + + long ts = startSec; + boolean first = true; + for (final int[] s : segs) { + final ActivityTrack.SegmentInfo info = new ActivityTrack.SegmentInfo( + s[2] == 1 ? ActivityTrack.SegmentIntensity.ACTIVE : ActivityTrack.SegmentIntensity.REST, + null, + s[1]); + if (first) { + track.setCurrentSegmentInfo(info); + first = false; + } else { + track.startNewSegment(info); + } + // Add enough records that segments are not "tiny" (>= 5 records, >= 10s) so + // the FitExporter keeps them as separate laps. Use one record per second. + for (int i = 0; i < s[0]; i++) { + final ActivityPoint p = new ActivityPoint(new Date((ts + i) * 1000L)); + p.setHeartRate(140); + track.addTrackPoint(p); + } + ts += s[0]; + } + return track; + } + + /** Single-segment track with `nSec` heart-rate-only records — no per-record distance + * or speed. Forces the exporter to rely on summary fields and the new fallbacks. */ + private static ActivityTrack singleSegmentTrack(final long startSec, final long nSec) { + final ActivityTrack track = new ActivityTrack(); + track.setCurrentSegmentInfo(new ActivityTrack.SegmentInfo(ActivityTrack.SegmentIntensity.ACTIVE)); + for (long i = 0; i < nSec; i++) { + final ActivityPoint p = new ActivityPoint(new Date((startSec + i) * 1000L)); + p.setHeartRate(150); + track.addTrackPoint(p); + } + return track; + } + + private static FitSession onlySession(final FitFile fit) { + FitSession s = null; + for (final RecordData r : fit.getRecords()) { + if (r instanceof FitSession) { + if (s != null) { + throw new AssertionError("multiple sessions"); + } + s = (FitSession) r; + } + } + assertNotNull("no session record", s); + return s; + } + + private static List laps(final FitFile fit) { + final List out = new ArrayList<>(); + for (final RecordData r : fit.getRecords()) { + if (r instanceof FitLap) out.add((FitLap) r); + } + assertTrue("expected at least one lap", !out.isEmpty()); + return out; + } +} diff --git a/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/export/FitExporterReadmeRoundTripTest.java b/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/export/FitExporterReadmeRoundTripTest.java new file mode 100644 index 0000000000..9a3f91823f --- /dev/null +++ b/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/export/FitExporterReadmeRoundTripTest.java @@ -0,0 +1,758 @@ +/* Copyright (C) 2026 Dany Mestas + + This file is part of Gadgetbridge. + + Gadgetbridge is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Gadgetbridge is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . */ +package nodomain.freeyourgadget.gadgetbridge.export; + +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import org.junit.Ignore; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary; +import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind; +import nodomain.freeyourgadget.gadgetbridge.model.ActivityPoint; +import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryData; +import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries; +import nodomain.freeyourgadget.gadgetbridge.model.ActivityTrack; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.FieldDefinition; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.FitFile; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.RecordData; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.enums.GarminSport; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitFileId; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitLap; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitLength; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitRecord; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitSession; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitSet; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitSplit; + +/** + * Round-trip every FIT file referenced in + * {@code app/src/test/resources/FIT-test-files-main/README.md}: parse → build a minimal + * BaseActivitySummary + ActivityTrack from the original FitSession/FitRecord stream → + * export with FitExporter → re-parse the output and assert key fields are preserved. + * + *

Also logs a per-file summary of which messages and fields were present in the + * original (sport/subSport, manufacturer/product, record/lap/length/set/split counts) so + * the corpus can be reviewed for fields the exporter does not yet cover. + * + *

Pure JVM — no Robolectric, no Android context, no DB. + */ +@Ignore("requires FIT-test-files-main corpus, not committed to the repo — keep for local/future use") +public class FitExporterReadmeRoundTripTest { + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + private static final File README = new File( + "src/test/resources/FIT-test-files-main/README.md"); + private static final File CORPUS_ROOT = new File( + "src/test/resources/FIT-test-files-main"); + // README rows: | sport | subsport | [label](Activity/.../foo.fit) | + private static final Pattern README_LINK = Pattern.compile( + "\\(([^)\\s]+\\.fit)\\)"); + + @Test + public void roundTripAllReadmeFiles() throws Exception { + final List relPaths = readmeFitPaths(); + assertTrue("README must list at least one fit file", !relPaths.isEmpty()); + runRoundTripBatch(relPaths); + } + + /** Walks every {@code .fit} file under {@code Activity//} for years ≥ 2015 + * and round-trips it. Broader than {@link #roundTripAllReadmeFiles()} which only + * covers the curated README sample. Pre-2015 files (1989–2014) exercise legacy + * codec edge-cases unrelated to current devices and are skipped. */ + @Test + public void roundTripAllActivityFitFiles2015Onward() throws Exception { + final java.nio.file.Path activityRoot = new File(CORPUS_ROOT, "Activity").toPath(); + if (!java.nio.file.Files.exists(activityRoot)) { + // Test resource not present — skip silently. The README batch already + // runs against the curated subset. + return; + } + final java.util.regex.Pattern yearDir = java.util.regex.Pattern.compile( + "Activity/(\\d{4})/.*"); + final List relPaths = new ArrayList<>(); + try (java.util.stream.Stream walk = java.nio.file.Files.walk(activityRoot)) { + walk.filter(p -> p.toString().toLowerCase(java.util.Locale.ROOT).endsWith(".fit")) + .forEach(p -> { + final String rel = CORPUS_ROOT.toPath().relativize(p).toString().replace('\\', '/'); + final Matcher m = yearDir.matcher(rel); + if (m.matches() && Integer.parseInt(m.group(1)) >= 2015) { + relPaths.add(rel); + } + }); + } + assertTrue("Activity// must contain at least one fit file", !relPaths.isEmpty()); + runRoundTripBatch(relPaths); + } + + private void runRoundTripBatch(final List relPaths) throws Exception { + + final Map results = new LinkedHashMap<>(); + final List failures = new ArrayList<>(); + final List codecSkips = new ArrayList<>(); + final List noSessionSkips = new ArrayList<>(); + + for (final String rel : relPaths) { + final File in = new File(CORPUS_ROOT, rel); + if (!in.exists()) { + failures.add(rel + " — missing file"); + continue; + } + try { + final RoundTripStats stats = roundTrip(in, rel); + if (stats == null) { + noSessionSkips.add(rel); + continue; + } + results.put(rel, stats); + } catch (final nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.exception.FitParseException + | IllegalArgumentException + | NullPointerException + | java.nio.BufferUnderflowException codec) { + // Codec edge case in input file (unsupported BaseType, malformed + // record header, etc.) — orthogonal to exporter behaviour. Warn and + // continue; these surface as a separate corpus health stat. + codecSkips.add(rel + " — " + codec.getClass().getSimpleName() + + ": " + codec.getMessage()); + } catch (final Throwable t) { + failures.add(rel + " — " + t.getClass().getSimpleName() + ": " + t.getMessage()); + } + } + + // Print per-file stats so the test output documents the corpus coverage. + System.out.println("=== ReadmeRoundTrip results (" + results.size() + " files) ==="); + for (final Map.Entry e : results.entrySet()) { + System.out.println(e.getKey() + " → " + e.getValue()); + } + if (!codecSkips.isEmpty()) { + System.out.println("=== Codec-skip (" + codecSkips.size() + " files, parser limitation) ==="); + for (final String s : codecSkips) System.out.println(" - " + s); + } + if (!noSessionSkips.isEmpty()) { + System.out.println("=== No-session skip (" + noSessionSkips.size() + + " files, sensor-only / tracker-upload, exporter-NA) ==="); + for (final String s : noSessionSkips) System.out.println(" - " + s); + } + + if (!failures.isEmpty()) { + final StringBuilder sb = new StringBuilder("Round-trip failures:\n"); + for (final String f : failures) sb.append(" - ").append(f).append('\n'); + fail(sb.toString()); + } + } + + // ---------- core round-trip ---------- + + private RoundTripStats roundTrip(final File in, final String relPath) throws Exception { + final FitFile inFit = FitFile.parseIncoming(in); + + FitFileId fileId = null; + FitSession session = null; + final List records = new ArrayList<>(); + final List laps = new ArrayList<>(); + final List lengths = new ArrayList<>(); + final List splits = new ArrayList<>(); + final List setMessages = new ArrayList<>(); + // Field-number tally per message type — schema seen in input vs schema emitted + // by the exporter. Surfaces device-vendor field-encoding gaps. + final java.util.Set inSessionFields = new java.util.TreeSet<>(); + final java.util.Set inLapFields = new java.util.TreeSet<>(); + final java.util.Set inRecordFields = new java.util.TreeSet<>(); + for (final RecordData r : inFit.getRecords()) { + if (r instanceof FitFileId) fileId = (FitFileId) r; + else if (r instanceof FitSession) { + if (session == null) session = (FitSession) r; // first-only (multi-session not supported) + collectFieldNumbers(r, inSessionFields); + } else if (r instanceof FitRecord) { + records.add((FitRecord) r); + collectFieldNumbers(r, inRecordFields); + } else if (r instanceof FitLap) { + laps.add((FitLap) r); + collectFieldNumbers(r, inLapFields); + } else if (r instanceof FitLength) lengths.add((FitLength) r); + else if (r instanceof FitSplit) splits.add((FitSplit) r); + else if (r instanceof FitSet) setMessages.add((FitSet) r); + } + // Sensor-only / tracker-upload files (e.g. Vivosmart sleep, Edge cycling + // computer GPX-only logs) carry no session record. Exporter cannot produce + // a session-keyed FIT for them — surface as a separate skip stat rather + // than a failure. + if (session == null || fileId == null) return null; + + final RoundTripStats stats = new RoundTripStats(); + stats.inSize = in.length(); + stats.manufacturer = fileId.getManufacturer(); + stats.product = fileId.getProduct(); + stats.sport = session.getSport(); + stats.subSport = session.getSubSport(); + stats.numRecords = records.size(); + stats.numLaps = laps.size(); + stats.numLengths = lengths.size(); + stats.numSplits = splits.size(); + stats.numSets = setMessages.size(); + + // Build the export inputs from the parsed file. + final BaseActivitySummary summary = buildSummaryFromSession(session, in.getName()); + final ActivityTrack track = buildTrackFromRecordsAndLaps(records, laps); + // Pass lengths/splits/sets through so the exporter can re-emit them on + // round-trip. Mirrors what FitActivityTrackProvider does for the production + // import path. + for (final FitLength len : lengths) { + final Long startTime = len.getStartTime(); + if (startTime == null) continue; + track.addLength(new ActivityTrack.LengthInfo( + startTime, + len.getTotalElapsedTime() != null ? len.getTotalElapsedTime() : 0.0, + len.getTotalTimerTime() != null ? len.getTotalTimerTime() : 0.0, + len.getTotalStrokes(), + len.getAvgSpeed(), + len.getSwimStroke(), + len.getLengthType(), + len.getAvgSwimmingCadence())); + } + for (final FitSplit sp : splits) { + final Long startTime = sp.getStartTime(); + if (startTime == null) continue; + track.addSplit(new ActivityTrack.SplitInfo( + startTime, + sp.getEndTime(), + sp.getSplitType(), + sp.getTotalElapsedTime(), + sp.getTotalTimerTime(), + sp.getTotalDistance(), + sp.getAvgSpeed(), + sp.getMaxSpeed(), + sp.getTotalAscent(), + sp.getTotalDescent(), + sp.getTotalCalories(), + sp.getStartElevation(), + sp.getStartPositionLat(), + sp.getStartPositionLong(), + sp.getEndPositionLat(), + sp.getEndPositionLong())); + } + for (final FitSet sm : setMessages) { + final Long startTime = sm.getStartTime(); + if (startTime == null) continue; + track.addSet(new ActivityTrack.SetInfo( + startTime, + sm.getDuration(), + sm.getRepetitions(), + sm.getWeight(), + sm.getSetType(), + sm.getWeightDisplayUnit(), + sm.getMessageIndex())); + } + final ActivitySummaryData data = buildSummaryDataFromSession(session); + + // Export. Hash the relPath into the temp filename — the same basename + // (e.g. Lap-Swimming-Fenix6x.fit) appears under multiple year dirs in the + // corpus; without a hash the second one collides with TemporaryFolder. + final String tmpName = in.getName() + "." + + Integer.toHexString(relPath.hashCode()) + ".out.fit"; + final File out = tmp.newFile(tmpName); + new FitExporter().performExport(track, summary, data, out); + stats.outSize = out.length(); + + // Re-parse and assert the key fields survived. + final FitFile outFit = FitFile.parseIncoming(out); + FitSession outSession = null; + int outRecords = 0; + int outLaps = 0; + int outLengths = 0; + int outSplits = 0; + int outSets = 0; + final java.util.Set outSessionFields = new java.util.TreeSet<>(); + final java.util.Set outLapFields = new java.util.TreeSet<>(); + final java.util.Set outRecordFields = new java.util.TreeSet<>(); + for (final RecordData r : outFit.getRecords()) { + if (r instanceof FitSession) { + if (outSession == null) outSession = (FitSession) r; + collectFieldNumbers(r, outSessionFields); + } else if (r instanceof FitRecord) { + outRecords++; + collectFieldNumbers(r, outRecordFields); + } else if (r instanceof FitLap) { + outLaps++; + collectFieldNumbers(r, outLapFields); + } else if (r instanceof FitLength) { + outLengths++; + } else if (r instanceof FitSplit) { + outSplits++; + } else if (r instanceof FitSet) { + outSets++; + } + } + stats.outLengths = outLengths; + stats.outSplits = outSplits; + stats.outSets = outSets; + stats.fieldDiff = formatFieldDiff(inSessionFields, outSessionFields, + inLapFields, outLapFields, + inRecordFields, outRecordFields); + assertNotNull("exported file has no session: " + in.getName(), outSession); + // The exporter always emits at least one lap and (when a track is present) one + // record per timestamped point. Tiny segments may be merged into the first lap. + assertTrue("exported file has no laps: " + in.getName(), outLaps >= 1); + assertTrue("output should be < 5x input size: " + in.getName() + " in=" + in.length() + " out=" + out.length(), + out.length() < Math.max(in.length() * 5L, 32_768L)); + + // Sport/subSport: log mismatches instead of failing. The ActivityKind ↔ GarminSport + // round-trip is not 1:1 (multiple FIT codes alias to one ActivityKind, e.g. sport=0 + // sub=15 ELLIPTICAL_TRAINER and sport=4 sub=15 FITNESS_EQUIPMENT both → ELLIPTICAL). + // These mapping gaps are tracked separately; the round-trip itself still works. + if (!java.util.Objects.equals(session.getSport(), outSession.getSport()) + || !java.util.Objects.equals(session.getSubSport(), outSession.getSubSport())) { + stats.sportRemap = "in=" + session.getSport() + "/" + session.getSubSport() + + " out=" + outSession.getSport() + "/" + outSession.getSubSport(); + } + + // Per-track preservation: pick a stride of input records and verify the matching + // output record (by timestamp) preserves the meaningful fields the parser exposed. + verifyTrackPreservation(records, outFit, in.getName(), stats); + + // Session aggregate preservation: distance, hr, speed, calories. + verifySessionPreservation(session, outSession, in.getName(), stats); + + stats.outRecords = outRecords; + stats.outLaps = outLaps; + return stats; + } + + /** Pick up to ~30 input records spread across the activity and assert the + * corresponding output record (matched by timestamp) preserves the fields that + * drive Strava/Endurain rendering: GPS, altitude, speed, HR, cadence, distance, + * power, temperature. Allows ±1 unit tolerance for re-encoded floats. */ + private static void verifyTrackPreservation(final List inRecords, + final FitFile outFit, + final String fileName, + final RoundTripStats stats) { + // Index output records by timestamp. + final Map outByTs = new LinkedHashMap<>(); + for (final RecordData r : outFit.getRecords()) { + if (r instanceof FitRecord rec) { + final Long ts = rec.getComputedTimestamp(); + if (ts != null) outByTs.putIfAbsent(ts, rec); + } + } + + // FitExporter dedups records sharing a 1s timestamp (some sources emit + // multiple records per second). Mirror that here so sampled input records + // correspond to what the exporter actually kept; otherwise a sample landing + // on the 2nd-of-pair would never find a matching output. + final List uniqueIn = new ArrayList<>(inRecords.size()); + long lastTs = Long.MIN_VALUE; + for (final FitRecord r : inRecords) { + final Long ts = r.getComputedTimestamp(); + if (ts == null || ts == lastTs) continue; + uniqueIn.add(r); + lastTs = ts; + } + final int sampleCount = Math.min(30, uniqueIn.size()); + if (sampleCount == 0) return; + final int stride = Math.max(1, uniqueIn.size() / sampleCount); + + int gpsHits = 0, gpsTotal = 0; + int altHits = 0, altTotal = 0; + int hrHits = 0, hrTotal = 0; + int spdHits = 0, spdTotal = 0; + int cadHits = 0, cadTotal = 0; + int distHits = 0, distTotal = 0; + int pwrHits = 0, pwrTotal = 0; + int tempHits = 0, tempTotal = 0; + + for (int i = 0; i < uniqueIn.size(); i += stride) { + final FitRecord inRec = uniqueIn.get(i); + final Long ts = inRec.getComputedTimestamp(); + if (ts == null) continue; + final FitRecord outRec = outByTs.get(ts); + if (outRec == null) continue; // dedup may drop duplicates — skip silently + + // GPS — both lat & lon must round-trip. FIT semicircle scale is exact, + // so equality is OK after both encodes use the same scale. + if (inRec.getLatitude() != null && inRec.getLongitude() != null) { + gpsTotal++; + if (inRec.getLatitude().equals(outRec.getLatitude()) + && inRec.getLongitude().equals(outRec.getLongitude())) { + gpsHits++; + } + } + // Altitude — input may use altitude OR enhanced_altitude; we always export + // enhanced_altitude. Compare whichever the input had against the same + // *canonicalised* meters value the parser computes. + final Double inAlt = inRec.getEnhancedAltitude() != null + ? inRec.getEnhancedAltitude() + : (inRec.getAltitude() != null ? inRec.getAltitude().doubleValue() : null); + if (inAlt != null) { + altTotal++; + final Double outAlt = outRec.getEnhancedAltitude() != null + ? outRec.getEnhancedAltitude() + : (outRec.getAltitude() != null ? outRec.getAltitude().doubleValue() : null); + if (outAlt != null && Math.abs(outAlt - inAlt) <= 0.5) altHits++; + } + // HR — integer bpm. Some recorders (Zwift virtual rides without a strap) + // write hr=0 as a sentinel for "no measurement"; FitExporter drops zeros + // explicitly. Mirror that — only count records with a real HR sample. + if (inRec.getHeartRate() != null && inRec.getHeartRate() > 0) { + hrTotal++; + if (java.util.Objects.equals(inRec.getHeartRate(), outRec.getHeartRate())) hrHits++; + } + // Speed — input may use speed OR enhanced_speed; exporter writes enhanced_speed. + final Double inSpd = inRec.getEnhancedSpeed() != null + ? inRec.getEnhancedSpeed() + : (inRec.getSpeed() != null ? inRec.getSpeed().doubleValue() : null); + if (inSpd != null) { + spdTotal++; + final Double outSpd = outRec.getEnhancedSpeed() != null + ? outRec.getEnhancedSpeed() + : (outRec.getSpeed() != null ? outRec.getSpeed().doubleValue() : null); + if (outSpd != null && Math.abs(outSpd - inSpd) <= 0.05) spdHits++; + } + // Cadence: same sensor-absent sentinel as HR — when the source has no + // cadence sensor it writes 0; FitExporter now drops cadence on tracks where + // every sample is 0. Only count records with a real measurement. + if (inRec.getCadence() != null && inRec.getCadence() > 0) { + cadTotal++; + if (java.util.Objects.equals(inRec.getCadence(), outRec.getCadence())) cadHits++; + } + if (inRec.getDistance() != null) { + distTotal++; + if (outRec.getDistance() != null + && Math.abs(outRec.getDistance() - inRec.getDistance()) <= 1.0) { + distHits++; + } + } + // Power: same sentinel handling. + if (inRec.getPower() != null && inRec.getPower() > 0) { + pwrTotal++; + if (java.util.Objects.equals(inRec.getPower(), outRec.getPower())) pwrHits++; + } + if (inRec.getTemperature() != null) { + tempTotal++; + if (java.util.Objects.equals(inRec.getTemperature(), outRec.getTemperature())) tempHits++; + } + } + + stats.gpsKept = pct(gpsHits, gpsTotal); + stats.altKept = pct(altHits, altTotal); + stats.hrKept = pct(hrHits, hrTotal); + stats.spdKept = pct(spdHits, spdTotal); + stats.cadKept = pct(cadHits, cadTotal); + stats.distKept = pct(distHits, distTotal); + stats.pwrKept = pct(pwrHits, pwrTotal); + stats.tempKept = pct(tempHits, tempTotal); + + // Hard assertions — values that are present in input must round-trip on the + // sampled records. Tolerate the very rare dedup miss by requiring ≥ 90%. + final List regressions = new ArrayList<>(); + if (gpsTotal >= 5 && gpsHits * 100 / gpsTotal < 90) regressions.add("gps " + stats.gpsKept); + if (altTotal >= 5 && altHits * 100 / altTotal < 90) regressions.add("alt " + stats.altKept); + if (hrTotal >= 5 && hrHits * 100 / hrTotal < 90) regressions.add("hr " + stats.hrKept); + if (spdTotal >= 5 && spdHits * 100 / spdTotal < 90) regressions.add("spd " + stats.spdKept); + if (cadTotal >= 5 && cadHits * 100 / cadTotal < 90) regressions.add("cad " + stats.cadKept); + if (distTotal >= 5 && distHits * 100 / distTotal < 90) regressions.add("dist " + stats.distKept); + if (pwrTotal >= 5 && pwrHits * 100 / pwrTotal < 90) regressions.add("pwr " + stats.pwrKept); + if (tempTotal >= 5 && tempHits * 100 / tempTotal < 90) regressions.add("temp " + stats.tempKept); + if (!regressions.isEmpty()) { + throw new AssertionError(fileName + " field preservation regressions: " + regressions); + } + } + + /** Re-parse session aggregates and compare against the input session. */ + private static void verifySessionPreservation(final FitSession in, + final FitSession out, + final String fileName, + final RoundTripStats stats) { + final List mismatches = new ArrayList<>(); + // Distance preservation: getter returns raw meters (codec applies scale=100). + // Skip when input is 0 (sentinel for "not measured" — common on rowing / + // strength / indoor sessions where the device wrote no distance but the + // exporter may legitimately derive a value from per-record GPS or strokes). + if (in.getTotalDistance() != null && out.getTotalDistance() != null + && in.getTotalDistance() > 0) { + final double inM = in.getTotalDistance(); + final double outM = out.getTotalDistance(); + // Accept ±1 m or 0.5% — exporter rounds via Math.round. + if (Math.abs(outM - inM) > Math.max(1.0, inM * 0.005)) { + mismatches.add("distance in=" + inM + "m out=" + outM + "m"); + } + stats.sessionDistanceM = outM; + } + // HR. Skip when input avg=0 (no strap → sentinel; some devices record + // record-level HR but never aggregate to session — exporter computes one). + if (in.getAverageHeartRate() != null && out.getAverageHeartRate() != null + && in.getAverageHeartRate() > 0 + && !in.getAverageHeartRate().equals(out.getAverageHeartRate())) { + mismatches.add("avgHr in=" + in.getAverageHeartRate() + " out=" + out.getAverageHeartRate()); + } + // Avg speed: input scale is FIT m/s × 1000; we round-trip via Float. + // Exporter intentionally re-derives avg_speed from distance/elapsed when the + // source recorded 0 (devices without per-record speed) — see pace-fallback in + // FitExporter.buildSession. Skip the comparison in that case. + if (in.getAvgSpeed() != null && out.getAvgSpeed() != null + && in.getAvgSpeed() > 0.001f + && Math.abs(in.getAvgSpeed() - out.getAvgSpeed()) > 0.05) { + mismatches.add("avgSpeed in=" + in.getAvgSpeed() + " out=" + out.getAvgSpeed()); + } + // Calories. Skip when input=0 — exporter may compute one from track data. + if (in.getTotalCalories() != null && out.getTotalCalories() != null + && in.getTotalCalories() > 0 + && !in.getTotalCalories().equals(out.getTotalCalories())) { + mismatches.add("cal in=" + in.getTotalCalories() + " out=" + out.getTotalCalories()); + } + if (!mismatches.isEmpty()) { + throw new AssertionError(fileName + " session aggregate regressions: " + mismatches); + } + } + + private static String pct(final int hits, final int total) { + if (total == 0) return "—"; + return hits + "/" + total + " (" + (hits * 100 / total) + "%)"; + } + + /** Add every FieldDefinition number from a parsed record's definition into the + * given set. The same field may appear across many records of the same type; + * collecting into a Set yields the schema actually emitted by the source. */ + private static void collectFieldNumbers(final RecordData r, final java.util.Set dst) { + if (r.getRecordDefinition() == null) return; + final java.util.List defs = r.getRecordDefinition().getFieldDefinitions(); + if (defs == null) return; + for (final FieldDefinition fd : defs) { + dst.add(fd.getNumber()); + } + } + + /** Build a one-line "in vs out" field-number diff for session/lap/record. The + * in-only side surfaces device-emitted fields the exporter does not yet write; + * the out-only side flags fields the exporter adds (e.g. enhanced_altitude on + * records when source only had legacy altitude). */ + private static String formatFieldDiff(final java.util.Set inSession, final java.util.Set outSession, + final java.util.Set inLap, final java.util.Set outLap, + final java.util.Set inRec, final java.util.Set outRec) { + return "session{" + diff(inSession, outSession) + "} lap{" + diff(inLap, outLap) + + "} record{" + diff(inRec, outRec) + "}"; + } + + private static String diff(final java.util.Set in, final java.util.Set out) { + final java.util.Set inOnly = new java.util.TreeSet<>(in); + inOnly.removeAll(out); + final java.util.Set outOnly = new java.util.TreeSet<>(out); + outOnly.removeAll(in); + return "in-only=" + inOnly + " out-only=" + outOnly; + } + + private static BaseActivitySummary buildSummaryFromSession(final FitSession session, + final String fileName) { + final BaseActivitySummary s = new BaseActivitySummary(); + // FitSession.startTime is FIT epoch seconds (timestamp_t base), already absolute + // unix seconds — codec subtracts the Garmin epoch on decode. + final Long startSec = session.getStartTime(); + final long startMs = startSec != null ? startSec * 1000L : 0L; + s.setStartTime(new Date(startMs)); + // total_elapsed_time is stored unscaled (raw uint32 = ms per FIT spec scale=1000). + final Long elapsedMs = session.getTotalElapsedTime(); + s.setEndTime(new Date(startMs + (elapsedMs != null ? elapsedMs : 0L))); + s.setName(fileName); + final ActivityKind kind = mapSportToKind(session.getSport(), session.getSubSport()); + s.setActivityKind(kind.getCode()); + return s; + } + + private static ActivityKind mapSportToKind(final Integer sport, final Integer subSport) { + if (sport == null) return ActivityKind.UNKNOWN; + final Optional gs = GarminSport.fromCodes(sport, subSport != null ? subSport : 0); + return gs.map(GarminSport::getActivityKind).orElse(ActivityKind.UNKNOWN); + } + + private static ActivityTrack buildTrackFromRecordsAndLaps(final List records, + final List laps) { + final ActivityTrack track = new ActivityTrack(); + track.setCurrentSegmentInfo(new ActivityTrack.SegmentInfo(ActivityTrack.SegmentIntensity.ACTIVE)); + // Lap boundaries: split records into segments by lap.startTime monotone walk. + final long[] lapBoundaries = laps.stream() + .map(FitLap::getStartTime) + .filter(java.util.Objects::nonNull) + .mapToLong(Long::longValue) + .sorted() + .toArray(); + int boundaryIdx = 0; + boolean firstSeg = true; + for (final FitRecord rec : records) { + final ActivityPoint p = rec.toActivityPoint(); + // Cross next lap boundary → start a new segment. + while (boundaryIdx < lapBoundaries.length + && p.getTime() != null + && p.getTime().getTime() / 1000L >= lapBoundaries[boundaryIdx]) { + if (!firstSeg) { + track.startNewSegment(new ActivityTrack.SegmentInfo(ActivityTrack.SegmentIntensity.ACTIVE)); + } + firstSeg = false; + boundaryIdx++; + } + track.addTrackPoint(p); + } + return track; + } + + /** Translate a small but useful subset of FitSession aggregate fields back into + * ActivitySummaryData entries so FitExporter populates the same lap/session + * aggregates on the way out. Mirrors the fields GarminWorkoutParser emits. */ + private static ActivitySummaryData buildSummaryDataFromSession(final FitSession session) { + final ActivitySummaryData d = new ActivitySummaryData(); + // Getter returns raw meters (codec applies scale=100 on decode). + final Double totalDistance = session.getTotalDistance(); + if (totalDistance != null) { + d.add(ActivitySummaryEntries.DISTANCE_METERS, + totalDistance, + ActivitySummaryEntries.UNIT_METERS); + } + if (session.getTotalCalories() != null) { + d.add(ActivitySummaryEntries.CALORIES_BURNT, + session.getTotalCalories(), + ActivitySummaryEntries.UNIT_KCAL); + } + if (session.getAvgSpeed() != null) { + d.add(ActivitySummaryEntries.SPEED_AVG, + session.getAvgSpeed(), + ActivitySummaryEntries.UNIT_METERS_PER_SECOND); + } + if (session.getMaxSpeed() != null) { + d.add(ActivitySummaryEntries.SPEED_MAX, + session.getMaxSpeed(), + ActivitySummaryEntries.UNIT_METERS_PER_SECOND); + } + if (session.getAverageHeartRate() != null) { + d.add(ActivitySummaryEntries.HR_AVG, + session.getAverageHeartRate(), + ActivitySummaryEntries.UNIT_BPM); + } + if (session.getMaxHeartRate() != null) { + d.add(ActivitySummaryEntries.HR_MAX, + session.getMaxHeartRate(), + ActivitySummaryEntries.UNIT_BPM); + } + if (session.getAvgCadence() != null) { + d.add(ActivitySummaryEntries.CADENCE_AVG, + session.getAvgCadence(), + ActivitySummaryEntries.UNIT_NONE); + } + if (session.getMaxCadence() != null) { + d.add(ActivitySummaryEntries.CADENCE_MAX, + session.getMaxCadence(), + ActivitySummaryEntries.UNIT_NONE); + } + if (session.getTotalAscent() != null) { + d.add(ActivitySummaryEntries.ASCENT_METERS, + session.getTotalAscent(), + ActivitySummaryEntries.UNIT_METERS); + } + if (session.getTotalDescent() != null) { + d.add(ActivitySummaryEntries.DESCENT_METERS, + session.getTotalDescent(), + ActivitySummaryEntries.UNIT_METERS); + } + return d; + } + + // ---------- README parsing ---------- + + private static List readmeFitPaths() throws Exception { + final String contents = new String(Files.readAllBytes(README.toPath()), StandardCharsets.UTF_8); + final List out = new ArrayList<>(); + final Matcher m = README_LINK.matcher(contents); + while (m.find()) { + final String path = m.group(1); + // Skip any non-Activity links (e.g. AllFitMessageTypes.fit lives at root). + // Keep only relative paths into the corpus. + if (path.startsWith("Activity/")) { + out.add(path); + } + } + return out; + } + + // ---------- stats ---------- + + private static final class RoundTripStats { + long inSize; + long outSize; + Integer manufacturer; + Integer product; + Integer sport; + Integer subSport; + int numRecords; + int numLaps; + int numLengths; + int numSplits; + int numSets; + int outRecords; + int outLaps; + int outLengths; + int outSplits; + int outSets; + String sportRemap; + String fieldDiff = ""; + String gpsKept = "—"; + String altKept = "—"; + String hrKept = "—"; + String spdKept = "—"; + String cadKept = "—"; + String distKept = "—"; + String pwrKept = "—"; + String tempKept = "—"; + Double sessionDistanceM; + + @Override + public String toString() { + final StringBuilder sb = new StringBuilder(); + sb.append("in=").append(inSize).append("B out=").append(outSize).append("B") + .append(" mfr=").append(manufacturer).append(" prod=").append(product) + .append(" sport=").append(sport).append('/').append(subSport) + .append(" in[rec=").append(numRecords).append(",lap=").append(numLaps) + .append(",len=").append(numLengths).append(",split=").append(numSplits) + .append(",set=").append(numSets).append(']') + .append(" out[rec=").append(outRecords).append(",lap=").append(outLaps) + .append(",len=").append(outLengths) + .append(",split=").append(outSplits) + .append(",set=").append(outSets).append(']'); + sb.append(" preserved[gps=").append(gpsKept).append(" alt=").append(altKept) + .append(" hr=").append(hrKept).append(" spd=").append(spdKept) + .append(" cad=").append(cadKept).append(" dist=").append(distKept) + .append(" pwr=").append(pwrKept).append(" temp=").append(tempKept).append(']'); + if (sportRemap != null) sb.append(" sportRemap[").append(sportRemap).append(']'); + if (fieldDiff != null && !fieldDiff.isEmpty()) sb.append("\n fieldDiff: ").append(fieldDiff); + return sb.toString(); + } + } +} diff --git a/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/baseTypes/BaseTypeEncodeRoundTripTest.java b/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/baseTypes/BaseTypeEncodeRoundTripTest.java new file mode 100644 index 0000000000..22ab8fe1c4 --- /dev/null +++ b/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/baseTypes/BaseTypeEncodeRoundTripTest.java @@ -0,0 +1,146 @@ +/* Copyright (C) 2026 Dany Mestas + + This file is part of Gadgetbridge. + + Gadgetbridge is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Gadgetbridge is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . */ +package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.baseTypes; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +/** + * Round-trip tests for the FIT base-type encode path. + * + *

Background: commit {@code f2f6536ea} (Aug 2024) introduced the spec-correct + * {@code stored = (physical + offset) * scale} formula in {@code BaseTypeShort/Int/Byte + * .encode}, but kept an {@code intValue()} / {@code longValue()} cast that truncated + * fractional {@code Float} / {@code Double} inputs before applying the scale multiplier. + * Commit {@code fd1e81ff6} (Aug 2024) fixed the symmetric truncation on the decode path + * but missed the encode side. The result: every fractional value passed to a scaled + * UINT8/16/32/64 field was silently rounded toward zero on disk. + * + *

For example, on a UINT16 scale=1000 field (FIT {@code session.avg_speed}): + * {@code setAvgSpeed(2.5f)} → encoded {@code (int)2.5 * 1000 = 2000} → decoded {@code 2.0} + * instead of {@code 2.5}. {@code setAvgSpeed(0.778f)} → encoded {@code 0} → decoded {@code 0}. + * + *

This test class encodes a fractional value, decodes it back through the same + * BaseType, and asserts the value survives the round-trip. Each assertion fails + * deterministically against the pre-fix encoder (the decoded value is the truncated + * integer rather than the original fractional value) and passes against the fixed + * encoder. The tests therefore double as regression guards. + */ +public class BaseTypeEncodeRoundTripTest { + + private static double roundTrip(final BaseType type, final Object physical, + final double scale, final int offset) { + final ByteBuffer enc = ByteBuffer.allocate(16).order(ByteOrder.LITTLE_ENDIAN); + type.encode(enc, physical, scale, offset); + enc.flip(); + final Object decoded = type.decode(enc, scale, offset); + return ((Number) decoded).doubleValue(); + } + + // ---------------- UINT16 (BaseTypeShort) ---------------- + + @Test + public void uint16_avgSpeedScale1000_preservesHalfMs() { + // Real bug-bait: FIT session.avg_speed (UINT16 scale=1000 unit=m/s). + // Pre-fix encoder: (int)2.5 * 1000 = 2000 stored → 2.0 decoded. + assertEquals(2.5, roundTrip(BaseType.UINT16, 2.5f, 1000, 0), 0.0005); + } + + @Test + public void uint16_avgSpeedScale1000_preservesSlowSubMeterPerSecond() { + // Pre-fix: (int)0.778 * 1000 = 0 → decoded 0.0 (the running-pace bug). + assertEquals(0.778, roundTrip(BaseType.UINT16, 0.778f, 1000, 0), 0.001); + } + + @Test + public void uint16_avgStrokeDistanceScale100_preservesQuarterMeter() { + // FIT session.avg_stroke_distance (UINT16 scale=100 unit=m). + // Pre-fix: (int)2.5 * 100 = 200 → decoded 2.0. + assertEquals(2.5, roundTrip(BaseType.UINT16, 2.5f, 100, 0), 0.005); + } + + @Test + public void uint16_altitudeScale5Offset500_preservesHalfMeter() { + // FIT record.altitude (UINT16 scale=5 offset=500). + // Encoder: (123.5 + 500) * 5 = 3117.5 → stored 3117. Pre-fix: ((int)123.5+500)*5 = 3115. + // Decoded: 3117/5 - 500 = 123.4. Pre-fix: 3115/5 - 500 = 123. + assertEquals(123.4, roundTrip(BaseType.UINT16, 123.5, 5, 500), 0.05); + } + + @Test + public void uint16_integerInputUnchanged() { + // Integer/Long inputs should be unaffected by the doubleValue() change. + assertEquals(1500.0, roundTrip(BaseType.UINT16, 1500, 1, 0), 0.0001); + assertEquals(2.0, roundTrip(BaseType.UINT16, 2, 1000, 0), 0.0001); + } + + // ---------------- UINT32 (BaseTypeInt) ---------------- + + @Test + public void uint32_speedScale1000_preservesHalfMs() { + // FIT enhanced_avg_speed (UINT32 scale=1000 unit=m/s). + // Pre-fix: (long)3.7 * 1000 = 3000 → 3.0. + assertEquals(3.7, roundTrip(BaseType.UINT32, 3.7f, 1000, 0), 0.0005); + } + + @Test + public void uint32_distanceScale100_preservesHalfMeter() { + // FIT lap.total_distance (UINT32 scale=100 unit=m). + // Pre-fix: (long)1234.5 * 100 = 123400 → 1234.0. Fix: 123450 → 1234.5. + assertEquals(1234.5, roundTrip(BaseType.UINT32, 1234.5, 100, 0), 0.005); + } + + @Test + public void uint32_integerInputUnchanged() { + assertEquals(38600.0, roundTrip(BaseType.UINT32, 38600L, 1, 0), 0.0001); + } + + // ---------------- UINT8 (BaseTypeByte) ---------------- + + @Test + public void uint8_scale10_preservesTenth() { + // FIT pct fields (UINT8 scale=10 unit=%). + // Pre-fix: (int)5.5 * 10 = 50 → 5.0. Fix: 55 → 5.5. + assertEquals(5.5, roundTrip(BaseType.UINT8, 5.5f, 10, 0), 0.05); + } + + @Test + public void uint8_integerInputUnchanged() { + assertEquals(75.0, roundTrip(BaseType.UINT8, 75, 1, 0), 0.0001); + } + + // ---------------- SINT16 ---------------- + + @Test + public void sint16_negativeFractional_preserved() { + // Negative fractional. Pre-fix: (int)-1.5 = -1, *100 = -100 → -1.0. Fix: -150 → -1.5. + assertEquals(-1.5, roundTrip(BaseType.SINT16, -1.5f, 100, 0), 0.005); + } + + // ---------------- UINT64 (BaseTypeLong) ---------------- + // BaseTypeLong shares the same encoder pattern. No production fields with non-1 + // scale currently use it, so this test guards against future regressions only. + + @Test + public void uint64_integerInputUnchanged() { + assertEquals(123456789L, ((Number) roundTrip(BaseType.UINT64, 123456789L, 1, 0)).longValue()); + } +} diff --git a/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/enums/GarminSportTest.java b/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/enums/GarminSportTest.java index 26b7e223d3..16d2e4a412 100644 --- a/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/enums/GarminSportTest.java +++ b/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/enums/GarminSportTest.java @@ -1,5 +1,3 @@ -package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.enums; - /* Copyright (C) 2024-2026 José Rebelo, Thomas Kuehne This file is part of Gadgetbridge. @@ -16,6 +14,9 @@ package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.enums; You should have received a copy of the GNU Affero General Public License along with this program. If not, see . */ +package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.enums; + +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; import android.util.Pair; @@ -58,4 +59,29 @@ public class GarminSportTest extends TestBase { } } } + + @Test + public void outdoorAliasesFallBackToGenericSport() { + // Xiaomi-specific OUTDOOR_* kinds had no GarminSport entry, causing exports to fall + // back to GENERIC (sport=0) — third-party importers showed these as unrecognised + // "workout" sessions. They now resolve onto the matching street sub-variant + // (running/walking/cycling sport with subtype=2 STREET), which keeps the FIT sport + // code recognised instead of generic. OUTDOOR_RUNNING has no direct entry and is + // aliased to STREET_RUNNING (STREET_RUN 1/2); OUTDOOR_WALKING / OUTDOOR_CYCLING map + // directly to STREET_WALKING (11/2) / STREET_CYCLING (2/2). + final Optional run = GarminSport.fromActivityKind(ActivityKind.OUTDOOR_RUNNING); + assertTrue("OUTDOOR_RUNNING should map", run.isPresent()); + assertEquals(1, run.get().getType()); + assertEquals(2, run.get().getSubtype()); + + final Optional walk = GarminSport.fromActivityKind(ActivityKind.OUTDOOR_WALKING); + assertTrue("OUTDOOR_WALKING should map", walk.isPresent()); + assertEquals(11, walk.get().getType()); + assertEquals(2, walk.get().getSubtype()); + + final Optional bike = GarminSport.fromActivityKind(ActivityKind.OUTDOOR_CYCLING); + assertTrue("OUTDOOR_CYCLING should map", bike.isPresent()); + assertEquals(2, bike.get().getType()); + assertEquals(2, bike.get().getSubtype()); + } } diff --git a/app/src/test/resources/TestGpxImport.course.fit b/app/src/test/resources/TestGpxImport.course.fit index 2ac027f023b929dc861bcca1c9bbeca450514898..b0c38756c4539495bea5a46c4a24051de7bbb66d 100644 GIT binary patch delta 49 zcmV-10M7rl1GNK?!vXG*#9jz0003sY99sjEDgi44EVi1HSOG8yegFWWxG1e~laK)- H1MZZRth^CA delta 49 zcmV-10M7rl1GNK?!vW-x#9jz2003o>99h_tDgi44D7KlCSOG8ybN~RLC@8DolaK)- H1LX3Vy9p8i diff --git a/app/src/test/resources/gpx-exporter-test-SampleTrack.course.fit b/app/src/test/resources/gpx-exporter-test-SampleTrack.course.fit index 173aa86cdd979513d1aaacad761fe1b529bbcf9c..2566a479616dffd2ab0017928736c625d70150a7 100644 GIT binary patch delta 246 zcmbQtGMQzpSu6_`K*RE3kC+RcCJ+*SER|O9@qsjjJpORlp&vb@X7gx_IV5p zJewdwS@Nle^UgPPh%zwnX+nhZ@fpViQ2!N9y(3=9Gp5TRoE)RU&?8#;6u7=$~xR-JqV E05_pl<^TWy diff --git a/app/src/test/resources/gpx-parser-test-multiple-segments.course.fit b/app/src/test/resources/gpx-parser-test-multiple-segments.course.fit index 8c8ade750f87add9d0ddea6cf5f601cdfb66667e..20e5bd3b9bdd65a27cb873c202b71a28bd9b813f 100644 GIT binary patch delta 55 zcmV-70LcH<0@VVL!vTts#AYi+4~zvqm6@f@zjQkKA6L@=06;|z#RWc