mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-02-02 20:02:26 -05: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:
@@ -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"
|
||||
|
||||
@@ -327,6 +327,15 @@ constructor(
|
||||
private val _showQuickChat = MutableStateFlow(preferences.getBoolean("show-quick-chat", false))
|
||||
val showQuickChat: StateFlow<Boolean> = _showQuickChat
|
||||
|
||||
private val _hasShownNotPairedWarning =
|
||||
MutableStateFlow(preferences.getBoolean(HAS_SHOWN_NOT_PAIRED_WARNING_PREF, false))
|
||||
val hasShownNotPairedWarning: StateFlow<Boolean> = _hasShownNotPairedWarning.asStateFlow()
|
||||
|
||||
fun suppressNoPairedWarning() {
|
||||
_hasShownNotPairedWarning.value = true
|
||||
preferences.edit { putBoolean(HAS_SHOWN_NOT_PAIRED_WARNING_PREF, true) }
|
||||
}
|
||||
|
||||
private fun toggleBooleanPreference(
|
||||
state: MutableStateFlow<Boolean>,
|
||||
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
|
||||
|
||||
@@ -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<Node?>(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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<BTScanModel.DeviceListEntry>,
|
||||
btDevices: List<DeviceListEntry>,
|
||||
selectedDevice: String,
|
||||
showBluetoothRationaleDialog: () -> Unit,
|
||||
requestBluetoothPermission: (Array<String>) -> 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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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<BTScanModel.DeviceListEntry>,
|
||||
discoveredNetworkDevices: List<DeviceListEntry>,
|
||||
recentNetworkDevices: List<DeviceListEntry>,
|
||||
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<BTScanModel.DeviceListEntry?>(null) }
|
||||
var deviceToDelete by remember { mutableStateOf<DeviceListEntry?>(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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<BTScanModel.DeviceListEntry>,
|
||||
usbDevices: List<DeviceListEntry>,
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -809,4 +809,6 @@
|
||||
<string name="latest_alpha_firmware">Latest alpha</string>
|
||||
<string name="supported_by_community">Supported by Meshtastic Community</string>
|
||||
<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>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user