Add device-agnostic FIT activity export (#6268)

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/<year>/` 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
This commit is contained in:
DanyPM
2026-06-21 23:35:20 +02:00
committed by José Rebelo
co-authored by Dany Mestas
parent 1f84901c14
commit 835884ab16
38 changed files with 4338 additions and 87 deletions
+9
View File
@@ -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 {
+4
View File
@@ -234,6 +234,10 @@
android:name=".activities.automations.AutoExportGpxSettingsActivity"
android:label="@string/pref_header_auto_export_gpx"
android:parentActivityName=".activities.SettingsActivity" />
<activity
android:name=".activities.automations.AutoExportFitSettingsActivity"
android:label="@string/pref_header_auto_export_fit"
android:parentActivityName=".activities.SettingsActivity" />
<activity
android:name=".activities.maps.MapsTrackActivity"
android:label="@string/maps"
@@ -0,0 +1,122 @@
/* 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 <https://www.gnu.org/licenses/>. */
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<Preference>(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<Preference>(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<MultiSelectListPreference>(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()
}
}
}
}
@@ -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
@@ -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<Entry> stepLengthPoints = new ArrayList<>(initialCapacity);
final List<Entry> n2LoadPoints = new ArrayList<>(initialCapacity);
final List<Entry> cnsToxicityPoints = new ArrayList<>(initialCapacity);
final List<Entry> verticalOscillationPoints = new ArrayList<>(initialCapacity);
final List<Entry> stanceTimePercentPoints = new ArrayList<>(initialCapacity);
final List<Entry> stanceTimePoints = new ArrayList<>(initialCapacity);
final List<Entry> verticalRatioPoints = new ArrayList<>(initialCapacity);
final List<Entry> stanceTimeBalancePoints = new ArrayList<>(initialCapacity);
final List<Entry> 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<Entry> 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<Entry> 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<Entry> 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<Entry> 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<Entry> 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<Entry> 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) {
@@ -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<FitTimeInZone> timesInZone = new ArrayList<>();
private final List<ActivityPoint> activityPoints = new ArrayList<>();
private List<ActivityPoint> 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<FitSession> 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<GenericMetricSample> 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<FitSession> 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<Integer, FitDeviceInfo> 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;
@@ -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 <https://www.gnu.org/licenses/>. */
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<String> 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);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -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),
@@ -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; // 0100, 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 0100 (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;
}
@@ -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";
@@ -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<List<ActivityPoint>> segments = new ArrayList<>() {{
add(currentSegment);
}};
private final List<SegmentInfo> segmentInfos = new ArrayList<>() {{
add(new SegmentInfo());
}};
private final List<LengthInfo> lengths = new ArrayList<>();
private final List<SplitInfo> splits = new ArrayList<>();
private final List<SetInfo> 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<List<ActivityPoint>> newSegments,
final List<SegmentInfo> 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<ActivityPoint> 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<List<ActivityPoint>> getSegments() {
return segments;
}
public List<SegmentInfo> getSegmentInfos() {
return segmentInfos;
}
public List<LengthInfo> getLengths() {
return lengths;
}
public void addLength(final LengthInfo info) {
if (info != null) lengths.add(info);
}
public List<SplitInfo> getSplits() {
return splits;
}
public void addSplit(final SplitInfo info) {
if (info != null) splits.add(info);
}
public List<SetInfo> getSets() {
return sets;
}
public void addSet(final SetInfo info) {
if (info != null) sets.add(info);
}
public List<ActivityPoint> 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<ActivityPoint> byTime = Comparator.comparing(ActivityPoint::getTime);
for (final List<ActivityPoint> segment : segments) {
segment.sort(byTime);
}
}
public Date getBaseTime() {
return baseTime;
}
@@ -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;
}
}
@@ -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,
@@ -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() {
@@ -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);
}
}
}
@@ -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();
}
@@ -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() {
@@ -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;
@@ -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;
@@ -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;
@@ -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<GarminSport> 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;
}
}
}
@@ -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());
@@ -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
@@ -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);
@@ -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;
}
@@ -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";
@@ -62,6 +62,12 @@
android:title="@string/activity_detail_upload_to_wanderer"
app:showAsAction="never" />
<item
android:id="@+id/activity_action_share_fit"
android:icon="@drawable/ic_share"
android:title="@string/activity_detail_export_fit"
app:showAsAction="never" />
<item
android:id="@+id/activity_action_dev_tools"
android:icon="@drawable/ic_developer_mode"
+11
View File
@@ -664,6 +664,11 @@
<string name="pref_auto_export_gpx_all_devices_summary">Export GPX tracks from all devices</string>
<string name="pref_auto_export_gpx_selected_devices">Selected devices</string>
<string name="pref_auto_export_gpx_selected_devices_summary">Choose which devices to export GPX tracks from</string>
<!-- FIT Auto export -->
<string name="pref_header_auto_export_fit">Auto export FIT tracks</string>
<string name="pref_auto_export_fit_summary">Automatically export FIT files when syncing activity tracks</string>
<string name="pref_auto_export_fit_all_devices_summary">Export FIT tracks from all devices</string>
<string name="pref_auto_export_fit_selected_devices_summary">Choose which devices to export FIT tracks from</string>
<!-- Auto fetch activity preferences -->
<string name="pref_header_auto_fetch">Auto fetch</string>
<string name="pref_auto_fetch">Auto fetch activity data</string>
@@ -1661,6 +1666,8 @@
<string name="activity_type_other">Other</string>
<string name="activity_type_trekking">Trekking</string>
<string name="activity_type_trail_run">Trail run</string>
<string name="activity_type_trail_hike">Trail hike</string>
<string name="activity_type_mma_hiit">MMA HIIT</string>
<string name="activity_type_wrestling">Wrestling</string>
<string name="activity_type_unknown">Unknown activity</string>
<string name="activity_type_navigate">Navigate</string>
@@ -2962,6 +2969,8 @@
<string name="ground_contact_time_balance">Ground Contact Time Balance</string>
<string name="running_step_speed_loss">Step Speed Loss</string>
<string name="running_step_speed_loss_percentage">Step Speed Loss %</string>
<string name="stance_time_percent">Stance Time %</string>
<string name="performance_condition">Performance Condition</string>
<string name="workout_repetitions">Repetitions</string>
<string name="workout_revolutions">Revolutions</string>
<string name="workout_swings">Swings</string>
@@ -3003,6 +3012,8 @@
<string name="activity_detail_duration_label">Duration</string>
<string name="activity_detail_show_gps_label">Show GPS Track</string>
<string name="activity_detail_share_gps_label">Share GPS Track</string>
<string name="activity_detail_export_fit">Share FIT File</string>
<string name="activity_detail_export_fit_failed">Failed to export FIT file</string>
<string name="activity_detail_inspect_file">Inspect file</string>
<string name="activity_detail_share_raw_summary">Share Raw Summary</string>
<string name="activity_detail_share_raw_details">Share Raw Details</string>
@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<SwitchPreferenceCompat
android:defaultValue="false"
android:icon="@drawable/ic_toggle_on"
android:key="fit_auto_export_enabled"
android:layout="@layout/preference_checkbox"
android:summary="@string/pref_auto_export_fit_summary"
android:title="@string/pref_title_auto_export_enabled"
app:iconSpaceReserved="false" />
<Preference
android:dependency="fit_auto_export_enabled"
android:icon="@drawable/ic_folder"
android:key="fit_auto_export_directory"
android:summary="@string/not_set"
android:title="@string/pref_export_directory"
app:iconSpaceReserved="false" />
<PreferenceCategory
android:key="pref_header_fit_auto_export_devices"
android:title="@string/bottom_nav_devices"
app:iconSpaceReserved="false">
<SwitchPreferenceCompat
android:defaultValue="true"
android:dependency="fit_auto_export_enabled"
android:disableDependentsState="true"
android:key="fit_auto_export_all_devices"
android:layout="@layout/preference_checkbox"
android:summary="@string/pref_auto_export_fit_all_devices_summary"
android:title="@string/pref_dashboard_all_devices_title"
app:iconSpaceReserved="false" />
<MultiSelectListPreference
android:dependency="fit_auto_export_all_devices"
android:dialogTitle="@string/pref_auto_export_gpx_selected_devices"
android:entries="@array/empty_array"
android:entryValues="@array/empty_array"
android:key="fit_auto_export_selected_devices"
android:summary="@string/pref_auto_export_fit_selected_devices_summary"
android:title="@string/pref_auto_export_gpx_selected_devices"
app:iconSpaceReserved="false" />
</PreferenceCategory>
</PreferenceScreen>
@@ -28,6 +28,14 @@
android:targetPackage="@string/applicationId" />
</Preference>
<Preference
android:icon="@drawable/ic_activity_unknown_small"
android:title="@string/pref_header_auto_export_fit">
<intent
android:targetClass="nodomain.freeyourgadget.gadgetbridge.activities.automations.AutoExportFitSettingsActivity"
android:targetPackage="@string/applicationId" />
</Preference>
</PreferenceCategory>
<PreferenceCategory android:title="@string/pref_header_auto_fetch">
@@ -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 <https://www.gnu.org/licenses/>. */
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.
*
* <p>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<FitLap> 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 strokedistance 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<FitLap> 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<FitLap> 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<FitLap> laps(final FitFile fit) {
final List<FitLap> 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;
}
}
@@ -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 <https://www.gnu.org/licenses/>. */
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.
*
* <p>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.
*
* <p>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<String> relPaths = readmeFitPaths();
assertTrue("README must list at least one fit file", !relPaths.isEmpty());
runRoundTripBatch(relPaths);
}
/** Walks every {@code .fit} file under {@code Activity/<year>/} for years 2015
* and round-trips it. Broader than {@link #roundTripAllReadmeFiles()} which only
* covers the curated README sample. Pre-2015 files (19892014) 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<String> relPaths = new ArrayList<>();
try (java.util.stream.Stream<java.nio.file.Path> 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/<year≥2015>/ must contain at least one fit file", !relPaths.isEmpty());
runRoundTripBatch(relPaths);
}
private void runRoundTripBatch(final List<String> relPaths) throws Exception {
final Map<String, RoundTripStats> results = new LinkedHashMap<>();
final List<String> failures = new ArrayList<>();
final List<String> codecSkips = new ArrayList<>();
final List<String> 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<String, RoundTripStats> 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<FitRecord> records = new ArrayList<>();
final List<FitLap> laps = new ArrayList<>();
final List<FitLength> lengths = new ArrayList<>();
final List<FitSplit> splits = new ArrayList<>();
final List<FitSet> 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<Integer> inSessionFields = new java.util.TreeSet<>();
final java.util.Set<Integer> inLapFields = new java.util.TreeSet<>();
final java.util.Set<Integer> 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<Integer> outSessionFields = new java.util.TreeSet<>();
final java.util.Set<Integer> outLapFields = new java.util.TreeSet<>();
final java.util.Set<Integer> 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<FitRecord> inRecords,
final FitFile outFit,
final String fileName,
final RoundTripStats stats) {
// Index output records by timestamp.
final Map<Long, FitRecord> 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<FitRecord> 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<String> 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<String> 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<Integer> dst) {
if (r.getRecordDefinition() == null) return;
final java.util.List<FieldDefinition> 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<Integer> inSession, final java.util.Set<Integer> outSession,
final java.util.Set<Integer> inLap, final java.util.Set<Integer> outLap,
final java.util.Set<Integer> inRec, final java.util.Set<Integer> outRec) {
return "session{" + diff(inSession, outSession) + "} lap{" + diff(inLap, outLap)
+ "} record{" + diff(inRec, outRec) + "}";
}
private static String diff(final java.util.Set<Integer> in, final java.util.Set<Integer> out) {
final java.util.Set<Integer> inOnly = new java.util.TreeSet<>(in);
inOnly.removeAll(out);
final java.util.Set<Integer> 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<GarminSport> gs = GarminSport.fromCodes(sport, subSport != null ? subSport : 0);
return gs.map(GarminSport::getActivityKind).orElse(ActivityKind.UNKNOWN);
}
private static ActivityTrack buildTrackFromRecordsAndLaps(final List<FitRecord> records,
final List<FitLap> 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<String> readmeFitPaths() throws Exception {
final String contents = new String(Files.readAllBytes(README.toPath()), StandardCharsets.UTF_8);
final List<String> 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();
}
}
}
@@ -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 <https://www.gnu.org/licenses/>. */
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.
*
* <p>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.
*
* <p>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}.
*
* <p>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());
}
}
@@ -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 <https://www.gnu.org/licenses/>. */
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<GarminSport> 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<GarminSport> 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<GarminSport> bike = GarminSport.fromActivityKind(ActivityKind.OUTDOOR_CYCLING);
assertTrue("OUTDOOR_CYCLING should map", bike.isPresent());
assertEquals(2, bike.get().getType());
assertEquals(2, bike.get().getSubtype());
}
}
Binary file not shown.