mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-09-20 02:37:21 -04:00
fix(settings): name config exports after the long name, not the short name (#7248)
This commit is contained in:
1 parent
9eebc5c96a
commit
2539b9bcfa
3 files changed
+209
-2
No files matched your search
+2
-2
@@ -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)
|
||||
|
||||
+95
@@ -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"
|
||||
}
|
||||
+112
@@ -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'")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user