fix(connections): replace tab-based UI with unified device list and gate NSD scanning

On Android 15+ NsdManager.discoverServices() triggers an unavoidable system
consent dialog. This replaces the BLE/Network/USB tab bar with a single
scrollable device list and gates NSD behind an explicit user-initiated scan
toggle, preventing the dialog from appearing unexpectedly.

- Add gated NSD flow (flatMapLatest on isNetworkScanning) in ScannerViewModel
- Simplify GetDiscoveredDevicesUseCase to accept resolvedList as a parameter
- Create unified DeviceList composable with per-transport sections
- Add BLE scan toggle matching the network scan pattern
- Delete BLEDevices, NetworkDevices, UsbDevices, ConnectionsSegmentedBar
- Add onCleared() scan cleanup as a safety net
- Remove unused supportedDeviceTypes from ViewModel
This commit is contained in:
James Rich 2026-04-13 17:47:33 -05:00
parent 28be6933c8
commit 34066fa661
15 changed files with 598 additions and 576 deletions

View file

@ -910,7 +910,13 @@
<string name="firmware_edition">Firmware Edition</string>
<string name="recent_network_devices">Recent Network Devices</string>
<string name="discovered_network_devices">Discovered Network Devices</string>
<string name="scan_network_devices">Scan for network devices</string>
<string name="scanning_network">Scanning…</string>
<string name="scan_bluetooth_devices">Scan for Bluetooth devices</string>
<string name="scanning_bluetooth">Scanning…</string>
<string name="bluetooth_available_devices">Available Bluetooth Devices</string>
<string name="add_network_device_manually">Add device manually…</string>
<string name="no_devices_found">No devices found</string>
<string name="get_started">Get started</string>
<string name="intro_welcome">Welcome to</string>

View file

@ -27,6 +27,7 @@ import org.meshtastic.core.ble.BluetoothRepository
import org.meshtastic.core.datastore.RecentAddressesDataSource
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.model.util.anonymize
import org.meshtastic.core.network.repository.NetworkRepository
import org.meshtastic.core.network.repository.UsbRepository
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.RadioPrefs
@ -44,6 +45,7 @@ class AndroidScannerViewModel(
radioPrefs: RadioPrefs,
recentAddressesDataSource: RecentAddressesDataSource,
getDiscoveredDevicesUseCase: GetDiscoveredDevicesUseCase,
networkRepository: NetworkRepository,
dispatchers: org.meshtastic.core.di.CoroutineDispatchers,
private val bluetoothRepository: BluetoothRepository,
private val usbRepository: UsbRepository,
@ -55,6 +57,7 @@ class AndroidScannerViewModel(
radioPrefs,
recentAddressesDataSource,
getDiscoveredDevicesUseCase,
networkRepository,
dispatchers,
bleScanner,
) {

View file

@ -28,7 +28,6 @@ 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.UsbRepository
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.RadioInterfaceService
@ -46,7 +45,6 @@ import java.util.Locale
@Single
class AndroidGetDiscoveredDevicesUseCase(
private val bluetoothRepository: BluetoothRepository,
private val networkRepository: NetworkRepository,
private val recentAddressesDataSource: RecentAddressesDataSource,
private val nodeRepository: NodeRepository,
private val databaseManager: DatabaseManager,
@ -57,16 +55,13 @@ class AndroidGetDiscoveredDevicesUseCase(
private val macSuffixLength = 8
@Suppress("LongMethod", "CyclomaticComplexMethod")
override fun invoke(showMock: Boolean): Flow<DiscoveredDevices> {
override fun invoke(showMock: Boolean, resolvedList: Flow<List<DiscoveredService>>): Flow<DiscoveredDevices> {
val nodeDb = nodeRepository.nodeDBbyNum
val bondedBleFlow = bluetoothRepository.state.map { ble -> ble.bondedDevices.map { DeviceListEntry.Ble(it) } }
val processedTcpFlow =
combine(networkRepository.resolvedList, recentAddressesDataSource.recentAddresses) {
tcpServices,
recentList,
->
combine(resolvedList, recentAddressesDataSource.recentAddresses) { tcpServices, recentList ->
val defaultName = getString(Res.string.meshtastic)
processTcpServices(tcpServices, recentList, defaultName)
}
@ -92,7 +87,7 @@ class AndroidGetDiscoveredDevicesUseCase(
bondedBleFlow,
processedTcpFlow,
usbDevicesFlow,
networkRepository.resolvedList,
resolvedList,
recentAddressesDataSource.recentAddresses,
) { args: Array<Any> ->
@Suppress("UNCHECKED_CAST", "MagicNumber")
@ -113,40 +108,9 @@ 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 ->
val matchingNode =
if (databaseManager.hasDatabaseFor(entry.fullAddress)) {
db.values.find { node ->
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
}
entry.copy(node = matchingNode)
}
.sortedBy { it.name }
val bleForUi = matchBleNodes(bondedBle, db)
val usbForUi = matchUsbNodes(usbDevices, showMock, db)
// 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 ->
entry.copy(node = findNodeByNameSuffix(entry.name, entry.fullAddress, db, databaseManager))
}
// Shared TCP logic via helpers
val discoveredTcpForUi = matchDiscoveredTcpNodes(processedTcp, db, resolved, databaseManager)
val discoveredTcpAddresses = processedTcp.map { it.fullAddress }.toSet()
val recentTcpForUi = buildRecentTcpEntries(recentList, discoveredTcpAddresses, db, databaseManager)
@ -159,4 +123,33 @@ class AndroidGetDiscoveredDevicesUseCase(
)
}
}
private fun matchBleNodes(bondedBle: List<DeviceListEntry.Ble>, db: Map<Int, Node>): List<DeviceListEntry.Ble> =
bondedBle
.map { entry ->
val matchingNode =
if (databaseManager.hasDatabaseFor(entry.fullAddress)) {
db.values.find { node ->
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
}
entry.copy(node = matchingNode)
}
.sortedBy { it.name }
private suspend fun matchUsbNodes(
usbDevices: List<DeviceListEntry.Usb>,
showMock: Boolean,
db: Map<Int, Node>,
): List<DeviceListEntry> =
(usbDevices + if (showMock) listOf(DeviceListEntry.Mock(getString(Res.string.demo_mode))) else emptyList())
.map { entry ->
entry.copy(node = findNodeByNameSuffix(entry.name, entry.fullAddress, db, databaseManager))
}
}

View file

@ -24,6 +24,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
@ -34,6 +35,7 @@ import org.meshtastic.core.datastore.RecentAddressesDataSource
import org.meshtastic.core.datastore.model.RecentAddress
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.model.util.anonymize
import org.meshtastic.core.network.repository.NetworkRepository
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.RadioPrefs
import org.meshtastic.core.repository.ServiceRepository
@ -49,6 +51,7 @@ open class ScannerViewModel(
private val radioPrefs: RadioPrefs,
private val recentAddressesDataSource: RecentAddressesDataSource,
private val getDiscoveredDevicesUseCase: GetDiscoveredDevicesUseCase,
private val networkRepository: NetworkRepository,
private val dispatchers: org.meshtastic.core.di.CoroutineDispatchers,
private val bleScanner: org.meshtastic.core.ble.BleScanner? = null,
) : ViewModel() {
@ -103,11 +106,32 @@ open class ScannerViewModel(
isBleScanningState.value = false
}
private val _isNetworkScanning = MutableStateFlow(false)
val isNetworkScanning: StateFlow<Boolean> = _isNetworkScanning.asStateFlow()
/**
* The resolved NSD services flow, gated by [_isNetworkScanning]. When scanning is inactive, this emits
* `emptyList()` so `NsdManager.discoverServices()` is never triggered. On Android 15+ subscribing to the real
* `resolvedList` shows a system consent dialog, so this ensures NSD only runs when the user explicitly requests it.
*/
private val gatedResolvedList =
_isNetworkScanning.flatMapLatest { scanning ->
if (scanning) networkRepository.resolvedList else flowOf(emptyList())
}
private val discoveredDevicesFlow =
showMockTransport
.flatMapLatest { showMock -> getDiscoveredDevicesUseCase.invoke(showMock) }
.flatMapLatest { showMock -> getDiscoveredDevicesUseCase.invoke(showMock, gatedResolvedList) }
.stateInWhileSubscribed(initialValue = null)
fun startNetworkScan() {
_isNetworkScanning.value = true
}
fun stopNetworkScan() {
_isNetworkScanning.value = false
}
/** A combined list of bonded and scanned BLE devices for the UI. */
val bleDevicesForUi: StateFlow<List<DeviceListEntry>> =
kotlinx.coroutines.flow
@ -138,7 +162,7 @@ open class ScannerViewModel(
.distinctUntilChanged()
.stateInWhileSubscribed(initialValue = emptyList())
/** UI StateFlow for discovered TCP devices (NSD). */
/** UI StateFlow for discovered TCP devices (NSD), only populated during an active network scan. */
val discoveredTcpDevicesForUi: StateFlow<List<DeviceListEntry>> =
discoveredDevicesFlow
.map { it?.discoveredTcpDevices ?: emptyList() }
@ -162,8 +186,6 @@ open class ScannerViewModel(
.map { it ?: NO_DEVICE_SELECTED }
.stateInWhileSubscribed(initialValue = selectedAddressFlow.value ?: NO_DEVICE_SELECTED)
val supportedDeviceTypes: List<org.meshtastic.core.model.DeviceType> = radioInterfaceService.supportedDeviceTypes
init {
serviceRepository.connectionProgress.onEach { _errorText.value = it }.launchIn(viewModelScope)
Logger.d { "ScannerViewModel created" }
@ -171,6 +193,8 @@ open class ScannerViewModel(
override fun onCleared() {
super.onCleared()
stopBleScan()
stopNetworkScan()
Logger.d { "ScannerViewModel cleared" }
}

View file

@ -18,11 +18,12 @@ package org.meshtastic.feature.connections.domain.usecase
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOf
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.DiscoveredService
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.demo_mode
@ -36,46 +37,42 @@ class CommonGetDiscoveredDevicesUseCase(
private val recentAddressesDataSource: RecentAddressesDataSource,
private val nodeRepository: NodeRepository,
private val databaseManager: DatabaseManager,
private val networkRepository: NetworkRepository,
private val usbScanner: UsbScanner? = null,
) : GetDiscoveredDevicesUseCase {
override fun invoke(showMock: Boolean): Flow<DiscoveredDevices> {
override fun invoke(showMock: Boolean, resolvedList: Flow<List<DiscoveredService>>): Flow<DiscoveredDevices> {
val nodeDb = nodeRepository.nodeDBbyNum
val usbFlow = usbScanner?.scanUsbDevices() ?: kotlinx.coroutines.flow.flowOf(emptyList())
val usbFlow = usbScanner?.scanUsbDevices() ?: flowOf(emptyList())
val processedTcpFlow =
combine(networkRepository.resolvedList, recentAddressesDataSource.recentAddresses) {
tcpServices,
recentList,
->
combine(resolvedList, recentAddressesDataSource.recentAddresses) { tcpServices, recentList ->
val defaultName = runCatching { getString(Res.string.meshtastic) }.getOrDefault("Meshtastic")
processTcpServices(tcpServices, recentList, defaultName)
}
return combine(
nodeDb,
processedTcpFlow,
networkRepository.resolvedList,
recentAddressesDataSource.recentAddresses,
usbFlow,
) { db, processedTcp, resolved, recentList, usbList ->
return combine(nodeDb, processedTcpFlow, resolvedList, recentAddressesDataSource.recentAddresses, usbFlow) {
db,
processedTcp,
resolved,
recentList,
usbList,
->
val discoveredTcpForUi = matchDiscoveredTcpNodes(processedTcp, db, resolved, databaseManager)
val discoveredTcpAddresses = processedTcp.map { it.fullAddress }.toSet()
val recentTcpForUi = buildRecentTcpEntries(recentList, discoveredTcpAddresses, db, databaseManager)
val mockEntries =
if (showMock) {
val label = runCatching { getString(Res.string.demo_mode) }.getOrDefault("Demo Mode")
listOf(DeviceListEntry.Mock(label))
} else {
emptyList()
}
DiscoveredDevices(
discoveredTcpDevices = discoveredTcpForUi,
recentTcpDevices = recentTcpForUi,
usbDevices =
usbList +
if (showMock) {
val demoModeLabel =
runCatching { getString(Res.string.demo_mode) }.getOrDefault("Demo Mode")
listOf(DeviceListEntry.Mock(demoModeLabel))
} else {
emptyList()
},
usbDevices = usbList + mockEntries,
)
}
}

View file

@ -17,6 +17,7 @@
package org.meshtastic.feature.connections.model
import kotlinx.coroutines.flow.Flow
import org.meshtastic.core.network.repository.DiscoveredService
data class DiscoveredDevices(
val bleDevices: List<DeviceListEntry> = emptyList(),
@ -26,5 +27,12 @@ data class DiscoveredDevices(
)
interface GetDiscoveredDevicesUseCase {
fun invoke(showMock: Boolean): Flow<DiscoveredDevices>
/**
* Returns a flow of all discovered devices (BLE, USB, TCP).
*
* @param resolvedList the NSD/mDNS resolved services flow. On Android 15+, subscribing to
* `NetworkRepository.resolvedList` triggers a system consent dialog, so callers should pass `flowOf(emptyList())`
* unless the user has explicitly requested a network scan.
*/
fun invoke(showMock: Boolean, resolvedList: Flow<List<DiscoveredService>>): Flow<DiscoveredDevices>
}

View file

@ -25,8 +25,6 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
@ -49,7 +47,6 @@ import org.jetbrains.compose.resources.getString
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DeviceType
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.navigation.SettingsRoute
import org.meshtastic.core.resources.Res
@ -73,13 +70,10 @@ import org.meshtastic.core.ui.viewmodel.ConnectionsViewModel
import org.meshtastic.feature.connections.NO_DEVICE_SELECTED
import org.meshtastic.feature.connections.ScannerViewModel
import org.meshtastic.feature.connections.model.DeviceListEntry
import org.meshtastic.feature.connections.ui.components.BLEDevices
import org.meshtastic.feature.connections.ui.components.ConnectingDeviceInfo
import org.meshtastic.feature.connections.ui.components.ConnectionsSegmentedBar
import org.meshtastic.feature.connections.ui.components.CurrentlyConnectedInfo
import org.meshtastic.feature.connections.ui.components.DeviceList
import org.meshtastic.feature.connections.ui.components.EmptyStateContent
import org.meshtastic.feature.connections.ui.components.NetworkDevices
import org.meshtastic.feature.connections.ui.components.UsbDevices
import org.meshtastic.feature.settings.navigation.ConfigRoute
import org.meshtastic.feature.settings.navigation.getNavRouteFrom
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
@ -111,6 +105,8 @@ fun ConnectionsScreen(
val discoveredTcpDevices by scanModel.discoveredTcpDevicesForUi.collectAsStateWithLifecycle()
val recentTcpDevices by scanModel.recentTcpDevicesForUi.collectAsStateWithLifecycle()
val usbDevices by scanModel.usbDevicesForUi.collectAsStateWithLifecycle()
val isBleScanning by scanModel.isBleScanning.collectAsStateWithLifecycle()
val isNetworkScanning by scanModel.isNetworkScanning.collectAsStateWithLifecycle()
/* Animate waiting for the configurations */
var isWaiting by remember { mutableStateOf(false) }
@ -209,69 +205,23 @@ fun ConnectionsScreen(
}
}
var selectedDeviceType by remember { mutableStateOf(DeviceType.BLE) }
LaunchedEffect(selectedDevice) {
DeviceType.fromAddress(selectedDevice)?.let { selectedDeviceType = it }
}
val supportedDeviceTypes = scanModel.supportedDeviceTypes
// Fallback to a supported type if the current one isn't
LaunchedEffect(supportedDeviceTypes) {
if (selectedDeviceType !in supportedDeviceTypes && supportedDeviceTypes.isNotEmpty()) {
selectedDeviceType = supportedDeviceTypes.first()
}
}
ConnectionsSegmentedBar(
selectedDeviceType = selectedDeviceType,
supportedDeviceTypes = supportedDeviceTypes,
modifier = Modifier.fillMaxWidth(),
) {
selectedDeviceType = it
}
// ── Unified device list (replaces tab bar + per-transport composables) ──
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
when (selectedDeviceType) {
DeviceType.BLE -> {
BLEDevices(
connectionState = connectionState,
selectedDevice = selectedDevice,
scanModel = scanModel,
)
}
DeviceType.TCP -> {
Column(
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
NetworkDevices(
connectionState = connectionState,
discoveredNetworkDevices = discoveredTcpDevices,
recentNetworkDevices = recentTcpDevices,
selectedDevice = selectedDevice,
scanModel = scanModel,
)
Spacer(modifier = Modifier.height(16.dp))
}
}
DeviceType.USB -> {
Column(
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
UsbDevices(
connectionState = connectionState,
usbDevices = usbDevices,
selectedDevice = selectedDevice,
scanModel = scanModel,
)
Spacer(modifier = Modifier.height(16.dp))
}
}
}
DeviceList(
connectionState = connectionState,
selectedDevice = selectedDevice,
bleDevices = bleDevices,
usbDevices = usbDevices,
discoveredTcpDevices = discoveredTcpDevices,
recentTcpDevices = recentTcpDevices,
isBleScanning = isBleScanning,
isNetworkScanning = isNetworkScanning,
scanModel = scanModel,
onToggleBleScan = { if (isBleScanning) scanModel.stopBleScan() else scanModel.startBleScan() },
onToggleNetworkScan = {
if (isNetworkScanning) scanModel.stopNetworkScan() else scanModel.startNetworkScan()
},
)
}
}
scanStatusText?.let {

View file

@ -1,89 +0,0 @@
/*
* 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.ui.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.bluetooth_available_devices
import org.meshtastic.feature.connections.ScannerViewModel
/**
* Composable that displays a list of Bluetooth Low Energy (BLE) devices and allows scanning.
*
* @param connectionState The current connection state of the MeshService.
* @param selectedDevice The full address of the currently selected device.
* @param scanModel The ViewModel responsible for Bluetooth scanning logic.
*/
@Composable
fun BLEDevices(connectionState: ConnectionState, selectedDevice: String, scanModel: ScannerViewModel) {
val bleDevices by scanModel.bleDevicesForUi.collectAsStateWithLifecycle()
val isScanning by scanModel.isBleScanning.collectAsStateWithLifecycle()
DisposableEffect(Unit) {
scanModel.startBleScan()
onDispose { scanModel.stopBleScan() }
}
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = stringResource(Res.string.bluetooth_available_devices),
modifier = Modifier.padding(horizontal = 8.dp).padding(bottom = 16.dp).fillMaxWidth(),
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.primary,
)
if (isScanning) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp))
}
LazyColumn(modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp)) {
items(bleDevices, key = { it.fullAddress }) { device ->
Card(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
shape = MaterialTheme.shapes.large,
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh),
) {
DeviceListItem(
connectionState =
connectionState.takeIf { device.fullAddress == selectedDevice }
?: ConnectionState.Disconnected,
device = device,
onSelect = { scanModel.onSelected(device) },
rssi = null,
)
}
}
}
}
}

View file

@ -1,69 +0,0 @@
/*
* 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.ui.components
import androidx.compose.material3.Icon
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import org.jetbrains.compose.resources.DrawableResource
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.jetbrains.compose.resources.vectorResource
import org.meshtastic.core.model.DeviceType
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.bluetooth
import org.meshtastic.core.resources.ic_bluetooth
import org.meshtastic.core.resources.ic_usb
import org.meshtastic.core.resources.ic_wifi
import org.meshtastic.core.resources.network
import org.meshtastic.core.resources.serial
@Suppress("LambdaParameterEventTrailing")
@Composable
fun ConnectionsSegmentedBar(
selectedDeviceType: DeviceType,
supportedDeviceTypes: List<DeviceType>,
modifier: Modifier = Modifier,
onClickDeviceType: (DeviceType) -> Unit,
) {
val visibleItems = Item.entries.filter { it.deviceType in supportedDeviceTypes }
if (visibleItems.isEmpty()) return
SingleChoiceSegmentedButtonRow(modifier = modifier) {
visibleItems.forEachIndexed { index, item ->
val text = stringResource(item.textRes)
SegmentedButton(
shape = SegmentedButtonDefaults.itemShape(index, visibleItems.size),
onClick = { onClickDeviceType(item.deviceType) },
selected = item.deviceType == selectedDeviceType,
icon = { Icon(imageVector = vectorResource(item.icon), contentDescription = text) },
label = { Text(text = text, maxLines = 1, overflow = TextOverflow.Ellipsis) },
)
}
}
}
private enum class Item(val icon: DrawableResource, val textRes: StringResource, val deviceType: DeviceType) {
BLUETOOTH(icon = Res.drawable.ic_bluetooth, textRes = Res.string.bluetooth, deviceType = DeviceType.BLE),
NETWORK(icon = Res.drawable.ic_wifi, textRes = Res.string.network, deviceType = DeviceType.TCP),
SERIAL(icon = Res.drawable.ic_usb, textRes = Res.string.serial, deviceType = DeviceType.USB),
}

View file

@ -0,0 +1,365 @@
/*
* 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.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SheetState
import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldLabelPosition
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.isValidAddress
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.network.repository.NetworkConstants
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.add_network_device
import org.meshtastic.core.resources.add_network_device_manually
import org.meshtastic.core.resources.address
import org.meshtastic.core.resources.bluetooth
import org.meshtastic.core.resources.cancel
import org.meshtastic.core.resources.discovered_network_devices
import org.meshtastic.core.resources.ip_port
import org.meshtastic.core.resources.no_devices_found
import org.meshtastic.core.resources.recent_network_devices
import org.meshtastic.core.resources.scan_bluetooth_devices
import org.meshtastic.core.resources.scan_network_devices
import org.meshtastic.core.resources.scanning_bluetooth
import org.meshtastic.core.resources.scanning_network
import org.meshtastic.core.resources.usb
import org.meshtastic.core.ui.icon.Add
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.NoDevice
import org.meshtastic.feature.connections.ScannerViewModel
import org.meshtastic.feature.connections.model.DeviceListEntry
/**
* Unified device list composable that displays all available devices grouped by transport type.
*
* Replaces the previous tab-based UI (BLE / Network / Serial tabs) with a single scrollable list. Each transport type
* is rendered as a section with a header. Empty sections are hidden.
*
* BLE and network scanning are controlled by explicit toggle buttons rather than auto-starting.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Suppress("LongMethod", "LongParameterList")
@Composable
fun DeviceList(
connectionState: ConnectionState,
selectedDevice: String,
bleDevices: List<DeviceListEntry>,
usbDevices: List<DeviceListEntry>,
discoveredTcpDevices: List<DeviceListEntry>,
recentTcpDevices: List<DeviceListEntry>,
isBleScanning: Boolean,
isNetworkScanning: Boolean,
scanModel: ScannerViewModel,
onToggleBleScan: () -> Unit,
onToggleNetworkScan: () -> Unit,
modifier: Modifier = Modifier,
) {
// Stop scans when this composable leaves the composition
DisposableEffect(Unit) {
onDispose {
scanModel.stopBleScan()
scanModel.stopNetworkScan()
}
}
var showAddDialog by remember { mutableStateOf(false) }
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val scope = rememberCoroutineScope()
val hideAndDismiss: () -> Unit = {
scope.launch { sheetState.hide() }.invokeOnCompletion { if (!sheetState.isVisible) showAddDialog = false }
}
if (showAddDialog) {
AddDeviceDialog(
sheetState = sheetState,
onHideDialog = hideAndDismiss,
onClickAdd = { address, fullAddress ->
scanModel.addRecentAddress(fullAddress, address)
scanModel.changeDeviceAddress(fullAddress)
hideAndDismiss()
},
)
}
val hasAnyDevices =
bleDevices.isNotEmpty() ||
usbDevices.isNotEmpty() ||
discoveredTcpDevices.isNotEmpty() ||
recentTcpDevices.isNotEmpty()
if (!hasAnyDevices && !isBleScanning && !isNetworkScanning) {
EmptyDeviceList(
onToggleBleScan = onToggleBleScan,
onToggleNetworkScan = onToggleNetworkScan,
onAddManually = { showAddDialog = true },
)
return
}
Column(
modifier = modifier.fillMaxSize().verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// ── Bluetooth section ──
BluetoothSection(
connectionState = connectionState,
selectedDevice = selectedDevice,
bleDevices = bleDevices,
isBleScanning = isBleScanning,
scanModel = scanModel,
onToggleBleScan = onToggleBleScan,
)
// ── USB section ──
if (usbDevices.isNotEmpty()) {
usbDevices.DeviceListSection(
title = stringResource(Res.string.usb),
connectionState = connectionState,
selectedDevice = selectedDevice,
onSelect = scanModel::onSelected,
)
}
// ── Network section ──
NetworkSection(
connectionState = connectionState,
selectedDevice = selectedDevice,
discoveredTcpDevices = discoveredTcpDevices,
recentTcpDevices = recentTcpDevices,
isNetworkScanning = isNetworkScanning,
scanModel = scanModel,
onToggleNetworkScan = onToggleNetworkScan,
onAddManually = { showAddDialog = true },
)
Spacer(modifier = Modifier.height(16.dp))
}
}
/** Bluetooth section: scan toggle + device list. */
@Composable
private fun BluetoothSection(
connectionState: ConnectionState,
selectedDevice: String,
bleDevices: List<DeviceListEntry>,
isBleScanning: Boolean,
scanModel: ScannerViewModel,
onToggleBleScan: () -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
ScanToggleButton(
isScanning = isBleScanning,
scanLabel = stringResource(Res.string.scan_bluetooth_devices),
scanningLabel = stringResource(Res.string.scanning_bluetooth),
onToggle = onToggleBleScan,
)
if (bleDevices.isNotEmpty()) {
bleDevices.DeviceListSection(
title = stringResource(Res.string.bluetooth),
connectionState = connectionState,
selectedDevice = selectedDevice,
onSelect = { scanModel.onSelected(it) },
)
}
}
}
/** Network section: scan toggle + discovered + recent + add manually. */
@Suppress("LongParameterList")
@Composable
private fun NetworkSection(
connectionState: ConnectionState,
selectedDevice: String,
discoveredTcpDevices: List<DeviceListEntry>,
recentTcpDevices: List<DeviceListEntry>,
isNetworkScanning: Boolean,
scanModel: ScannerViewModel,
onToggleNetworkScan: () -> Unit,
onAddManually: () -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
if (discoveredTcpDevices.isNotEmpty()) {
discoveredTcpDevices.DeviceListSection(
title = stringResource(Res.string.discovered_network_devices),
connectionState = connectionState,
selectedDevice = selectedDevice,
onSelect = { scanModel.onSelected(it) },
)
}
if (recentTcpDevices.isNotEmpty()) {
recentTcpDevices.DeviceListSection(
title = stringResource(Res.string.recent_network_devices),
connectionState = connectionState,
selectedDevice = selectedDevice,
onSelect = { scanModel.onSelected(it) },
onDelete = { scanModel.removeRecentAddress(it.fullAddress) },
)
}
ScanToggleButton(
isScanning = isNetworkScanning,
scanLabel = stringResource(Res.string.scan_network_devices),
scanningLabel = stringResource(Res.string.scanning_network),
onToggle = onToggleNetworkScan,
)
OutlinedButton(onClick = onAddManually, modifier = Modifier.fillMaxWidth()) {
Icon(MeshtasticIcons.Add, contentDescription = null, modifier = Modifier.padding(end = 8.dp))
Text(stringResource(Res.string.add_network_device_manually))
}
}
}
/** Reusable scan toggle button with progress indicator. */
@Composable
private fun ScanToggleButton(isScanning: Boolean, scanLabel: String, scanningLabel: String, onToggle: () -> Unit) {
OutlinedButton(onClick = onToggle, modifier = Modifier.fillMaxWidth()) {
Text(if (isScanning) scanningLabel else scanLabel)
}
if (isScanning) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
}
/** Shown when there are no devices of any type and no scan is running. */
@Composable
private fun EmptyDeviceList(onToggleBleScan: () -> Unit, onToggleNetworkScan: () -> Unit, onAddManually: () -> Unit) {
Column(modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(16.dp)) {
EmptyStateContent(
text = stringResource(Res.string.no_devices_found),
imageVector = MeshtasticIcons.NoDevice,
modifier = Modifier.weight(1f),
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = onToggleBleScan, modifier = Modifier.fillMaxWidth()) {
Text(stringResource(Res.string.scan_bluetooth_devices))
}
Button(onClick = onToggleNetworkScan, modifier = Modifier.fillMaxWidth()) {
Text(stringResource(Res.string.scan_network_devices))
}
OutlinedButton(onClick = onAddManually, modifier = Modifier.fillMaxWidth()) {
Icon(MeshtasticIcons.Add, contentDescription = null, modifier = Modifier.padding(end = 8.dp))
Text(stringResource(Res.string.add_network_device_manually))
}
}
}
}
}
/** Dialog for manually adding a TCP device by IP address and port. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun AddDeviceDialog(
sheetState: SheetState,
onHideDialog: () -> Unit,
onClickAdd: (address: String, fullAddress: String) -> Unit,
) {
val addressState = rememberTextFieldState("")
val portState = rememberTextFieldState(NetworkConstants.SERVICE_PORT.toString())
@Suppress("MagicNumber")
ModalBottomSheet(onDismissRequest = onHideDialog, sheetState = sheetState) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
state = addressState,
labelPosition = TextFieldLabelPosition.Above(),
lineLimits = TextFieldLineLimits.SingleLine,
label = { Text(stringResource(Res.string.address)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
modifier = Modifier.weight(.7f),
)
OutlinedTextField(
state = portState,
labelPosition = TextFieldLabelPosition.Above(),
placeholder = { Text(NetworkConstants.SERVICE_PORT.toString()) },
lineLimits = TextFieldLineLimits.SingleLine,
label = { Text(stringResource(Res.string.ip_port)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal, imeAction = ImeAction.Done),
modifier = Modifier.weight(.3f),
)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(modifier = Modifier.weight(1f), onClick = { onHideDialog() }) {
Text(stringResource(Res.string.cancel))
}
Button(
modifier = Modifier.weight(1f),
onClick = {
val address = addressState.text.toString()
if (address.isValidAddress()) {
val portString = portState.text.toString()
val port = portString.toIntOrNull()
val combinedString =
if (port != null && port != NetworkConstants.SERVICE_PORT) {
"$address:$portString"
} else {
address
}
onClickAdd(combinedString, "t$combinedString")
}
},
) {
Text(stringResource(Res.string.add_network_device))
}
}
}
}
}

View file

@ -1,200 +0,0 @@
/*
* 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.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SheetState
import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldLabelPosition
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.isValidAddress
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.network.repository.NetworkConstants
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.add_network_device
import org.meshtastic.core.resources.address
import org.meshtastic.core.resources.cancel
import org.meshtastic.core.resources.discovered_network_devices
import org.meshtastic.core.resources.ip_port
import org.meshtastic.core.resources.no_network_devices_found
import org.meshtastic.core.resources.recent_network_devices
import org.meshtastic.core.ui.icon.Add
import org.meshtastic.core.ui.icon.HardwareModel
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.feature.connections.ScannerViewModel
import org.meshtastic.feature.connections.model.DeviceListEntry
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NetworkDevices(
connectionState: ConnectionState,
discoveredNetworkDevices: List<DeviceListEntry>,
recentNetworkDevices: List<DeviceListEntry>,
selectedDevice: String,
scanModel: ScannerViewModel,
) {
var showAddDialog by remember { mutableStateOf(false) }
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val scope = rememberCoroutineScope()
if (showAddDialog) {
AddDeviceDialog(
sheetState = sheetState,
onHideDialog = {
scope
.launch { sheetState.hide() }
.invokeOnCompletion { if (!sheetState.isVisible) showAddDialog = false }
},
onClickAdd = { address, fullAddress ->
scanModel.addRecentAddress(fullAddress, address)
scanModel.changeDeviceAddress(fullAddress)
scope
.launch { sheetState.hide() }
.invokeOnCompletion { if (!sheetState.isVisible) showAddDialog = false }
},
)
}
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
if (discoveredNetworkDevices.isEmpty() && recentNetworkDevices.isEmpty()) {
EmptyStateContent(
text = stringResource(Res.string.no_network_devices_found),
imageVector = MeshtasticIcons.HardwareModel,
modifier = Modifier.padding(vertical = 32.dp),
) {
Button(onClick = { showAddDialog = true }) {
Icon(MeshtasticIcons.Add, contentDescription = null)
Text(stringResource(Res.string.add_network_device))
}
}
} else {
if (discoveredNetworkDevices.isNotEmpty()) {
discoveredNetworkDevices.DeviceListSection(
title = stringResource(Res.string.discovered_network_devices),
connectionState = connectionState,
selectedDevice = selectedDevice,
onSelect = { scanModel.onSelected(it) },
)
}
if (recentNetworkDevices.isNotEmpty()) {
recentNetworkDevices.DeviceListSection(
title = stringResource(Res.string.recent_network_devices),
connectionState = connectionState,
selectedDevice = selectedDevice,
onSelect = { scanModel.onSelected(it) },
onDelete = { scanModel.removeRecentAddress(it.fullAddress) },
)
}
Row(modifier = Modifier.padding(top = 8.dp)) {
FloatingActionButton(onClick = { showAddDialog = true }) {
Icon(MeshtasticIcons.Add, contentDescription = stringResource(Res.string.add_network_device))
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun AddDeviceDialog(
sheetState: SheetState,
onHideDialog: () -> Unit,
onClickAdd: (address: String, fullAddress: String) -> Unit,
) {
val addressState = rememberTextFieldState("")
val portState = rememberTextFieldState(NetworkConstants.SERVICE_PORT.toString())
@Suppress("MagicNumber")
ModalBottomSheet(onDismissRequest = onHideDialog, sheetState = sheetState) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
state = addressState,
labelPosition = TextFieldLabelPosition.Above(),
lineLimits = TextFieldLineLimits.SingleLine,
label = { Text(stringResource(Res.string.address)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
modifier = Modifier.weight(.7f),
)
OutlinedTextField(
state = portState,
labelPosition = TextFieldLabelPosition.Above(),
placeholder = { Text(NetworkConstants.SERVICE_PORT.toString()) },
lineLimits = TextFieldLineLimits.SingleLine,
label = { Text(stringResource(Res.string.ip_port)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal, imeAction = ImeAction.Done),
modifier = Modifier.weight(.3f),
)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(modifier = Modifier.weight(1f), onClick = { onHideDialog() }) {
Text(stringResource(Res.string.cancel))
}
Button(
modifier = Modifier.weight(1f),
onClick = {
val address = addressState.text.toString()
if (address.isValidAddress()) {
val portString = portState.text.toString()
val port = portString.toIntOrNull()
val combinedString =
if (port != null && port != NetworkConstants.SERVICE_PORT) {
"$address:$portString"
} else {
address
}
onClickAdd(combinedString, "t$combinedString")
}
},
) {
Text(stringResource(Res.string.add_network_device))
}
}
}
}
}

View file

@ -1,54 +0,0 @@
/*
* 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.ui.components
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.no_usb_devices_found
import org.meshtastic.core.resources.usb
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.UsbOff
import org.meshtastic.feature.connections.ScannerViewModel
import org.meshtastic.feature.connections.model.DeviceListEntry
@Composable
fun UsbDevices(
connectionState: ConnectionState,
usbDevices: List<DeviceListEntry>,
selectedDevice: String,
scanModel: ScannerViewModel,
) {
if (usbDevices.isEmpty()) {
EmptyStateContent(
text = stringResource(Res.string.no_usb_devices_found),
imageVector = MeshtasticIcons.UsbOff,
modifier = Modifier.padding(vertical = 32.dp),
)
} else {
usbDevices.DeviceListSection(
title = stringResource(Res.string.usb),
connectionState = connectionState,
selectedDevice = selectedDevice,
onSelect = scanModel::onSelected,
)
}
}

View file

@ -22,14 +22,20 @@ import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.matcher.any
import dev.mokkery.mock
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.meshtastic.core.datastore.RecentAddressesDataSource
import org.meshtastic.core.network.repository.DiscoveredService
import org.meshtastic.core.network.repository.NetworkRepository
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.RadioPrefs
import org.meshtastic.core.testing.FakeRadioController
import org.meshtastic.core.testing.FakeServiceRepository
import org.meshtastic.feature.connections.model.DeviceListEntry
import org.meshtastic.feature.connections.model.DiscoveredDevices
import org.meshtastic.feature.connections.model.GetDiscoveredDevicesUseCase
import kotlin.test.BeforeTest
@ -46,22 +52,40 @@ class ScannerViewModelTest {
private val radioInterfaceService: RadioInterfaceService = mock(MockMode.autofill)
private val radioPrefs: RadioPrefs = mock(MockMode.autofill)
private val recentAddressesDataSource: RecentAddressesDataSource = mock(MockMode.autofill)
private val getDiscoveredDevicesUseCase: GetDiscoveredDevicesUseCase = mock(MockMode.autofill)
private val networkRepository: NetworkRepository = mock(MockMode.autofill)
private val bleScanner: org.meshtastic.core.ble.BleScanner = mock(MockMode.autofill)
private val discoveredDevicesFlow = MutableStateFlow(DiscoveredDevices())
private val resolvedServicesFlow = MutableStateFlow<List<DiscoveredService>>(emptyList())
private val baseDevicesFlow = MutableStateFlow(DiscoveredDevices())
/**
* A fake [GetDiscoveredDevicesUseCase] that mirrors the real behavior: it combines the provided [resolvedList] with
* base device data so tests can verify NSD gating.
*/
private val getDiscoveredDevicesUseCase =
object : GetDiscoveredDevicesUseCase {
override fun invoke(
showMock: Boolean,
resolvedList: Flow<List<DiscoveredService>>,
): Flow<DiscoveredDevices> = combine(baseDevicesFlow, resolvedList) { base, resolved ->
val tcpDevices =
resolved.map { DeviceListEntry.Tcp(name = it.name, fullAddress = "t${it.hostAddress}") }
base.copy(discoveredTcpDevices = tcpDevices)
}
}
@BeforeTest
fun setUp() {
every { radioInterfaceService.isMockTransport() } returns false
every { radioInterfaceService.currentDeviceAddressFlow } returns MutableStateFlow(null)
every { radioInterfaceService.supportedDeviceTypes } returns emptyList()
every { getDiscoveredDevicesUseCase.invoke(any()) } returns discoveredDevicesFlow
every { recentAddressesDataSource.recentAddresses } returns MutableStateFlow(emptyList())
every { networkRepository.resolvedList } returns resolvedServicesFlow
every { networkRepository.networkAvailable } returns flowOf(true)
serviceRepository.setConnectionProgress("")
discoveredDevicesFlow.value = DiscoveredDevices()
baseDevicesFlow.value = DiscoveredDevices()
resolvedServicesFlow.value = emptyList()
viewModel =
ScannerViewModel(
@ -71,6 +95,7 @@ class ScannerViewModelTest {
radioPrefs = radioPrefs,
recentAddressesDataSource = recentAddressesDataSource,
getDiscoveredDevicesUseCase = getDiscoveredDevicesUseCase,
networkRepository = networkRepository,
dispatchers =
org.meshtastic.core.di.CoroutineDispatchers(
io = UnconfinedTestDispatcher(),
@ -124,16 +149,61 @@ class ScannerViewModelTest {
assertEquals(emptyList(), awaitItem())
val device =
org.meshtastic.feature.connections.model.DeviceListEntry.Usb(
DeviceListEntry.Usb(
usbData = object : org.meshtastic.feature.connections.model.UsbDeviceData {},
name = "USB Device",
fullAddress = "usb_address",
bonded = true,
)
discoveredDevicesFlow.value = DiscoveredDevices(usbDevices = listOf(device))
baseDevicesFlow.value = DiscoveredDevices(usbDevices = listOf(device))
assertEquals(listOf(device), awaitItem())
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `isNetworkScanning defaults to false`() {
assertEquals(false, viewModel.isNetworkScanning.value)
}
@Test
fun `startNetworkScan updates isNetworkScanning`() = runTest {
viewModel.isNetworkScanning.test {
assertEquals(false, awaitItem())
viewModel.startNetworkScan()
assertEquals(true, awaitItem())
viewModel.stopNetworkScan()
assertEquals(false, awaitItem())
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `discoveredTcpDevicesForUi is empty when not scanning`() = runTest {
resolvedServicesFlow.value =
listOf(DiscoveredService(name = "NSD Device", hostAddress = "192.168.1.50", port = 4403))
viewModel.discoveredTcpDevicesForUi.test {
assertEquals(emptyList(), awaitItem())
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `discoveredTcpDevicesForUi populates when scanning is active`() = runTest {
resolvedServicesFlow.value =
listOf(DiscoveredService(name = "NSD Device", hostAddress = "192.168.1.50", port = 4403))
viewModel.discoveredTcpDevicesForUi.test {
assertEquals(emptyList(), awaitItem())
viewModel.startNetworkScan()
val result = awaitItem()
assertEquals(1, result.size)
assertEquals("t192.168.1.50", result[0].fullAddress)
viewModel.stopNetworkScan()
assertEquals(emptyList(), awaitItem())
cancelAndIgnoreRemainingEvents()
}
}
}

View file

@ -29,7 +29,6 @@ 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
@ -43,7 +42,6 @@ class CommonGetDiscoveredDevicesUseCaseTest {
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())
@ -51,24 +49,19 @@ class CommonGetDiscoveredDevicesUseCaseTest {
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,
)
}
@Test
fun testEmptyRecentAddresses() = runTest {
setUp()
useCase.invoke(showMock = false).test {
useCase.invoke(showMock = false, resolvedList = resolvedServicesFlow).test {
val result = awaitItem()
assertTrue(result.recentTcpDevices.isEmpty(), "No recent TCP devices when empty")
assertTrue(result.usbDevices.isEmpty(), "No USB devices when showMock=false")
@ -83,7 +76,7 @@ class CommonGetDiscoveredDevicesUseCaseTest {
recentAddressesFlow.value =
listOf(RecentAddress("t192.168.1.100", "Zebra_Node"), RecentAddress("t192.168.1.101", "Alpha_Node"))
useCase.invoke(showMock = false).test {
useCase.invoke(showMock = false, resolvedList = resolvedServicesFlow).test {
val result = awaitItem()
result.recentTcpDevices.size shouldBe 2
result.recentTcpDevices[0].name shouldBe "Alpha_Node"
@ -95,7 +88,7 @@ class CommonGetDiscoveredDevicesUseCaseTest {
@Test
fun testShowMockAddsDemo() = runTest {
setUp()
useCase.invoke(showMock = true).test {
useCase.invoke(showMock = true, resolvedList = resolvedServicesFlow).test {
val result = awaitItem()
result.usbDevices.size shouldBe 1
cancelAndIgnoreRemainingEvents()
@ -105,7 +98,7 @@ class CommonGetDiscoveredDevicesUseCaseTest {
@Test
fun testHideMockNoDemo() = runTest {
setUp()
useCase.invoke(showMock = false).test {
useCase.invoke(showMock = false, resolvedList = resolvedServicesFlow).test {
val result = awaitItem()
assertTrue(result.usbDevices.isEmpty(), "No mock device when showMock=false")
cancelAndIgnoreRemainingEvents()
@ -124,12 +117,11 @@ class CommonGetDiscoveredDevicesUseCaseTest {
recentAddressesDataSource = recentAddressesDataSource,
nodeRepository = nodeRepository,
databaseManager = databaseManager,
networkRepository = networkRepository,
)
recentAddressesFlow.value = listOf(RecentAddress("tMeshtastic_1234", "Meshtastic_1234"))
useCase.invoke(showMock = false).test {
useCase.invoke(showMock = false, resolvedList = resolvedServicesFlow).test {
val result = awaitItem()
result.recentTcpDevices.size shouldBe 1
assertNotNull(result.recentTcpDevices[0].node, "Node should be matched by suffix")
@ -146,7 +138,7 @@ class CommonGetDiscoveredDevicesUseCaseTest {
recentAddressesFlow.value = listOf(RecentAddress("tMeshtastic_1234", "Meshtastic_1234"))
useCase.invoke(showMock = false).test {
useCase.invoke(showMock = false, resolvedList = resolvedServicesFlow).test {
val result = awaitItem()
result.recentTcpDevices.size shouldBe 1
assertNull(result.recentTcpDevices[0].node, "Node should not be matched when no database")
@ -159,7 +151,7 @@ class CommonGetDiscoveredDevicesUseCaseTest {
setUp()
recentAddressesFlow.value = listOf(RecentAddress("t192.168.1.100", "Node_A"))
useCase.invoke(showMock = false).test {
useCase.invoke(showMock = false, resolvedList = resolvedServicesFlow).test {
val firstResult = awaitItem()
firstResult.recentTcpDevices.size shouldBe 1
@ -184,7 +176,7 @@ class CommonGetDiscoveredDevicesUseCaseTest {
),
)
useCase.invoke(showMock = false).test {
useCase.invoke(showMock = false, resolvedList = resolvedServicesFlow).test {
val result = awaitItem()
result.discoveredTcpDevices.size shouldBe 1
result.discoveredTcpDevices[0].name shouldBe "Mesh_1234"
@ -206,7 +198,6 @@ class CommonGetDiscoveredDevicesUseCaseTest {
recentAddressesDataSource = recentAddressesDataSource,
nodeRepository = nodeRepository,
databaseManager = databaseManager,
networkRepository = networkRepository,
)
resolvedServicesFlow.value =
@ -219,7 +210,7 @@ class CommonGetDiscoveredDevicesUseCaseTest {
),
)
useCase.invoke(showMock = false).test {
useCase.invoke(showMock = false, resolvedList = resolvedServicesFlow).test {
val result = awaitItem()
result.discoveredTcpDevices.size shouldBe 1
assertNotNull(result.discoveredTcpDevices[0].node)
@ -228,4 +219,28 @@ class CommonGetDiscoveredDevicesUseCaseTest {
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun testEmptyResolvedListReturnsNoDiscoveredDevices() = runTest {
setUp()
recentAddressesFlow.value = listOf(RecentAddress("t192.168.1.100", "Recent_Node"))
useCase.invoke(showMock = false, resolvedList = flowOf(emptyList())).test {
val result = awaitItem()
assertTrue(result.discoveredTcpDevices.isEmpty(), "No NSD devices when resolvedList is empty")
result.recentTcpDevices.size shouldBe 1
result.recentTcpDevices[0].name shouldBe "Recent_Node"
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun testEmptyResolvedListIncludesMock() = runTest {
setUp()
useCase.invoke(showMock = true, resolvedList = flowOf(emptyList())).test {
val result = awaitItem()
result.usbDevices.size shouldBe 1
cancelAndIgnoreRemainingEvents()
}
}
}

View file

@ -19,6 +19,7 @@ package org.meshtastic.feature.connections
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.datastore.RecentAddressesDataSource
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.network.repository.NetworkRepository
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.RadioPrefs
import org.meshtastic.core.repository.ServiceRepository
@ -39,6 +40,7 @@ class JvmScannerViewModel(
radioPrefs: RadioPrefs,
recentAddressesDataSource: RecentAddressesDataSource,
getDiscoveredDevicesUseCase: GetDiscoveredDevicesUseCase,
networkRepository: NetworkRepository,
dispatchers: org.meshtastic.core.di.CoroutineDispatchers,
bleScanner: org.meshtastic.core.ble.BleScanner? = null,
) : ScannerViewModel(
@ -48,6 +50,7 @@ class JvmScannerViewModel(
radioPrefs,
recentAddressesDataSource,
getDiscoveredDevicesUseCase,
networkRepository,
dispatchers,
bleScanner,
)