From 2539b9bcfa892a8f4e71e1fa7904a02f49e80abc Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Sat, 19 Sep 2026 20:29:56 +0000
Subject: [PATCH] fix(settings): name config exports after the long name, not
the short name (#7248)
---
.../feature/settings/SettingsScreen.kt | 4 +-
.../util/DeviceProfileExportFileName.kt | 95 +++++++++++++++
.../util/DeviceProfileExportFileNameTest.kt | 112 ++++++++++++++++++
3 files changed, 209 insertions(+), 2 deletions(-)
create mode 100644 feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/util/DeviceProfileExportFileName.kt
create mode 100644 feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/util/DeviceProfileExportFileNameTest.kt
diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt
index 0d3aef21e3..82e59f84c0 100644
--- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt
+++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt
@@ -89,6 +89,7 @@ import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.feature.settings.radio.component.EditDeviceProfileDialog
import org.meshtastic.feature.settings.util.LanguageUtils
import org.meshtastic.feature.settings.util.LanguageUtils.languageMap
+import org.meshtastic.feature.settings.util.deviceProfileExportFileName
import org.meshtastic.proto.DeviceProfile
import kotlin.time.Instant.Companion.fromEpochMilliseconds
@@ -146,7 +147,6 @@ fun SettingsScreen(
viewModel.installProfile(it)
} else {
deviceProfile = it
- val nodeName = (it.short_name ?: "").ifBlank { "node" }
val dateStr =
fromEpochMilliseconds(nowMillis)
.toLocalDateTime(TimeZone.currentSystemDefault())
@@ -157,7 +157,7 @@ fun SettingsScreen(
day()
},
)
- val fileName = "Meshtastic_${nodeName}_${dateStr}_nodeConfig.cfg"
+ val fileName = deviceProfileExportFileName(it.long_name, it.short_name, dateStr)
val intent =
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/util/DeviceProfileExportFileName.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/util/DeviceProfileExportFileName.kt
new file mode 100644
index 0000000000..4e64897fdb
--- /dev/null
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/util/DeviceProfileExportFileName.kt
@@ -0,0 +1,95 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program 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 General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.settings.util
+
+private const val FALLBACK_NODE_NAME = "node"
+
+/** Upper bound on the name segment so the whole file name stays well inside filesystem limits. */
+private const val MAX_NAME_LENGTH = 48
+
+private const val SEPARATOR = '_'
+
+/**
+ * Letters and digits of any script are kept, in any Unicode plane, so a node called `Küche`, `東京` or `𐐀` keeps its
+ * name. Only characters a storage provider would actually choke on — separators, the Windows reserved set, whitespace,
+ * control characters, emoji — are replaced.
+ */
+private fun Char.isFileNameSafe() = isLetterOrDigit() || this == SEPARATOR || this == '-'
+
+/**
+ * Reduces a free-text node name to something safe to hand to a file picker as a suggested name.
+ *
+ * A run of unsafe characters collapses to one separator rather than one per character, so `Roof // Node` becomes
+ * `Roof_Node`. Returns `null` when nothing usable survives (an all-emoji name, for example) so the caller can fall back
+ * to another name.
+ */
+internal fun sanitizeExportNameSegment(name: String?): String? {
+ if (name == null) return null
+ val sanitized = StringBuilder(name.length)
+ var index = 0
+ // Steps whole code points: a supplementary-plane letter is two UTF-16 units, and testing either half alone
+ // reports a lone surrogate rather than the letter it belongs to.
+ while (index < name.length) {
+ val high = name[index]
+ val paired = high.isHighSurrogate() && index + 1 < name.length && name[index + 1].isLowSurrogate()
+ val width = if (paired) 2 else 1
+ val safe = if (paired) isSupplementaryNameChar(codePointAt(name, index)) else high.isFileNameSafe()
+ when {
+ safe -> sanitized.append(name, index, index + width)
+
+ // Collapse a run of unsafe characters, and never open the name with a separator.
+ sanitized.isNotEmpty() && sanitized.last() != SEPARATOR -> sanitized.append(SEPARATOR)
+ }
+ index += width
+ }
+ return sanitized.toString().take(MAX_NAME_LENGTH).trimEnd(SEPARATOR).takeIf { it.isNotEmpty() }
+}
+
+private const val SURROGATE_SHIFT = 10
+private const val SURROGATE_OFFSET = 0x10000
+private const val HIGH_SURROGATE_BASE = 0xD800
+private const val LOW_SURROGATE_BASE = 0xDC00
+
+/**
+ * Whether a supplementary-plane code point belongs in a name.
+ *
+ * `Char.isLetterOrDigit()` only classifies one UTF-16 unit, and the stdlib has no code-point form in `commonMain`.
+ * Ranges beat a platform lookup here: the pictographic blocks are what a name has to shed, and everything else above
+ * the basic plane is a script — Deseret, Linear B, the CJK extensions — that a node may legitimately be named in.
+ */
+private fun isSupplementaryNameChar(codePoint: Int): Boolean = PICTOGRAPHIC_RANGES.none { codePoint in it }
+
+/** Musical notation, emoji and pictographs, and the legacy-computing symbols. */
+private val PICTOGRAPHIC_RANGES = listOf(0x1D000..0x1D1FF, 0x1F000..0x1FAFF, 0x1FB00..0x1FBFF)
+
+/** The code point of the surrogate pair starting at [index]. */
+private fun codePointAt(text: String, index: Int): Int = SURROGATE_OFFSET +
+ ((text[index].code - HIGH_SURROGATE_BASE) shl SURROGATE_SHIFT) +
+ (text[index + 1].code - LOW_SURROGATE_BASE)
+
+/**
+ * Builds the suggested file name for a device profile ("node config") export.
+ *
+ * Prefers the long name, which since firmware 2.8 is short enough to be practical and is the field that actually
+ * distinguishes a person's nodes from each other — short names are routinely identical across them (see #7082). Falls
+ * back to the short name, then to a generic placeholder, when the preferred name is absent (the export dialog can
+ * exclude it) or has no file-name-safe characters.
+ */
+internal fun deviceProfileExportFileName(longName: String?, shortName: String?, dateStamp: String): String {
+ val nodeName = sanitizeExportNameSegment(longName) ?: sanitizeExportNameSegment(shortName) ?: FALLBACK_NODE_NAME
+ return "Meshtastic_${nodeName}_${dateStamp}_nodeConfig.cfg"
+}
diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/util/DeviceProfileExportFileNameTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/util/DeviceProfileExportFileNameTest.kt
new file mode 100644
index 0000000000..084e2cc32a
--- /dev/null
+++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/util/DeviceProfileExportFileNameTest.kt
@@ -0,0 +1,112 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program 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 General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.settings.util
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class DeviceProfileExportFileNameTest {
+
+ private val date = "20260919"
+
+ @Test
+ fun `prefers the long name over the short name`() {
+ assertEquals(
+ "Meshtastic_BaseStation_20260919_nodeConfig.cfg",
+ deviceProfileExportFileName(longName = "BaseStation", shortName = "BASE", dateStamp = date),
+ )
+ }
+
+ @Test
+ fun `falls back to the short name when the long name is missing or blank`() {
+ val expected = "Meshtastic_BASE_20260919_nodeConfig.cfg"
+ assertEquals(expected, deviceProfileExportFileName(longName = null, shortName = "BASE", dateStamp = date))
+ assertEquals(expected, deviceProfileExportFileName(longName = " ", shortName = "BASE", dateStamp = date))
+ }
+
+ @Test
+ fun `falls back to a placeholder when neither name is usable`() {
+ assertEquals(
+ "Meshtastic_node_20260919_nodeConfig.cfg",
+ deviceProfileExportFileName(longName = null, shortName = null, dateStamp = date),
+ )
+ assertEquals(
+ "Meshtastic_node_20260919_nodeConfig.cfg",
+ deviceProfileExportFileName(longName = "", shortName = "", dateStamp = date),
+ )
+ }
+
+ @Test
+ fun `replaces characters a storage provider would mangle`() {
+ assertEquals(
+ "Meshtastic_James_s_Roof_Node_20260919_nodeConfig.cfg",
+ deviceProfileExportFileName(longName = "James's Roof/Node", shortName = "ROOF", dateStamp = date),
+ )
+ }
+
+ @Test
+ fun `keeps letters and digits of any script`() {
+ assertEquals(
+ "Meshtastic_Küche_東京_2_20260919_nodeConfig.cfg",
+ deviceProfileExportFileName(longName = "Küche 東京 2", shortName = "KU", dateStamp = date),
+ )
+ }
+
+ @Test
+ fun `keeps a supplementary-plane letter`() {
+ // U+10400 DESERET CAPITAL LETTER LONG I is one code point across two UTF-16 units.
+ assertEquals(
+ "Meshtastic_\uD801\uDC00_20260919_nodeConfig.cfg",
+ deviceProfileExportFileName(longName = "\uD801\uDC00", shortName = "DS", dateStamp = date),
+ )
+ }
+
+ @Test
+ fun `drops a supplementary-plane emoji`() {
+ // U+1F4CD ROUND PUSHPIN is also two units, but it is a symbol rather than a letter.
+ assertEquals(
+ "Meshtastic_PIN_20260919_nodeConfig.cfg",
+ deviceProfileExportFileName(longName = "\uD83D\uDCCD", shortName = "PIN", dateStamp = date),
+ )
+ }
+
+ @Test
+ fun `collapses a run of unsafe characters into one separator`() {
+ assertEquals(
+ "Meshtastic_Roof_Node_20260919_nodeConfig.cfg",
+ deviceProfileExportFileName(longName = " Roof // Node ", shortName = "ROOF", dateStamp = date),
+ )
+ }
+
+ @Test
+ fun `falls back to the short name when sanitizing empties the long name`() {
+ assertEquals(
+ "Meshtastic_ROOF_20260919_nodeConfig.cfg",
+ deviceProfileExportFileName(longName = "📡🌲", shortName = "ROOF", dateStamp = date),
+ )
+ }
+
+ @Test
+ fun `caps an over-long name without leaving a trailing separator`() {
+ val fileName = deviceProfileExportFileName(longName = "a ".repeat(60), shortName = "AAAA", dateStamp = date)
+ val nodeName = fileName.removePrefix("Meshtastic_").removeSuffix("_${date}_nodeConfig.cfg")
+ assertTrue(nodeName.length <= 48, "expected the name segment to be capped, was ${nodeName.length}")
+ assertFalse(nodeName.endsWith("_"), "expected no trailing separator, was '$nodeName'")
+ }
+}