mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
fix(connections): replace tab-based UI with unified device list and gate NSD scanning
On Android 15+ NsdManager.discoverServices() triggers an unavoidable system consent dialog. This replaces the BLE/Network/USB tab bar with a single scrollable device list and gates NSD behind an explicit user-initiated scan toggle, preventing the dialog from appearing unexpectedly. - Add gated NSD flow (flatMapLatest on isNetworkScanning) in ScannerViewModel - Simplify GetDiscoveredDevicesUseCase to accept resolvedList as a parameter - Create unified DeviceList composable with per-transport sections - Add BLE scan toggle matching the network scan pattern - Delete BLEDevices, NetworkDevices, UsbDevices, ConnectionsSegmentedBar - Add onCleared() scan cleanup as a safety net - Remove unused supportedDeviceTypes from ViewModel
This commit is contained in:
parent
28be6933c8
commit
34066fa661
15 changed files with 598 additions and 576 deletions
|
|
@ -910,7 +910,13 @@
|
|||
<string name="firmware_edition">Firmware Edition</string>
|
||||
<string name="recent_network_devices">Recent Network Devices</string>
|
||||
<string name="discovered_network_devices">Discovered Network Devices</string>
|
||||
<string name="scan_network_devices">Scan for network devices</string>
|
||||
<string name="scanning_network">Scanning…</string>
|
||||
<string name="scan_bluetooth_devices">Scan for Bluetooth devices</string>
|
||||
<string name="scanning_bluetooth">Scanning…</string>
|
||||
<string name="bluetooth_available_devices">Available Bluetooth Devices</string>
|
||||
<string name="add_network_device_manually">Add device manually…</string>
|
||||
<string name="no_devices_found">No devices found</string>
|
||||
|
||||
<string name="get_started">Get started</string>
|
||||
<string name="intro_welcome">Welcome to</string>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ import org.meshtastic.core.datastore.RecentAddressesDataSource
|
|||
import org.meshtastic.core.datastore.model.RecentAddress
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.network.repository.DiscoveredService
|
||||
import org.meshtastic.core.network.repository.NetworkRepository
|
||||
import org.meshtastic.core.network.repository.UsbRepository
|
||||
import org.meshtastic.core.repository.NodeRepository
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
|
|
@ -46,7 +45,6 @@ import java.util.Locale
|
|||
@Single
|
||||
class AndroidGetDiscoveredDevicesUseCase(
|
||||
private val bluetoothRepository: BluetoothRepository,
|
||||
private val networkRepository: NetworkRepository,
|
||||
private val recentAddressesDataSource: RecentAddressesDataSource,
|
||||
private val nodeRepository: NodeRepository,
|
||||
private val databaseManager: DatabaseManager,
|
||||
|
|
@ -57,16 +55,13 @@ class AndroidGetDiscoveredDevicesUseCase(
|
|||
private val macSuffixLength = 8
|
||||
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
override fun invoke(showMock: Boolean): Flow<DiscoveredDevices> {
|
||||
override fun invoke(showMock: Boolean, resolvedList: Flow<List<DiscoveredService>>): Flow<DiscoveredDevices> {
|
||||
val nodeDb = nodeRepository.nodeDBbyNum
|
||||
|
||||
val bondedBleFlow = bluetoothRepository.state.map { ble -> ble.bondedDevices.map { DeviceListEntry.Ble(it) } }
|
||||
|
||||
val processedTcpFlow =
|
||||
combine(networkRepository.resolvedList, recentAddressesDataSource.recentAddresses) {
|
||||
tcpServices,
|
||||
recentList,
|
||||
->
|
||||
combine(resolvedList, recentAddressesDataSource.recentAddresses) { tcpServices, recentList ->
|
||||
val defaultName = getString(Res.string.meshtastic)
|
||||
processTcpServices(tcpServices, recentList, defaultName)
|
||||
}
|
||||
|
|
@ -92,7 +87,7 @@ class AndroidGetDiscoveredDevicesUseCase(
|
|||
bondedBleFlow,
|
||||
processedTcpFlow,
|
||||
usbDevicesFlow,
|
||||
networkRepository.resolvedList,
|
||||
resolvedList,
|
||||
recentAddressesDataSource.recentAddresses,
|
||||
) { args: Array<Any> ->
|
||||
@Suppress("UNCHECKED_CAST", "MagicNumber")
|
||||
|
|
@ -113,40 +108,9 @@ class AndroidGetDiscoveredDevicesUseCase(
|
|||
@Suppress("UNCHECKED_CAST", "MagicNumber")
|
||||
val recentList = args[5] as List<RecentAddress>
|
||||
|
||||
// Android-specific: BLE node matching by MAC suffix and Meshtastic short name
|
||||
val bleForUi =
|
||||
bondedBle
|
||||
.map { entry ->
|
||||
val matchingNode =
|
||||
if (databaseManager.hasDatabaseFor(entry.fullAddress)) {
|
||||
db.values.find { node ->
|
||||
val macSuffix =
|
||||
entry.device.address
|
||||
.replace(":", "")
|
||||
.takeLast(macSuffixLength)
|
||||
.lowercase(Locale.ROOT)
|
||||
val nameSuffix = entry.device.getMeshtasticShortName()?.lowercase(Locale.ROOT)
|
||||
node.user.id.lowercase(Locale.ROOT).endsWith(macSuffix) ||
|
||||
(nameSuffix != null && node.user.id.lowercase(Locale.ROOT).endsWith(nameSuffix))
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
entry.copy(node = matchingNode)
|
||||
}
|
||||
.sortedBy { it.name }
|
||||
val bleForUi = matchBleNodes(bondedBle, db)
|
||||
val usbForUi = matchUsbNodes(usbDevices, showMock, db)
|
||||
|
||||
// Android-specific: USB node matching via shared helper
|
||||
val usbForUi =
|
||||
(
|
||||
usbDevices +
|
||||
if (showMock) listOf(DeviceListEntry.Mock(getString(Res.string.demo_mode))) else emptyList()
|
||||
)
|
||||
.map { entry ->
|
||||
entry.copy(node = findNodeByNameSuffix(entry.name, entry.fullAddress, db, databaseManager))
|
||||
}
|
||||
|
||||
// Shared TCP logic via helpers
|
||||
val discoveredTcpForUi = matchDiscoveredTcpNodes(processedTcp, db, resolved, databaseManager)
|
||||
val discoveredTcpAddresses = processedTcp.map { it.fullAddress }.toSet()
|
||||
val recentTcpForUi = buildRecentTcpEntries(recentList, discoveredTcpAddresses, db, databaseManager)
|
||||
|
|
@ -159,4 +123,33 @@ class AndroidGetDiscoveredDevicesUseCase(
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun matchBleNodes(bondedBle: List<DeviceListEntry.Ble>, db: Map<Int, Node>): List<DeviceListEntry.Ble> =
|
||||
bondedBle
|
||||
.map { entry ->
|
||||
val matchingNode =
|
||||
if (databaseManager.hasDatabaseFor(entry.fullAddress)) {
|
||||
db.values.find { node ->
|
||||
val macSuffix =
|
||||
entry.device.address.replace(":", "").takeLast(macSuffixLength).lowercase(Locale.ROOT)
|
||||
val nameSuffix = entry.device.getMeshtasticShortName()?.lowercase(Locale.ROOT)
|
||||
node.user.id.lowercase(Locale.ROOT).endsWith(macSuffix) ||
|
||||
(nameSuffix != null && node.user.id.lowercase(Locale.ROOT).endsWith(nameSuffix))
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
entry.copy(node = matchingNode)
|
||||
}
|
||||
.sortedBy { it.name }
|
||||
|
||||
private suspend fun matchUsbNodes(
|
||||
usbDevices: List<DeviceListEntry.Usb>,
|
||||
showMock: Boolean,
|
||||
db: Map<Int, Node>,
|
||||
): List<DeviceListEntry> =
|
||||
(usbDevices + if (showMock) listOf(DeviceListEntry.Mock(getString(Res.string.demo_mode))) else emptyList())
|
||||
.map { entry ->
|
||||
entry.copy(node = findNodeByNameSuffix(entry.name, entry.fullAddress, db, databaseManager))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import kotlinx.coroutines.flow.StateFlow
|
|||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
|
@ -34,6 +35,7 @@ import org.meshtastic.core.datastore.RecentAddressesDataSource
|
|||
import org.meshtastic.core.datastore.model.RecentAddress
|
||||
import org.meshtastic.core.model.RadioController
|
||||
import org.meshtastic.core.model.util.anonymize
|
||||
import org.meshtastic.core.network.repository.NetworkRepository
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
import org.meshtastic.core.repository.RadioPrefs
|
||||
import org.meshtastic.core.repository.ServiceRepository
|
||||
|
|
@ -49,6 +51,7 @@ open class ScannerViewModel(
|
|||
private val radioPrefs: RadioPrefs,
|
||||
private val recentAddressesDataSource: RecentAddressesDataSource,
|
||||
private val getDiscoveredDevicesUseCase: GetDiscoveredDevicesUseCase,
|
||||
private val networkRepository: NetworkRepository,
|
||||
private val dispatchers: org.meshtastic.core.di.CoroutineDispatchers,
|
||||
private val bleScanner: org.meshtastic.core.ble.BleScanner? = null,
|
||||
) : ViewModel() {
|
||||
|
|
@ -103,11 +106,32 @@ open class ScannerViewModel(
|
|||
isBleScanningState.value = false
|
||||
}
|
||||
|
||||
private val _isNetworkScanning = MutableStateFlow(false)
|
||||
val isNetworkScanning: StateFlow<Boolean> = _isNetworkScanning.asStateFlow()
|
||||
|
||||
/**
|
||||
* The resolved NSD services flow, gated by [_isNetworkScanning]. When scanning is inactive, this emits
|
||||
* `emptyList()` so `NsdManager.discoverServices()` is never triggered. On Android 15+ subscribing to the real
|
||||
* `resolvedList` shows a system consent dialog, so this ensures NSD only runs when the user explicitly requests it.
|
||||
*/
|
||||
private val gatedResolvedList =
|
||||
_isNetworkScanning.flatMapLatest { scanning ->
|
||||
if (scanning) networkRepository.resolvedList else flowOf(emptyList())
|
||||
}
|
||||
|
||||
private val discoveredDevicesFlow =
|
||||
showMockTransport
|
||||
.flatMapLatest { showMock -> getDiscoveredDevicesUseCase.invoke(showMock) }
|
||||
.flatMapLatest { showMock -> getDiscoveredDevicesUseCase.invoke(showMock, gatedResolvedList) }
|
||||
.stateInWhileSubscribed(initialValue = null)
|
||||
|
||||
fun startNetworkScan() {
|
||||
_isNetworkScanning.value = true
|
||||
}
|
||||
|
||||
fun stopNetworkScan() {
|
||||
_isNetworkScanning.value = false
|
||||
}
|
||||
|
||||
/** A combined list of bonded and scanned BLE devices for the UI. */
|
||||
val bleDevicesForUi: StateFlow<List<DeviceListEntry>> =
|
||||
kotlinx.coroutines.flow
|
||||
|
|
@ -138,7 +162,7 @@ open class ScannerViewModel(
|
|||
.distinctUntilChanged()
|
||||
.stateInWhileSubscribed(initialValue = emptyList())
|
||||
|
||||
/** UI StateFlow for discovered TCP devices (NSD). */
|
||||
/** UI StateFlow for discovered TCP devices (NSD), only populated during an active network scan. */
|
||||
val discoveredTcpDevicesForUi: StateFlow<List<DeviceListEntry>> =
|
||||
discoveredDevicesFlow
|
||||
.map { it?.discoveredTcpDevices ?: emptyList() }
|
||||
|
|
@ -162,8 +186,6 @@ open class ScannerViewModel(
|
|||
.map { it ?: NO_DEVICE_SELECTED }
|
||||
.stateInWhileSubscribed(initialValue = selectedAddressFlow.value ?: NO_DEVICE_SELECTED)
|
||||
|
||||
val supportedDeviceTypes: List<org.meshtastic.core.model.DeviceType> = radioInterfaceService.supportedDeviceTypes
|
||||
|
||||
init {
|
||||
serviceRepository.connectionProgress.onEach { _errorText.value = it }.launchIn(viewModelScope)
|
||||
Logger.d { "ScannerViewModel created" }
|
||||
|
|
@ -171,6 +193,8 @@ open class ScannerViewModel(
|
|||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
stopBleScan()
|
||||
stopNetworkScan()
|
||||
Logger.d { "ScannerViewModel cleared" }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,11 +18,12 @@ package org.meshtastic.feature.connections.domain.usecase
|
|||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.common.database.DatabaseManager
|
||||
import org.meshtastic.core.datastore.RecentAddressesDataSource
|
||||
import org.meshtastic.core.network.repository.NetworkRepository
|
||||
import org.meshtastic.core.network.repository.DiscoveredService
|
||||
import org.meshtastic.core.repository.NodeRepository
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.demo_mode
|
||||
|
|
@ -36,46 +37,42 @@ class CommonGetDiscoveredDevicesUseCase(
|
|||
private val recentAddressesDataSource: RecentAddressesDataSource,
|
||||
private val nodeRepository: NodeRepository,
|
||||
private val databaseManager: DatabaseManager,
|
||||
private val networkRepository: NetworkRepository,
|
||||
private val usbScanner: UsbScanner? = null,
|
||||
) : GetDiscoveredDevicesUseCase {
|
||||
|
||||
override fun invoke(showMock: Boolean): Flow<DiscoveredDevices> {
|
||||
override fun invoke(showMock: Boolean, resolvedList: Flow<List<DiscoveredService>>): Flow<DiscoveredDevices> {
|
||||
val nodeDb = nodeRepository.nodeDBbyNum
|
||||
val usbFlow = usbScanner?.scanUsbDevices() ?: kotlinx.coroutines.flow.flowOf(emptyList())
|
||||
val usbFlow = usbScanner?.scanUsbDevices() ?: flowOf(emptyList())
|
||||
|
||||
val processedTcpFlow =
|
||||
combine(networkRepository.resolvedList, recentAddressesDataSource.recentAddresses) {
|
||||
tcpServices,
|
||||
recentList,
|
||||
->
|
||||
combine(resolvedList, recentAddressesDataSource.recentAddresses) { tcpServices, recentList ->
|
||||
val defaultName = runCatching { getString(Res.string.meshtastic) }.getOrDefault("Meshtastic")
|
||||
processTcpServices(tcpServices, recentList, defaultName)
|
||||
}
|
||||
|
||||
return combine(
|
||||
nodeDb,
|
||||
processedTcpFlow,
|
||||
networkRepository.resolvedList,
|
||||
recentAddressesDataSource.recentAddresses,
|
||||
usbFlow,
|
||||
) { db, processedTcp, resolved, recentList, usbList ->
|
||||
return combine(nodeDb, processedTcpFlow, resolvedList, recentAddressesDataSource.recentAddresses, usbFlow) {
|
||||
db,
|
||||
processedTcp,
|
||||
resolved,
|
||||
recentList,
|
||||
usbList,
|
||||
->
|
||||
val discoveredTcpForUi = matchDiscoveredTcpNodes(processedTcp, db, resolved, databaseManager)
|
||||
val discoveredTcpAddresses = processedTcp.map { it.fullAddress }.toSet()
|
||||
val recentTcpForUi = buildRecentTcpEntries(recentList, discoveredTcpAddresses, db, databaseManager)
|
||||
|
||||
val mockEntries =
|
||||
if (showMock) {
|
||||
val label = runCatching { getString(Res.string.demo_mode) }.getOrDefault("Demo Mode")
|
||||
listOf(DeviceListEntry.Mock(label))
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
DiscoveredDevices(
|
||||
discoveredTcpDevices = discoveredTcpForUi,
|
||||
recentTcpDevices = recentTcpForUi,
|
||||
usbDevices =
|
||||
usbList +
|
||||
if (showMock) {
|
||||
val demoModeLabel =
|
||||
runCatching { getString(Res.string.demo_mode) }.getOrDefault("Demo Mode")
|
||||
listOf(DeviceListEntry.Mock(demoModeLabel))
|
||||
} else {
|
||||
emptyList()
|
||||
},
|
||||
usbDevices = usbList + mockEntries,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
package org.meshtastic.feature.connections.model
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.meshtastic.core.network.repository.DiscoveredService
|
||||
|
||||
data class DiscoveredDevices(
|
||||
val bleDevices: List<DeviceListEntry> = emptyList(),
|
||||
|
|
@ -26,5 +27,12 @@ data class DiscoveredDevices(
|
|||
)
|
||||
|
||||
interface GetDiscoveredDevicesUseCase {
|
||||
fun invoke(showMock: Boolean): Flow<DiscoveredDevices>
|
||||
/**
|
||||
* Returns a flow of all discovered devices (BLE, USB, TCP).
|
||||
*
|
||||
* @param resolvedList the NSD/mDNS resolved services flow. On Android 15+, subscribing to
|
||||
* `NetworkRepository.resolvedList` triggers a system consent dialog, so callers should pass `flowOf(emptyList())`
|
||||
* unless the user has explicitly requested a network scan.
|
||||
*/
|
||||
fun invoke(showMock: Boolean, resolvedList: Flow<List<DiscoveredService>>): Flow<DiscoveredDevices>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -1,89 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.feature.connections.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.bluetooth_available_devices
|
||||
import org.meshtastic.feature.connections.ScannerViewModel
|
||||
|
||||
/**
|
||||
* Composable that displays a list of Bluetooth Low Energy (BLE) devices and allows scanning.
|
||||
*
|
||||
* @param connectionState The current connection state of the MeshService.
|
||||
* @param selectedDevice The full address of the currently selected device.
|
||||
* @param scanModel The ViewModel responsible for Bluetooth scanning logic.
|
||||
*/
|
||||
@Composable
|
||||
fun BLEDevices(connectionState: ConnectionState, selectedDevice: String, scanModel: ScannerViewModel) {
|
||||
val bleDevices by scanModel.bleDevicesForUi.collectAsStateWithLifecycle()
|
||||
val isScanning by scanModel.isBleScanning.collectAsStateWithLifecycle()
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
scanModel.startBleScan()
|
||||
onDispose { scanModel.stopBleScan() }
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = stringResource(Res.string.bluetooth_available_devices),
|
||||
modifier = Modifier.padding(horizontal = 8.dp).padding(bottom = 16.dp).fillMaxWidth(),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
|
||||
if (isScanning) {
|
||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp))
|
||||
}
|
||||
|
||||
LazyColumn(modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp)) {
|
||||
items(bleDevices, key = { it.fullAddress }) { device ->
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
shape = MaterialTheme.shapes.large,
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh),
|
||||
) {
|
||||
DeviceListItem(
|
||||
connectionState =
|
||||
connectionState.takeIf { device.fullAddress == selectedDevice }
|
||||
?: ConnectionState.Disconnected,
|
||||
device = device,
|
||||
onSelect = { scanModel.onSelected(device) },
|
||||
rssi = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.feature.connections.ui.components
|
||||
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.SegmentedButton
|
||||
import androidx.compose.material3.SegmentedButtonDefaults
|
||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import org.jetbrains.compose.resources.DrawableResource
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.jetbrains.compose.resources.vectorResource
|
||||
import org.meshtastic.core.model.DeviceType
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.bluetooth
|
||||
import org.meshtastic.core.resources.ic_bluetooth
|
||||
import org.meshtastic.core.resources.ic_usb
|
||||
import org.meshtastic.core.resources.ic_wifi
|
||||
import org.meshtastic.core.resources.network
|
||||
import org.meshtastic.core.resources.serial
|
||||
|
||||
@Suppress("LambdaParameterEventTrailing")
|
||||
@Composable
|
||||
fun ConnectionsSegmentedBar(
|
||||
selectedDeviceType: DeviceType,
|
||||
supportedDeviceTypes: List<DeviceType>,
|
||||
modifier: Modifier = Modifier,
|
||||
onClickDeviceType: (DeviceType) -> Unit,
|
||||
) {
|
||||
val visibleItems = Item.entries.filter { it.deviceType in supportedDeviceTypes }
|
||||
if (visibleItems.isEmpty()) return
|
||||
|
||||
SingleChoiceSegmentedButtonRow(modifier = modifier) {
|
||||
visibleItems.forEachIndexed { index, item ->
|
||||
val text = stringResource(item.textRes)
|
||||
SegmentedButton(
|
||||
shape = SegmentedButtonDefaults.itemShape(index, visibleItems.size),
|
||||
onClick = { onClickDeviceType(item.deviceType) },
|
||||
selected = item.deviceType == selectedDeviceType,
|
||||
icon = { Icon(imageVector = vectorResource(item.icon), contentDescription = text) },
|
||||
label = { Text(text = text, maxLines = 1, overflow = TextOverflow.Ellipsis) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum class Item(val icon: DrawableResource, val textRes: StringResource, val deviceType: DeviceType) {
|
||||
BLUETOOTH(icon = Res.drawable.ic_bluetooth, textRes = Res.string.bluetooth, deviceType = DeviceType.BLE),
|
||||
NETWORK(icon = Res.drawable.ic_wifi, textRes = Res.string.network, deviceType = DeviceType.TCP),
|
||||
SERIAL(icon = Res.drawable.ic_usb, textRes = Res.string.serial, deviceType = DeviceType.USB),
|
||||
}
|
||||
|
|
@ -0,0 +1,365 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.feature.connections.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.text.input.TextFieldLineLimits
|
||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.SheetState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextFieldLabelPosition
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.common.util.isValidAddress
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.network.repository.NetworkConstants
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.add_network_device
|
||||
import org.meshtastic.core.resources.add_network_device_manually
|
||||
import org.meshtastic.core.resources.address
|
||||
import org.meshtastic.core.resources.bluetooth
|
||||
import org.meshtastic.core.resources.cancel
|
||||
import org.meshtastic.core.resources.discovered_network_devices
|
||||
import org.meshtastic.core.resources.ip_port
|
||||
import org.meshtastic.core.resources.no_devices_found
|
||||
import org.meshtastic.core.resources.recent_network_devices
|
||||
import org.meshtastic.core.resources.scan_bluetooth_devices
|
||||
import org.meshtastic.core.resources.scan_network_devices
|
||||
import org.meshtastic.core.resources.scanning_bluetooth
|
||||
import org.meshtastic.core.resources.scanning_network
|
||||
import org.meshtastic.core.resources.usb
|
||||
import org.meshtastic.core.ui.icon.Add
|
||||
import org.meshtastic.core.ui.icon.MeshtasticIcons
|
||||
import org.meshtastic.core.ui.icon.NoDevice
|
||||
import org.meshtastic.feature.connections.ScannerViewModel
|
||||
import org.meshtastic.feature.connections.model.DeviceListEntry
|
||||
|
||||
/**
|
||||
* Unified device list composable that displays all available devices grouped by transport type.
|
||||
*
|
||||
* Replaces the previous tab-based UI (BLE / Network / Serial tabs) with a single scrollable list. Each transport type
|
||||
* is rendered as a section with a header. Empty sections are hidden.
|
||||
*
|
||||
* BLE and network scanning are controlled by explicit toggle buttons rather than auto-starting.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Suppress("LongMethod", "LongParameterList")
|
||||
@Composable
|
||||
fun DeviceList(
|
||||
connectionState: ConnectionState,
|
||||
selectedDevice: String,
|
||||
bleDevices: List<DeviceListEntry>,
|
||||
usbDevices: List<DeviceListEntry>,
|
||||
discoveredTcpDevices: List<DeviceListEntry>,
|
||||
recentTcpDevices: List<DeviceListEntry>,
|
||||
isBleScanning: Boolean,
|
||||
isNetworkScanning: Boolean,
|
||||
scanModel: ScannerViewModel,
|
||||
onToggleBleScan: () -> Unit,
|
||||
onToggleNetworkScan: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
// Stop scans when this composable leaves the composition
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
scanModel.stopBleScan()
|
||||
scanModel.stopNetworkScan()
|
||||
}
|
||||
}
|
||||
|
||||
var showAddDialog by remember { mutableStateOf(false) }
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val hideAndDismiss: () -> Unit = {
|
||||
scope.launch { sheetState.hide() }.invokeOnCompletion { if (!sheetState.isVisible) showAddDialog = false }
|
||||
}
|
||||
|
||||
if (showAddDialog) {
|
||||
AddDeviceDialog(
|
||||
sheetState = sheetState,
|
||||
onHideDialog = hideAndDismiss,
|
||||
onClickAdd = { address, fullAddress ->
|
||||
scanModel.addRecentAddress(fullAddress, address)
|
||||
scanModel.changeDeviceAddress(fullAddress)
|
||||
hideAndDismiss()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
val hasAnyDevices =
|
||||
bleDevices.isNotEmpty() ||
|
||||
usbDevices.isNotEmpty() ||
|
||||
discoveredTcpDevices.isNotEmpty() ||
|
||||
recentTcpDevices.isNotEmpty()
|
||||
|
||||
if (!hasAnyDevices && !isBleScanning && !isNetworkScanning) {
|
||||
EmptyDeviceList(
|
||||
onToggleBleScan = onToggleBleScan,
|
||||
onToggleNetworkScan = onToggleNetworkScan,
|
||||
onAddManually = { showAddDialog = true },
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier.fillMaxSize().verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
// ── Bluetooth section ──
|
||||
BluetoothSection(
|
||||
connectionState = connectionState,
|
||||
selectedDevice = selectedDevice,
|
||||
bleDevices = bleDevices,
|
||||
isBleScanning = isBleScanning,
|
||||
scanModel = scanModel,
|
||||
onToggleBleScan = onToggleBleScan,
|
||||
)
|
||||
|
||||
// ── USB section ──
|
||||
if (usbDevices.isNotEmpty()) {
|
||||
usbDevices.DeviceListSection(
|
||||
title = stringResource(Res.string.usb),
|
||||
connectionState = connectionState,
|
||||
selectedDevice = selectedDevice,
|
||||
onSelect = scanModel::onSelected,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Network section ──
|
||||
NetworkSection(
|
||||
connectionState = connectionState,
|
||||
selectedDevice = selectedDevice,
|
||||
discoveredTcpDevices = discoveredTcpDevices,
|
||||
recentTcpDevices = recentTcpDevices,
|
||||
isNetworkScanning = isNetworkScanning,
|
||||
scanModel = scanModel,
|
||||
onToggleNetworkScan = onToggleNetworkScan,
|
||||
onAddManually = { showAddDialog = true },
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
/** Bluetooth section: scan toggle + device list. */
|
||||
@Composable
|
||||
private fun BluetoothSection(
|
||||
connectionState: ConnectionState,
|
||||
selectedDevice: String,
|
||||
bleDevices: List<DeviceListEntry>,
|
||||
isBleScanning: Boolean,
|
||||
scanModel: ScannerViewModel,
|
||||
onToggleBleScan: () -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ScanToggleButton(
|
||||
isScanning = isBleScanning,
|
||||
scanLabel = stringResource(Res.string.scan_bluetooth_devices),
|
||||
scanningLabel = stringResource(Res.string.scanning_bluetooth),
|
||||
onToggle = onToggleBleScan,
|
||||
)
|
||||
|
||||
if (bleDevices.isNotEmpty()) {
|
||||
bleDevices.DeviceListSection(
|
||||
title = stringResource(Res.string.bluetooth),
|
||||
connectionState = connectionState,
|
||||
selectedDevice = selectedDevice,
|
||||
onSelect = { scanModel.onSelected(it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Network section: scan toggle + discovered + recent + add manually. */
|
||||
@Suppress("LongParameterList")
|
||||
@Composable
|
||||
private fun NetworkSection(
|
||||
connectionState: ConnectionState,
|
||||
selectedDevice: String,
|
||||
discoveredTcpDevices: List<DeviceListEntry>,
|
||||
recentTcpDevices: List<DeviceListEntry>,
|
||||
isNetworkScanning: Boolean,
|
||||
scanModel: ScannerViewModel,
|
||||
onToggleNetworkScan: () -> Unit,
|
||||
onAddManually: () -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
if (discoveredTcpDevices.isNotEmpty()) {
|
||||
discoveredTcpDevices.DeviceListSection(
|
||||
title = stringResource(Res.string.discovered_network_devices),
|
||||
connectionState = connectionState,
|
||||
selectedDevice = selectedDevice,
|
||||
onSelect = { scanModel.onSelected(it) },
|
||||
)
|
||||
}
|
||||
|
||||
if (recentTcpDevices.isNotEmpty()) {
|
||||
recentTcpDevices.DeviceListSection(
|
||||
title = stringResource(Res.string.recent_network_devices),
|
||||
connectionState = connectionState,
|
||||
selectedDevice = selectedDevice,
|
||||
onSelect = { scanModel.onSelected(it) },
|
||||
onDelete = { scanModel.removeRecentAddress(it.fullAddress) },
|
||||
)
|
||||
}
|
||||
|
||||
ScanToggleButton(
|
||||
isScanning = isNetworkScanning,
|
||||
scanLabel = stringResource(Res.string.scan_network_devices),
|
||||
scanningLabel = stringResource(Res.string.scanning_network),
|
||||
onToggle = onToggleNetworkScan,
|
||||
)
|
||||
|
||||
OutlinedButton(onClick = onAddManually, modifier = Modifier.fillMaxWidth()) {
|
||||
Icon(MeshtasticIcons.Add, contentDescription = null, modifier = Modifier.padding(end = 8.dp))
|
||||
Text(stringResource(Res.string.add_network_device_manually))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Reusable scan toggle button with progress indicator. */
|
||||
@Composable
|
||||
private fun ScanToggleButton(isScanning: Boolean, scanLabel: String, scanningLabel: String, onToggle: () -> Unit) {
|
||||
OutlinedButton(onClick = onToggle, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(if (isScanning) scanningLabel else scanLabel)
|
||||
}
|
||||
|
||||
if (isScanning) {
|
||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
|
||||
/** Shown when there are no devices of any type and no scan is running. */
|
||||
@Composable
|
||||
private fun EmptyDeviceList(onToggleBleScan: () -> Unit, onToggleNetworkScan: () -> Unit, onAddManually: () -> Unit) {
|
||||
Column(modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
EmptyStateContent(
|
||||
text = stringResource(Res.string.no_devices_found),
|
||||
imageVector = MeshtasticIcons.NoDevice,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = onToggleBleScan, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(stringResource(Res.string.scan_bluetooth_devices))
|
||||
}
|
||||
Button(onClick = onToggleNetworkScan, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(stringResource(Res.string.scan_network_devices))
|
||||
}
|
||||
OutlinedButton(onClick = onAddManually, modifier = Modifier.fillMaxWidth()) {
|
||||
Icon(MeshtasticIcons.Add, contentDescription = null, modifier = Modifier.padding(end = 8.dp))
|
||||
Text(stringResource(Res.string.add_network_device_manually))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Dialog for manually adding a TCP device by IP address and port. */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun AddDeviceDialog(
|
||||
sheetState: SheetState,
|
||||
onHideDialog: () -> Unit,
|
||||
onClickAdd: (address: String, fullAddress: String) -> Unit,
|
||||
) {
|
||||
val addressState = rememberTextFieldState("")
|
||||
val portState = rememberTextFieldState(NetworkConstants.SERVICE_PORT.toString())
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
ModalBottomSheet(onDismissRequest = onHideDialog, sheetState = sheetState) {
|
||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
state = addressState,
|
||||
labelPosition = TextFieldLabelPosition.Above(),
|
||||
lineLimits = TextFieldLineLimits.SingleLine,
|
||||
label = { Text(stringResource(Res.string.address)) },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
|
||||
modifier = Modifier.weight(.7f),
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
state = portState,
|
||||
labelPosition = TextFieldLabelPosition.Above(),
|
||||
placeholder = { Text(NetworkConstants.SERVICE_PORT.toString()) },
|
||||
lineLimits = TextFieldLineLimits.SingleLine,
|
||||
label = { Text(stringResource(Res.string.ip_port)) },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal, imeAction = ImeAction.Done),
|
||||
modifier = Modifier.weight(.3f),
|
||||
)
|
||||
}
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(modifier = Modifier.weight(1f), onClick = { onHideDialog() }) {
|
||||
Text(stringResource(Res.string.cancel))
|
||||
}
|
||||
|
||||
Button(
|
||||
modifier = Modifier.weight(1f),
|
||||
onClick = {
|
||||
val address = addressState.text.toString()
|
||||
if (address.isValidAddress()) {
|
||||
val portString = portState.text.toString()
|
||||
val port = portString.toIntOrNull()
|
||||
|
||||
val combinedString =
|
||||
if (port != null && port != NetworkConstants.SERVICE_PORT) {
|
||||
"$address:$portString"
|
||||
} else {
|
||||
address
|
||||
}
|
||||
|
||||
onClickAdd(combinedString, "t$combinedString")
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text(stringResource(Res.string.add_network_device))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,200 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.feature.connections.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.text.input.TextFieldLineLimits
|
||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.SheetState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextFieldLabelPosition
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.common.util.isValidAddress
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.network.repository.NetworkConstants
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.add_network_device
|
||||
import org.meshtastic.core.resources.address
|
||||
import org.meshtastic.core.resources.cancel
|
||||
import org.meshtastic.core.resources.discovered_network_devices
|
||||
import org.meshtastic.core.resources.ip_port
|
||||
import org.meshtastic.core.resources.no_network_devices_found
|
||||
import org.meshtastic.core.resources.recent_network_devices
|
||||
import org.meshtastic.core.ui.icon.Add
|
||||
import org.meshtastic.core.ui.icon.HardwareModel
|
||||
import org.meshtastic.core.ui.icon.MeshtasticIcons
|
||||
import org.meshtastic.feature.connections.ScannerViewModel
|
||||
import org.meshtastic.feature.connections.model.DeviceListEntry
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun NetworkDevices(
|
||||
connectionState: ConnectionState,
|
||||
discoveredNetworkDevices: List<DeviceListEntry>,
|
||||
recentNetworkDevices: List<DeviceListEntry>,
|
||||
selectedDevice: String,
|
||||
scanModel: ScannerViewModel,
|
||||
) {
|
||||
var showAddDialog by remember { mutableStateOf(false) }
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
if (showAddDialog) {
|
||||
AddDeviceDialog(
|
||||
sheetState = sheetState,
|
||||
onHideDialog = {
|
||||
scope
|
||||
.launch { sheetState.hide() }
|
||||
.invokeOnCompletion { if (!sheetState.isVisible) showAddDialog = false }
|
||||
},
|
||||
onClickAdd = { address, fullAddress ->
|
||||
scanModel.addRecentAddress(fullAddress, address)
|
||||
scanModel.changeDeviceAddress(fullAddress)
|
||||
scope
|
||||
.launch { sheetState.hide() }
|
||||
.invokeOnCompletion { if (!sheetState.isVisible) showAddDialog = false }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
if (discoveredNetworkDevices.isEmpty() && recentNetworkDevices.isEmpty()) {
|
||||
EmptyStateContent(
|
||||
text = stringResource(Res.string.no_network_devices_found),
|
||||
imageVector = MeshtasticIcons.HardwareModel,
|
||||
modifier = Modifier.padding(vertical = 32.dp),
|
||||
) {
|
||||
Button(onClick = { showAddDialog = true }) {
|
||||
Icon(MeshtasticIcons.Add, contentDescription = null)
|
||||
Text(stringResource(Res.string.add_network_device))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (discoveredNetworkDevices.isNotEmpty()) {
|
||||
discoveredNetworkDevices.DeviceListSection(
|
||||
title = stringResource(Res.string.discovered_network_devices),
|
||||
connectionState = connectionState,
|
||||
selectedDevice = selectedDevice,
|
||||
onSelect = { scanModel.onSelected(it) },
|
||||
)
|
||||
}
|
||||
|
||||
if (recentNetworkDevices.isNotEmpty()) {
|
||||
recentNetworkDevices.DeviceListSection(
|
||||
title = stringResource(Res.string.recent_network_devices),
|
||||
connectionState = connectionState,
|
||||
selectedDevice = selectedDevice,
|
||||
onSelect = { scanModel.onSelected(it) },
|
||||
onDelete = { scanModel.removeRecentAddress(it.fullAddress) },
|
||||
)
|
||||
}
|
||||
|
||||
Row(modifier = Modifier.padding(top = 8.dp)) {
|
||||
FloatingActionButton(onClick = { showAddDialog = true }) {
|
||||
Icon(MeshtasticIcons.Add, contentDescription = stringResource(Res.string.add_network_device))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun AddDeviceDialog(
|
||||
sheetState: SheetState,
|
||||
onHideDialog: () -> Unit,
|
||||
onClickAdd: (address: String, fullAddress: String) -> Unit,
|
||||
) {
|
||||
val addressState = rememberTextFieldState("")
|
||||
val portState = rememberTextFieldState(NetworkConstants.SERVICE_PORT.toString())
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
ModalBottomSheet(onDismissRequest = onHideDialog, sheetState = sheetState) {
|
||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
state = addressState,
|
||||
labelPosition = TextFieldLabelPosition.Above(),
|
||||
lineLimits = TextFieldLineLimits.SingleLine,
|
||||
label = { Text(stringResource(Res.string.address)) },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
|
||||
modifier = Modifier.weight(.7f),
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
state = portState,
|
||||
labelPosition = TextFieldLabelPosition.Above(),
|
||||
placeholder = { Text(NetworkConstants.SERVICE_PORT.toString()) },
|
||||
lineLimits = TextFieldLineLimits.SingleLine,
|
||||
label = { Text(stringResource(Res.string.ip_port)) },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal, imeAction = ImeAction.Done),
|
||||
modifier = Modifier.weight(.3f),
|
||||
)
|
||||
}
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(modifier = Modifier.weight(1f), onClick = { onHideDialog() }) {
|
||||
Text(stringResource(Res.string.cancel))
|
||||
}
|
||||
|
||||
Button(
|
||||
modifier = Modifier.weight(1f),
|
||||
onClick = {
|
||||
val address = addressState.text.toString()
|
||||
if (address.isValidAddress()) {
|
||||
val portString = portState.text.toString()
|
||||
val port = portString.toIntOrNull()
|
||||
|
||||
val combinedString =
|
||||
if (port != null && port != NetworkConstants.SERVICE_PORT) {
|
||||
"$address:$portString"
|
||||
} else {
|
||||
address
|
||||
}
|
||||
|
||||
onClickAdd(combinedString, "t$combinedString")
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text(stringResource(Res.string.add_network_device))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.feature.connections.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.no_usb_devices_found
|
||||
import org.meshtastic.core.resources.usb
|
||||
import org.meshtastic.core.ui.icon.MeshtasticIcons
|
||||
import org.meshtastic.core.ui.icon.UsbOff
|
||||
import org.meshtastic.feature.connections.ScannerViewModel
|
||||
import org.meshtastic.feature.connections.model.DeviceListEntry
|
||||
|
||||
@Composable
|
||||
fun UsbDevices(
|
||||
connectionState: ConnectionState,
|
||||
usbDevices: List<DeviceListEntry>,
|
||||
selectedDevice: String,
|
||||
scanModel: ScannerViewModel,
|
||||
) {
|
||||
if (usbDevices.isEmpty()) {
|
||||
EmptyStateContent(
|
||||
text = stringResource(Res.string.no_usb_devices_found),
|
||||
imageVector = MeshtasticIcons.UsbOff,
|
||||
modifier = Modifier.padding(vertical = 32.dp),
|
||||
)
|
||||
} else {
|
||||
usbDevices.DeviceListSection(
|
||||
title = stringResource(Res.string.usb),
|
||||
connectionState = connectionState,
|
||||
selectedDevice = selectedDevice,
|
||||
onSelect = scanModel::onSelected,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -22,14 +22,20 @@ import dev.mokkery.answering.returns
|
|||
import dev.mokkery.every
|
||||
import dev.mokkery.matcher.any
|
||||
import dev.mokkery.mock
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.meshtastic.core.datastore.RecentAddressesDataSource
|
||||
import org.meshtastic.core.network.repository.DiscoveredService
|
||||
import org.meshtastic.core.network.repository.NetworkRepository
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
import org.meshtastic.core.repository.RadioPrefs
|
||||
import org.meshtastic.core.testing.FakeRadioController
|
||||
import org.meshtastic.core.testing.FakeServiceRepository
|
||||
import org.meshtastic.feature.connections.model.DeviceListEntry
|
||||
import org.meshtastic.feature.connections.model.DiscoveredDevices
|
||||
import org.meshtastic.feature.connections.model.GetDiscoveredDevicesUseCase
|
||||
import kotlin.test.BeforeTest
|
||||
|
|
@ -46,22 +52,40 @@ class ScannerViewModelTest {
|
|||
private val radioInterfaceService: RadioInterfaceService = mock(MockMode.autofill)
|
||||
private val radioPrefs: RadioPrefs = mock(MockMode.autofill)
|
||||
private val recentAddressesDataSource: RecentAddressesDataSource = mock(MockMode.autofill)
|
||||
private val getDiscoveredDevicesUseCase: GetDiscoveredDevicesUseCase = mock(MockMode.autofill)
|
||||
private val networkRepository: NetworkRepository = mock(MockMode.autofill)
|
||||
private val bleScanner: org.meshtastic.core.ble.BleScanner = mock(MockMode.autofill)
|
||||
|
||||
private val discoveredDevicesFlow = MutableStateFlow(DiscoveredDevices())
|
||||
private val resolvedServicesFlow = MutableStateFlow<List<DiscoveredService>>(emptyList())
|
||||
private val baseDevicesFlow = MutableStateFlow(DiscoveredDevices())
|
||||
|
||||
/**
|
||||
* A fake [GetDiscoveredDevicesUseCase] that mirrors the real behavior: it combines the provided [resolvedList] with
|
||||
* base device data so tests can verify NSD gating.
|
||||
*/
|
||||
private val getDiscoveredDevicesUseCase =
|
||||
object : GetDiscoveredDevicesUseCase {
|
||||
override fun invoke(
|
||||
showMock: Boolean,
|
||||
resolvedList: Flow<List<DiscoveredService>>,
|
||||
): Flow<DiscoveredDevices> = combine(baseDevicesFlow, resolvedList) { base, resolved ->
|
||||
val tcpDevices =
|
||||
resolved.map { DeviceListEntry.Tcp(name = it.name, fullAddress = "t${it.hostAddress}") }
|
||||
base.copy(discoveredTcpDevices = tcpDevices)
|
||||
}
|
||||
}
|
||||
|
||||
@BeforeTest
|
||||
fun setUp() {
|
||||
every { radioInterfaceService.isMockTransport() } returns false
|
||||
every { radioInterfaceService.currentDeviceAddressFlow } returns MutableStateFlow(null)
|
||||
every { radioInterfaceService.supportedDeviceTypes } returns emptyList()
|
||||
|
||||
every { getDiscoveredDevicesUseCase.invoke(any()) } returns discoveredDevicesFlow
|
||||
every { recentAddressesDataSource.recentAddresses } returns MutableStateFlow(emptyList())
|
||||
every { networkRepository.resolvedList } returns resolvedServicesFlow
|
||||
every { networkRepository.networkAvailable } returns flowOf(true)
|
||||
|
||||
serviceRepository.setConnectionProgress("")
|
||||
discoveredDevicesFlow.value = DiscoveredDevices()
|
||||
baseDevicesFlow.value = DiscoveredDevices()
|
||||
resolvedServicesFlow.value = emptyList()
|
||||
|
||||
viewModel =
|
||||
ScannerViewModel(
|
||||
|
|
@ -71,6 +95,7 @@ class ScannerViewModelTest {
|
|||
radioPrefs = radioPrefs,
|
||||
recentAddressesDataSource = recentAddressesDataSource,
|
||||
getDiscoveredDevicesUseCase = getDiscoveredDevicesUseCase,
|
||||
networkRepository = networkRepository,
|
||||
dispatchers =
|
||||
org.meshtastic.core.di.CoroutineDispatchers(
|
||||
io = UnconfinedTestDispatcher(),
|
||||
|
|
@ -124,16 +149,61 @@ class ScannerViewModelTest {
|
|||
assertEquals(emptyList(), awaitItem())
|
||||
|
||||
val device =
|
||||
org.meshtastic.feature.connections.model.DeviceListEntry.Usb(
|
||||
DeviceListEntry.Usb(
|
||||
usbData = object : org.meshtastic.feature.connections.model.UsbDeviceData {},
|
||||
name = "USB Device",
|
||||
fullAddress = "usb_address",
|
||||
bonded = true,
|
||||
)
|
||||
discoveredDevicesFlow.value = DiscoveredDevices(usbDevices = listOf(device))
|
||||
baseDevicesFlow.value = DiscoveredDevices(usbDevices = listOf(device))
|
||||
|
||||
assertEquals(listOf(device), awaitItem())
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isNetworkScanning defaults to false`() {
|
||||
assertEquals(false, viewModel.isNetworkScanning.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `startNetworkScan updates isNetworkScanning`() = runTest {
|
||||
viewModel.isNetworkScanning.test {
|
||||
assertEquals(false, awaitItem())
|
||||
viewModel.startNetworkScan()
|
||||
assertEquals(true, awaitItem())
|
||||
viewModel.stopNetworkScan()
|
||||
assertEquals(false, awaitItem())
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `discoveredTcpDevicesForUi is empty when not scanning`() = runTest {
|
||||
resolvedServicesFlow.value =
|
||||
listOf(DiscoveredService(name = "NSD Device", hostAddress = "192.168.1.50", port = 4403))
|
||||
|
||||
viewModel.discoveredTcpDevicesForUi.test {
|
||||
assertEquals(emptyList(), awaitItem())
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `discoveredTcpDevicesForUi populates when scanning is active`() = runTest {
|
||||
resolvedServicesFlow.value =
|
||||
listOf(DiscoveredService(name = "NSD Device", hostAddress = "192.168.1.50", port = 4403))
|
||||
|
||||
viewModel.discoveredTcpDevicesForUi.test {
|
||||
assertEquals(emptyList(), awaitItem())
|
||||
viewModel.startNetworkScan()
|
||||
val result = awaitItem()
|
||||
assertEquals(1, result.size)
|
||||
assertEquals("t192.168.1.50", result[0].fullAddress)
|
||||
viewModel.stopNetworkScan()
|
||||
assertEquals(emptyList(), awaitItem())
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ import org.meshtastic.core.common.database.DatabaseManager
|
|||
import org.meshtastic.core.datastore.RecentAddressesDataSource
|
||||
import org.meshtastic.core.datastore.model.RecentAddress
|
||||
import org.meshtastic.core.network.repository.DiscoveredService
|
||||
import org.meshtastic.core.network.repository.NetworkRepository
|
||||
import org.meshtastic.core.testing.FakeNodeRepository
|
||||
import org.meshtastic.core.testing.TestDataFactory
|
||||
import kotlin.test.Test
|
||||
|
|
@ -43,7 +42,6 @@ class CommonGetDiscoveredDevicesUseCaseTest {
|
|||
private lateinit var nodeRepository: FakeNodeRepository
|
||||
private lateinit var recentAddressesDataSource: RecentAddressesDataSource
|
||||
private lateinit var databaseManager: DatabaseManager
|
||||
private lateinit var networkRepository: NetworkRepository
|
||||
private val recentAddressesFlow = MutableStateFlow<List<RecentAddress>>(emptyList())
|
||||
private val resolvedServicesFlow = MutableStateFlow<List<DiscoveredService>>(emptyList())
|
||||
|
||||
|
|
@ -51,24 +49,19 @@ class CommonGetDiscoveredDevicesUseCaseTest {
|
|||
nodeRepository = FakeNodeRepository()
|
||||
recentAddressesDataSource = mock { every { recentAddresses } returns recentAddressesFlow }
|
||||
databaseManager = mock { every { hasDatabaseFor(any()) } returns false }
|
||||
networkRepository = mock {
|
||||
every { resolvedList } returns resolvedServicesFlow
|
||||
every { networkAvailable } returns flowOf(true)
|
||||
}
|
||||
|
||||
useCase =
|
||||
CommonGetDiscoveredDevicesUseCase(
|
||||
recentAddressesDataSource = recentAddressesDataSource,
|
||||
nodeRepository = nodeRepository,
|
||||
databaseManager = databaseManager,
|
||||
networkRepository = networkRepository,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testEmptyRecentAddresses() = runTest {
|
||||
setUp()
|
||||
useCase.invoke(showMock = false).test {
|
||||
useCase.invoke(showMock = false, resolvedList = resolvedServicesFlow).test {
|
||||
val result = awaitItem()
|
||||
assertTrue(result.recentTcpDevices.isEmpty(), "No recent TCP devices when empty")
|
||||
assertTrue(result.usbDevices.isEmpty(), "No USB devices when showMock=false")
|
||||
|
|
@ -83,7 +76,7 @@ class CommonGetDiscoveredDevicesUseCaseTest {
|
|||
recentAddressesFlow.value =
|
||||
listOf(RecentAddress("t192.168.1.100", "Zebra_Node"), RecentAddress("t192.168.1.101", "Alpha_Node"))
|
||||
|
||||
useCase.invoke(showMock = false).test {
|
||||
useCase.invoke(showMock = false, resolvedList = resolvedServicesFlow).test {
|
||||
val result = awaitItem()
|
||||
result.recentTcpDevices.size shouldBe 2
|
||||
result.recentTcpDevices[0].name shouldBe "Alpha_Node"
|
||||
|
|
@ -95,7 +88,7 @@ class CommonGetDiscoveredDevicesUseCaseTest {
|
|||
@Test
|
||||
fun testShowMockAddsDemo() = runTest {
|
||||
setUp()
|
||||
useCase.invoke(showMock = true).test {
|
||||
useCase.invoke(showMock = true, resolvedList = resolvedServicesFlow).test {
|
||||
val result = awaitItem()
|
||||
result.usbDevices.size shouldBe 1
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
|
|
@ -105,7 +98,7 @@ class CommonGetDiscoveredDevicesUseCaseTest {
|
|||
@Test
|
||||
fun testHideMockNoDemo() = runTest {
|
||||
setUp()
|
||||
useCase.invoke(showMock = false).test {
|
||||
useCase.invoke(showMock = false, resolvedList = resolvedServicesFlow).test {
|
||||
val result = awaitItem()
|
||||
assertTrue(result.usbDevices.isEmpty(), "No mock device when showMock=false")
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
|
|
@ -124,12 +117,11 @@ class CommonGetDiscoveredDevicesUseCaseTest {
|
|||
recentAddressesDataSource = recentAddressesDataSource,
|
||||
nodeRepository = nodeRepository,
|
||||
databaseManager = databaseManager,
|
||||
networkRepository = networkRepository,
|
||||
)
|
||||
|
||||
recentAddressesFlow.value = listOf(RecentAddress("tMeshtastic_1234", "Meshtastic_1234"))
|
||||
|
||||
useCase.invoke(showMock = false).test {
|
||||
useCase.invoke(showMock = false, resolvedList = resolvedServicesFlow).test {
|
||||
val result = awaitItem()
|
||||
result.recentTcpDevices.size shouldBe 1
|
||||
assertNotNull(result.recentTcpDevices[0].node, "Node should be matched by suffix")
|
||||
|
|
@ -146,7 +138,7 @@ class CommonGetDiscoveredDevicesUseCaseTest {
|
|||
|
||||
recentAddressesFlow.value = listOf(RecentAddress("tMeshtastic_1234", "Meshtastic_1234"))
|
||||
|
||||
useCase.invoke(showMock = false).test {
|
||||
useCase.invoke(showMock = false, resolvedList = resolvedServicesFlow).test {
|
||||
val result = awaitItem()
|
||||
result.recentTcpDevices.size shouldBe 1
|
||||
assertNull(result.recentTcpDevices[0].node, "Node should not be matched when no database")
|
||||
|
|
@ -159,7 +151,7 @@ class CommonGetDiscoveredDevicesUseCaseTest {
|
|||
setUp()
|
||||
recentAddressesFlow.value = listOf(RecentAddress("t192.168.1.100", "Node_A"))
|
||||
|
||||
useCase.invoke(showMock = false).test {
|
||||
useCase.invoke(showMock = false, resolvedList = resolvedServicesFlow).test {
|
||||
val firstResult = awaitItem()
|
||||
firstResult.recentTcpDevices.size shouldBe 1
|
||||
|
||||
|
|
@ -184,7 +176,7 @@ class CommonGetDiscoveredDevicesUseCaseTest {
|
|||
),
|
||||
)
|
||||
|
||||
useCase.invoke(showMock = false).test {
|
||||
useCase.invoke(showMock = false, resolvedList = resolvedServicesFlow).test {
|
||||
val result = awaitItem()
|
||||
result.discoveredTcpDevices.size shouldBe 1
|
||||
result.discoveredTcpDevices[0].name shouldBe "Mesh_1234"
|
||||
|
|
@ -206,7 +198,6 @@ class CommonGetDiscoveredDevicesUseCaseTest {
|
|||
recentAddressesDataSource = recentAddressesDataSource,
|
||||
nodeRepository = nodeRepository,
|
||||
databaseManager = databaseManager,
|
||||
networkRepository = networkRepository,
|
||||
)
|
||||
|
||||
resolvedServicesFlow.value =
|
||||
|
|
@ -219,7 +210,7 @@ class CommonGetDiscoveredDevicesUseCaseTest {
|
|||
),
|
||||
)
|
||||
|
||||
useCase.invoke(showMock = false).test {
|
||||
useCase.invoke(showMock = false, resolvedList = resolvedServicesFlow).test {
|
||||
val result = awaitItem()
|
||||
result.discoveredTcpDevices.size shouldBe 1
|
||||
assertNotNull(result.discoveredTcpDevices[0].node)
|
||||
|
|
@ -228,4 +219,28 @@ class CommonGetDiscoveredDevicesUseCaseTest {
|
|||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testEmptyResolvedListReturnsNoDiscoveredDevices() = runTest {
|
||||
setUp()
|
||||
recentAddressesFlow.value = listOf(RecentAddress("t192.168.1.100", "Recent_Node"))
|
||||
|
||||
useCase.invoke(showMock = false, resolvedList = flowOf(emptyList())).test {
|
||||
val result = awaitItem()
|
||||
assertTrue(result.discoveredTcpDevices.isEmpty(), "No NSD devices when resolvedList is empty")
|
||||
result.recentTcpDevices.size shouldBe 1
|
||||
result.recentTcpDevices[0].name shouldBe "Recent_Node"
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testEmptyResolvedListIncludesMock() = runTest {
|
||||
setUp()
|
||||
useCase.invoke(showMock = true, resolvedList = flowOf(emptyList())).test {
|
||||
val result = awaitItem()
|
||||
result.usbDevices.size shouldBe 1
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue