fix(settings): name config exports after the long name, not the short name (#7248)

This commit is contained in:
James Rich authored and GitHub committed 2026-09-19 20:29:56 +00:00
1 parent 9eebc5c96a
commit 2539b9bcfa
3 files changed
+209 -2

No files matched your search

@@ -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)
@@ -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 <https://www.gnu.org/licenses/>.
*/
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"
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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'")
}
}