mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
Refactor command handling, enhance tests, and improve discovery logic (#4878)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
parent
d136b162a4
commit
c38bfc64de
76 changed files with 2220 additions and 1277 deletions
|
|
@ -29,7 +29,6 @@ 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
|
||||
import org.meshtastic.core.repository.NodeRepository
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
|
|
@ -55,7 +54,6 @@ class AndroidGetDiscoveredDevicesUseCase(
|
|||
private val radioInterfaceService: RadioInterfaceService,
|
||||
private val usbManagerLazy: Lazy<UsbManager>,
|
||||
) : GetDiscoveredDevicesUseCase {
|
||||
private val suffixLength = 4
|
||||
private val macSuffixLength = 8
|
||||
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
|
|
@ -69,24 +67,8 @@ class AndroidGetDiscoveredDevicesUseCase(
|
|||
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 { String(it, Charsets.UTF_8) } ?: getString(Res.string.meshtastic)
|
||||
val deviceId = idBytes?.let { String(it, Charsets.UTF_8) }?.replace("!", "")
|
||||
var displayName = recentMap[address] ?: shortName
|
||||
if (deviceId != null && (displayName.split("_").none { it == deviceId })) {
|
||||
displayName += "_$deviceId"
|
||||
}
|
||||
DeviceListEntry.Tcp(displayName, address)
|
||||
}
|
||||
.sortedBy { it.name }
|
||||
val defaultName = getString(Res.string.meshtastic)
|
||||
processTcpServices(tcpServices, recentList, defaultName)
|
||||
}
|
||||
|
||||
val usbDevicesFlow =
|
||||
|
|
@ -131,6 +113,7 @@ class AndroidGetDiscoveredDevicesUseCase(
|
|||
@Suppress("UNCHECKED_CAST", "MagicNumber")
|
||||
val recentList = args[5] as List<RecentAddress>
|
||||
|
||||
// Android-specific: BLE node matching by MAC suffix and Meshtastic short name
|
||||
val bleForUi =
|
||||
bondedBle
|
||||
.map { entry ->
|
||||
|
|
@ -153,61 +136,20 @@ class AndroidGetDiscoveredDevicesUseCase(
|
|||
}
|
||||
.sortedBy { it.name }
|
||||
|
||||
// Android-specific: USB node matching via shared helper
|
||||
val usbForUi =
|
||||
(
|
||||
usbDevices +
|
||||
if (showMock) listOf(DeviceListEntry.Mock(getString(Res.string.demo_mode))) else emptyList()
|
||||
)
|
||||
.map { entry ->
|
||||
val matchingNode =
|
||||
if (databaseManager.hasDatabaseFor(entry.fullAddress)) {
|
||||
db.values.find { node ->
|
||||
val suffix = entry.name.split("_").lastOrNull()?.lowercase(Locale.ROOT)
|
||||
suffix != null &&
|
||||
suffix.length >= suffixLength &&
|
||||
node.user.id.lowercase(Locale.ROOT).endsWith(suffix)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
entry.copy(node = matchingNode)
|
||||
entry.copy(node = findNodeByNameSuffix(entry.name, entry.fullAddress, db, databaseManager))
|
||||
}
|
||||
|
||||
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 { String(it, Charsets.UTF_8) }
|
||||
db.values.find { node ->
|
||||
node.user.id == deviceId || (deviceId != null && node.user.id == "!$deviceId")
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
entry.copy(node = matchingNode)
|
||||
}
|
||||
|
||||
// Shared TCP logic via helpers
|
||||
val discoveredTcpForUi = matchDiscoveredTcpNodes(processedTcp, db, resolved, databaseManager)
|
||||
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 =
|
||||
if (databaseManager.hasDatabaseFor(entry.fullAddress)) {
|
||||
val suffix = entry.name.split("_").lastOrNull()?.lowercase(Locale.ROOT)
|
||||
db.values.find { node ->
|
||||
suffix != null &&
|
||||
suffix.length >= suffixLength &&
|
||||
node.user.id.lowercase(Locale.ROOT).endsWith(suffix)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
entry.copy(node = matchingNode)
|
||||
}
|
||||
.sortedBy { it.name }
|
||||
val recentTcpForUi = buildRecentTcpEntries(recentList, discoveredTcpAddresses, db, databaseManager)
|
||||
|
||||
DiscoveredDevices(
|
||||
bleDevices = bleForUi,
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ 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
|
||||
|
|
@ -40,9 +39,7 @@ class CommonGetDiscoveredDevicesUseCase(
|
|||
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())
|
||||
|
|
@ -52,25 +49,8 @@ class CommonGetDiscoveredDevicesUseCase(
|
|||
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 }
|
||||
val defaultName = runCatching { getString(Res.string.meshtastic) }.getOrDefault("Meshtastic")
|
||||
processTcpServices(tcpServices, recentList, defaultName)
|
||||
}
|
||||
|
||||
return combine(
|
||||
|
|
@ -80,42 +60,9 @@ class CommonGetDiscoveredDevicesUseCase(
|
|||
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 discoveredTcpForUi = matchDiscoveredTcpNodes(processedTcp, db, resolved, databaseManager)
|
||||
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 =
|
||||
if (databaseManager.hasDatabaseFor(entry.fullAddress)) {
|
||||
val suffix = entry.name.split("_").lastOrNull()?.lowercase()
|
||||
db.values.find { node ->
|
||||
suffix != null &&
|
||||
suffix.length >= suffixLength &&
|
||||
node.user.id.lowercase().endsWith(suffix)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
entry.copy(node = matchingNode)
|
||||
}
|
||||
.sortedBy { it.name }
|
||||
val recentTcpForUi = buildRecentTcpEntries(recentList, discoveredTcpAddresses, db, databaseManager)
|
||||
|
||||
DiscoveredDevices(
|
||||
discoveredTcpDevices = discoveredTcpForUi,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,112 @@
|
|||
/*
|
||||
* 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.feature.connections.domain.usecase
|
||||
|
||||
import org.meshtastic.core.common.database.DatabaseManager
|
||||
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.Companion.toAddressString
|
||||
import org.meshtastic.feature.connections.model.DeviceListEntry
|
||||
|
||||
private const val SUFFIX_LENGTH = 4
|
||||
|
||||
/**
|
||||
* Shared helpers for TCP device discovery logic used by both [CommonGetDiscoveredDevicesUseCase] and the
|
||||
* Android-specific variant.
|
||||
*/
|
||||
|
||||
/** Converts a list of [DiscoveredService] into [DeviceListEntry.Tcp] with display names derived from TXT records. */
|
||||
internal fun processTcpServices(
|
||||
tcpServices: List<DiscoveredService>,
|
||||
recentAddresses: List<RecentAddress>,
|
||||
defaultShortName: String = "Meshtastic",
|
||||
): List<DeviceListEntry.Tcp> {
|
||||
val recentMap = recentAddresses.associateBy({ it.address }) { it.name }
|
||||
return tcpServices
|
||||
.map { service ->
|
||||
val address = "t${service.toAddressString()}"
|
||||
val txtRecords = service.txt
|
||||
val shortNameBytes = txtRecords["shortname"]
|
||||
val idBytes = txtRecords["id"]
|
||||
|
||||
val shortName = shortNameBytes?.decodeToString() ?: defaultShortName
|
||||
val deviceId = idBytes?.decodeToString()?.replace("!", "")
|
||||
var displayName = recentMap[address] ?: shortName
|
||||
if (deviceId != null && displayName.split("_").none { it == deviceId }) {
|
||||
displayName += "_$deviceId"
|
||||
}
|
||||
DeviceListEntry.Tcp(displayName, address)
|
||||
}
|
||||
.sortedBy { it.name }
|
||||
}
|
||||
|
||||
/** Matches each discovered TCP entry to a [Node] from the database using its mDNS device ID. */
|
||||
internal fun matchDiscoveredTcpNodes(
|
||||
entries: List<DeviceListEntry.Tcp>,
|
||||
nodeDb: Map<Int, Node>,
|
||||
resolvedServices: List<DiscoveredService>,
|
||||
databaseManager: DatabaseManager,
|
||||
): List<DeviceListEntry.Tcp> = entries.map { entry ->
|
||||
val matchingNode =
|
||||
if (databaseManager.hasDatabaseFor(entry.fullAddress)) {
|
||||
val resolvedService = resolvedServices.find { "t${it.toAddressString()}" == entry.fullAddress }
|
||||
val deviceId = resolvedService?.txt?.get("id")?.decodeToString()
|
||||
nodeDb.values.find { node ->
|
||||
node.user.id == deviceId || (deviceId != null && node.user.id == "!$deviceId")
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
entry.copy(node = matchingNode)
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the "recent TCP devices" list by filtering out currently discovered addresses and matching each entry to a
|
||||
* [Node] by name suffix.
|
||||
*/
|
||||
internal fun buildRecentTcpEntries(
|
||||
recentAddresses: List<RecentAddress>,
|
||||
discoveredAddresses: Set<String>,
|
||||
nodeDb: Map<Int, Node>,
|
||||
databaseManager: DatabaseManager,
|
||||
): List<DeviceListEntry.Tcp> = recentAddresses
|
||||
.filterNot { discoveredAddresses.contains(it.address) }
|
||||
.map { DeviceListEntry.Tcp(it.name, it.address) }
|
||||
.map { entry ->
|
||||
entry.copy(node = findNodeByNameSuffix(entry.name, entry.fullAddress, nodeDb, databaseManager))
|
||||
}
|
||||
.sortedBy { it.name }
|
||||
|
||||
/**
|
||||
* Finds a [Node] matching the last `_`-delimited segment of [displayName], if a local database exists for the given
|
||||
* [fullAddress]. Used by both TCP recent-device matching and Android USB device matching to avoid duplicated
|
||||
* suffix-lookup logic.
|
||||
*/
|
||||
internal fun findNodeByNameSuffix(
|
||||
displayName: String,
|
||||
fullAddress: String,
|
||||
nodeDb: Map<Int, Node>,
|
||||
databaseManager: DatabaseManager,
|
||||
): Node? {
|
||||
val suffix = displayName.split("_").lastOrNull()?.lowercase()
|
||||
return if (!databaseManager.hasDatabaseFor(fullAddress) || suffix == null || suffix.length < SUFFIX_LENGTH) {
|
||||
null
|
||||
} else {
|
||||
nodeDb.values.find { it.user.id.lowercase().endsWith(suffix) }
|
||||
}
|
||||
}
|
||||
|
|
@ -47,7 +47,6 @@ import androidx.compose.ui.text.style.TextAlign
|
|||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
|
|
@ -86,7 +85,6 @@ import org.meshtastic.feature.settings.navigation.ConfigRoute
|
|||
import org.meshtastic.feature.settings.navigation.getNavRouteFrom
|
||||
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
|
||||
import org.meshtastic.feature.settings.radio.component.PacketResponseStateDialog
|
||||
import org.meshtastic.proto.Config
|
||||
import kotlin.uuid.ExperimentalUuidApi
|
||||
|
||||
/** Composable screen for managing device connections (BLE, TCP, USB). It displays connection status. */
|
||||
|
|
@ -102,25 +100,12 @@ fun ConnectionsScreen(
|
|||
onConfigNavigate: (Route) -> Unit,
|
||||
) {
|
||||
val radioConfigState by radioConfigViewModel.radioConfigState.collectAsStateWithLifecycle()
|
||||
val config by connectionsViewModel.localConfig.collectAsStateWithLifecycle()
|
||||
val scanStatusText by scanModel.errorText.collectAsStateWithLifecycle()
|
||||
val connectionState by connectionsViewModel.connectionState.collectAsStateWithLifecycle()
|
||||
|
||||
// Prevent continuous recomposition from lastHeard and snr updates on the node
|
||||
val ourNode by
|
||||
remember(connectionsViewModel.ourNodeInfo) {
|
||||
connectionsViewModel.ourNodeInfo.distinctUntilChanged { old, new ->
|
||||
old?.num == new?.num &&
|
||||
old?.user == new?.user &&
|
||||
old?.batteryLevel == new?.batteryLevel &&
|
||||
old?.voltage == new?.voltage &&
|
||||
old?.metadata?.firmware_version == new?.metadata?.firmware_version
|
||||
}
|
||||
}
|
||||
.collectAsStateWithLifecycle(initialValue = connectionsViewModel.ourNodeInfo.value)
|
||||
val ourNode by connectionsViewModel.ourNodeForDisplay.collectAsStateWithLifecycle()
|
||||
val regionUnset by connectionsViewModel.regionUnset.collectAsStateWithLifecycle()
|
||||
|
||||
val selectedDevice by scanModel.selectedNotNullFlow.collectAsStateWithLifecycle()
|
||||
val regionUnset = config.lora?.region == Config.LoRaConfig.RegionCode.UNSET
|
||||
|
||||
val bleDevices by scanModel.bleDevicesForUi.collectAsStateWithLifecycle()
|
||||
val discoveredTcpDevices by scanModel.discoveredTcpDevicesForUi.collectAsStateWithLifecycle()
|
||||
|
|
@ -192,63 +177,31 @@ fun ConnectionsScreen(
|
|||
|
||||
Crossfade(targetState = uiState, label = "connection_state") { state ->
|
||||
when (state) {
|
||||
2 -> {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
ourNode?.let { node ->
|
||||
TitledCard(title = stringResource(Res.string.connected_device)) {
|
||||
CurrentlyConnectedInfo(
|
||||
node = node,
|
||||
bleDevice =
|
||||
bleDevices.find { it.fullAddress == selectedDevice }
|
||||
as DeviceListEntry.Ble?,
|
||||
onNavigateToNodeDetails = onNavigateToNodeDetails,
|
||||
onClickDisconnect = { scanModel.disconnect() },
|
||||
)
|
||||
}
|
||||
}
|
||||
2 ->
|
||||
ConnectedDeviceContent(
|
||||
ourNode = ourNode,
|
||||
regionUnset = regionUnset,
|
||||
selectedDevice = selectedDevice,
|
||||
bleDevices = bleDevices,
|
||||
onNavigateToNodeDetails = onNavigateToNodeDetails,
|
||||
onClickDisconnect = { scanModel.disconnect() },
|
||||
onSetRegion = {
|
||||
isWaiting = true
|
||||
radioConfigViewModel.setResponseStateLoading(ConfigRoute.LORA)
|
||||
},
|
||||
)
|
||||
|
||||
if (regionUnset && selectedDevice != "m") {
|
||||
TitledCard(title = null) {
|
||||
ListItem(
|
||||
leadingIcon = Icons.Rounded.Language,
|
||||
text = stringResource(Res.string.set_your_region),
|
||||
) {
|
||||
isWaiting = true
|
||||
radioConfigViewModel.setResponseStateLoading(ConfigRoute.LORA)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1 ->
|
||||
ConnectingDeviceContent(
|
||||
selectedDevice = selectedDevice,
|
||||
bleDevices = bleDevices,
|
||||
discoveredTcpDevices = discoveredTcpDevices,
|
||||
recentTcpDevices = recentTcpDevices,
|
||||
usbDevices = usbDevices,
|
||||
onClickDisconnect = { scanModel.disconnect() },
|
||||
)
|
||||
|
||||
1 -> {
|
||||
val selectedEntry =
|
||||
bleDevices.find { it.fullAddress == selectedDevice }
|
||||
?: discoveredTcpDevices.find { it.fullAddress == selectedDevice }
|
||||
?: recentTcpDevices.find { it.fullAddress == selectedDevice }
|
||||
?: usbDevices.find { it.fullAddress == selectedDevice }
|
||||
|
||||
val name = selectedEntry?.name ?: stringResource(Res.string.unknown_device)
|
||||
val address = selectedEntry?.address ?: selectedDevice
|
||||
|
||||
TitledCard(title = stringResource(Res.string.connected_device)) {
|
||||
ConnectingDeviceInfo(
|
||||
deviceName = name,
|
||||
deviceAddress = address,
|
||||
onClickDisconnect = { scanModel.disconnect() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
EmptyStateContent(
|
||||
imageVector = MeshtasticIcons.NoDevice,
|
||||
text = stringResource(Res.string.no_device_selected),
|
||||
modifier = Modifier.height(160.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> NoDeviceContent()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -334,3 +287,74 @@ fun ConnectionsScreen(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Content shown when connected to a device with node info available. */
|
||||
@Composable
|
||||
private fun ConnectedDeviceContent(
|
||||
ourNode: org.meshtastic.core.model.Node?,
|
||||
regionUnset: Boolean,
|
||||
selectedDevice: String,
|
||||
bleDevices: List<DeviceListEntry>,
|
||||
onNavigateToNodeDetails: (Int) -> Unit,
|
||||
onClickDisconnect: () -> Unit,
|
||||
onSetRegion: () -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
ourNode?.let { node ->
|
||||
TitledCard(title = stringResource(Res.string.connected_device)) {
|
||||
CurrentlyConnectedInfo(
|
||||
node = node,
|
||||
bleDevice = bleDevices.find { it.fullAddress == selectedDevice } as DeviceListEntry.Ble?,
|
||||
onNavigateToNodeDetails = onNavigateToNodeDetails,
|
||||
onClickDisconnect = onClickDisconnect,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (regionUnset && selectedDevice != "m") {
|
||||
TitledCard(title = null) {
|
||||
ListItem(
|
||||
leadingIcon = Icons.Rounded.Language,
|
||||
text = stringResource(Res.string.set_your_region),
|
||||
onClick = onSetRegion,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Content shown when connecting or a device is selected but node info is not yet available. */
|
||||
@Composable
|
||||
private fun ConnectingDeviceContent(
|
||||
selectedDevice: String,
|
||||
bleDevices: List<DeviceListEntry>,
|
||||
discoveredTcpDevices: List<DeviceListEntry>,
|
||||
recentTcpDevices: List<DeviceListEntry>,
|
||||
usbDevices: List<DeviceListEntry>,
|
||||
onClickDisconnect: () -> Unit,
|
||||
) {
|
||||
val selectedEntry =
|
||||
bleDevices.find { it.fullAddress == selectedDevice }
|
||||
?: discoveredTcpDevices.find { it.fullAddress == selectedDevice }
|
||||
?: recentTcpDevices.find { it.fullAddress == selectedDevice }
|
||||
?: usbDevices.find { it.fullAddress == selectedDevice }
|
||||
|
||||
val name = selectedEntry?.name ?: stringResource(Res.string.unknown_device)
|
||||
val address = selectedEntry?.address ?: selectedDevice
|
||||
|
||||
TitledCard(title = stringResource(Res.string.connected_device)) {
|
||||
ConnectingDeviceInfo(deviceName = name, deviceAddress = address, onClickDisconnect = onClickDisconnect)
|
||||
}
|
||||
}
|
||||
|
||||
/** Content shown when no device is selected. */
|
||||
@Composable
|
||||
private fun NoDeviceContent() {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
EmptyStateContent(
|
||||
imageVector = MeshtasticIcons.NoDevice,
|
||||
text = stringResource(Res.string.no_device_selected),
|
||||
modifier = Modifier.height(160.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,232 @@
|
|||
/*
|
||||
* 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.connections.domain.usecase
|
||||
|
||||
import dev.mokkery.answering.returns
|
||||
import dev.mokkery.every
|
||||
import dev.mokkery.matcher.any
|
||||
import dev.mokkery.mock
|
||||
import io.kotest.matchers.shouldBe
|
||||
import org.meshtastic.core.common.database.DatabaseManager
|
||||
import org.meshtastic.core.datastore.model.RecentAddress
|
||||
import org.meshtastic.core.network.repository.DiscoveredService
|
||||
import org.meshtastic.core.testing.TestDataFactory
|
||||
import org.meshtastic.feature.connections.model.DeviceListEntry
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
|
||||
/** Unit tests for the shared TCP discovery helper functions. */
|
||||
class TcpDiscoveryHelpersTest {
|
||||
|
||||
@Test
|
||||
fun `processTcpServices maps services to DeviceListEntry with shortname and id`() {
|
||||
val services =
|
||||
listOf(
|
||||
DiscoveredService(
|
||||
name = "Meshtastic_abcd",
|
||||
hostAddress = "192.168.1.10",
|
||||
port = 4403,
|
||||
txt = mapOf("shortname" to "Mesh".encodeToByteArray(), "id" to "!abcd".encodeToByteArray()),
|
||||
),
|
||||
)
|
||||
|
||||
val result = processTcpServices(services, emptyList())
|
||||
|
||||
result.size shouldBe 1
|
||||
result[0].name shouldBe "Mesh_abcd"
|
||||
result[0].fullAddress shouldBe "t192.168.1.10"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `processTcpServices uses default shortname when missing`() {
|
||||
val services =
|
||||
listOf(DiscoveredService(name = "TestDevice", hostAddress = "10.0.0.1", port = 4403, txt = emptyMap()))
|
||||
|
||||
val result = processTcpServices(services, emptyList(), defaultShortName = "Meshtastic")
|
||||
|
||||
result.size shouldBe 1
|
||||
result[0].name shouldBe "Meshtastic"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `processTcpServices uses recent name over shortname`() {
|
||||
val services =
|
||||
listOf(
|
||||
DiscoveredService(
|
||||
name = "Meshtastic_1234",
|
||||
hostAddress = "192.168.1.50",
|
||||
port = 4403,
|
||||
txt = mapOf("shortname" to "Mesh".encodeToByteArray()),
|
||||
),
|
||||
)
|
||||
val recentAddresses = listOf(RecentAddress("t192.168.1.50", "MyNode"))
|
||||
|
||||
val result = processTcpServices(services, recentAddresses)
|
||||
|
||||
result.size shouldBe 1
|
||||
result[0].name shouldBe "MyNode"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `processTcpServices does not duplicate id in display name`() {
|
||||
val services =
|
||||
listOf(
|
||||
DiscoveredService(
|
||||
name = "Meshtastic_1234",
|
||||
hostAddress = "192.168.1.50",
|
||||
port = 4403,
|
||||
txt = mapOf("shortname" to "Mesh".encodeToByteArray(), "id" to "!1234".encodeToByteArray()),
|
||||
),
|
||||
)
|
||||
val recentAddresses = listOf(RecentAddress("t192.168.1.50", "Mesh_1234"))
|
||||
|
||||
val result = processTcpServices(services, recentAddresses)
|
||||
|
||||
result.size shouldBe 1
|
||||
// Should NOT become "Mesh_1234_1234"
|
||||
result[0].name shouldBe "Mesh_1234"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `processTcpServices results are sorted by name`() {
|
||||
val services =
|
||||
listOf(
|
||||
DiscoveredService("Z", "10.0.0.2", 4403, mapOf("shortname" to "Zulu".encodeToByteArray())),
|
||||
DiscoveredService("A", "10.0.0.1", 4403, mapOf("shortname" to "Alpha".encodeToByteArray())),
|
||||
)
|
||||
|
||||
val result = processTcpServices(services, emptyList())
|
||||
|
||||
result[0].name shouldBe "Alpha"
|
||||
result[1].name shouldBe "Zulu"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `matchDiscoveredTcpNodes matches node by device id`() {
|
||||
val node = TestDataFactory.createTestNode(num = 1, userId = "!1234")
|
||||
val nodeDb = mapOf(1 to node)
|
||||
val entries = listOf(DeviceListEntry.Tcp("Mesh_1234", "t192.168.1.50"))
|
||||
val resolved =
|
||||
listOf(
|
||||
DiscoveredService(
|
||||
name = "Meshtastic",
|
||||
hostAddress = "192.168.1.50",
|
||||
port = 4403,
|
||||
txt = mapOf("id" to "!1234".encodeToByteArray()),
|
||||
),
|
||||
)
|
||||
val databaseManager = mock<DatabaseManager> { every { hasDatabaseFor("t192.168.1.50") } returns true }
|
||||
|
||||
val result = matchDiscoveredTcpNodes(entries, nodeDb, resolved, databaseManager)
|
||||
|
||||
result.size shouldBe 1
|
||||
assertNotNull(result[0].node)
|
||||
result[0].node?.user?.id shouldBe "!1234"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `matchDiscoveredTcpNodes returns null node when no database`() {
|
||||
val node = TestDataFactory.createTestNode(num = 1, userId = "!1234")
|
||||
val nodeDb = mapOf(1 to node)
|
||||
val entries = listOf(DeviceListEntry.Tcp("Mesh_1234", "t192.168.1.50"))
|
||||
val resolved =
|
||||
listOf(
|
||||
DiscoveredService(
|
||||
name = "Meshtastic",
|
||||
hostAddress = "192.168.1.50",
|
||||
port = 4403,
|
||||
txt = mapOf("id" to "!1234".encodeToByteArray()),
|
||||
),
|
||||
)
|
||||
val databaseManager = mock<DatabaseManager> { every { hasDatabaseFor("t192.168.1.50") } returns false }
|
||||
|
||||
val result = matchDiscoveredTcpNodes(entries, nodeDb, resolved, databaseManager)
|
||||
|
||||
result.size shouldBe 1
|
||||
assertNull(result[0].node)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `buildRecentTcpEntries filters out discovered addresses`() {
|
||||
val recentAddresses = listOf(RecentAddress("t192.168.1.50", "NodeA"), RecentAddress("t192.168.1.51", "NodeB"))
|
||||
val discoveredAddresses = setOf("t192.168.1.50")
|
||||
val databaseManager = mock<DatabaseManager> { every { hasDatabaseFor(any()) } returns false }
|
||||
|
||||
val result = buildRecentTcpEntries(recentAddresses, discoveredAddresses, emptyMap(), databaseManager)
|
||||
|
||||
result.size shouldBe 1
|
||||
result[0].name shouldBe "NodeB"
|
||||
result[0].fullAddress shouldBe "t192.168.1.51"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `buildRecentTcpEntries matches node by suffix`() {
|
||||
val node = TestDataFactory.createTestNode(num = 1, userId = "!test1234")
|
||||
val recentAddresses = listOf(RecentAddress("tMeshtastic_1234", "Meshtastic_1234"))
|
||||
val databaseManager = mock<DatabaseManager> { every { hasDatabaseFor("tMeshtastic_1234") } returns true }
|
||||
|
||||
val result = buildRecentTcpEntries(recentAddresses, emptySet(), mapOf(1 to node), databaseManager)
|
||||
|
||||
result.size shouldBe 1
|
||||
assertNotNull(result[0].node)
|
||||
result[0].node?.user?.id shouldBe "!test1234"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `buildRecentTcpEntries results are sorted by name`() {
|
||||
val recentAddresses = listOf(RecentAddress("t10.0.0.2", "Zebra"), RecentAddress("t10.0.0.1", "Alpha"))
|
||||
val databaseManager = mock<DatabaseManager> { every { hasDatabaseFor(any()) } returns false }
|
||||
|
||||
val result = buildRecentTcpEntries(recentAddresses, emptySet(), emptyMap(), databaseManager)
|
||||
|
||||
result[0].name shouldBe "Alpha"
|
||||
result[1].name shouldBe "Zebra"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `findNodeByNameSuffix returns null when no database`() {
|
||||
val node = TestDataFactory.createTestNode(num = 1, userId = "!abcd1234")
|
||||
val databaseManager = mock<DatabaseManager> { every { hasDatabaseFor(any()) } returns false }
|
||||
|
||||
val result = findNodeByNameSuffix("Device_1234", "s/dev/ttyUSB0", mapOf(1 to node), databaseManager)
|
||||
|
||||
assertNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `findNodeByNameSuffix matches by last underscore segment`() {
|
||||
val node = TestDataFactory.createTestNode(num = 1, userId = "!abcd1234")
|
||||
val databaseManager = mock<DatabaseManager> { every { hasDatabaseFor("s/dev/ttyUSB0") } returns true }
|
||||
|
||||
val result = findNodeByNameSuffix("Device_1234", "s/dev/ttyUSB0", mapOf(1 to node), databaseManager)
|
||||
|
||||
assertNotNull(result)
|
||||
result.user.id shouldBe "!abcd1234"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `findNodeByNameSuffix returns null when suffix is too short`() {
|
||||
val node = TestDataFactory.createTestNode(num = 1, userId = "!abcd1234")
|
||||
val databaseManager = mock<DatabaseManager> { every { hasDatabaseFor("s/dev/ttyUSB0") } returns true }
|
||||
|
||||
val result = findNodeByNameSuffix("Device_ab", "s/dev/ttyUSB0", mapOf(1 to node), databaseManager)
|
||||
|
||||
// "ab" is only 2 chars, below the minimum SUFFIX_LENGTH of 4
|
||||
assertNull(result)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue