feat: Implement KMP ServiceDiscovery for TCP devices (#4854)

This commit is contained in:
James Rich 2026-03-19 12:19:58 -05:00 committed by GitHub
parent a5d3914149
commit b982b145e6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 523 additions and 77 deletions

View file

@ -17,7 +17,6 @@
package org.meshtastic.feature.connections.domain.usecase
import android.hardware.usb.UsbManager
import android.net.nsd.NsdServiceInfo
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
@ -28,6 +27,7 @@ import org.meshtastic.core.common.database.DatabaseManager
import org.meshtastic.core.datastore.RecentAddressesDataSource
import org.meshtastic.core.datastore.model.RecentAddress
import org.meshtastic.core.model.Node
import org.meshtastic.core.network.repository.DiscoveredService
import org.meshtastic.core.network.repository.NetworkRepository
import org.meshtastic.core.network.repository.NetworkRepository.Companion.toAddressString
import org.meshtastic.core.network.repository.UsbRepository
@ -56,6 +56,7 @@ class AndroidGetDiscoveredDevicesUseCase(
private val usbManagerLazy: Lazy<UsbManager>,
) : GetDiscoveredDevicesUseCase {
private val suffixLength = 4
private val macSuffixLength = 8
@Suppress("LongMethod", "CyclomaticComplexMethod")
override fun invoke(showMock: Boolean): Flow<DiscoveredDevices> {
@ -72,7 +73,7 @@ class AndroidGetDiscoveredDevicesUseCase(
tcpServices
.map { service ->
val address = "t${service.toAddressString()}"
val txtRecords = service.attributes
val txtRecords = service.txt
val shortNameBytes = txtRecords["shortname"]
val idBytes = txtRecords["id"]
@ -125,7 +126,7 @@ class AndroidGetDiscoveredDevicesUseCase(
val usbDevices = args[3] as List<DeviceListEntry.Usb>
@Suppress("UNCHECKED_CAST", "MagicNumber")
val resolved = args[4] as List<NsdServiceInfo>
val resolved = args[4] as List<DiscoveredService>
@Suppress("UNCHECKED_CAST", "MagicNumber")
val recentList = args[5] as List<RecentAddress>
@ -136,8 +137,14 @@ class AndroidGetDiscoveredDevicesUseCase(
val matchingNode =
if (databaseManager.hasDatabaseFor(entry.fullAddress)) {
db.values.find { node ->
val suffix = entry.device.getMeshtasticShortName()?.lowercase(Locale.ROOT)
suffix != null && node.user.id.lowercase(Locale.ROOT).endsWith(suffix)
val macSuffix =
entry.device.address
.replace(":", "")
.takeLast(macSuffixLength)
.lowercase(Locale.ROOT)
val nameSuffix = entry.device.getMeshtasticShortName()?.lowercase(Locale.ROOT)
node.user.id.lowercase(Locale.ROOT).endsWith(macSuffix) ||
(nameSuffix != null && node.user.id.lowercase(Locale.ROOT).endsWith(nameSuffix))
}
} else {
null
@ -171,7 +178,7 @@ class AndroidGetDiscoveredDevicesUseCase(
val matchingNode =
if (databaseManager.hasDatabaseFor(entry.fullAddress)) {
val resolvedService = resolved.find { "t${it.toAddressString()}" == entry.fullAddress }
val deviceId = resolvedService?.attributes?.get("id")?.let { String(it, Charsets.UTF_8) }
val deviceId = resolvedService?.txt?.get("id")?.let { String(it, Charsets.UTF_8) }
db.values.find { node ->
node.user.id == deviceId || (deviceId != null && node.user.id == "!$deviceId")
}

View file

@ -22,9 +22,12 @@ import org.jetbrains.compose.resources.getString
import org.koin.core.annotation.Single
import org.meshtastic.core.common.database.DatabaseManager
import org.meshtastic.core.datastore.RecentAddressesDataSource
import org.meshtastic.core.network.repository.NetworkRepository
import org.meshtastic.core.network.repository.NetworkRepository.Companion.toAddressString
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.demo_mode
import org.meshtastic.core.resources.meshtastic
import org.meshtastic.feature.connections.model.DeviceListEntry
import org.meshtastic.feature.connections.model.DiscoveredDevices
import org.meshtastic.feature.connections.model.GetDiscoveredDevicesUseCase
@ -34,17 +37,69 @@ class CommonGetDiscoveredDevicesUseCase(
private val recentAddressesDataSource: RecentAddressesDataSource,
private val nodeRepository: NodeRepository,
private val databaseManager: DatabaseManager,
private val networkRepository: NetworkRepository,
private val usbScanner: UsbScanner? = null,
) : GetDiscoveredDevicesUseCase {
private val suffixLength = 4
@Suppress("LongMethod", "CyclomaticComplexMethod")
override fun invoke(showMock: Boolean): Flow<DiscoveredDevices> {
val nodeDb = nodeRepository.nodeDBbyNum
val usbFlow = usbScanner?.scanUsbDevices() ?: kotlinx.coroutines.flow.flowOf(emptyList())
return combine(nodeDb, recentAddressesDataSource.recentAddresses, usbFlow) { db, recentList, usbList ->
val processedTcpFlow =
combine(networkRepository.resolvedList, recentAddressesDataSource.recentAddresses) {
tcpServices,
recentList,
->
val recentMap = recentList.associateBy({ it.address }) { it.name }
tcpServices
.map { service ->
val address = "t${service.toAddressString()}"
val txtRecords = service.txt
val shortNameBytes = txtRecords["shortname"]
val idBytes = txtRecords["id"]
val shortName =
shortNameBytes?.let { it.decodeToString() }
?: runCatching { getString(Res.string.meshtastic) }.getOrDefault("Meshtastic")
val deviceId = idBytes?.let { it.decodeToString() }?.replace("!", "")
var displayName = recentMap[address] ?: shortName
if (deviceId != null && (displayName.split("_").none { it == deviceId })) {
displayName += "_$deviceId"
}
DeviceListEntry.Tcp(displayName, address)
}
.sortedBy { it.name }
}
return combine(
nodeDb,
processedTcpFlow,
networkRepository.resolvedList,
recentAddressesDataSource.recentAddresses,
usbFlow,
) { db, processedTcp, resolved, recentList, usbList ->
val discoveredTcpForUi =
processedTcp.map { entry ->
val matchingNode =
if (databaseManager.hasDatabaseFor(entry.fullAddress)) {
val resolvedService = resolved.find { "t${it.toAddressString()}" == entry.fullAddress }
val deviceId = resolvedService?.txt?.get("id")?.let { it.decodeToString() }
db.values.find { node ->
node.user.id == deviceId || (deviceId != null && node.user.id == "!$deviceId")
}
} else {
null
}
entry.copy(node = matchingNode)
}
val discoveredTcpAddresses = processedTcp.map { it.fullAddress }.toSet()
val recentTcpForUi =
recentList
.filterNot { discoveredTcpAddresses.contains(it.address) }
.map { DeviceListEntry.Tcp(it.name, it.address) }
.map { entry ->
val matchingNode =
@ -63,6 +118,7 @@ class CommonGetDiscoveredDevicesUseCase(
.sortedBy { it.name }
DiscoveredDevices(
discoveredTcpDevices = discoveredTcpForUi,
recentTcpDevices = recentTcpForUi,
usbDevices =
usbList +

View file

@ -16,25 +16,52 @@
*/
package org.meshtastic.feature.connections.domain.usecase
import app.cash.turbine.test
import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.matcher.any
import dev.mokkery.mock
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.meshtastic.core.common.database.DatabaseManager
import org.meshtastic.core.datastore.RecentAddressesDataSource
import org.meshtastic.core.datastore.model.RecentAddress
import org.meshtastic.core.network.repository.DiscoveredService
import org.meshtastic.core.network.repository.NetworkRepository
import org.meshtastic.core.testing.FakeNodeRepository
import org.meshtastic.core.testing.TestDataFactory
import kotlin.test.Test
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/** Tests for [CommonGetDiscoveredDevicesUseCase] covering TCP device discovery and node matching. */
class CommonGetDiscoveredDevicesUseCaseTest {
/*
private lateinit var useCase: CommonGetDiscoveredDevicesUseCase
private lateinit var nodeRepository: FakeNodeRepository
private lateinit var recentAddressesDataSource: RecentAddressesDataSource
private lateinit var databaseManager: DatabaseManager
private lateinit var networkRepository: NetworkRepository
private val recentAddressesFlow = MutableStateFlow<List<RecentAddress>>(emptyList())
private val resolvedServicesFlow = MutableStateFlow<List<DiscoveredService>>(emptyList())
private fun setUp() {
nodeRepository = FakeNodeRepository()
recentAddressesDataSource = mock { every { recentAddresses } returns recentAddressesFlow }
databaseManager = mock { every { hasDatabaseFor(any()) } returns false }
networkRepository = mock {
every { resolvedList } returns resolvedServicesFlow
every { networkAvailable } returns flowOf(true)
}
useCase =
CommonGetDiscoveredDevicesUseCase(
recentAddressesDataSource = recentAddressesDataSource,
nodeRepository = nodeRepository,
databaseManager = databaseManager,
networkRepository = networkRepository,
)
}
@ -45,7 +72,6 @@ class CommonGetDiscoveredDevicesUseCaseTest {
val result = awaitItem()
assertTrue(result.recentTcpDevices.isEmpty(), "No recent TCP devices when empty")
assertTrue(result.usbDevices.isEmpty(), "No USB devices when showMock=false")
assertTrue(result.bleDevices.isEmpty(), "No BLE devices in common use case")
assertTrue(result.discoveredTcpDevices.isEmpty(), "No discovered TCP in common use case")
cancelAndIgnoreRemainingEvents()
}
@ -71,7 +97,7 @@ class CommonGetDiscoveredDevicesUseCaseTest {
setUp()
useCase.invoke(showMock = true).test {
val result = awaitItem()
"Mock device should appear in usbDevices" shouldBe 1, result.usbDevices.size
result.usbDevices.size shouldBe 1
cancelAndIgnoreRemainingEvents()
}
}
@ -92,7 +118,14 @@ class CommonGetDiscoveredDevicesUseCaseTest {
val testNode = TestDataFactory.createTestNode(num = 1, userId = "!test1234", longName = "Test Node")
nodeRepository.setNodes(listOf(testNode))
every { databaseManager.hasDatabaseFor("tMeshtastic_1234") } returns true
databaseManager = mock { every { hasDatabaseFor("tMeshtastic_1234") } returns true }
useCase =
CommonGetDiscoveredDevicesUseCase(
recentAddressesDataSource = recentAddressesDataSource,
nodeRepository = nodeRepository,
databaseManager = databaseManager,
networkRepository = networkRepository,
)
recentAddressesFlow.value = listOf(RecentAddress("tMeshtastic_1234", "Meshtastic_1234"))
@ -111,8 +144,6 @@ class CommonGetDiscoveredDevicesUseCaseTest {
val testNode = TestDataFactory.createTestNode(num = 1, userId = "!test1234")
nodeRepository.setNodes(listOf(testNode))
every { databaseManager.hasDatabaseFor(any()) } returns false
recentAddressesFlow.value = listOf(RecentAddress("tMeshtastic_1234", "Meshtastic_1234"))
useCase.invoke(showMock = false).test {
@ -123,24 +154,6 @@ class CommonGetDiscoveredDevicesUseCaseTest {
}
}
@Test
fun testSuffixTooShortForMatch() = runTest {
setUp()
val testNode = TestDataFactory.createTestNode(num = 1, userId = "!test1234")
nodeRepository.setNodes(listOf(testNode))
every { databaseManager.hasDatabaseFor("tShort_ab") } returns true
recentAddressesFlow.value = listOf(RecentAddress("tShort_ab", "Short_ab"))
useCase.invoke(showMock = false).test {
val result = awaitItem()
result.recentTcpDevices.size shouldBe 1
assertNull(result.recentTcpDevices[0].node, "Suffix 'ab' is too short (< 4) to match")
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun testReactiveNodeUpdates() = runTest {
setUp()
@ -153,10 +166,66 @@ class CommonGetDiscoveredDevicesUseCaseTest {
// Add a node to the repository — flow should re-emit
nodeRepository.setNodes(TestDataFactory.createTestNodes(2))
val secondResult = awaitItem()
"Recent TCP devices count unchanged" shouldBe 1, secondResult.recentTcpDevices.size
secondResult.recentTcpDevices.size shouldBe 1
cancelAndIgnoreRemainingEvents()
}
}
*/
@Test
fun testDiscoveredTcpDevices() = runTest {
setUp()
resolvedServicesFlow.value =
listOf(
DiscoveredService(
name = "Meshtastic_1234",
hostAddress = "192.168.1.50",
port = 4403,
txt = mapOf("id" to "!1234".encodeToByteArray(), "shortname" to "Mesh".encodeToByteArray()),
),
)
useCase.invoke(showMock = false).test {
val result = awaitItem()
result.discoveredTcpDevices.size shouldBe 1
result.discoveredTcpDevices[0].name shouldBe "Mesh_1234"
result.discoveredTcpDevices[0].fullAddress shouldBe "t192.168.1.50"
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun testDiscoveredTcpDeviceMatchesNode() = runTest {
setUp()
val testNode = TestDataFactory.createTestNode(num = 1, userId = "!1234", longName = "Mesh")
nodeRepository.setNodes(listOf(testNode))
databaseManager = mock { every { hasDatabaseFor("t192.168.1.50") } returns true }
useCase =
CommonGetDiscoveredDevicesUseCase(
recentAddressesDataSource = recentAddressesDataSource,
nodeRepository = nodeRepository,
databaseManager = databaseManager,
networkRepository = networkRepository,
)
resolvedServicesFlow.value =
listOf(
DiscoveredService(
name = "Meshtastic_1234",
hostAddress = "192.168.1.50",
port = 4403,
txt = mapOf("id" to "!1234".encodeToByteArray(), "shortname" to "Mesh".encodeToByteArray()),
),
)
useCase.invoke(showMock = false).test {
val result = awaitItem()
result.discoveredTcpDevices.size shouldBe 1
assertNotNull(result.discoveredTcpDevices[0].node)
result.discoveredTcpDevices[0].node?.user?.id shouldBe "!1234"
cancelAndIgnoreRemainingEvents()
}
}
}