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'") + } +}