From ceabafb545fb1eb279eec86bf622b2f9ad53b298 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Mon, 28 Jul 2025 20:49:17 -0500 Subject: [PATCH] refactor(connections)!: Use sealed class for device list entries (#2538) Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- .../com/geeksville/mesh/model/BTScanModel.kt | 337 +++++++------- .../java/com/geeksville/mesh/model/UIState.kt | 11 + .../mesh/ui/connections/Connections.kt | 416 ++++++++---------- .../ui/connections/components/BLEDevices.kt | 50 +-- .../connections/components/DeviceListItem.kt | 42 +- .../connections/components/NetworkDevices.kt | 163 +++---- .../ui/connections/components/UsbDevices.kt | 22 +- app/src/main/res/values/strings.xml | 2 + 8 files changed, 506 insertions(+), 537 deletions(-) diff --git a/app/src/main/java/com/geeksville/mesh/model/BTScanModel.kt b/app/src/main/java/com/geeksville/mesh/model/BTScanModel.kt index 028af4acd..39dc3a5d5 100644 --- a/app/src/main/java/com/geeksville/mesh/model/BTScanModel.kt +++ b/app/src/main/java/com/geeksville/mesh/model/BTScanModel.kt @@ -41,7 +41,6 @@ import com.geeksville.mesh.service.ServiceRepository import com.geeksville.mesh.util.anonymize import com.hoho.android.usbserial.driver.UsbSerialDriver import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -54,6 +53,49 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * A sealed class is used here to represent the different types of devices that can be displayed in the list. This is + * more type-safe and idiomatic than using a base class with boolean flags (e.g., isBLE, isUSB). It allows for + * exhaustive `when` expressions in the code, making it more robust and readable. + * + * @param name The display name of the device. + * @param fullAddress The unique address of the device, prefixed with a type identifier. + * @param bonded Indicates whether the device is bonded (for BLE) or has permission (for USB). + */ +sealed class DeviceListEntry(open val name: String, open val fullAddress: String, open val bonded: Boolean) { + val address: String + get() = fullAddress.substring(1) + + override fun toString(): String = + "DeviceListEntry(name=${name.anonymize}, addr=${address.anonymize}, bonded=$bonded)" + + @Suppress("MissingPermission") + data class Ble(val device: BluetoothDevice) : + DeviceListEntry( + name = device.name ?: "unnamed-${device.address}", + fullAddress = "x${device.address}", + bonded = device.bondState == BluetoothDevice.BOND_BONDED, + ) + + data class Usb( + private val radioInterfaceService: RadioInterfaceService, + private val usbManager: UsbManager, + val driver: UsbSerialDriver, + ) : DeviceListEntry( + name = driver.device.deviceName, + fullAddress = radioInterfaceService.toInterfaceAddress(InterfaceId.SERIAL, driver.device.deviceName), + bonded = usbManager.hasPermission(driver.device), + ) + + data class Tcp(override val name: String, override val fullAddress: String) : + DeviceListEntry(name, fullAddress, true) + + data class Disconnect(override val name: String) : DeviceListEntry(name, NO_DEVICE_SELECTED, true) + + data class Mock(override val name: String) : DeviceListEntry(name, "m", true) +} @HiltViewModel @Suppress("LongParameterList", "TooManyFunctions") @@ -68,133 +110,108 @@ constructor( private val networkRepository: NetworkRepository, private val radioInterfaceService: RadioInterfaceService, private val recentAddressesRepository: RecentAddressesRepository, -) : ViewModel(), Logging { +) : ViewModel(), + Logging { private val context: Context get() = application.applicationContext - val devices = MutableLiveData>(mutableMapOf()) val errorText = MutableLiveData(null) - private val recentIpAddresses = recentAddressesRepository.recentAddresses - - private val showMockInterface: StateFlow + val showMockInterface: StateFlow get() = MutableStateFlow(radioInterfaceService.isMockInterface()).asStateFlow() - init { - combine( - bluetoothRepository.state, - networkRepository.resolvedList, - recentIpAddresses, - usbRepository.serialDevicesWithDrivers, - showMockInterface, - ) { ble, tcp, recent, usb, showMockInterface -> - devices.value = - mutableMapOf().apply { - fun addDevice(entry: DeviceListEntry) { - this[entry.fullAddress] = entry - } + private val bleDevicesFlow: StateFlow> = + bluetoothRepository.state + .map { ble -> ble.bondedDevices.map { DeviceListEntry.Ble(it) }.sortedBy { it.name } } + .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) - // Include a placeholder for "None" - addDevice( - DeviceListEntry( - context.getString(R.string.none), - NO_DEVICE_SELECTED, - true, - ) - ) + // Flow for discovered TCP devices, using recent addresses for potential name enrichment + private val processedDiscoveredTcpDevicesFlow: StateFlow> = + combine(networkRepository.resolvedList, recentAddressesRepository.recentAddresses) { tcpServices, recentList -> + val recentMap = recentList.associateBy({ it.address }, { it.name }) + tcpServices + .map { service -> + val address = "t${service.toAddressString()}" + val txtRecords = service.attributes // Map + val shortNameBytes = txtRecords["shortname"] + val idBytes = txtRecords["id"] - if (showMockInterface) { - addDevice(DeviceListEntry("Demo Mode", "m", true)) - } - - // Include paired Bluetooth devices - ble.bondedDevices - .map(::BLEDeviceListEntry) - .sortedBy { it.name } - .forEach(::addDevice) - - // Include Network Service Discovery - tcp.forEach { service -> - val address = service.toAddressString() - val txtRecords = service.attributes // Map - val shortNameBytes = txtRecords["shortname"] - val idBytes = txtRecords["id"] - - val shortName = - shortNameBytes?.let { String(it, Charsets.UTF_8) } - ?: context.getString(R.string.meshtastic) - val deviceId = - idBytes?.let { String(it, Charsets.UTF_8) }?.replace("!", "") - var displayName = shortName - if (deviceId != null) { - displayName += "_$deviceId" - } - addDevice(DeviceListEntry(displayName, "t$address", true)) - } - - // Include saved IP connections - recent.forEach { addDevice(DeviceListEntry(it.name, it.address, true)) } - - usb.forEach { (_, d) -> - addDevice( - USBDeviceListEntry(radioInterfaceService, usbManagerLazy.get(), d) - ) - } + val shortName = + shortNameBytes?.let { String(it, Charsets.UTF_8) } ?: context.getString(R.string.meshtastic) + val deviceId = idBytes?.let { String(it, Charsets.UTF_8) }?.replace("!", "") + var displayName = recentMap[address] ?: shortName + if (deviceId != null && !displayName.split("_").none { it == deviceId }) { + displayName += "_$deviceId" } - } - .launchIn(viewModelScope) + DeviceListEntry.Tcp(displayName, address) + } + .sortedBy { it.name } + } + .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + // Flow for recent TCP devices, filtered to exclude any currently discovered devices + private val filteredRecentTcpDevicesFlow: StateFlow> = + combine(recentAddressesRepository.recentAddresses, processedDiscoveredTcpDevicesFlow) { + recentList, + discoveredDevices, + -> + val discoveredDeviceAddresses = discoveredDevices.map { it.fullAddress }.toSet() + recentList + .filterNot { recentAddress -> discoveredDeviceAddresses.contains(recentAddress.address) } + .map { recentAddress -> DeviceListEntry.Tcp(recentAddress.name, recentAddress.address) } + .sortedBy { it.name } + } + .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + + private val usbDevicesFlow: StateFlow> = + usbRepository.serialDevicesWithDrivers + .map { usb -> usb.map { (_, d) -> DeviceListEntry.Usb(radioInterfaceService, usbManagerLazy.get(), d) } } + .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + + val disconnectDevice = DeviceListEntry.Disconnect(context.getString(R.string.none)) + + val mockDevice = DeviceListEntry.Mock("Demo Mode") + + val bleDevicesForUi: StateFlow> = + bleDevicesFlow + .map { devices -> listOf(disconnectDevice) + devices } + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(SHARING_STARTED_TIMEOUT_MS), + listOf(disconnectDevice), + ) + + /** UI StateFlow for discovered TCP devices. */ + val discoveredTcpDevicesForUi: StateFlow> = + processedDiscoveredTcpDevicesFlow.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(SHARING_STARTED_TIMEOUT_MS), + listOf(), + ) + + /** UI StateFlow for recently connected TCP devices that are not currently discovered. */ + val recentTcpDevicesForUi: StateFlow> = + filteredRecentTcpDevicesFlow.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(SHARING_STARTED_TIMEOUT_MS), + listOf(), + ) + + val usbDevicesForUi: StateFlow> = + combine(usbDevicesFlow, showMockInterface) { usb, showMock -> + listOf(disconnectDevice) + usb + if (showMock) listOf(mockDevice) else emptyList() + } + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(SHARING_STARTED_TIMEOUT_MS), + listOf(disconnectDevice), + ) + + init { serviceRepository.statusMessage.onEach { errorText.value = it }.launchIn(viewModelScope) - debug("BTScanModel created") } - /** @param fullAddress Interface [prefix] + [address] (example: "x7C:9E:BD:F0:BE:BE") */ - open class DeviceListEntry(val name: String, val fullAddress: String, val bonded: Boolean) { - val prefix - get() = fullAddress[0] - - val address - get() = fullAddress.substring(1) - - override fun toString(): String = - "DeviceListEntry(name=${name.anonymize}, addr=${address.anonymize}, bonded=$bonded)" - - val isBLE: Boolean - get() = prefix == 'x' - - val isUSB: Boolean - get() = prefix == 's' - - val isTCP: Boolean - get() = prefix == 't' - - val isMock: Boolean - get() = prefix == 'm' - - val isDisconnect: Boolean - get() = prefix == 'n' - } - - @SuppressLint("MissingPermission") - class BLEDeviceListEntry(device: BluetoothDevice) : - DeviceListEntry( - device.name ?: "unnamed-${device.address}", // some devices might not have a name - "x${device.address}", - device.bondState == BluetoothDevice.BOND_BONDED, - ) - - class USBDeviceListEntry( - radioInterfaceService: RadioInterfaceService, - usbManager: UsbManager, - val usb: UsbSerialDriver, - ) : - DeviceListEntry( - usb.device.deviceName, - radioInterfaceService.toInterfaceAddress(InterfaceId.SERIAL, usb.device.deviceName), - usbManager.hasPermission(usb.device), - ) - override fun onCleared() { super.onCleared() debug("BTScanModel cleared") @@ -230,9 +247,7 @@ constructor( try { scanJob?.cancel() } catch (ex: Throwable) { - warn( - "Ignoring error stopping scan, probably BT adapter was disabled suddenly: ${ex.message}" - ) + warn("Ignoring error stopping scan, probably BT adapter was disabled suddenly: ${ex.message}") } finally { scanJob = null } @@ -250,35 +265,26 @@ constructor( .scan() .onEach { result -> val fullAddress = - radioInterfaceService.toInterfaceAddress( - InterfaceId.BLUETOOTH, - result.device.address, - ) + radioInterfaceService.toInterfaceAddress(InterfaceId.BLUETOOTH, result.device.address) // prevent log spam because we'll get lots of redundant scan results - val isBonded = result.device.bondState == BluetoothDevice.BOND_BONDED val oldDevs = scanResult.value!! val oldEntry = oldDevs[fullAddress] // Don't spam the GUI with endless updates for non changing nodes - if (oldEntry == null || oldEntry.bonded != isBonded) { - val entry = DeviceListEntry(result.device.name, fullAddress, isBonded) + if ( + oldEntry == null || oldEntry.bonded != (result.device.bondState == BluetoothDevice.BOND_BONDED) + ) { + val entry = DeviceListEntry.Ble(result.device) oldDevs[entry.fullAddress] = entry scanResult.value = oldDevs } } - .catch { ex -> - serviceRepository.setErrorMessage( - "Unexpected Bluetooth scan failure: ${ex.message}" - ) - } + .catch { ex -> serviceRepository.setErrorMessage("Unexpected Bluetooth scan failure: ${ex.message}") } .launchIn(viewModelScope) } private fun changeDeviceAddress(address: String) { try { - serviceRepository.meshService?.let { service -> - MeshService.changeDeviceAddress(context, service, address) - } - devices.value = devices.value // Force a GUI update + serviceRepository.meshService?.let { service -> MeshService.changeDeviceAddress(context, service, address) } } catch (ex: RemoteException) { errormsg("changeDeviceSelection failed, probably it is shutting down", ex) // ignore the failure and the GUI won't be updating anyways @@ -286,8 +292,7 @@ constructor( } @SuppressLint("MissingPermission") - private fun requestBonding(it: DeviceListEntry) { - val device = bluetoothRepository.getRemoteDevice(it.address) ?: return + private fun requestBonding(device: BluetoothDevice) { info("Starting bonding for ${device.anonymize}") bluetoothRepository @@ -298,7 +303,7 @@ constructor( debug("Bonding completed, state=$state") if (state == BluetoothDevice.BOND_BONDED) { setErrorText(context.getString(R.string.pairing_completed)) - changeDeviceAddress(it.fullAddress) + changeDeviceAddress("x${device.address}") } else { setErrorText(context.getString(R.string.pairing_failed_try_again)) } @@ -311,9 +316,9 @@ constructor( .launchIn(viewModelScope) } - private fun requestPermission(it: USBDeviceListEntry) { + private fun requestPermission(it: DeviceListEntry.Usb) { usbRepository - .requestPermission(it.usb.device) + .requestPermission(it.driver.device) .onEach { granted -> if (granted) { info("User approved USB access") @@ -325,13 +330,9 @@ constructor( .launchIn(viewModelScope) } - // Remove 'name' parameter from addRecentAddress and related logic - fun addRecentAddress(address: String, overrideName: String? = null) { + fun addRecentAddress(address: String, name: String) { if (!address.startsWith("t")) return - viewModelScope.launch { - val displayName = overrideName ?: context.getString(R.string.meshtastic) - recentAddressesRepository.add(RecentAddress(address, displayName)) - } + viewModelScope.launch { recentAddressesRepository.add(RecentAddress(address, name)) } } fun removeRecentAddress(address: String) { @@ -341,33 +342,49 @@ constructor( // Called by the GUI when a new device has been selected by the user // @returns true if we were able to change to that item fun onSelected(it: DeviceListEntry): Boolean { - // If the device is paired, let user select it, otherwise start the pairing flow - if (it.bonded) { - addRecentAddress(it.fullAddress, connectedNodeLongName) - changeDeviceAddress(it.fullAddress) - return true - } else { - // Handle requesting USB or bluetooth permissions for the device - debug("Requesting permissions for the device") - - if (it.isBLE) { - requestBonding(it) + // Using a `when` expression on the sealed class is much cleaner and safer than if/else chains. + // It ensures that all device types are handled, and the compiler can catch any omissions. + return when (it) { + is DeviceListEntry.Ble -> { + if (it.bonded) { + changeDeviceAddress(it.fullAddress) + true + } else { + requestBonding(it.device) + false + } } - if (it.isUSB) { - requestPermission(it as USBDeviceListEntry) + is DeviceListEntry.Usb -> { + if (it.bonded) { + changeDeviceAddress(it.fullAddress) + true + } else { + requestPermission(it) + false + } } - return false + is DeviceListEntry.Tcp -> { + viewModelScope.launch { + addRecentAddress(it.fullAddress, it.name) + changeDeviceAddress(it.fullAddress) + } + true + } + + is DeviceListEntry.Disconnect, + is DeviceListEntry.Mock, + -> { + changeDeviceAddress(it.fullAddress) + true + } } } private val _spinner = MutableStateFlow(false) val spinner: StateFlow get() = _spinner.asStateFlow() - - // Add a new property to hold the connected node's long name - var connectedNodeLongName: String? = null } const val NO_DEVICE_SELECTED = "n" diff --git a/app/src/main/java/com/geeksville/mesh/model/UIState.kt b/app/src/main/java/com/geeksville/mesh/model/UIState.kt index 2433595e6..9f572fcd8 100644 --- a/app/src/main/java/com/geeksville/mesh/model/UIState.kt +++ b/app/src/main/java/com/geeksville/mesh/model/UIState.kt @@ -327,6 +327,15 @@ constructor( private val _showQuickChat = MutableStateFlow(preferences.getBoolean("show-quick-chat", false)) val showQuickChat: StateFlow = _showQuickChat + private val _hasShownNotPairedWarning = + MutableStateFlow(preferences.getBoolean(HAS_SHOWN_NOT_PAIRED_WARNING_PREF, false)) + val hasShownNotPairedWarning: StateFlow = _hasShownNotPairedWarning.asStateFlow() + + fun suppressNoPairedWarning() { + _hasShownNotPairedWarning.value = true + preferences.edit { putBoolean(HAS_SHOWN_NOT_PAIRED_WARNING_PREF, true) } + } + private fun toggleBooleanPreference( state: MutableStateFlow, key: String, @@ -719,6 +728,8 @@ constructor( companion object { fun getPreferences(context: Context): SharedPreferences = context.getSharedPreferences("ui-prefs", Context.MODE_PRIVATE) + + const val HAS_SHOWN_NOT_PAIRED_WARNING_PREF = "has_shown_not_paired_warning" } // Connection state to our radio device diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/Connections.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/Connections.kt index f2fed9f9e..7fe123c2a 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/connections/Connections.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/connections/Connections.kt @@ -92,6 +92,8 @@ import com.geeksville.mesh.android.isGooglePlayAvailable import com.geeksville.mesh.android.permissionMissing import com.geeksville.mesh.model.BTScanModel import com.geeksville.mesh.model.BluetoothViewModel +import com.geeksville.mesh.model.DeviceListEntry +import com.geeksville.mesh.model.NO_DEVICE_SELECTED import com.geeksville.mesh.model.Node import com.geeksville.mesh.model.UIViewModel import com.geeksville.mesh.navigation.ConfigRoute @@ -110,13 +112,11 @@ import com.geeksville.mesh.ui.radioconfig.components.PacketResponseStateDialog import com.geeksville.mesh.ui.sharing.SharedContactDialog import kotlinx.coroutines.delay -fun String?.isIPAddress(): Boolean { - return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { - @Suppress("DEPRECATION") - this != null && Patterns.IP_ADDRESS.matcher(this).matches() - } else { - InetAddresses.isNumericAddress(this.toString()) - } +fun String?.isIPAddress(): Boolean = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + @Suppress("DEPRECATION") + this != null && Patterns.IP_ADDRESS.matcher(this).matches() +} else { + InetAddresses.isNumericAddress(this.toString()) } @Suppress("CyclomaticComplexMethod", "LongMethod", "MagicNumber") @@ -128,7 +128,7 @@ fun ConnectionsScreen( radioConfigViewModel: RadioConfigViewModel = hiltViewModel(), onNavigateToRadioConfig: () -> Unit, onNavigateToNodeDetails: (Int) -> Unit, - onConfigNavigate: (Route) -> Unit + onConfigNavigate: (Route) -> Unit, ) { val radioConfigState by radioConfigViewModel.radioConfigState.collectAsStateWithLifecycle() val config by uiViewModel.localConfig.collectAsState() @@ -136,7 +136,6 @@ fun ConnectionsScreen( val scrollState = rememberScrollState() val scanStatusText by scanModel.errorText.observeAsState("") val connectionState by uiViewModel.connectionState.collectAsState(MeshService.ConnectionState.DISCONNECTED) - val devices by scanModel.devices.observeAsState(emptyMap()) val scanning by scanModel.spinner.collectAsStateWithLifecycle(false) val receivingLocationUpdates by uiViewModel.receivingLocationUpdates.collectAsState(false) val context = LocalContext.current @@ -144,9 +143,15 @@ fun ConnectionsScreen( val info by uiViewModel.myNodeInfo.collectAsState() val selectedDevice by scanModel.selectedNotNullFlow.collectAsStateWithLifecycle() val bluetoothEnabled by bluetoothViewModel.enabled.observeAsState() - val regionUnset = currentRegion == ConfigProtos.Config.LoRaConfig.RegionCode.UNSET && + val regionUnset = + currentRegion == ConfigProtos.Config.LoRaConfig.RegionCode.UNSET && connectionState == MeshService.ConnectionState.CONNECTED + val bleDevices by scanModel.bleDevicesForUi.collectAsStateWithLifecycle() + val discoveredTcpDevices by scanModel.discoveredTcpDevicesForUi.collectAsStateWithLifecycle() + val recentTcpDevices by scanModel.recentTcpDevicesForUi.collectAsStateWithLifecycle() + val usbDevices by scanModel.usbDevicesForUi.collectAsStateWithLifecycle() + /* Animate waiting for the configurations */ var isWaiting by remember { mutableStateOf(false) } if (isWaiting) { @@ -201,39 +206,41 @@ fun ConnectionsScreen( var showReportBugDialog by remember { mutableStateOf(false) } // Remember the permission launchers - val requestLocationPermissionLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.RequestMultiplePermissions(), - onResult = { permissions -> - if (permissions.entries.all { it.value }) { - uiViewModel.setProvideLocation(true) - uiViewModel.meshService?.startProvideLocation() - } else { - debug("User denied location permission") - uiViewModel.showSnackbar(context.getString(R.string.why_background_required)) - } - bluetoothViewModel.permissionsUpdated() - } - ) + val requestLocationPermissionLauncher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestMultiplePermissions(), + onResult = { permissions -> + if (permissions.entries.all { it.value }) { + uiViewModel.setProvideLocation(true) + uiViewModel.meshService?.startProvideLocation() + } else { + debug("User denied location permission") + uiViewModel.showSnackbar(context.getString(R.string.why_background_required)) + } + bluetoothViewModel.permissionsUpdated() + }, + ) - val requestBluetoothPermissionLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.RequestMultiplePermissions(), - onResult = { permissions -> - if (permissions.entries.all { it.value }) { - info("Bluetooth permissions granted") - // We need to call the scan function which is in the Fragment - // Since we can't directly call scanLeDevice() from Composable, - // we might need to rethink how scanning is triggered or - // pass the scan trigger as a lambda. - // For now, let's assume we trigger the scan outside the Composable - // after permissions are granted. We can add a callback to the ViewModel. - scanModel.startScan() - } else { - warn("Bluetooth permissions denied") - uiViewModel.showSnackbar(context.permissionMissing) - } - bluetoothViewModel.permissionsUpdated() - } - ) + val requestBluetoothPermissionLauncher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestMultiplePermissions(), + onResult = { permissions -> + if (permissions.entries.all { it.value }) { + info("Bluetooth permissions granted") + // We need to call the scan function which is in the Fragment + // Since we can't directly call scanLeDevice() from Composable, + // we might need to rethink how scanning is triggered or + // pass the scan trigger as a lambda. + // For now, let's assume we trigger the scan outside the Composable + // after permissions are granted. We can add a callback to the ViewModel. + scanModel.startScan() + } else { + warn("Bluetooth permissions denied") + uiViewModel.showSnackbar(context.permissionMissing) + } + bluetoothViewModel.permissionsUpdated() + }, + ) // Observe scan results to show the dialog if (scanResults.isNotEmpty()) { @@ -249,42 +256,32 @@ fun ConnectionsScreen( MeshService.ConnectionState.DISCONNECTED -> R.string.not_connected MeshService.ConnectionState.DEVICE_SLEEP -> R.string.connected_sleeping }.let { - val firmwareString = - info?.firmwareString ?: context.getString(R.string.unknown) + val firmwareString = info?.firmwareString ?: context.getString(R.string.unknown) scanModel.setErrorText(context.getString(it, firmwareString)) } } var showSharedContact by remember { mutableStateOf(null) } if (showSharedContact != null) { - SharedContactDialog( - contact = showSharedContact, - onDismiss = { showSharedContact = null } - ) + SharedContactDialog(contact = showSharedContact, onDismiss = { showSharedContact = null }) } Box(modifier = Modifier.fillMaxSize()) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(8.dp) - ) { + Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) { Text( text = scanStatusText.orEmpty(), fontSize = 14.sp, textAlign = TextAlign.Start, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) Spacer(modifier = Modifier.height(8.dp)) val isConnected by uiViewModel.isConnected.collectAsState(false) val ourNode by uiViewModel.ourNodeInfo.collectAsState() - // Set the connected node long name for BTScanModel - scanModel.connectedNodeLongName = ourNode?.user?.longName if (isConnected) { ourNode?.let { node -> Row( modifier = Modifier.padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { NodeChip( node = node, @@ -308,15 +305,12 @@ fun ConnectionsScreen( Text( modifier = Modifier.weight(1f, fill = true), text = node.user.longName, - style = MaterialTheme.typography.titleLarge + style = MaterialTheme.typography.titleLarge, ) - IconButton( - enabled = true, - onClick = onNavigateToRadioConfig - ) { + IconButton(enabled = true, onClick = onNavigateToRadioConfig) { Icon( imageVector = Icons.Default.Settings, - contentDescription = stringResource(id = R.string.radio_configuration) + contentDescription = stringResource(id = R.string.radio_configuration), ) } } @@ -330,124 +324,83 @@ fun ConnectionsScreen( onClick = { isWaiting = true radioConfigViewModel.setResponseStateLoading(ConfigRoute.LORA) - } + }, ) Spacer(modifier = Modifier.height(8.dp)) if (scanning) { - LinearProgressIndicator( - modifier = Modifier.fillMaxWidth() - ) + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) } } } var selectedDeviceType by remember { mutableStateOf(DeviceType.BLE) } LaunchedEffect(selectedDevice) { - DeviceType.fromAddress(selectedDevice)?.let { type -> - selectedDeviceType = type - } + DeviceType.fromAddress(selectedDevice)?.let { type -> selectedDeviceType = type } } - SingleChoiceSegmentedButtonRow( - modifier = Modifier.fillMaxWidth(), - ) { + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { SegmentedButton( - shape = SegmentedButtonDefaults.itemShape( - DeviceType.BLE.ordinal, - DeviceType.entries.size - ), - onClick = { - selectedDeviceType = DeviceType.BLE - }, + shape = SegmentedButtonDefaults.itemShape(DeviceType.BLE.ordinal, DeviceType.entries.size), + onClick = { selectedDeviceType = DeviceType.BLE }, selected = (selectedDeviceType == DeviceType.BLE), icon = { Icon( imageVector = Icons.Default.Bluetooth, - contentDescription = stringResource(id = R.string.bluetooth) + contentDescription = stringResource(id = R.string.bluetooth), ) }, - label = { - Text(text = stringResource(id = R.string.bluetooth)) - } + label = { Text(text = stringResource(id = R.string.bluetooth)) }, ) SegmentedButton( - shape = SegmentedButtonDefaults.itemShape( - DeviceType.TCP.ordinal, - DeviceType.entries.size - ), - onClick = { - selectedDeviceType = DeviceType.TCP - }, + shape = SegmentedButtonDefaults.itemShape(DeviceType.TCP.ordinal, DeviceType.entries.size), + onClick = { selectedDeviceType = DeviceType.TCP }, selected = (selectedDeviceType == DeviceType.TCP), icon = { Icon( imageVector = Icons.Default.Wifi, - contentDescription = stringResource(id = R.string.network) + contentDescription = stringResource(id = R.string.network), ) }, - label = { - Text(text = stringResource(id = R.string.network)) - } + label = { Text(text = stringResource(id = R.string.network)) }, ) SegmentedButton( - shape = SegmentedButtonDefaults.itemShape( - DeviceType.USB.ordinal, - DeviceType.entries.size - ), - onClick = { - selectedDeviceType = DeviceType.USB - }, + shape = SegmentedButtonDefaults.itemShape(DeviceType.USB.ordinal, DeviceType.entries.size), + onClick = { selectedDeviceType = DeviceType.USB }, selected = (selectedDeviceType == DeviceType.USB), icon = { - Icon( - imageVector = Icons.Default.Usb, - contentDescription = stringResource(id = R.string.serial) - ) + Icon(imageVector = Icons.Default.Usb, contentDescription = stringResource(id = R.string.serial)) }, - label = { - Text(text = stringResource(id = R.string.serial)) - } + label = { Text(text = stringResource(id = R.string.serial)) }, ) } - Column( - modifier = Modifier - .fillMaxSize() - .padding(8.dp) - .verticalScroll(scrollState) - ) { + Column(modifier = Modifier.fillMaxSize().padding(8.dp).verticalScroll(scrollState)) { when (selectedDeviceType) { DeviceType.BLE -> { - BLEDevices( - connectionState, - devices.values.filter { it.isBLE || it.isDisconnect }, - selectedDevice, - showBluetoothRationaleDialog = { - showBluetoothRationaleDialog = true - }, - requestBluetoothPermission = { - requestBluetoothPermissionLauncher.launch( - it - ) - }, - scanModel + connectionState = connectionState, + btDevices = bleDevices, + selectedDevice = selectedDevice, + showBluetoothRationaleDialog = { showBluetoothRationaleDialog = true }, + requestBluetoothPermission = { requestBluetoothPermissionLauncher.launch(it) }, + scanModel = scanModel, ) } DeviceType.TCP -> { NetworkDevices( - connectionState, - devices.values.filter { it.isTCP || it.isDisconnect }, - selectedDevice, - scanModel + connectionState = connectionState, + discoveredNetworkDevices = discoveredTcpDevices, + recentNetworkDevices = recentTcpDevices, + selectedDevice = selectedDevice, + scanModel = scanModel, ) } DeviceType.USB -> { UsbDevices( - connectionState, - devices.values.filter { it.isUSB || it.isDisconnect || it.isMock }, - selectedDevice, - scanModel + connectionState = connectionState, + usbDevices = usbDevices, + selectedDevice = selectedDevice, + scanModel = scanModel, ) } } @@ -472,27 +425,25 @@ fun ConnectionsScreen( } } Row( - modifier = Modifier - .fillMaxWidth() + modifier = + Modifier.fillMaxWidth() .toggleable( value = provideLocation, - onValueChange = { checked -> - uiViewModel.setProvideLocation(checked) - }, - enabled = !isGpsDisabled + onValueChange = { checked -> uiViewModel.setProvideLocation(checked) }, + enabled = !isGpsDisabled, ) .padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { Checkbox( checked = receivingLocationUpdates, onCheckedChange = null, - enabled = !isGpsDisabled // Disable if GPS is disabled + enabled = !isGpsDisabled, // Disable if GPS is disabled ) Text( text = stringResource(R.string.provide_location_to_mesh), style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.padding(start = 16.dp) + modifier = Modifier.padding(start = 16.dp), ) } } @@ -501,15 +452,21 @@ fun ConnectionsScreen( Spacer(modifier = Modifier.height(16.dp)) // Warning Not Paired - val showWarningNotPaired = !devices.any { it.value.bonded } + val hasShownNotPairedWarning by uiViewModel.hasShownNotPairedWarning.collectAsStateWithLifecycle() + val showWarningNotPaired = + !isConnected && + !hasShownNotPairedWarning && + bleDevices.none { it is DeviceListEntry.Ble && it.bonded } if (showWarningNotPaired) { Text( text = stringResource(R.string.warning_not_paired), color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(horizontal = 16.dp) + modifier = Modifier.padding(horizontal = 16.dp), ) Spacer(modifier = Modifier.height(16.dp)) + + LaunchedEffect(Unit) { uiViewModel.suppressNoPairedWarning() } } // Analytics Okay Checkbox @@ -518,12 +475,10 @@ fun ConnectionsScreen( val isAnalyticsAllowed = app.isAnalyticsAllowed && isGooglePlayAvailable if (isGooglePlayAvailable) { var loading by remember { mutableStateOf(false) } - LaunchedEffect(isAnalyticsAllowed) { - loading = false - } + LaunchedEffect(isAnalyticsAllowed) { loading = false } Row( - modifier = Modifier - .fillMaxWidth() + modifier = + Modifier.fillMaxWidth() .toggleable( value = isAnalyticsAllowed, onValueChange = { @@ -532,32 +487,24 @@ fun ConnectionsScreen( loading = true }, role = Role.Checkbox, - enabled = isGooglePlayAvailable && !loading + enabled = isGooglePlayAvailable && !loading, ) .padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { - Checkbox( - enabled = isGooglePlayAvailable, - checked = isAnalyticsAllowed, - onCheckedChange = null - ) + Checkbox(enabled = isGooglePlayAvailable, checked = isAnalyticsAllowed, onCheckedChange = null) Text( text = stringResource(R.string.analytics_okay), style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.padding(start = 16.dp) + modifier = Modifier.padding(start = 16.dp), ) } Spacer(modifier = Modifier.height(16.dp)) // Report Bug Button Button( - onClick = { - showReportBugDialog = true - }, // Set state to show Report Bug dialog - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - enabled = isAnalyticsAllowed + onClick = { showReportBugDialog = true }, // Set state to show Report Bug dialog + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + enabled = isAnalyticsAllowed, ) { Text(stringResource(R.string.report_bug)) } @@ -565,44 +512,48 @@ fun ConnectionsScreen( } } -// Compose Device Scan Dialog + // Compose Device Scan Dialog if (showScanDialog) { - Dialog(onDismissRequest = { - showScanDialog = false - scanModel.clearScanResults() - }) { + Dialog( + onDismissRequest = { + showScanDialog = false + scanModel.clearScanResults() + }, + ) { Surface(shape = MaterialTheme.shapes.medium) { Column(modifier = Modifier.padding(16.dp)) { Text( text = "Select a Bluetooth device", style = MaterialTheme.typography.titleLarge, - modifier = Modifier.padding(bottom = 16.dp) + modifier = Modifier.padding(bottom = 16.dp), ) Column(modifier = Modifier.selectableGroup()) { scanResults.values.forEach { device -> Row( - modifier = Modifier - .fillMaxWidth() + modifier = + Modifier.fillMaxWidth() .selectable( selected = false, // No pre-selection in this dialog onClick = { scanModel.onSelected(device) scanModel.clearScanResults() showScanDialog = false - } + }, ) .padding(vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { Text(text = device.name) } } } Spacer(modifier = Modifier.height(16.dp)) - TextButton(onClick = { - scanModel.clearScanResults() - showScanDialog = false - }) { + TextButton( + onClick = { + scanModel.clearScanResults() + showScanDialog = false + }, + ) { Text(stringResource(R.string.cancel)) } } @@ -610,31 +561,31 @@ fun ConnectionsScreen( } } -// Compose Location Permission Rationale Dialog + // Compose Location Permission Rationale Dialog if (showLocationRationaleDialog) { AlertDialog( onDismissRequest = { showLocationRationaleDialog = false }, title = { Text(stringResource(R.string.background_required)) }, text = { Text(stringResource(R.string.why_background_required)) }, confirmButton = { - Button(onClick = { - showLocationRationaleDialog = false - if (!context.hasLocationPermission()) { - requestLocationPermissionLauncher.launch(context.getLocationPermissions()) - } - }) { + Button( + onClick = { + showLocationRationaleDialog = false + if (!context.hasLocationPermission()) { + requestLocationPermissionLauncher.launch(context.getLocationPermissions()) + } + }, + ) { Text(stringResource(R.string.accept)) } }, dismissButton = { - Button(onClick = { showLocationRationaleDialog = false }) { - Text(stringResource(R.string.cancel)) - } - } + Button(onClick = { showLocationRationaleDialog = false }) { Text(stringResource(R.string.cancel)) } + }, ) } -// Compose Bluetooth Permission Rationale Dialog + // Compose Bluetooth Permission Rationale Dialog if (showBluetoothRationaleDialog) { val bluetoothPermissions = context.getBluetoothPermissions() AlertDialog( @@ -642,49 +593,53 @@ fun ConnectionsScreen( title = { Text(stringResource(R.string.required_permissions)) }, text = { Text(stringResource(R.string.permission_missing_31)) }, confirmButton = { - Button(onClick = { - showBluetoothRationaleDialog = false - if (bluetoothPermissions.isNotEmpty()) { - requestBluetoothPermissionLauncher.launch(bluetoothPermissions) - } else { - // If somehow no permissions are required, just scan - scanModel.startScan() - } - }) { + Button( + onClick = { + showBluetoothRationaleDialog = false + if (bluetoothPermissions.isNotEmpty()) { + requestBluetoothPermissionLauncher.launch(bluetoothPermissions) + } else { + // If somehow no permissions are required, just scan + scanModel.startScan() + } + }, + ) { Text(stringResource(R.string.okay)) } }, dismissButton = { - Button(onClick = { showBluetoothRationaleDialog = false }) { - Text(stringResource(R.string.cancel)) - } - } + Button(onClick = { showBluetoothRationaleDialog = false }) { Text(stringResource(R.string.cancel)) } + }, ) } -// Compose Report Bug Dialog + // Compose Report Bug Dialog if (showReportBugDialog) { AlertDialog( onDismissRequest = { showReportBugDialog = false }, title = { Text(stringResource(R.string.report_a_bug)) }, text = { Text(stringResource(R.string.report_bug_text)) }, confirmButton = { - Button(onClick = { - showReportBugDialog = false - reportError("Clicked Report A Bug") - uiViewModel.showSnackbar("Bug report sent!") - }) { + Button( + onClick = { + showReportBugDialog = false + reportError("Clicked Report A Bug") + uiViewModel.showSnackbar("Bug report sent!") + }, + ) { Text(stringResource(R.string.report)) } }, dismissButton = { - Button(onClick = { - showReportBugDialog = false - debug("Decided not to report a bug") - }) { + Button( + onClick = { + showReportBugDialog = false + debug("Decided not to report a bug") + }, + ) { Text(stringResource(R.string.cancel)) } - } + }, ) } } @@ -699,22 +654,21 @@ private tailrec fun Context.findActivity(): Activity = when (this) { private enum class DeviceType { BLE, TCP, - USB; + USB, + ; companion object { - fun fromAddress(address: String): DeviceType? { - val prefix = address[0] - val isBLE: Boolean = prefix == 'x' - val isUSB: Boolean = prefix == 's' - val isTCP: Boolean = prefix == 't' - val isMock: Boolean = prefix == 'm' - return when { - isBLE -> BLE - isUSB -> USB - isTCP -> TCP - isMock -> USB // Treat mock as USB for UI purposes - else -> null - } + fun fromAddress(address: String): DeviceType? = when (address.firstOrNull()) { + 'x' -> BLE + 's' -> USB + 't' -> TCP + 'm' -> USB // Treat mock as USB for UI purposes + 'n' -> + when (address) { + NO_DEVICE_SELECTED -> null + else -> null + } + else -> null } } } diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/components/BLEDevices.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/components/BLEDevices.kt index 53c53a6ce..6a7606d34 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/connections/components/BLEDevices.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/connections/components/BLEDevices.kt @@ -42,24 +42,25 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.geeksville.mesh.R import com.geeksville.mesh.android.getBluetoothPermissions import com.geeksville.mesh.model.BTScanModel +import com.geeksville.mesh.model.DeviceListEntry import com.geeksville.mesh.service.MeshService @Suppress("LongMethod") @Composable fun BLEDevices( connectionState: MeshService.ConnectionState, - btDevices: List, + btDevices: List, selectedDevice: String, showBluetoothRationaleDialog: () -> Unit, requestBluetoothPermission: (Array) -> Unit, - scanModel: BTScanModel + scanModel: BTScanModel, ) { val context = LocalContext.current val isScanning by scanModel.spinner.collectAsStateWithLifecycle(false) Text( text = stringResource(R.string.bluetooth), style = MaterialTheme.typography.titleLarge, - modifier = Modifier.padding(vertical = 8.dp) + modifier = Modifier.padding(vertical = 8.dp), ) btDevices.forEach { device -> DeviceListItem( @@ -67,41 +68,29 @@ fun BLEDevices( device = device, selected = device.fullAddress == selectedDevice, onSelect = { scanModel.onSelected(device) }, - modifier = Modifier + modifier = Modifier, ) } if (isScanning) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp), - horizontalAlignment = CenterHorizontally - ) { - CircularProgressIndicator( - modifier = Modifier.size(96.dp) - ) + Column(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), horizontalAlignment = CenterHorizontally) { + CircularProgressIndicator(modifier = Modifier.size(96.dp)) Text( text = stringResource(R.string.scanning), style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(vertical = 8.dp) + modifier = Modifier.padding(vertical = 8.dp), ) } - } else if (btDevices.filterNot { it.isDisconnect }.isEmpty()) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp), - horizontalAlignment = CenterHorizontally - ) { + } else if (btDevices.filterNot { it is DeviceListEntry.Disconnect }.isEmpty()) { + Column(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), horizontalAlignment = CenterHorizontally) { Icon( imageVector = Icons.Default.BluetoothDisabled, contentDescription = stringResource(R.string.no_ble_devices), - modifier = Modifier.size(96.dp) + modifier = Modifier.size(96.dp), ) Text( text = stringResource(R.string.no_ble_devices), style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(vertical = 8.dp) + modifier = Modifier.padding(vertical = 8.dp), ) } } @@ -114,11 +103,9 @@ fun BLEDevices( // If no permissions needed, trigger the scan directly (or via ViewModel) scanModel.startScan() } else { - if (bluetoothPermissions.any { permission -> - ActivityCompat.shouldShowRequestPermissionRationale( - context as Activity, - permission - ) + if ( + bluetoothPermissions.any { permission -> + ActivityCompat.shouldShowRequestPermissionRationale(context as Activity, permission) } ) { showBluetoothRationaleDialog() @@ -126,12 +113,9 @@ fun BLEDevices( requestBluetoothPermission(bluetoothPermissions) } } - } + }, ) { - Icon( - imageVector = Icons.Default.Bluetooth, - contentDescription = stringResource(R.string.scan) - ) + Icon(imageVector = Icons.Default.Bluetooth, contentDescription = stringResource(R.string.scan)) Text(stringResource(R.string.scan)) } } diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/components/DeviceListItem.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/components/DeviceListItem.kt index 6434a2314..f33d0ac1f 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/connections/components/DeviceListItem.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/connections/components/DeviceListItem.kt @@ -37,7 +37,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import com.geeksville.mesh.R -import com.geeksville.mesh.model.BTScanModel +import com.geeksville.mesh.model.DeviceListEntry import com.geeksville.mesh.service.MeshService import com.geeksville.mesh.ui.common.theme.StatusColors.StatusGreen import com.geeksville.mesh.ui.common.theme.StatusColors.StatusRed @@ -46,40 +46,32 @@ import com.geeksville.mesh.ui.common.theme.StatusColors.StatusRed @Composable fun DeviceListItem( connectionState: MeshService.ConnectionState, - device: BTScanModel.DeviceListEntry, + device: DeviceListEntry, selected: Boolean, onSelect: () -> Unit, modifier: Modifier = Modifier, ) { val icon = - if (device.isBLE) { - Icons.Default.Bluetooth - } else if (device.isUSB) { - Icons.Default.Usb - } else if (device.isTCP) { - Icons.Default.Wifi - } else if (device.isDisconnect) { // This is the "Disconnect" entry type - Icons.Default.Cancel - } else { - Icons.Default.Add + when (device) { + is DeviceListEntry.Ble -> Icons.Default.Bluetooth + is DeviceListEntry.Usb -> Icons.Default.Usb + is DeviceListEntry.Tcp -> Icons.Default.Wifi + is DeviceListEntry.Disconnect -> Icons.Default.Cancel + is DeviceListEntry.Mock -> Icons.Default.Add } val contentDescription = - if (device.isBLE) { - stringResource(R.string.bluetooth) - } else if (device.isUSB) { - stringResource(R.string.serial) - } else if (device.isTCP) { - stringResource(R.string.network) - } else if (device.isDisconnect) { // This is the "Disconnect" entry type - stringResource(R.string.disconnect) - } else { - stringResource(R.string.add) + when (device) { + is DeviceListEntry.Ble -> stringResource(R.string.bluetooth) + is DeviceListEntry.Usb -> stringResource(R.string.serial) + is DeviceListEntry.Tcp -> stringResource(R.string.network) + is DeviceListEntry.Disconnect -> stringResource(R.string.disconnect) + is DeviceListEntry.Mock -> stringResource(R.string.add) } val colors = when { - selected && device.isDisconnect -> { + selected && device is DeviceListEntry.Disconnect -> { ListItemDefaults.colors( containerColor = MaterialTheme.colorScheme.errorContainer, headlineColor = MaterialTheme.colorScheme.onErrorContainer, @@ -126,12 +118,12 @@ fun DeviceListItem( ) }, supportingContent = { - if (device.isTCP) { + if (device is DeviceListEntry.Tcp) { Text(device.address) } }, trailingContent = { - if (device.isDisconnect) { + if (device is DeviceListEntry.Disconnect) { Icon(imageVector = Icons.Default.CloudOff, contentDescription = stringResource(R.string.disconnect)) } else if (connectionState == MeshService.ConnectionState.CONNECTED) { Icon(imageVector = Icons.Default.CloudDone, contentDescription = stringResource(R.string.connected)) diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/components/NetworkDevices.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/components/NetworkDevices.kt index a43afe00c..73bf48e19 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/connections/components/NetworkDevices.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/connections/components/NetworkDevices.kt @@ -17,6 +17,8 @@ package com.geeksville.mesh.ui.connections.components +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -28,6 +30,8 @@ import androidx.compose.foundation.text.input.TextFieldLineLimits import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.WifiFind +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -35,6 +39,10 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment.Companion.CenterHorizontally import androidx.compose.ui.Modifier @@ -44,52 +52,73 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import com.geeksville.mesh.R import com.geeksville.mesh.model.BTScanModel +import com.geeksville.mesh.model.DeviceListEntry import com.geeksville.mesh.repository.network.NetworkRepository import com.geeksville.mesh.service.MeshService import com.geeksville.mesh.ui.connections.isIPAddress -import androidx.compose.foundation.combinedClickable -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.compose.runtime.remember -@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalFoundationApi::class) @Suppress("MagicNumber", "LongMethod") @Composable fun NetworkDevices( connectionState: MeshService.ConnectionState, - networkDevices: List, + discoveredNetworkDevices: List, + recentNetworkDevices: List, selectedDevice: String, scanModel: BTScanModel, ) { val manualIpAddress = rememberTextFieldState("") val manualIpPort = rememberTextFieldState(NetworkRepository.Companion.SERVICE_PORT.toString()) var showDeleteDialog by remember { mutableStateOf(false) } - var deviceToDelete by remember { mutableStateOf(null) } + var deviceToDelete by remember { mutableStateOf(null) } Text( text = stringResource(R.string.network), style = MaterialTheme.typography.titleLarge, - modifier = Modifier.padding(vertical = 8.dp) + modifier = Modifier.padding(vertical = 8.dp), ) - networkDevices.forEach { device -> - val isRecent = device.isTCP && device.fullAddress.startsWith("t") - val modifier = if (isRecent) { - Modifier.combinedClickable( - onClick = { scanModel.onSelected(device) }, - onLongClick = { - deviceToDelete = device - showDeleteDialog = true - } - ) - } else { - Modifier - } - DeviceListItem( - connectionState, device, device.fullAddress == selectedDevice, onSelect = { scanModel.onSelected(device) }, - modifier = modifier + DeviceListItem( + connectionState = connectionState, + device = scanModel.disconnectDevice, + selected = scanModel.disconnectDevice.fullAddress == selectedDevice, + onSelect = { scanModel.onSelected(scanModel.disconnectDevice) }, + ) + if (discoveredNetworkDevices.isNotEmpty()) { + Text( + text = stringResource(R.string.discovered_network_devices), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(vertical = 8.dp), ) + discoveredNetworkDevices.forEach { device -> + DeviceListItem( + connectionState, + device, + device.fullAddress == selectedDevice, + onSelect = { scanModel.onSelected(device) }, + ) + } + } + if (recentNetworkDevices.isNotEmpty()) { + Text( + text = stringResource(R.string.recent_network_devices), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(vertical = 8.dp), + ) + recentNetworkDevices.forEach { device -> + DeviceListItem( + connectionState, + device, + device.fullAddress == selectedDevice, + onSelect = { scanModel.onSelected(device) }, + modifier = + Modifier.combinedClickable( + onClick = { scanModel.onSelected(device) }, + onLongClick = { + deviceToDelete = device + showDeleteDialog = true + }, + ), + ) + } } if (showDeleteDialog && deviceToDelete != null) { AlertDialog( @@ -97,94 +126,78 @@ fun NetworkDevices( title = { Text(stringResource(R.string.delete)) }, text = { Text(stringResource(R.string.confirm_delete_node)) }, confirmButton = { - Button(onClick = { - scanModel.removeRecentAddress(deviceToDelete!!.fullAddress) - showDeleteDialog = false - }) { + Button( + onClick = { + scanModel.removeRecentAddress(deviceToDelete!!.fullAddress) + showDeleteDialog = false + }, + ) { Text(stringResource(R.string.delete)) } }, dismissButton = { - Button(onClick = { showDeleteDialog = false }) { - Text(stringResource(R.string.cancel)) - } - } + Button(onClick = { showDeleteDialog = false }) { Text(stringResource(R.string.cancel)) } + }, ) } - if (networkDevices.filterNot { it.isDisconnect }.isEmpty()) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp), - horizontalAlignment = CenterHorizontally - ) { + if (discoveredNetworkDevices.isEmpty() && recentNetworkDevices.isEmpty()) { + Column(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), horizontalAlignment = CenterHorizontally) { Icon( imageVector = Icons.Default.WifiFind, contentDescription = stringResource(R.string.no_network_devices), - modifier = Modifier.size(96.dp) + modifier = Modifier.size(96.dp), ) Text( text = stringResource(R.string.no_network_devices), style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(vertical = 8.dp) + modifier = Modifier.padding(vertical = 8.dp), ) } } Row( - modifier = Modifier - .fillMaxWidth() - .padding(8.dp), + modifier = Modifier.fillMaxWidth().padding(8.dp), verticalAlignment = Alignment.Companion.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp, CenterHorizontally) + horizontalArrangement = Arrangement.spacedBy(8.dp, CenterHorizontally), ) { OutlinedTextField( state = manualIpAddress, lineLimits = TextFieldLineLimits.SingleLine, label = { Text(stringResource(R.string.ip_address)) }, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Companion.Decimal, - imeAction = ImeAction.Next - ), - modifier = Modifier.weight(.7f, fill = false) // Fill 70% of the space + keyboardOptions = + KeyboardOptions(keyboardType = KeyboardType.Companion.Decimal, imeAction = ImeAction.Next), + modifier = Modifier.weight(.7f, fill = false), // Fill 70% of the space ) OutlinedTextField( state = manualIpPort, placeholder = { Text(NetworkRepository.SERVICE_PORT.toString()) }, lineLimits = TextFieldLineLimits.SingleLine, label = { Text(stringResource(R.string.ip_port)) }, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Companion.Decimal, - imeAction = ImeAction.Done - ), - modifier = Modifier.weight(.3f, fill = false) // Fill remaining space + keyboardOptions = + KeyboardOptions(keyboardType = KeyboardType.Companion.Decimal, imeAction = ImeAction.Done), + modifier = Modifier.weight(.3f, fill = false), // Fill remaining space ) IconButton( onClick = { if (manualIpAddress.text.toString().isIPAddress()) { val fullAddress = - "t" + if ( - manualIpPort.text.isNotEmpty() && - manualIpPort.text.toString().toInt() != NetworkRepository.SERVICE_PORT - ) { - "${manualIpAddress.text}:${manualIpPort.text}" - } else { - "${manualIpAddress.text}" - } - scanModel.onSelected( - BTScanModel.DeviceListEntry( - "${manualIpAddress.text}", - fullAddress, - true - ) - ) + "t" + + if ( + manualIpPort.text.isNotEmpty() && + manualIpPort.text.toString().toInt() != NetworkRepository.SERVICE_PORT + ) { + "${manualIpAddress.text}:${manualIpPort.text}" + } else { + manualIpAddress.text.toString() + } + scanModel.onSelected(DeviceListEntry.Tcp(manualIpAddress.text.toString(), fullAddress)) } - } + }, ) { Icon( imageVector = Icons.Default.WifiFind, contentDescription = stringResource(R.string.add), - modifier = Modifier.size(32.dp) + modifier = Modifier.size(32.dp), ) } } diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/components/UsbDevices.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/components/UsbDevices.kt index 412d9c72b..25856fd41 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/connections/components/UsbDevices.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/connections/components/UsbDevices.kt @@ -33,19 +33,20 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.geeksville.mesh.R import com.geeksville.mesh.model.BTScanModel +import com.geeksville.mesh.model.DeviceListEntry import com.geeksville.mesh.service.MeshService @Composable fun UsbDevices( connectionState: MeshService.ConnectionState, - usbDevices: List, + usbDevices: List, selectedDevice: String, - scanModel: BTScanModel + scanModel: BTScanModel, ) { Text( text = stringResource(R.string.serial), style = MaterialTheme.typography.titleLarge, - modifier = Modifier.padding(vertical = 8.dp) + modifier = Modifier.padding(vertical = 8.dp), ) usbDevices.forEach { device -> DeviceListItem( @@ -53,25 +54,20 @@ fun UsbDevices( device = device, selected = device.fullAddress == selectedDevice, onSelect = { scanModel.onSelected(device) }, - modifier = Modifier + modifier = Modifier, ) } - if (usbDevices.filterNot { it.isDisconnect || it.isMock }.isEmpty()) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp), - horizontalAlignment = CenterHorizontally - ) { + if (usbDevices.filterNot { it is DeviceListEntry.Disconnect || it is DeviceListEntry.Mock }.isEmpty()) { + Column(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), horizontalAlignment = CenterHorizontally) { Icon( imageVector = Icons.Default.UsbOff, contentDescription = stringResource(R.string.no_usb_devices), - modifier = Modifier.size(96.dp) + modifier = Modifier.size(96.dp), ) Text( text = stringResource(R.string.no_usb_devices), style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(vertical = 8.dp) + modifier = Modifier.padding(vertical = 8.dp), ) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 81bdfa56c..3070abac8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -809,4 +809,6 @@ Latest alpha Supported by Meshtastic Community Firmware Edition + Recent Network Devices + Discovered Network Devices