diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt
index f7c85daad..88cd5cc9b 100644
--- a/.skills/compose-ui/strings-index.txt
+++ b/.skills/compose-ui/strings-index.txt
@@ -530,6 +530,7 @@ expires
### EXPORT ###
export_configuration
export_data_csv
+export_gpx
export_keys
export_keys_confirmation
export_tak_data_package
diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index 642059bab..b1d27208e 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -554,6 +554,7 @@
Export configuration
Export all packets
+ Export GPX
Export Keys
Exports public and private keys to a file. Please store somewhere securely.
Export TAK Data Package
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt
index 032663661..3225f3b6f 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt
@@ -374,6 +374,12 @@ open class MetricsViewModel(
}
}
+ fun savePositionGpx(uri: CommonUri, data: List, trackName: String) {
+ safeLaunch(context = dispatchers.io, tag = "exportGpx") {
+ fileService.write(uri) { sink -> sink.writeUtf8(buildGpx(data, trackName)) }
+ }
+ }
+
fun saveLocalStatsCSV(uri: CommonUri, data: List) {
exportCsv(
uri = uri,
@@ -544,3 +550,25 @@ open class MetricsViewModel(
private const val ONE_WIRE_SENSOR_COUNT = 8
}
}
+
+private fun buildGpx(positions: List, trackName: String): String {
+ val trkpts = buildString {
+ for (pos in positions) {
+ val lat = formatString("%.7f", (pos.latitude_i ?: 0) * GeoConstants.DEG_D)
+ val lon = formatString("%.7f", (pos.longitude_i ?: 0) * GeoConstants.DEG_D)
+ append(" ")
+ if ((pos.altitude ?: 0) != 0) append("${pos.altitude}")
+ if (pos.time > 0) append("")
+ append("\n")
+ }
+ }
+ val name = trackName.replace("&", "&").replace("<", "<").replace(">", ">")
+ return """
+
+
+ $name
+
+$trkpts
+
+"""
+}
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt
index bcb1bba40..b1ef685e5 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt
@@ -27,8 +27,10 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.clear
+import org.meshtastic.core.resources.export_gpx
import org.meshtastic.core.resources.position_log
import org.meshtastic.core.ui.icon.Delete
+import org.meshtastic.core.ui.icon.FileDownload
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Refresh
import org.meshtastic.core.ui.util.LocalNodeTrackMapProvider
@@ -40,6 +42,8 @@ fun PositionLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
val positions = state.positionLogs
val exportPositionLauncher = rememberSaveFileLauncher { uri -> viewModel.savePositionCSV(uri, positions) }
+ val nodeName = state.node?.user?.long_name ?: ""
+ val exportGpxLauncher = rememberSaveFileLauncher { uri -> viewModel.savePositionGpx(uri, positions, nodeName) }
val trackMap = LocalNodeTrackMapProvider.current
val destNum = state.node?.num ?: 0
@@ -48,12 +52,18 @@ fun PositionLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
onNavigateUp = onNavigateUp,
telemetryType = null,
titleRes = Res.string.position_log,
- nodeName = state.node?.user?.long_name ?: "",
+ nodeName = nodeName,
data = positions,
timeProvider = { it.time.toDouble() },
onExportCsv = { exportPositionLauncher("position.csv", "text/csv") },
extraActions = {
if (positions.isNotEmpty()) {
+ IconButton(onClick = { exportGpxLauncher("track.gpx", "application/gpx+xml") }) {
+ Icon(
+ imageVector = MeshtasticIcons.FileDownload,
+ contentDescription = stringResource(Res.string.export_gpx),
+ )
+ }
IconButton(onClick = { viewModel.clearPosition() }) {
Icon(imageVector = MeshtasticIcons.Delete, contentDescription = stringResource(Res.string.clear))
}