mirror of
https://codeberg.org/Freeyourgadget/Gadgetbridge.git
synced 2026-07-31 07:44:24 +02:00
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:
committed by
José Rebelo
co-authored by
Dany Mestas
parent
1f84901c14
commit
835884ab16
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
+122
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+113
-55
@@ -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
|
||||
|
||||
+196
@@ -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) {
|
||||
|
||||
+65
-14
@@ -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; // 0–100, integer per FIT spec
|
||||
|
||||
// e.g. to describe a pause during the activity
|
||||
private @Nullable String description;
|
||||
|
||||
@@ -198,11 +207,66 @@ public class ActivityPoint {
|
||||
this.depth = depth;
|
||||
}
|
||||
|
||||
/// vertical bounce of torso while running, in mm
|
||||
public float getVerticalOscillation() {
|
||||
return verticalOscillation;
|
||||
}
|
||||
|
||||
public void setVerticalOscillation(final float verticalOscillation) {
|
||||
this.verticalOscillation = verticalOscillation;
|
||||
}
|
||||
|
||||
/// fraction of step cycle the foot is on the ground, in %
|
||||
public float getStanceTimePercent() {
|
||||
return stanceTimePercent;
|
||||
}
|
||||
|
||||
public void setStanceTimePercent(final float stanceTimePercent) {
|
||||
this.stanceTimePercent = stanceTimePercent;
|
||||
}
|
||||
|
||||
/// foot ground-contact time, in ms
|
||||
public float getStanceTime() {
|
||||
return stanceTime;
|
||||
}
|
||||
|
||||
public void setStanceTime(final float stanceTime) {
|
||||
this.stanceTime = stanceTime;
|
||||
}
|
||||
|
||||
/// vertical oscillation as % of step length
|
||||
public float getVerticalRatio() {
|
||||
return verticalRatio;
|
||||
}
|
||||
|
||||
public void setVerticalRatio(final float verticalRatio) {
|
||||
this.verticalRatio = verticalRatio;
|
||||
}
|
||||
|
||||
/// left/right stance-time balance, in %
|
||||
public float getStanceTimeBalance() {
|
||||
return stanceTimeBalance;
|
||||
}
|
||||
|
||||
public void setStanceTimeBalance(final float stanceTimeBalance) {
|
||||
this.stanceTimeBalance = stanceTimeBalance;
|
||||
}
|
||||
|
||||
/// performance condition score 0–100 (Garmin)
|
||||
public int getPerformanceCondition() {
|
||||
return performanceCondition;
|
||||
}
|
||||
|
||||
public void setPerformanceCondition(final int performanceCondition) {
|
||||
this.performanceCondition = performanceCondition;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (!(o instanceof ActivityPoint that)) return false;
|
||||
return heartRate == that.heartRate &&
|
||||
Float.compare(speed, that.speed) == 0 &&
|
||||
stepLength == that.stepLength &&
|
||||
cadence == that.cadence &&
|
||||
Float.compare(power, that.power) == 0 &&
|
||||
Float.compare(respiratoryRate, that.respiratoryRate) == 0 &&
|
||||
@@ -214,12 +278,20 @@ public class ActivityPoint {
|
||||
Double.compare(distance, that.distance) == 0 &&
|
||||
Double.compare(altitude, that.altitude) == 0 &&
|
||||
Float.compare(bodyEnergy, that.bodyEnergy) == 0 &&
|
||||
Float.compare(stamina, that.stamina) == 0;
|
||||
Float.compare(stamina, that.stamina) == 0 &&
|
||||
Float.compare(cnsToxicity, that.cnsToxicity) == 0 &&
|
||||
Float.compare(n2Load, that.n2Load) == 0 &&
|
||||
Float.compare(verticalOscillation, that.verticalOscillation) == 0 &&
|
||||
Float.compare(stanceTimePercent, that.stanceTimePercent) == 0 &&
|
||||
Float.compare(stanceTime, that.stanceTime) == 0 &&
|
||||
Float.compare(verticalRatio, that.verticalRatio) == 0 &&
|
||||
Float.compare(stanceTimeBalance, that.stanceTimeBalance) == 0 &&
|
||||
performanceCondition == that.performanceCondition;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(time, location, heartRate, speed, cadence, power, respiratoryRate, depth, temperature, description, distance, altitude, bodyEnergy, stamina);
|
||||
return Objects.hash(time, location, heartRate, speed, stepLength, cadence, power, respiratoryRate, depth, temperature, description, distance, altitude, bodyEnergy, stamina, cnsToxicity, n2Load, verticalOscillation, stanceTimePercent, stanceTime, verticalRatio, stanceTimeBalance, performanceCondition);
|
||||
}
|
||||
|
||||
public static class Builder {
|
||||
@@ -247,6 +319,12 @@ public class ActivityPoint {
|
||||
private float stamina = Float.NaN;
|
||||
private float cnsToxicity = Float.NaN;
|
||||
private float n2Load = Float.NaN;
|
||||
private float verticalOscillation = Float.NaN;
|
||||
private float stanceTimePercent = Float.NaN;
|
||||
private float stanceTime = Float.NaN;
|
||||
private float verticalRatio = Float.NaN;
|
||||
private float stanceTimeBalance = Float.NaN;
|
||||
private int performanceCondition = Integer.MIN_VALUE;
|
||||
|
||||
public Builder() {
|
||||
}
|
||||
@@ -509,6 +587,30 @@ public class ActivityPoint {
|
||||
this.n2Load = n2Load;
|
||||
}
|
||||
|
||||
public void setVerticalOscillation(@Nullable Number verticalOscillation) {
|
||||
this.verticalOscillation = (verticalOscillation == null) ? Float.NaN : verticalOscillation.floatValue();
|
||||
}
|
||||
|
||||
public void setStanceTimePercent(@Nullable Number stanceTimePercent) {
|
||||
this.stanceTimePercent = (stanceTimePercent == null) ? Float.NaN : stanceTimePercent.floatValue();
|
||||
}
|
||||
|
||||
public void setStanceTime(@Nullable Number stanceTime) {
|
||||
this.stanceTime = (stanceTime == null) ? Float.NaN : stanceTime.floatValue();
|
||||
}
|
||||
|
||||
public void setVerticalRatio(@Nullable Number verticalRatio) {
|
||||
this.verticalRatio = (verticalRatio == null) ? Float.NaN : verticalRatio.floatValue();
|
||||
}
|
||||
|
||||
public void setStanceTimeBalance(@Nullable Number stanceTimeBalance) {
|
||||
this.stanceTimeBalance = (stanceTimeBalance == null) ? Float.NaN : stanceTimeBalance.floatValue();
|
||||
}
|
||||
|
||||
public void setPerformanceCondition(@Nullable Number performanceCondition) {
|
||||
this.performanceCondition = (performanceCondition == null) ? Integer.MIN_VALUE : performanceCondition.intValue();
|
||||
}
|
||||
|
||||
|
||||
public ActivityPoint build() {
|
||||
final ActivityPoint activityPoint = new ActivityPoint(date);
|
||||
@@ -571,6 +673,24 @@ public class ActivityPoint {
|
||||
if(!Float.isNaN(n2Load)){
|
||||
activityPoint.n2Load = n2Load;
|
||||
}
|
||||
if (!Float.isNaN(verticalOscillation)) {
|
||||
activityPoint.verticalOscillation = verticalOscillation;
|
||||
}
|
||||
if (!Float.isNaN(stanceTimePercent)) {
|
||||
activityPoint.stanceTimePercent = stanceTimePercent;
|
||||
}
|
||||
if (!Float.isNaN(stanceTime)) {
|
||||
activityPoint.stanceTime = stanceTime;
|
||||
}
|
||||
if (!Float.isNaN(verticalRatio)) {
|
||||
activityPoint.verticalRatio = verticalRatio;
|
||||
}
|
||||
if (!Float.isNaN(stanceTimeBalance)) {
|
||||
activityPoint.stanceTimeBalance = stanceTimeBalance;
|
||||
}
|
||||
if (performanceCondition > Integer.MIN_VALUE) {
|
||||
activityPoint.performanceCondition = performanceCondition;
|
||||
}
|
||||
|
||||
return activityPoint;
|
||||
}
|
||||
|
||||
+9
@@ -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;
|
||||
}
|
||||
|
||||
+55
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -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,
|
||||
|
||||
|
||||
+3
-1
@@ -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() {
|
||||
|
||||
+4
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+15
-1
@@ -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();
|
||||
}
|
||||
|
||||
+12
-1
@@ -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() {
|
||||
|
||||
+3
-1
@@ -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;
|
||||
|
||||
+4
-1
@@ -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;
|
||||
|
||||
+5
-1
@@ -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;
|
||||
|
||||
+50
-4
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+7
@@ -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());
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
+2
@@ -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);
|
||||
|
||||
|
||||
+2
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
+318
@@ -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 stroke→distance synth.
|
||||
assertEquals(Integer.valueOf(15), session.getSport());
|
||||
assertEquals(Integer.valueOf(14), session.getSubSport());
|
||||
|
||||
// ---- Lap expectations: every active rowing lap has distance/cycles/speed set ----
|
||||
// Both FitLap.getTotalDistance() and FitSession.getTotalDistance() return raw
|
||||
// meters (codec applies scale=100 on decode).
|
||||
double sumLapDistanceMeters = 0;
|
||||
for (final FitLap lap : laps) {
|
||||
assertNotNull("lap distance", lap.getTotalDistance());
|
||||
sumLapDistanceMeters += lap.getTotalDistance();
|
||||
assertNotNull("lap cycles", lap.getTotalCycles());
|
||||
assertNotNull("lap avgStrokeDistance", lap.getAvgStrokeDistance());
|
||||
// FIT lap.avg_stroke_distance is Integer cm-scaled (600 = 6.0 m)
|
||||
assertEquals(600, lap.getAvgStrokeDistance().intValue());
|
||||
// avg_speed must be set on every lap including rest laps (≈0 there).
|
||||
assertNotNull("lap avgSpeed", lap.getAvgSpeed());
|
||||
}
|
||||
// Sum of lap distances should equal session distance.
|
||||
assertEquals(7248.0, sumLapDistanceMeters, 0.5);
|
||||
}
|
||||
|
||||
/** Outdoor (on-water) rowing uses a longer 9.0 m/stroke default. Same 1208/2550 input
|
||||
* pinned to the ROWING (15, 0) FIT mapping to lock the indoor-vs-outdoor split. */
|
||||
@Test
|
||||
public void outdoorRowingSession_usesLongerStrokeLengthDefault() throws Exception {
|
||||
final long start = 1776705018L;
|
||||
final long elapsed = 2550L;
|
||||
final BaseActivitySummary summary = newSummary(start, elapsed, ActivityKind.ROWING);
|
||||
final ActivitySummaryData data = new ActivitySummaryData();
|
||||
data.add(ActivitySummaryEntries.STROKES, 1208, ActivitySummaryEntries.UNIT_STROKES);
|
||||
|
||||
final ActivityTrack track = newRowingTrack(start);
|
||||
|
||||
final File out = tmp.newFile("rowing-outdoor.fit");
|
||||
new FitExporter().performExport(track, summary, data, out);
|
||||
|
||||
final FitFile fit = FitFile.parseIncoming(out);
|
||||
final FitSession session = onlySession(fit);
|
||||
final List<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;
|
||||
}
|
||||
}
|
||||
+758
@@ -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 (1989–2014) exercise legacy
|
||||
* codec edge-cases unrelated to current devices and are skipped. */
|
||||
@Test
|
||||
public void roundTripAllActivityFitFiles2015Onward() throws Exception {
|
||||
final java.nio.file.Path activityRoot = new File(CORPUS_ROOT, "Activity").toPath();
|
||||
if (!java.nio.file.Files.exists(activityRoot)) {
|
||||
// Test resource not present — skip silently. The README batch already
|
||||
// runs against the curated subset.
|
||||
return;
|
||||
}
|
||||
final java.util.regex.Pattern yearDir = java.util.regex.Pattern.compile(
|
||||
"Activity/(\\d{4})/.*");
|
||||
final List<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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+146
@@ -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());
|
||||
}
|
||||
}
|
||||
+28
-2
@@ -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.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user