diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index 5d7eba25a..ff9104a7f 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -910,7 +910,13 @@
Firmware Edition
Recent Network Devices
Discovered Network Devices
+ Scan for network devices
+ Scanning…
+ Scan for Bluetooth devices
+ Scanning…
Available Bluetooth Devices
+ Add device manually…
+ No devices found
Get started
Welcome to
diff --git a/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/AndroidScannerViewModel.kt b/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/AndroidScannerViewModel.kt
index 3278812fb..f5dce4f51 100644
--- a/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/AndroidScannerViewModel.kt
+++ b/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/AndroidScannerViewModel.kt
@@ -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,
) {
diff --git a/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/domain/usecase/AndroidGetDiscoveredDevicesUseCase.kt b/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/domain/usecase/AndroidGetDiscoveredDevicesUseCase.kt
index b0a3d738c..0dfc10ae4 100644
--- a/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/domain/usecase/AndroidGetDiscoveredDevicesUseCase.kt
+++ b/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/domain/usecase/AndroidGetDiscoveredDevicesUseCase.kt
@@ -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 {
+ override fun invoke(showMock: Boolean, resolvedList: Flow>): Flow {
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 ->
@Suppress("UNCHECKED_CAST", "MagicNumber")
@@ -113,40 +108,9 @@ class AndroidGetDiscoveredDevicesUseCase(
@Suppress("UNCHECKED_CAST", "MagicNumber")
val recentList = args[5] as List
- // 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, db: Map): List =
+ 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,
+ showMock: Boolean,
+ db: Map,
+ ): List =
+ (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))
+ }
}
diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt
index ccdc9ea24..8d2e4a4fc 100644
--- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt
+++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt
@@ -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 = _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> =
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> =
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 = 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" }
}
diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/domain/usecase/CommonGetDiscoveredDevicesUseCase.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/domain/usecase/CommonGetDiscoveredDevicesUseCase.kt
index ecdaeb3c3..b8e94602b 100644
--- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/domain/usecase/CommonGetDiscoveredDevicesUseCase.kt
+++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/domain/usecase/CommonGetDiscoveredDevicesUseCase.kt
@@ -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 {
+ override fun invoke(showMock: Boolean, resolvedList: Flow>): Flow {
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,
)
}
}
diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/model/DiscoveredDevices.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/model/DiscoveredDevices.kt
index ee01872c0..5eb49b8b9 100644
--- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/model/DiscoveredDevices.kt
+++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/model/DiscoveredDevices.kt
@@ -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 = emptyList(),
@@ -26,5 +27,12 @@ data class DiscoveredDevices(
)
interface GetDiscoveredDevicesUseCase {
- fun invoke(showMock: Boolean): Flow
+ /**
+ * 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>): Flow
}
diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt
index 7fdc287cd..220044ac1 100644
--- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt
+++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt
@@ -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 {
diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/BLEDevices.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/BLEDevices.kt
deleted file mode 100644
index 40b3c9abb..000000000
--- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/BLEDevices.kt
+++ /dev/null
@@ -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 .
- */
-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,
- )
- }
- }
- }
- }
-}
diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/ConnectionsSegmentedBar.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/ConnectionsSegmentedBar.kt
deleted file mode 100644
index af09136f2..000000000
--- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/ConnectionsSegmentedBar.kt
+++ /dev/null
@@ -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 .
- */
-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,
- 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),
-}
diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/DeviceList.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/DeviceList.kt
new file mode 100644
index 000000000..c383c7157
--- /dev/null
+++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/DeviceList.kt
@@ -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 .
+ */
+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,
+ usbDevices: List,
+ discoveredTcpDevices: List,
+ recentTcpDevices: List,
+ 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,
+ 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,
+ recentTcpDevices: List,
+ 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))
+ }
+ }
+ }
+ }
+}
diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/NetworkDevices.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/NetworkDevices.kt
deleted file mode 100644
index 3ff51db1e..000000000
--- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/NetworkDevices.kt
+++ /dev/null
@@ -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 .
- */
-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,
- recentNetworkDevices: List,
- 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))
- }
- }
- }
- }
-}
diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/UsbDevices.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/UsbDevices.kt
deleted file mode 100644
index ef1183c3f..000000000
--- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/UsbDevices.kt
+++ /dev/null
@@ -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 .
- */
-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,
- 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,
- )
- }
-}
diff --git a/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelTest.kt b/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelTest.kt
index 04e9ac03e..0326e0abf 100644
--- a/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelTest.kt
+++ b/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelTest.kt
@@ -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>(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>,
+ ): Flow = 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()
+ }
+ }
}
diff --git a/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/domain/usecase/CommonGetDiscoveredDevicesUseCaseTest.kt b/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/domain/usecase/CommonGetDiscoveredDevicesUseCaseTest.kt
index c1ac1e70c..1f8bde5ab 100644
--- a/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/domain/usecase/CommonGetDiscoveredDevicesUseCaseTest.kt
+++ b/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/domain/usecase/CommonGetDiscoveredDevicesUseCaseTest.kt
@@ -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>(emptyList())
private val resolvedServicesFlow = MutableStateFlow>(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()
+ }
+ }
}
diff --git a/feature/connections/src/jvmMain/kotlin/org/meshtastic/feature/connections/JvmScannerViewModel.kt b/feature/connections/src/jvmMain/kotlin/org/meshtastic/feature/connections/JvmScannerViewModel.kt
index 1c1597466..f7168c734 100644
--- a/feature/connections/src/jvmMain/kotlin/org/meshtastic/feature/connections/JvmScannerViewModel.kt
+++ b/feature/connections/src/jvmMain/kotlin/org/meshtastic/feature/connections/JvmScannerViewModel.kt
@@ -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,
)