Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions app/src/main/kotlin/org/meshtastic/app/di/PrefsModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import org.meshtastic.core.prefs.di.MapDataStore
import org.meshtastic.core.prefs.di.MapTileProviderDataStore
import org.meshtastic.core.prefs.di.MeshDataStore
import org.meshtastic.core.prefs.di.MeshLogDataStore
import org.meshtastic.core.prefs.di.NodeDisplayNameDataStore
import org.meshtastic.core.prefs.di.RadioDataStore
import org.meshtastic.core.prefs.di.UiDataStore
import org.meshtastic.core.prefs.emoji.CustomEmojiPrefsImpl
Expand All @@ -52,6 +53,7 @@ import org.meshtastic.core.prefs.map.MapPrefsImpl
import org.meshtastic.core.prefs.map.MapTileProviderPrefsImpl
import org.meshtastic.core.prefs.mesh.MeshPrefsImpl
import org.meshtastic.core.prefs.meshlog.MeshLogPrefsImpl
import org.meshtastic.core.prefs.nodedisplay.NodeDisplayNamePrefsImpl
import org.meshtastic.core.prefs.radio.RadioPrefsImpl
import org.meshtastic.core.prefs.ui.UiPrefsImpl
import org.meshtastic.core.repository.AnalyticsPrefs
Expand All @@ -62,6 +64,7 @@ import org.meshtastic.core.repository.MapConsentPrefs
import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.core.repository.MapTileProviderPrefs
import org.meshtastic.core.repository.MeshLogPrefs
import org.meshtastic.core.repository.NodeDisplayNamePrefs
import org.meshtastic.core.repository.MeshPrefs
import org.meshtastic.core.repository.RadioPrefs
import org.meshtastic.core.repository.UiPrefs
Expand Down Expand Up @@ -139,6 +142,8 @@ interface PrefsModule {

@Binds fun bindRadioPrefs(radioPrefsImpl: RadioPrefsImpl): RadioPrefs

@Binds fun bindNodeDisplayNamePrefs(impl: NodeDisplayNamePrefsImpl): NodeDisplayNamePrefs

@Binds fun bindUiPrefs(uiPrefsImpl: UiPrefsImpl): UiPrefs

@Binds fun bindFilterPrefs(filterPrefsImpl: FilterPrefsImpl): FilterPrefs
Expand Down Expand Up @@ -265,5 +270,15 @@ interface PrefsModule {
scope = scope,
produceFile = { context.preferencesDataStoreFile("filter_ds") },
)

@Provides
@Singleton
@NodeDisplayNameDataStore
fun provideNodeDisplayNameDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
PreferenceDataStoreFactory.create(
migrations = listOf(SharedPreferencesMigration(context, "node_display_names_prefs")),
scope = scope,
produceFile = { context.preferencesDataStoreFile("node_display_names_ds") },
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/*
* Copyright (c) 2025-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.core.prefs.nodedisplay

import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import org.meshtastic.core.di.CoroutineDispatchers

class NodeDisplayNamePrefsTest {

@get:Rule val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build()

private lateinit var dataStore: DataStore<Preferences>
private lateinit var prefs: NodeDisplayNamePrefsImpl
private lateinit var dispatchers: CoroutineDispatchers

private val testDispatcher = UnconfinedTestDispatcher()
private val testScope = TestScope(testDispatcher)

@Before
fun setup() {
dataStore =
PreferenceDataStoreFactory.create(
scope = testScope,
produceFile = { tmpFolder.newFile("node_display_names.preferences_pb") },
)
dispatchers = mockk { every { default } returns testDispatcher }
prefs = NodeDisplayNamePrefsImpl(dataStore, dispatchers)
}

@Test
fun `displayNames defaults to empty map`() = testScope.runTest {
assertEquals(emptyMap<Int, String>(), prefs.displayNames.value)
}

@Test
fun `setDisplayName adds name and it is readable`() = testScope.runTest {
prefs.setDisplayName(123, "My Node")
assertEquals(mapOf(123 to "My Node"), prefs.displayNames.value)
}

@Test
fun `setDisplayName null removes entry`() = testScope.runTest {
prefs.setDisplayName(123, "My Node")
prefs.setDisplayName(123, null)
assertEquals(emptyMap<Int, String>(), prefs.displayNames.value)
}

@Test
fun `setDisplayName blank removes entry`() = testScope.runTest {
prefs.setDisplayName(456, " ")
assertEquals(emptyMap<Int, String>(), prefs.displayNames.value)
}

@Test
fun `multiple nodes can have display names`() = testScope.runTest {
prefs.setDisplayName(1, "Alice")
prefs.setDisplayName(2, "Bob")
assertEquals(mapOf(1 to "Alice", 2 to "Bob"), prefs.displayNames.value)
}

@Test
fun `trimmed name is stored`() = testScope.runTest {
prefs.setDisplayName(99, " Trimmed ")
assertEquals(mapOf(99 to "Trimmed"), prefs.displayNames.value)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,7 @@ annotation class MeshLogDataStore
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class FilterDataStore

@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class NodeDisplayNameDataStore
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/*
* Copyright (c) 2025-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.core.prefs.nodedisplay

import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.prefs.di.NodeDisplayNameDataStore
import org.meshtastic.core.repository.NodeDisplayNamePrefs
import javax.inject.Inject
import javax.inject.Singleton

private const val KEY_NODE_DISPLAY_NAMES = "node_display_names"
private const val ENTRY_SEP = '\u0002'
private const val KV_SEP = '\u0001'

@Singleton
class NodeDisplayNamePrefsImpl
@Inject
constructor(
@NodeDisplayNameDataStore private val dataStore: DataStore<Preferences>,
dispatchers: CoroutineDispatchers,
) : NodeDisplayNamePrefs {

private val scope = CoroutineScope(SupervisorJob() + dispatchers.default)
private val key = stringPreferencesKey(KEY_NODE_DISPLAY_NAMES)

override val displayNames: StateFlow<Map<Int, String>> =
dataStore.data
.map { prefs ->
val raw = prefs[key] ?: return@map emptyMap<Int, String>()
parseMap(raw)
}
.stateIn(scope, SharingStarted.Eagerly, emptyMap())

override fun setDisplayName(nodeNum: Int, name: String?) {
scope.launch {
dataStore.edit { prefs ->
val current = prefs[key]?.let { parseMap(it) } ?: emptyMap()
val next =
if (name.isNullOrBlank()) {
current - nodeNum
} else {
current + (nodeNum to name.trim())
}
prefs[key] = encodeMap(next)
}
}
}

private fun encodeValue(value: String): String =
value.replace("$", "$E").replace(KV_SEP, "$K").replace(ENTRY_SEP, "$V")

private fun decodeValue(value: String): String =
value.replace("$V", ENTRY_SEP.toString()).replace("$K", KV_SEP.toString()).replace("$E", "$")

private fun encodeMap(map: Map<Int, String>): String =
map.entries
.joinToString(ENTRY_SEP.toString()) { (num, value) ->
"$num$KV_SEP${encodeValue(value)}"
}

private fun parseMap(raw: String): Map<Int, String> {
if (raw.isEmpty()) return emptyMap()
return buildMap {
raw.split(ENTRY_SEP).forEach { entry ->
val idx = entry.indexOf(KV_SEP)
if (idx > 0) {
val num = entry.substring(0, idx).toIntOrNull() ?: return@forEach
val value = decodeValue(entry.substring(idx + 1))
put(num, value)
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,13 @@ fun RadioPrefs.isTcp() = devAddr.value?.startsWith("t") == true

fun RadioPrefs.isNoop() = devAddr.value?.startsWith("n") == true

/** Reactive interface for local node display name overrides (display-only, not sent to device). */
interface NodeDisplayNamePrefs {
val displayNames: StateFlow<Map<Int, String>>

fun setDisplayName(nodeNum: Int, name: String?)
}

/** Reactive interface for mesh connection settings. */
interface MeshPrefs {
val deviceAddress: StateFlow<String?>
Expand Down Expand Up @@ -169,5 +176,6 @@ interface AppPreferences {
val mapConsent: MapConsentPrefs
val mapTileProvider: MapTileProviderPrefs
val radio: RadioPrefs
val nodeDisplayNames: NodeDisplayNamePrefs
val mesh: MeshPrefs
}
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,10 @@
<string name="favorite">Favorite</string>
<string name="add_favorite">Add to favorites</string>
<string name="remove_favorite">Remove from favorites</string>
<string name="set_display_name">Set display name</string>
<string name="display_name">Display name</string>
<string name="display_name_hint">Local name for this node</string>
<string name="clear_display_name">Clear display name</string>
<string name="favorite_add">Add '%1$s' as a favorite node?</string>
<string name="favorite_remove">Remove '%1$s' as a favorite node?</string>
<string name="power_metrics_log">Power Metrics</string>
Expand Down
48 changes: 48 additions & 0 deletions docs/PULL_REQUEST_DISPLAY_NAMES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Local display names for nodes

Users can set a **local display name** for any node. That name is shown in the app instead of the device’s long/short name, and is stored only on the device (not sent over the mesh).

---

## Summary

<table>
<tr><td><strong>Storage</strong></td><td><code>NodeDisplayNamePrefs</code> (DataStore), keyed by <strong>node number</strong></td></tr>
<tr><td><strong>Scope</strong></td><td>Node list, node detail, detail screen title</td></tr>
<tr><td><strong>Edit entry points</strong></td><td>Long-press node → “Set display name”; Node detail → “Display name” row</td></tr>
</table>

---

## UI

<ul>
<li><strong>Node list</strong> – Each row shows the custom display name when set, otherwise the device long name.</li>
<li><strong>Long-press menu</strong> – “Set display name” (Edit icon) opens a dialog to set or clear the name.</li>
<li><strong>Node detail</strong> – Title shows the display name; a “Display name” row in the Details card is tappable to edit.</li>
<li><strong>Edit dialog</strong> – Text field (“Local name for this node”), <strong>Save</strong>, <strong>Cancel</strong>, and <strong>Clear display name</strong>.</li>
</ul>

---

## Technical details

- **Key:** Display names are stored and looked up by <strong>node number</strong> (<code>num</code>), the unique node ID.
- **Persistence:** Single DataStore preference; map encoded as <code>num\u0001name\u0002…</code> with escaping for names containing delimiters.
- **New strings:</strong> <code>set_display_name</code>, <code>display_name</code>, <code>display_name_hint</code>, <code>clear_display_name</code> (default locale only in this PR).

---

## Testing

- Unit tests: <code>core/prefs/…/NodeDisplayNamePrefsTest.kt</code> (defaults, set/get, clear, multiple nodes, trim).
- Run: <code>./gradlew :core:prefs:testDebugUnitTest</code>

---

## Checklist

- [x] Display name keyed by node number only
- [x] Shown in list and detail; editable from list context menu and detail row
- [x] Clear display name restores device name
- [x] No change to device identity or protocol; local-only
Loading