mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
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:
parent
aa6a048084
commit
ceabafb545
8 changed files with 506 additions and 537 deletions
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue