mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
feat: Implement KMP ServiceDiscovery for TCP devices (#4854)
This commit is contained in:
parent
a5d3914149
commit
b982b145e6
17 changed files with 523 additions and 77 deletions
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 +
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue