refactor(connections)!: Use sealed class for device list entries (#2538)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2025-07-28 20:49:17 -05:00 committed by GitHub
parent aa6a048084
commit ceabafb545
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 506 additions and 537 deletions

View file

@ -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<MutableMap<String, DeviceListEntry>>(mutableMapOf())
val errorText = MutableLiveData<String?>(null)
private val recentIpAddresses = recentAddressesRepository.recentAddresses
private val showMockInterface: StateFlow<Boolean>
val showMockInterface: StateFlow<Boolean>
get() = MutableStateFlow(radioInterfaceService.isMockInterface()).asStateFlow()
init {
combine(
bluetoothRepository.state,
networkRepository.resolvedList,
recentIpAddresses,
usbRepository.serialDevicesWithDrivers,
showMockInterface,
) { ble, tcp, recent, usb, showMockInterface ->
devices.value =
mutableMapOf<String, DeviceListEntry>().apply {
fun addDevice(entry: DeviceListEntry) {
this[entry.fullAddress] = entry
}
private val bleDevicesFlow: StateFlow<List<DeviceListEntry.Ble>> =
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<List<DeviceListEntry.Tcp>> =
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<String, ByteArray?>
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<String, ByteArray?>
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<List<DeviceListEntry.Tcp>> =
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<List<DeviceListEntry.Usb>> =
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<List<DeviceListEntry>> =
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<List<DeviceListEntry>> =
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<List<DeviceListEntry>> =
filteredRecentTcpDevicesFlow.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(SHARING_STARTED_TIMEOUT_MS),
listOf(),
)
val usbDevicesForUi: StateFlow<List<DeviceListEntry>> =
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<Boolean>
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"