Skip to content
Merged
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
4 changes: 4 additions & 0 deletions .skills/compose-ui/strings-index.txt

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import org.meshtastic.core.model.effectiveLongName
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.feature.docs.ai.AIDocAssistant
import org.meshtastic.feature.docs.data.DocBundleLoader
Expand Down Expand Up @@ -496,7 +497,7 @@ class GeminiNanoDocAssistant(

"""
|- Node Battery Status: $batteryStr ($voltStr)
|- User Identity: ${ourNode.user.short_name} (${ourNode.user.long_name})
|- User Identity: ${ourNode.user.short_name} (${ourNode.effectiveLongName})
|- Position: ${if (ourNode.isUnknownUser) "Unknown user" else "Valid user profile synced"}
"""
.trimIndent()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.TracerouteOverlay
import org.meshtastic.core.model.effectiveLongName
import org.meshtastic.core.model.geofence.toGeofence
import org.meshtastic.core.model.hasFix
import org.meshtastic.core.model.isLocked
Expand Down Expand Up @@ -1329,7 +1330,7 @@ internal fun rememberNodeClusterItems(
node = node,
nodePosition = latLng,
nodeTitle = "${node.user.short_name} ${formatAgo(node.position.time, unknownText, nowText)}",
nodeSnippet = node.user.long_name,
nodeSnippet = node.effectiveLongName,
myNodeNum = myNodeNum,
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import kotlinx.coroutines.withTimeout
import org.koin.core.annotation.Single
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.NodeAddress
import org.meshtastic.core.model.effectiveLongName
import org.meshtastic.core.model.util.TimeConstants
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
Expand Down Expand Up @@ -118,7 +119,7 @@ class AiFunctionProviderImpl(
val totalCount = nodeRepository.totalNodeCount.first()
val ourNode = nodeRepository.ourNodeInfo.value
val batteryLevel = ourNode?.batteryLevel?.takeIf { it in 1..MAX_BATTERY_LEVEL }
val nodeName = ourNode?.user?.long_name?.takeIf { it.isNotBlank() }
val nodeName = ourNode?.effectiveLongName?.takeIf { it.isNotBlank() }

MeshStatusResult(
connectionState = connectionState.name,
Expand All @@ -141,7 +142,7 @@ class AiFunctionProviderImpl(
nodeMap.values.map { node ->
NodeSummary(
id = NodeAddress.numToDefaultId(node.num),
name = node.user.long_name.takeIf { it.isNotBlank() } ?: "Node ${node.num}",
name = node.effectiveLongName.takeIf { it.isNotBlank() } ?: "Node ${node.num}",
batteryLevel = node.deviceMetrics.battery_level?.coerceIn(0, MAX_BATTERY_LEVEL),
lastHeard = node.lastHeard.toLong() * TimeConstants.MS_PER_SEC,
isOnline = node.isOnline,
Expand Down Expand Up @@ -194,7 +195,7 @@ class AiFunctionProviderImpl(
firmwareVersion = ourNode.metadata?.firmware_version ?: "Unknown",
batteryLevel = ourNode.deviceMetrics.battery_level?.coerceIn(0, MAX_BATTERY_LEVEL),
chargingStatus = "UNKNOWN",
deviceName = ourNode.user.long_name.takeIf { it.isNotBlank() },
deviceName = ourNode.effectiveLongName.takeIf { it.isNotBlank() },
isActive = serviceRepository.connectionState.value == ConnectionState.Connected,
)
GetDeviceStatusResult.Success(deviceStatus)
Expand Down Expand Up @@ -236,7 +237,7 @@ class AiFunctionProviderImpl(
NodeDetails(
id = NodeAddress.numToDefaultId(node.num),
userId = node.user.id,
name = node.user.long_name.takeIf { it.isNotBlank() } ?: "Node ${node.num}",
name = node.effectiveLongName.takeIf { it.isNotBlank() } ?: "Node ${node.num}",
batteryLevel = node.deviceMetrics.battery_level?.coerceIn(0, MAX_BATTERY_LEVEL),
voltage = node.deviceMetrics.voltage,
hardwareModel = node.metadata?.hw_model?.name ?: "Unknown",
Expand Down Expand Up @@ -367,7 +368,7 @@ class AiFunctionProviderImpl(
val channelSet = radioConfigRepository.channelSetFlow.first()
val summaries = messages.map { msg ->
MessageSummary(
senderName = msg.node.user.long_name.takeIf { it.isNotBlank() } ?: "Node ${msg.node.num}",
senderName = msg.node.effectiveLongName.takeIf { it.isNotBlank() } ?: "Node ${msg.node.num}",
text = msg.text,
contactName = resolveContactDisplayName(msg, channelSet),
receivedTime = msg.receivedTime,
Expand Down Expand Up @@ -407,7 +408,7 @@ class AiFunctionProviderImpl(
} else {
val userId = lastPacket.from ?: ""
val node = nodeMap.values.find { it.user.id == userId }
node?.user?.long_name?.takeIf { it.isNotBlank() } ?: "Unknown"
node?.effectiveLongName?.takeIf { it.isNotBlank() } ?: "Unknown"
}

ContactUnread(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package org.meshtastic.core.data.ai

import kotlinx.coroutines.flow.first
import org.koin.core.annotation.Single
import org.meshtastic.core.model.effectiveLongName
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.RadioConfigRepository

Expand All @@ -38,8 +39,8 @@ class FuzzyNameResolver(
val nodes = nodeRepository.nodeDBbyNum.value
val candidates =
nodes.values
.filter { it.user.long_name.isNotBlank() }
.map { NameCandidate(it.user.long_name, it.num, it.user.id) }
.filter { it.effectiveLongName.isNotBlank() }
.map { NameCandidate(it.effectiveLongName, it.num, it.user.id) }
Comment thread
jamesarich marked this conversation as resolved.

return matchName(query, candidates)
}
Expand Down Expand Up @@ -87,12 +88,13 @@ class FuzzyNameResolver(
private fun matchName(query: String, candidates: List<NameCandidate>): NodeNameResult {
if (candidates.isEmpty()) return NodeNameResult.NotFound

// Exact match first (case-insensitive)
candidates
.firstOrNull { it.name.equals(query, ignoreCase = true) }
?.let {
return NodeNameResult.Found(it.nodeNum, it.userId)
}
// Exact match first (case-insensitive). A display name can repeat another node's name, so a shared one is
// ambiguous rather than whichever node the map yields first.
val exact = candidates.filter { it.name.equals(query, ignoreCase = true) }.distinctBy { it.nodeNum }
if (exact.isNotEmpty()) {
return exact.singleOrNull()?.let { NodeNameResult.Found(it.nodeNum, it.userId) }
?: NodeNameResult.Ambiguous(exact.map { it.name })
}

// Fuzzy match using longest common substring
val minScore = (query.length * MATCH_THRESHOLD).toInt().coerceAtLeast(1)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import org.meshtastic.core.database.entity.MetadataEntity
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.entity.NodeEntity

@Suppress("TooManyFunctions") // One method per persisted node field; the count tracks the schema.
interface NodeInfoWriteDataSource {
suspend fun upsert(node: NodeEntity)

Expand All @@ -38,6 +39,8 @@ interface NodeInfoWriteDataSource {

suspend fun setNodeNotes(num: Int, notes: String)

suspend fun setNodeDisplayName(num: Int, displayName: String)

suspend fun markAllHeardOnCurrentLora()

suspend fun updatePowerChannelLabel(num: Int, channelIndex: Int, label: String)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.entity.NodeEntity
import org.meshtastic.core.di.CoroutineDispatchers

@Suppress("TooManyFunctions") // One method per persisted node field; the count tracks the schema.
@Single
class SwitchingNodeInfoWriteDataSource(
private val dbManager: DatabaseProvider,
Expand Down Expand Up @@ -67,6 +68,10 @@ class SwitchingNodeInfoWriteDataSource(
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().setNodeNotes(num, notes) } }
}

override suspend fun setNodeDisplayName(num: Int, displayName: String) {
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().setNodeDisplayName(num, displayName) } }
}

override suspend fun markAllHeardOnCurrentLora() {
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().markAllHeardOnCurrentLora() } }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import kotlinx.coroutines.launch
import org.koin.core.annotation.Single
import org.meshtastic.core.common.di.ServiceScope
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.effectiveLongName
import org.meshtastic.core.model.geofence.activeWaypointPackets
import org.meshtastic.core.model.geofence.geofencesToMonitor
import org.meshtastic.core.model.geofence.toGeofence
Expand Down Expand Up @@ -171,7 +172,7 @@ class GeofenceMonitor(

try {
val nodeName =
nodeManager.nodeDBbyNodeNum[nodeNum]?.user?.long_name?.takeIf { it.isNotBlank() }
nodeManager.nodeDBbyNodeNum[nodeNum]?.effectiveLongName?.takeIf { it.isNotBlank() }
?: getStringSuspend(Res.string.unknown_username)
val waypointName = waypoint.name.takeIf { it.isNotBlank() } ?: getStringSuspend(Res.string.geofence)
val title =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeAddress
import org.meshtastic.core.model.Reaction
import org.meshtastic.core.model.destination
import org.meshtastic.core.model.effectiveLongName
import org.meshtastic.core.model.geofence.activeWaypointPackets
import org.meshtastic.core.model.isBroadcast
import org.meshtastic.core.model.isFromLocal
Expand Down Expand Up @@ -588,9 +589,9 @@ class MeshDataHandlerImpl(
private suspend fun getSenderName(packet: DataPacket): String {
if (packet.source is NodeAddress.Local) {
val myId = nodeManager.getMyId()
return nodeManager.getNodeById(myId)?.user?.long_name ?: getStringSuspend(Res.string.unknown_username)
return nodeManager.getNodeById(myId)?.effectiveLongName ?: getStringSuspend(Res.string.unknown_username)
}
return nodeManager.getNodeById(packet.from.orEmpty())?.user?.long_name
return nodeManager.getNodeById(packet.from.orEmpty())?.effectiveLongName
?: getStringSuspend(Res.string.unknown_username)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ class NeighborInfoHandlerImpl(

override fun recordStartTime(requestId: Int) = requestTimer.start(requestId)

private fun neighborLongName(nodeNum: Int, broadcastName: String): String =
nodeRepository.nodeDBbyNum.value[nodeNum]?.displayName?.takeIf { it.isNotBlank() } ?: broadcastName

override fun handleNeighborInfo(packet: MeshPacket) {
val payload = packet.decoded?.payload ?: return
val ni = NeighborInfo.ADAPTER.decode(payload)
Expand All @@ -56,12 +59,12 @@ class NeighborInfoHandlerImpl(
val neighbors =
ni.neighbors.joinToString("\n") { n ->
val user = nodeRepository.getUser(n.node_id)
val name = "${user.long_name} (${user.short_name})"
val name = "${neighborLongName(n.node_id, user.long_name)} (${user.short_name})"
"• $name (SNR: ${MetricFormatter.snr(n.snr)})"
}

val fromUser = nodeRepository.getUser(from)
val formatted = "Neighbors of ${fromUser.long_name}:\n$neighbors"
val formatted = "Neighbors of ${neighborLongName(from, fromUser.long_name)}:\n$neighbors"

val responseText = requestTimer.appendDuration(requestId, formatted, "Neighbor info")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,14 @@ class TracerouteHandlerImpl(
internal var renderResponse: suspend (RouteDiscovery) -> String = { routeDiscovery ->
val unknownHopLabel = getStringSuspend(Res.string.traceroute_unknown_hop)
routeDiscovery.getTracerouteResponse(
getUser = { num -> tracerouteHopName(num, nodeRepository.getUser(num), unknownHopLabel) },
getUser = { num ->
tracerouteHopName(
num,
nodeRepository.getUser(num),
unknownHopLabel,
nodeRepository.nodeDBbyNum.value[num]?.displayName.orEmpty(),
)
},
headerTowards = getStringSuspend(Res.string.traceroute_route_towards_dest),
headerBack = getStringSuspend(Res.string.traceroute_route_back_to_us),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,9 @@ class NodeRepositoryImpl(
override suspend fun setNodeNotes(num: Int, notes: String) =
withContext(dispatchers.io) { nodeInfoWriteDataSource.setNodeNotes(num, notes) }

override suspend fun setNodeDisplayName(num: Int, displayName: String) =
withContext(dispatchers.io) { nodeInfoWriteDataSource.setNodeDisplayName(num, displayName) }

override suspend fun markAllHeardOnCurrentLora() =
withContext(dispatchers.io) { nodeInfoWriteDataSource.markAllHeardOnCurrentLora() }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,46 @@ class FuzzyNameResolverTest {
assertEquals(2, result.candidates.size)
}

@Test
fun resolveNodeName_exact_name_shared_by_a_display_name_is_ambiguous() {
val nodeRepository: NodeRepository = mock(MockMode.autofill)
val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill)
val nodes =
mapOf(
1 to
Node(
num = 1,
user =
User.Builder()
.also { wb ->
wb.id = "!00000001"
wb.long_name = "Base Camp"
wb.short_name = "BC"
}
.build(),
),
2 to
Node(
num = 2,
user =
User.Builder()
.also { wb ->
wb.id = "!00000002"
wb.long_name = "Hilltop"
wb.short_name = "HT"
}
.build(),
displayName = "Base Camp",
),
)
every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(nodes)

val result = FuzzyNameResolver(nodeRepository, radioConfigRepository).resolveNodeName("base camp")

assertIs<NodeNameResult.Ambiguous>(result)
assertEquals(2, result.candidates.size)
}

@Test
fun resolveNodeName_not_found_when_no_nodes() {
val nodeRepository: NodeRepository = mock(MockMode.autofill)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,12 @@ import dev.mokkery.MockMode
import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.matcher.any
import dev.mokkery.matcher.capture.capture
import dev.mokkery.mock
import dev.mokkery.verify
import kotlinx.coroutines.flow.MutableStateFlow
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.model.Node
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.ServiceRepository
Expand All @@ -35,7 +37,9 @@ import org.meshtastic.proto.User
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue

class NeighborInfoHandlerImplTest {

Expand All @@ -50,6 +54,7 @@ class NeighborInfoHandlerImplTest {
@BeforeTest
fun setUp() {
every { nodeManager.myNodeNum } returns MutableStateFlow<Int?>(myNodeNum)
every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(emptyMap())
handler = NeighborInfoHandlerImpl(nodeManager, serviceRepository, nodeRepository)
}

Expand Down Expand Up @@ -184,6 +189,33 @@ class NeighborInfoHandlerImplTest {
verify { serviceRepository.setNeighborInfoResponse(any()) }
}

@Test
fun `handleNeighborInfo names a neighbor by its local display name`() {
every { nodeRepository.nodeDBbyNum } returns
MutableStateFlow(mapOf(100 to Node(num = 100, displayName = "Nickname")))
val ni =
NeighborInfo.Builder()
.also { wb ->
wb.node_id = myNodeNum
wb.neighbors = listOf(Neighbor.Builder().also { nb -> nb.node_id = 100 }.build())
}
.build()
every { nodeRepository.getUser(any<Int>()) } returns
User.Builder()
.also { wb ->
wb.long_name = "Alice"
wb.short_name = "AL"
}
.build()
val responses = mutableListOf<String?>()
every { serviceRepository.setNeighborInfoResponse(capture(responses)) } returns Unit

handler.handleNeighborInfo(createPacketWithNeighborInfo(from = myNodeNum, ni = ni))

assertTrue(responses.single().orEmpty().contains("Nickname (AL)"))
assertFalse(responses.single().orEmpty().contains("Alice (AL)"))
}

@Test
fun `handleNeighborInfo ignores packet with null decoded`() {
val packet = MeshPacket.Builder().also { wb -> wb.from = myNodeNum }.build()
Expand Down
Loading
Loading