From 34066fa661d8a1ac068e9f67b5c5901d4cce43ec Mon Sep 17 00:00:00 2001 From: James Rich Date: Mon, 13 Apr 2026 17:47:33 -0500 Subject: [PATCH] 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 --- .../composeResources/values/strings.xml | 6 + .../connections/AndroidScannerViewModel.kt | 3 + .../AndroidGetDiscoveredDevicesUseCase.kt | 75 ++-- .../feature/connections/ScannerViewModel.kt | 32 +- .../CommonGetDiscoveredDevicesUseCase.kt | 45 +-- .../connections/model/DiscoveredDevices.kt | 10 +- .../connections/ui/ConnectionsScreen.kt | 88 +---- .../connections/ui/components/BLEDevices.kt | 89 ----- .../ui/components/ConnectionsSegmentedBar.kt | 69 ---- .../connections/ui/components/DeviceList.kt | 365 ++++++++++++++++++ .../ui/components/NetworkDevices.kt | 200 ---------- .../connections/ui/components/UsbDevices.kt | 54 --- .../connections/ScannerViewModelTest.kt | 84 +++- .../CommonGetDiscoveredDevicesUseCaseTest.kt | 51 ++- .../connections/JvmScannerViewModel.kt | 3 + 15 files changed, 598 insertions(+), 576 deletions(-) delete mode 100644 feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/BLEDevices.kt delete mode 100644 feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/ConnectionsSegmentedBar.kt create mode 100644 feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/DeviceList.kt delete mode 100644 feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/NetworkDevices.kt delete mode 100644 feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/UsbDevices.kt 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, )