feat(node): add GPX export to position log screen (#5919)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
James Rich
2026-06-23 14:43:29 -05:00
committed by GitHub
parent 56f522cd6c
commit e402df5823
4 changed files with 41 additions and 1 deletions

View File

@@ -530,6 +530,7 @@ expires
### EXPORT ###
export_configuration
export_data_csv
export_gpx
export_keys
export_keys_confirmation
export_tak_data_package

View File

@@ -554,6 +554,7 @@
<!-- EXPORT -->
<string name="export_configuration">Export configuration</string>
<string name="export_data_csv">Export all packets</string>
<string name="export_gpx">Export GPX</string>
<string name="export_keys">Export Keys</string>
<string name="export_keys_confirmation">Exports public and private keys to a file. Please store somewhere securely.</string>
<string name="export_tak_data_package">Export TAK Data Package</string>

View File

@@ -374,6 +374,12 @@ open class MetricsViewModel(
}
}
fun savePositionGpx(uri: CommonUri, data: List<org.meshtastic.proto.Position>, trackName: String) {
safeLaunch(context = dispatchers.io, tag = "exportGpx") {
fileService.write(uri) { sink -> sink.writeUtf8(buildGpx(data, trackName)) }
}
}
fun saveLocalStatsCSV(uri: CommonUri, data: List<Telemetry>) {
exportCsv(
uri = uri,
@@ -544,3 +550,25 @@ open class MetricsViewModel(
private const val ONE_WIRE_SENSOR_COUNT = 8
}
}
private fun buildGpx(positions: List<org.meshtastic.proto.Position>, 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(" <trkpt lat=\"$lat\" lon=\"$lon\">")
if ((pos.altitude ?: 0) != 0) append("<ele>${pos.altitude}</ele>")
if (pos.time > 0) append("<time>${Instant.fromEpochSeconds(pos.time.toLong())}</time>")
append("</trkpt>\n")
}
}
val name = trackName.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
return """<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="Meshtastic" xmlns="http://www.topografix.com/GPX/1/1">
<trk>
<name>$name</name>
<trkseg>
$trkpts </trkseg>
</trk>
</gpx>"""
}

View File

@@ -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))
}