From 73d933fe147f24ed81ee55ba152c1cd4decd974d Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Mon, 17 Nov 2025 15:15:22 -0600
Subject: [PATCH] feat(connections): `Connecting` state refactor (#3722)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
---
app/detekt-baseline.xml | 5 +-
.../repository/radio/RadioInterfaceService.kt | 10 +-
.../geeksville/mesh/service/MeshService.kt | 196 ++++++++++--------
.../mesh/service/MeshServiceBroadcasts.kt | 2 +-
.../MeshServiceConnectionStateHolder.kt | 9 +-
.../geeksville/mesh/service/PacketHandler.kt | 6 +-
.../main/java/com/geeksville/mesh/ui/Main.kt | 16 +-
.../mesh/ui/connections/ConnectionsScreen.kt | 28 ++-
.../ui/connections/components/BLEDevices.kt | 40 +---
.../components/ConnectionsNavIcon.kt | 58 ++++--
.../connections/components/DeviceListItem.kt | 27 ++-
.../components/DeviceListSection.kt | 47 +++++
.../connections/components/NetworkDevices.kt | 44 ++--
.../ui/connections/components/UsbDevices.kt | 28 +--
.../com/geeksville/mesh/ui/sharing/Channel.kt | 2 +-
.../core/service/ConnectionState.kt | 19 +-
.../core/service/ServiceRepository.kt | 4 +-
.../composeResources/values/strings.xml | 2 +
.../feature/node/component/NodeItem.kt | 15 +-
.../feature/node/component/NodeStatusIcons.kt | 78 +++++--
.../feature/node/list/NodeListScreen.kt | 4 +-
.../settings/radio/RadioConfigViewModel.kt | 2 +-
22 files changed, 379 insertions(+), 263 deletions(-)
create mode 100644 app/src/main/java/com/geeksville/mesh/ui/connections/components/DeviceListSection.kt
diff --git a/app/detekt-baseline.xml b/app/detekt-baseline.xml
index 6bc93d176..12f0bb0e0 100644
--- a/app/detekt-baseline.xml
+++ b/app/detekt-baseline.xml
@@ -33,7 +33,6 @@
FinalNewline:SerialInterfaceFactory.kt$com.geeksville.mesh.repository.radio.SerialInterfaceFactory.kt
FinalNewline:TCPInterfaceFactory.kt$com.geeksville.mesh.repository.radio.TCPInterfaceFactory.kt
FinalNewline:UsbRepositoryModule.kt$com.geeksville.mesh.repository.usb.UsbRepositoryModule.kt
- ImplicitDefaultLocale:MeshService.kt$MeshService$String.format("0x%02x", byte)
LambdaParameterEventTrailing:Channel.kt$onConfirm
LambdaParameterInRestartableEffect:Channel.kt$onConfirm
LargeClass:MeshService.kt$MeshService : Service
@@ -67,9 +66,8 @@
MagicNumber:UIState.kt$4
MatchingDeclarationName:MeshServiceStarter.kt$ServiceStarter : Worker
MaxLineLength:MeshService.kt$MeshService$"Config complete id mismatch: received=$configCompleteId expected one of [$configOnlyNonce,$nodeInfoNonce]"
- MaxLineLength:MeshService.kt$MeshService$"Failed to parse radio packet (len=${bytes.size} contents=$packet). Not a valid FromRadio or LogRecord."
MaxLineLength:MeshService.kt$MeshService$"setOwner Id: $id longName: ${longName.anonymize} shortName: $shortName isLicensed: $isLicensed isUnmessagable: $isUnmessagable"
- MaxLineLength:MeshService.kt$MeshService.<no name provided>$"sendData dest=${p.to}, id=${p.id} <- ${bytes.size} bytes (connectionState=${connectionStateHolder.getState()})"
+ MaxLineLength:MeshService.kt$MeshService.<no name provided>$"sendData dest=${p.to}, id=${p.id} <- ${bytes.size} bytes (connectionState=${connectionStateHolder.connectionState.value})"
MaxLineLength:NordicBleInterface.kt$NordicBleInterface$"[$address] Found fromNum: ${fromNumCharacteristic?.uuid}, ${fromNumCharacteristic?.instanceId}"
MaxLineLength:NordicBleInterface.kt$NordicBleInterface$"[$address] Found fromRadio: ${fromRadioCharacteristic?.uuid}, ${fromRadioCharacteristic?.instanceId}"
MaxLineLength:NordicBleInterface.kt$NordicBleInterface$"[$address] Found logRadio: ${logRadioCharacteristic?.uuid}, ${logRadioCharacteristic?.instanceId}"
@@ -120,7 +118,6 @@
SwallowedException:NsdManager.kt$ex: IllegalArgumentException
SwallowedException:ServiceClient.kt$ServiceClient$ex: IllegalArgumentException
SwallowedException:TCPInterface.kt$TCPInterface$ex: SocketTimeoutException
- TooGenericExceptionCaught:BTScanModel.kt$BTScanModel$ex: Throwable
TooGenericExceptionCaught:Exceptions.kt$ex: Throwable
TooGenericExceptionCaught:MQTTRepository.kt$MQTTRepository$ex: Exception
TooGenericExceptionCaught:MeshService.kt$MeshService$ex: Exception
diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt
index c85a27069..bb39b1ef6 100644
--- a/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt
+++ b/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt
@@ -77,7 +77,7 @@ constructor(
private val analytics: PlatformAnalytics,
) {
- private val _connectionState = MutableStateFlow(ConnectionState.DISCONNECTED)
+ private val _connectionState = MutableStateFlow(ConnectionState.Disconnected)
val connectionState: StateFlow = _connectionState.asStateFlow()
private val _receivedData = MutableSharedFlow()
@@ -248,13 +248,13 @@ constructor(
}
fun onConnect() {
- if (_connectionState.value != ConnectionState.CONNECTED) {
- broadcastConnectionChanged(ConnectionState.CONNECTED)
+ if (_connectionState.value != ConnectionState.Connected) {
+ broadcastConnectionChanged(ConnectionState.Connected)
}
}
fun onDisconnect(isPermanent: Boolean) {
- val newTargetState = if (isPermanent) ConnectionState.DISCONNECTED else ConnectionState.DEVICE_SLEEP
+ val newTargetState = if (isPermanent) ConnectionState.Disconnected else ConnectionState.DeviceSleep
if (_connectionState.value != newTargetState) {
broadcastConnectionChanged(newTargetState)
}
@@ -319,7 +319,7 @@ constructor(
* @return true if the device changed, false if no change
*/
private fun setBondedDeviceAddress(address: String?): Boolean =
- if (getBondedDeviceAddress() == address && isStarted && _connectionState.value == ConnectionState.CONNECTED) {
+ if (getBondedDeviceAddress() == address && isStarted && _connectionState.value == ConnectionState.Connected) {
Timber.w("Ignoring setBondedDevice ${address.anonymize}, because we are already using that device")
false
} else {
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt
index 2a8dca5a1..e26980e92 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt
@@ -94,6 +94,7 @@ import org.meshtastic.core.service.ServiceAction
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.connected_count
+import org.meshtastic.core.strings.connecting
import org.meshtastic.core.strings.critical_alert
import org.meshtastic.core.strings.device_sleeping
import org.meshtastic.core.strings.disconnected
@@ -1426,98 +1427,110 @@ class MeshService : Service() {
// Called when we gain/lose connection to our radio
@Suppress("CyclomaticComplexMethod")
private fun onConnectionChanged(c: ConnectionState) {
- Timber.d("onConnectionChanged: ${connectionStateHolder.getState()} -> $c")
-
- // Perform all the steps needed once we start waiting for device sleep to complete
- fun startDeviceSleep() {
- packetHandler.stopPacketQueue()
- stopLocationRequests()
- stopMqttClientProxy()
-
- if (connectTimeMsec != 0L) {
- val now = System.currentTimeMillis()
- connectTimeMsec = 0L
-
- analytics.track("connected_seconds", DataPair("connected_seconds", (now - connectTimeMsec) / 1000.0))
- }
-
- // Have our timeout fire in the appropriate number of seconds
- sleepTimeout =
- serviceScope.handledLaunch {
- try {
- // If we have a valid timeout, wait that long (+30 seconds) otherwise, just
- // wait 30 seconds
- val timeout = (localConfig.power?.lsSecs ?: 0) + 30
-
- Timber.d("Waiting for sleeping device, timeout=$timeout secs")
- delay(timeout * 1000L)
- Timber.w("Device timeout out, setting disconnected")
- onConnectionChanged(ConnectionState.DISCONNECTED)
- } catch (_: CancellationException) {
- Timber.d("device sleep timeout cancelled")
- }
- }
-
- // broadcast an intent with our new connection state
- serviceBroadcasts.broadcastConnection()
- }
-
- fun startDisconnect() {
- Timber.d("Starting disconnect")
- packetHandler.stopPacketQueue()
- stopLocationRequests()
- stopMqttClientProxy()
-
- analytics.track("mesh_disconnect", DataPair("num_nodes", numNodes), DataPair("num_online", numOnlineNodes))
- analytics.track("num_nodes", DataPair("num_nodes", numNodes))
-
- // broadcast an intent with our new connection state
- serviceBroadcasts.broadcastConnection()
- }
-
- fun startConnect() {
- Timber.d("Starting connect")
- historyLog {
- val address = meshPrefs.deviceAddress ?: "null"
- "onReconnect transport=${currentTransport()} node=$address"
- }
- try {
- connectTimeMsec = System.currentTimeMillis()
- startConfigOnly()
- } catch (ex: InvalidProtocolBufferException) {
- Timber.e(ex, "Invalid protocol buffer sent by device - update device software and try again")
- } catch (ex: RadioNotConnectedException) {
- Timber.e("Lost connection to radio during init - waiting for reconnect ${ex.message}")
- } catch (ex: RemoteException) {
- connectionStateHolder.setState(ConnectionState.DEVICE_SLEEP)
- startDeviceSleep()
- throw ex
- }
- }
+ if (connectionStateHolder.connectionState.value == c && c !is ConnectionState.Connected) return
+ Timber.d("onConnectionChanged: ${connectionStateHolder.connectionState.value} -> $c")
// Cancel any existing timeouts
- sleepTimeout?.let {
- it.cancel()
- sleepTimeout = null
- }
+ sleepTimeout?.cancel()
+ sleepTimeout = null
- connectionStateHolder.setState(c)
when (c) {
- ConnectionState.CONNECTED -> startConnect()
- ConnectionState.DEVICE_SLEEP -> startDeviceSleep()
- ConnectionState.DISCONNECTED -> startDisconnect()
+ is ConnectionState.Connecting -> {
+ connectionStateHolder.setState(ConnectionState.Connecting)
+ }
+
+ is ConnectionState.Connected -> {
+ handleConnected()
+ }
+
+ is ConnectionState.DeviceSleep -> {
+ handleDeviceSleep()
+ }
+
+ is ConnectionState.Disconnected -> {
+ handleDisconnected()
+ }
+ }
+ updateServiceStatusNotification()
+ }
+
+ private fun handleDisconnected() {
+ connectionStateHolder.setState(ConnectionState.Disconnected)
+ Timber.d("Starting disconnect")
+ packetHandler.stopPacketQueue()
+ stopLocationRequests()
+ stopMqttClientProxy()
+
+ analytics.track("mesh_disconnect", DataPair("num_nodes", numNodes), DataPair("num_online", numOnlineNodes))
+ analytics.track("num_nodes", DataPair("num_nodes", numNodes))
+
+ // broadcast an intent with our new connection state
+ serviceBroadcasts.broadcastConnection()
+ }
+
+ private fun handleDeviceSleep() {
+ connectionStateHolder.setState(ConnectionState.DeviceSleep)
+ packetHandler.stopPacketQueue()
+ stopLocationRequests()
+ stopMqttClientProxy()
+
+ if (connectTimeMsec != 0L) {
+ val now = System.currentTimeMillis()
+ connectTimeMsec = 0L
+
+ analytics.track("connected_seconds", DataPair("connected_seconds", (now - connectTimeMsec) / 1000.0))
}
- updateServiceStatusNotification()
+ // Have our timeout fire in the appropriate number of seconds
+ sleepTimeout =
+ serviceScope.handledLaunch {
+ try {
+ // If we have a valid timeout, wait that long (+30 seconds) otherwise, just
+ // wait 30 seconds
+ val timeout = (localConfig.power?.lsSecs ?: 0) + 30
+
+ Timber.d("Waiting for sleeping device, timeout=$timeout secs")
+ delay(timeout * 1000L)
+ Timber.w("Device timeout out, setting disconnected")
+ onConnectionChanged(ConnectionState.Disconnected)
+ } catch (_: CancellationException) {
+ Timber.d("device sleep timeout cancelled")
+ }
+ }
+
+ // broadcast an intent with our new connection state
+ serviceBroadcasts.broadcastConnection()
+ }
+
+ private fun handleConnected() {
+ connectionStateHolder.setState(ConnectionState.Connecting)
+ serviceBroadcasts.broadcastConnection()
+ Timber.d("Starting connect")
+ historyLog {
+ val address = meshPrefs.deviceAddress ?: "null"
+ "onReconnect transport=${currentTransport()} node=$address"
+ }
+ try {
+ connectTimeMsec = System.currentTimeMillis()
+ startConfigOnly()
+ } catch (ex: InvalidProtocolBufferException) {
+ Timber.e(ex, "Invalid protocol buffer sent by device - update device software and try again")
+ } catch (ex: RadioNotConnectedException) {
+ Timber.e("Lost connection to radio during init - waiting for reconnect ${ex.message}")
+ } catch (ex: RemoteException) {
+ onConnectionChanged(ConnectionState.DeviceSleep)
+ throw ex
+ }
}
private fun updateServiceStatusNotification(telemetry: TelemetryProtos.Telemetry? = null): Notification {
val notificationSummary =
- when (connectionStateHolder.getState()) {
- ConnectionState.CONNECTED -> getString(Res.string.connected_count).format(numOnlineNodes)
+ when (connectionStateHolder.connectionState.value) {
+ is ConnectionState.Connected -> getString(Res.string.connected_count).format(numOnlineNodes)
- ConnectionState.DISCONNECTED -> getString(Res.string.disconnected)
- ConnectionState.DEVICE_SLEEP -> getString(Res.string.device_sleeping)
+ is ConnectionState.Disconnected -> getString(Res.string.disconnected)
+ is ConnectionState.DeviceSleep -> getString(Res.string.device_sleeping)
+ is ConnectionState.Connecting -> getString(Res.string.connecting)
}
return serviceNotifications.updateServiceStateNotification(
summaryString = notificationSummary,
@@ -1533,15 +1546,16 @@ class MeshService : Service() {
val effectiveState =
when (newState) {
- ConnectionState.CONNECTED -> ConnectionState.CONNECTED
- ConnectionState.DEVICE_SLEEP ->
+ is ConnectionState.Connected -> ConnectionState.Connected
+ is ConnectionState.DeviceSleep ->
if (lsEnabled) {
- ConnectionState.DEVICE_SLEEP
+ ConnectionState.DeviceSleep
} else {
- ConnectionState.DISCONNECTED
+ ConnectionState.Disconnected
}
- ConnectionState.DISCONNECTED -> ConnectionState.DISCONNECTED
+ is ConnectionState.Connecting -> ConnectionState.Connecting
+ is ConnectionState.Disconnected -> ConnectionState.Disconnected
}
onConnectionChanged(effectiveState)
}
@@ -1972,7 +1986,6 @@ class MeshService : Service() {
private fun onHasSettings() {
processQueuedPackets()
startMqttClientProxy()
- serviceBroadcasts.broadcastConnection()
sendAnalytics()
reportConnection()
historyLog {
@@ -2055,6 +2068,9 @@ class MeshService : Service() {
flushEarlyReceivedPackets("node_info_complete")
sendAnalytics()
onHasSettings()
+ connectionStateHolder.setState(ConnectionState.Connected)
+ serviceBroadcasts.broadcastConnection()
+ updateServiceStatusNotification()
}
}
@@ -2323,7 +2339,7 @@ class MeshService : Service() {
if (p.id == 0) p.id = generatePacketId()
val bytes = p.bytes!!
Timber.i(
- "sendData dest=${p.to}, id=${p.id} <- ${bytes.size} bytes (connectionState=${connectionStateHolder.getState()})",
+ "sendData dest=${p.to}, id=${p.id} <- ${bytes.size} bytes (connectionState=${connectionStateHolder.connectionState.value})",
)
if (p.dataType == 0) throw Exception("Port numbers must be non-zero!")
if (bytes.size >= MeshProtos.Constants.DATA_PAYLOAD_LEN.number) {
@@ -2332,7 +2348,7 @@ class MeshService : Service() {
} else {
p.status = MessageStatus.QUEUED
}
- if (connectionStateHolder.getState() == ConnectionState.CONNECTED) {
+ if (connectionStateHolder.connectionState.value == ConnectionState.Connected) {
try {
sendNow(p)
} catch (ex: Exception) {
@@ -2450,7 +2466,7 @@ class MeshService : Service() {
}
override fun connectionState(): String = toRemoteExceptions {
- val r = connectionStateHolder.getState()
+ val r = connectionStateHolder.connectionState.value
Timber.i("in connectionState=$r")
r.toString()
}
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshServiceBroadcasts.kt b/app/src/main/java/com/geeksville/mesh/service/MeshServiceBroadcasts.kt
index 68b830542..5f148da72 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshServiceBroadcasts.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshServiceBroadcasts.kt
@@ -75,7 +75,7 @@ constructor(
/** Broadcast our current connection status */
fun broadcastConnection() {
- val connectionState = connectionStateHolder.getState()
+ val connectionState = connectionStateHolder.connectionState.value
val intent = Intent(MeshService.ACTION_MESH_CONNECTED).putExtra(EXTRA_CONNECTED, connectionState.toString())
serviceRepository.setConnectionState(connectionState)
explicitBroadcast(intent)
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshServiceConnectionStateHolder.kt b/app/src/main/java/com/geeksville/mesh/service/MeshServiceConnectionStateHolder.kt
index 3be8f7594..34b269d50 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshServiceConnectionStateHolder.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshServiceConnectionStateHolder.kt
@@ -17,17 +17,18 @@
package com.geeksville.mesh.service
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
import org.meshtastic.core.service.ConnectionState
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class MeshServiceConnectionStateHolder @Inject constructor() {
- private var connectionState = ConnectionState.DISCONNECTED
+ private val _connectionState = MutableStateFlow(ConnectionState.Disconnected)
+ val connectionState = _connectionState.asStateFlow()
fun setState(state: ConnectionState) {
- connectionState = state
+ _connectionState.value = state
}
-
- fun getState() = connectionState
}
diff --git a/app/src/main/java/com/geeksville/mesh/service/PacketHandler.kt b/app/src/main/java/com/geeksville/mesh/service/PacketHandler.kt
index 160491ebe..dd4886613 100644
--- a/app/src/main/java/com/geeksville/mesh/service/PacketHandler.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/PacketHandler.kt
@@ -135,7 +135,7 @@ constructor(
queueJob =
scope.handledLaunch {
Timber.d("packet queueJob started")
- while (connectionStateHolder.getState() == ConnectionState.CONNECTED) {
+ while (connectionStateHolder.connectionState.value == ConnectionState.Connected) {
// take the first packet from the queue head
val packet = queuedPackets.poll() ?: break
try {
@@ -181,7 +181,9 @@ constructor(
val future = CompletableFuture()
queueResponse[packet.id] = future
try {
- if (connectionStateHolder.getState() != ConnectionState.CONNECTED) throw RadioNotConnectedException()
+ if (connectionStateHolder.connectionState.value != ConnectionState.Connected) {
+ throw RadioNotConnectedException()
+ }
sendToRadio(ToRadio.newBuilder().apply { this.packet = packet })
} catch (ex: Exception) {
Timber.e(ex, "sendToRadio error: ${ex.message}")
diff --git a/app/src/main/java/com/geeksville/mesh/ui/Main.kt b/app/src/main/java/com/geeksville/mesh/ui/Main.kt
index 1ad2aa61a..b6a2196dc 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/Main.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/Main.kt
@@ -114,6 +114,7 @@ import org.meshtastic.core.strings.bottom_nav_settings
import org.meshtastic.core.strings.client_notification
import org.meshtastic.core.strings.compromised_keys
import org.meshtastic.core.strings.connected
+import org.meshtastic.core.strings.connecting
import org.meshtastic.core.strings.connections
import org.meshtastic.core.strings.conversations
import org.meshtastic.core.strings.device_sleeping
@@ -170,13 +171,13 @@ fun MainScreen(uIViewModel: UIViewModel = hiltViewModel(), scanModel: BTScanMode
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val notificationPermissionState = rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS)
LaunchedEffect(connectionState, notificationPermissionState) {
- if (connectionState == ConnectionState.CONNECTED && !notificationPermissionState.status.isGranted) {
+ if (connectionState == ConnectionState.Connected && !notificationPermissionState.status.isGranted) {
notificationPermissionState.launchPermissionRequest()
}
}
}
- if (connectionState == ConnectionState.CONNECTED) {
+ if (connectionState == ConnectionState.Connected) {
sharedContactRequested?.let {
SharedContactDialog(sharedContact = it, onDismiss = { uIViewModel.clearSharedContactRequested() })
}
@@ -297,10 +298,11 @@ fun MainScreen(uIViewModel: UIViewModel = hiltViewModel(), scanModel: BTScanMode
Text(
if (isConnectionsRoute) {
when (connectionState) {
- ConnectionState.CONNECTED -> stringResource(Res.string.connected)
- ConnectionState.DEVICE_SLEEP ->
+ ConnectionState.Connected -> stringResource(Res.string.connected)
+ ConnectionState.Connecting -> stringResource(Res.string.connecting)
+ ConnectionState.DeviceSleep ->
stringResource(Res.string.device_sleeping)
- ConnectionState.DISCONNECTED -> stringResource(Res.string.disconnected)
+ ConnectionState.Disconnected -> stringResource(Res.string.disconnected)
}
} else {
stringResource(destination.label)
@@ -447,7 +449,7 @@ private fun VersionChecks(viewModel: UIViewModel) {
val latestStableFirmwareRelease by
viewModel.latestStableFirmwareRelease.collectAsStateWithLifecycle(DeviceVersion("2.6.4"))
LaunchedEffect(connectionState, firmwareEdition) {
- if (connectionState == ConnectionState.CONNECTED) {
+ if (connectionState == ConnectionState.Connected) {
firmwareEdition?.let { edition ->
Timber.d("FirmwareEdition: ${edition.name}")
when (edition) {
@@ -465,7 +467,7 @@ private fun VersionChecks(viewModel: UIViewModel) {
// Check if the device is running an old app version or firmware version
LaunchedEffect(connectionState, myNodeInfo) {
- if (connectionState == ConnectionState.CONNECTED) {
+ if (connectionState == ConnectionState.Connected) {
myNodeInfo?.let { info ->
val isOld = info.minAppVersion > BuildConfig.VERSION_CODE && BuildConfig.DEBUG.not()
if (isOld) {
diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/ConnectionsScreen.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/ConnectionsScreen.kt
index cac2623df..14eee02f0 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/connections/ConnectionsScreen.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/connections/ConnectionsScreen.kt
@@ -25,15 +25,19 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
+import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Language
+import androidx.compose.material3.CircularWavyProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
@@ -68,6 +72,7 @@ import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.connected
import org.meshtastic.core.strings.connected_device
import org.meshtastic.core.strings.connected_sleeping
+import org.meshtastic.core.strings.connecting
import org.meshtastic.core.strings.connections
import org.meshtastic.core.strings.must_set_region
import org.meshtastic.core.strings.not_connected
@@ -93,7 +98,7 @@ fun String?.isIPAddress(): Boolean = if (Build.VERSION.SDK_INT < Build.VERSION_C
* Composable screen for managing device connections (BLE, TCP, USB). It handles permission requests for location and
* displays connection status.
*/
-@OptIn(ExperimentalPermissionsApi::class)
+@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3ExpressiveApi::class)
@Suppress("CyclomaticComplexMethod", "LongMethod", "MagicNumber", "ModifierMissing", "ComposableParamOrder")
@Composable
fun ConnectionsScreen(
@@ -109,7 +114,7 @@ fun ConnectionsScreen(
val scrollState = rememberScrollState()
val scanStatusText by scanModel.errorText.observeAsState("")
val connectionState by
- connectionsViewModel.connectionState.collectAsStateWithLifecycle(ConnectionState.DISCONNECTED)
+ connectionsViewModel.connectionState.collectAsStateWithLifecycle(ConnectionState.Disconnected)
val scanning by scanModel.spinner.collectAsStateWithLifecycle(false)
val ourNode by connectionsViewModel.ourNodeInfo.collectAsStateWithLifecycle()
val selectedDevice by scanModel.selectedNotNullFlow.collectAsStateWithLifecycle()
@@ -157,12 +162,13 @@ fun ConnectionsScreen(
LaunchedEffect(connectionState, regionUnset) {
when (connectionState) {
- ConnectionState.CONNECTED -> {
+ ConnectionState.Connected -> {
if (regionUnset) Res.string.must_set_region else Res.string.connected
}
+ ConnectionState.Connecting -> Res.string.connecting
- ConnectionState.DISCONNECTED -> Res.string.not_connected
- ConnectionState.DEVICE_SLEEP -> Res.string.connected_sleeping
+ ConnectionState.Disconnected -> Res.string.not_connected
+ ConnectionState.DeviceSleep -> Res.string.connected_sleeping
}.let { scanModel.setErrorText(getString(it)) }
}
@@ -189,6 +195,11 @@ fun ConnectionsScreen(
.padding(paddingValues)
.padding(16.dp),
) {
+ AnimatedVisibility(visible = connectionState == ConnectionState.Connecting) {
+ Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
+ CircularWavyProgressIndicator(modifier = Modifier.size(96.dp).padding(16.dp))
+ }
+ }
AnimatedVisibility(
visible = connectionState.isConnected(),
modifier = Modifier.padding(bottom = 16.dp),
@@ -288,12 +299,7 @@ fun ConnectionsScreen(
}
Box(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
- Text(
- text = scanStatusText.orEmpty(),
- modifier = Modifier.fillMaxWidth(),
- fontSize = 10.sp,
- textAlign = TextAlign.End,
- )
+ Text(text = scanStatusText.orEmpty(), fontSize = 10.sp, textAlign = TextAlign.End)
}
}
}
diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/components/BLEDevices.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/components/BLEDevices.kt
index dbdd9a34f..f62407607 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/connections/components/BLEDevices.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/connections/components/BLEDevices.kt
@@ -32,7 +32,8 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.BluetoothDisabled
import androidx.compose.material.icons.rounded.Search
import androidx.compose.material3.Button
-import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.CircularWavyProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -64,7 +65,6 @@ import org.meshtastic.core.strings.permission_missing
import org.meshtastic.core.strings.permission_missing_31
import org.meshtastic.core.strings.scan
import org.meshtastic.core.strings.scanning_bluetooth
-import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.core.ui.util.showToast
/**
@@ -72,11 +72,13 @@ import org.meshtastic.core.ui.util.showToast
* permissions using `accompanist-permissions`.
*
* @param connectionState The current connection state of the MeshService.
- * @param btDevices List of discovered BLE devices.
+ * @param bondedDevices List of discovered BLE devices.
+ * @param availableDevices
* @param selectedDevice The full address of the currently selected device.
* @param scanModel The ViewModel responsible for Bluetooth scanning logic.
+ * @param bluetoothEnabled
*/
-@OptIn(ExperimentalPermissionsApi::class)
+@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3ExpressiveApi::class)
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun BLEDevices(
@@ -159,7 +161,9 @@ fun BLEDevices(
}
if (isScanning) {
- CircularProgressIndicator(modifier = Modifier.size(24.dp).align(Alignment.Center))
+ CircularWavyProgressIndicator(
+ modifier = Modifier.size(24.dp).align(Alignment.Center),
+ )
}
}
}
@@ -177,14 +181,14 @@ fun BLEDevices(
actionButton = scanButton,
)
} else {
- bondedDevices.Section(
+ bondedDevices.DeviceListSection(
title = stringResource(Res.string.bluetooth_paired_devices),
connectionState = connectionState,
selectedDevice = selectedDevice,
onSelect = scanModel::onSelected,
)
- availableDevices.Section(
+ availableDevices.DeviceListSection(
title = stringResource(Res.string.bluetooth_available_devices),
connectionState = connectionState,
selectedDevice = selectedDevice,
@@ -226,25 +230,3 @@ private fun checkPermissionsAndScan(
permissionsState.launchMultiplePermissionRequest()
}
}
-
-@Composable
-private fun List.Section(
- title: String,
- connectionState: ConnectionState,
- selectedDevice: String,
- onSelect: (DeviceListEntry) -> Unit,
-) {
- if (isNotEmpty()) {
- TitledCard(title = title) {
- forEach { device ->
- val connected = connectionState == ConnectionState.CONNECTED && device.fullAddress == selectedDevice
- DeviceListItem(
- connected = connected,
- device = device,
- onSelect = { onSelect(device) },
- modifier = Modifier,
- )
- }
- }
- }
-}
diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/components/ConnectionsNavIcon.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/components/ConnectionsNavIcon.kt
index 7d79d2a84..6e51ae673 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/connections/components/ConnectionsNavIcon.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/connections/components/ConnectionsNavIcon.kt
@@ -19,6 +19,7 @@ package com.geeksville.mesh.ui.connections.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Bluetooth
+import androidx.compose.material.icons.rounded.Cached
import androidx.compose.material.icons.rounded.Snooze
import androidx.compose.material.icons.rounded.Usb
import androidx.compose.material.icons.rounded.Wifi
@@ -28,7 +29,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
+import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewLightDark
@@ -41,31 +44,15 @@ import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.NoDevice
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
+import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
import org.meshtastic.core.ui.theme.StatusColors.StatusYellow
@Composable
fun ConnectionsNavIcon(modifier: Modifier = Modifier, connectionState: ConnectionState, deviceType: DeviceType?) {
- val tint =
- when (connectionState) {
- ConnectionState.DISCONNECTED -> colorScheme.StatusRed
- ConnectionState.DEVICE_SLEEP -> colorScheme.StatusYellow
- else -> colorScheme.StatusGreen
- }
+ val tint = getTint(connectionState)
- val (backgroundIcon, connectionTypeIcon) =
- when (connectionState) {
- ConnectionState.DISCONNECTED -> MeshtasticIcons.NoDevice to null
- ConnectionState.DEVICE_SLEEP -> MeshtasticIcons.Device to Icons.Rounded.Snooze
- else ->
- MeshtasticIcons.Device to
- when (deviceType) {
- DeviceType.BLE -> Icons.Rounded.Bluetooth
- DeviceType.TCP -> Icons.Rounded.Wifi
- DeviceType.USB -> Icons.Rounded.Usb
- else -> null
- }
- }
+ val (backgroundIcon, connectionTypeIcon) = getIconPair(deviceType = deviceType, connectionState = connectionState)
val foregroundPainter = connectionTypeIcon?.let { rememberVectorPainter(it) }
@@ -85,11 +72,40 @@ fun ConnectionsNavIcon(modifier: Modifier = Modifier, connectionState: Connectio
)
}
+@Composable
+private fun getTint(connectionState: ConnectionState): Color = when (connectionState) {
+ ConnectionState.Connecting -> colorScheme.StatusOrange
+ ConnectionState.Disconnected -> colorScheme.StatusRed
+ ConnectionState.DeviceSleep -> colorScheme.StatusYellow
+ else -> colorScheme.StatusGreen
+}
+
class ConnectionStateProvider : PreviewParameterProvider {
override val values: Sequence =
- sequenceOf(ConnectionState.CONNECTED, ConnectionState.DEVICE_SLEEP, ConnectionState.DISCONNECTED)
+ sequenceOf(
+ ConnectionState.Connected,
+ ConnectionState.Connecting,
+ ConnectionState.DeviceSleep,
+ ConnectionState.Disconnected,
+ )
}
+@Composable
+fun getIconPair(connectionState: ConnectionState, deviceType: DeviceType? = null): Pair =
+ when (connectionState) {
+ ConnectionState.Disconnected -> MeshtasticIcons.NoDevice to null
+ ConnectionState.DeviceSleep -> MeshtasticIcons.Device to Icons.Rounded.Snooze
+ ConnectionState.Connecting -> MeshtasticIcons.Device to Icons.Rounded.Cached
+ else ->
+ MeshtasticIcons.Device to
+ when (deviceType) {
+ DeviceType.BLE -> Icons.Rounded.Bluetooth
+ DeviceType.TCP -> Icons.Rounded.Wifi
+ DeviceType.USB -> Icons.Rounded.Usb
+ else -> null
+ }
+ }
+
class DeviceTypeProvider : PreviewParameterProvider {
override val values: Sequence = sequenceOf(DeviceType.BLE, DeviceType.TCP, DeviceType.USB)
}
@@ -105,5 +121,5 @@ private fun ConnectionsNavIconPreviewConnectionStates(
@Preview(showBackground = true)
@Composable
private fun ConnectionsNavIconPreviewDeviceTypes(@PreviewParameter(DeviceTypeProvider::class) deviceType: DeviceType) {
- ConnectionsNavIcon(connectionState = ConnectionState.CONNECTED, deviceType = deviceType)
+ ConnectionsNavIcon(connectionState = ConnectionState.Connected, deviceType = deviceType)
}
diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/components/DeviceListItem.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/components/DeviceListItem.kt
index 42c8f2219..adc66d8d3 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/connections/components/DeviceListItem.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/connections/components/DeviceListItem.kt
@@ -19,12 +19,16 @@ package com.geeksville.mesh.ui.connections.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.rounded.BluetoothSearching
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.Bluetooth
import androidx.compose.material.icons.rounded.BluetoothConnected
import androidx.compose.material.icons.rounded.Usb
import androidx.compose.material.icons.rounded.Wifi
+import androidx.compose.material3.CircularWavyProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
@@ -33,25 +37,36 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
import com.geeksville.mesh.model.DeviceListEntry
import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.add
import org.meshtastic.core.strings.bluetooth
import org.meshtastic.core.strings.network
import org.meshtastic.core.strings.serial
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
-fun DeviceListItem(connected: Boolean, device: DeviceListEntry, onSelect: () -> Unit, modifier: Modifier = Modifier) {
+fun DeviceListItem(
+ connectionState: ConnectionState,
+ device: DeviceListEntry,
+ onSelect: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
val icon =
when (device) {
is DeviceListEntry.Ble ->
- if (connected) {
+ if (connectionState.isConnected()) {
Icons.Rounded.BluetoothConnected
+ } else if (connectionState.isConnecting()) {
+ Icons.AutoMirrored.Rounded.BluetoothSearching
} else {
Icons.Rounded.Bluetooth
}
+
is DeviceListEntry.Usb -> Icons.Rounded.Usb
is DeviceListEntry.Tcp -> Icons.Rounded.Wifi
is DeviceListEntry.Mock -> Icons.Rounded.Add
@@ -80,7 +95,13 @@ fun DeviceListItem(connected: Boolean, device: DeviceListEntry, onSelect: () ->
Text(device.address)
}
},
- trailingContent = { RadioButton(selected = connected, onClick = null) },
+ trailingContent = {
+ if (connectionState.isConnecting()) {
+ CircularWavyProgressIndicator(modifier = Modifier.size(24.dp))
+ } else {
+ RadioButton(selected = connectionState.isConnected(), onClick = null)
+ }
+ },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/components/DeviceListSection.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/components/DeviceListSection.kt
new file mode 100644
index 000000000..768f5bbc8
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/ui/connections/components/DeviceListSection.kt
@@ -0,0 +1,47 @@
+/*
+ * Copyright (c) 2025 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.geeksville.mesh.ui.connections.components
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import com.geeksville.mesh.model.DeviceListEntry
+import org.meshtastic.core.service.ConnectionState
+import org.meshtastic.core.ui.component.TitledCard
+
+@Composable
+fun List.DeviceListSection(
+ title: String,
+ connectionState: ConnectionState,
+ selectedDevice: String,
+ onSelect: (DeviceListEntry) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ if (isNotEmpty()) {
+ TitledCard(title = title, modifier = modifier) {
+ forEach { device ->
+ DeviceListItem(
+ connectionState =
+ connectionState.takeIf { device.fullAddress == selectedDevice } ?: ConnectionState.Disconnected,
+ device = device,
+ onSelect = { onSelect(device) },
+ modifier = Modifier.Companion,
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/components/NetworkDevices.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/components/NetworkDevices.kt
index 146342281..62bc67423 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/connections/components/NetworkDevices.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/connections/components/NetworkDevices.kt
@@ -17,7 +17,6 @@
package com.geeksville.mesh.ui.connections.components
-import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -67,7 +66,6 @@ import org.meshtastic.core.strings.ip_address
import org.meshtastic.core.strings.ip_port
import org.meshtastic.core.strings.no_network_devices
import org.meshtastic.core.strings.recent_network_devices
-import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.core.ui.theme.AppTheme
@OptIn(ExperimentalMaterial3Api::class)
@@ -130,39 +128,21 @@ fun NetworkDevices(
else -> {
if (recentNetworkDevices.isNotEmpty()) {
- TitledCard(title = stringResource(Res.string.recent_network_devices)) {
- recentNetworkDevices.forEach { device ->
- DeviceListItem(
- connected =
- connectionState == ConnectionState.CONNECTED &&
- device.fullAddress == selectedDevice,
- device = device,
- onSelect = { scanModel.onSelected(device) },
- modifier =
- Modifier.combinedClickable(
- onClick = { scanModel.onSelected(device) },
- onLongClick = {
- deviceToDelete = device
- showDeleteDialog = true
- },
- ),
- )
- }
- }
+ recentNetworkDevices.DeviceListSection(
+ title = stringResource(Res.string.recent_network_devices),
+ connectionState = connectionState,
+ selectedDevice = selectedDevice,
+ onSelect = scanModel::onSelected,
+ )
}
if (discoveredNetworkDevices.isNotEmpty()) {
- TitledCard(title = stringResource(Res.string.discovered_network_devices)) {
- discoveredNetworkDevices.forEach { device ->
- DeviceListItem(
- connected =
- connectionState == ConnectionState.CONNECTED &&
- device.fullAddress == selectedDevice,
- device = device,
- onSelect = { scanModel.onSelected(device) },
- )
- }
- }
+ discoveredNetworkDevices.DeviceListSection(
+ title = stringResource(Res.string.discovered_network_devices),
+ connectionState = connectionState,
+ selectedDevice = selectedDevice,
+ onSelect = scanModel::onSelected,
+ )
}
addButton()
diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/components/UsbDevices.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/components/UsbDevices.kt
index df4a9cc18..182735b6f 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/connections/components/UsbDevices.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/connections/components/UsbDevices.kt
@@ -20,7 +20,6 @@ package com.geeksville.mesh.ui.connections.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.UsbOff
import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.PreviewLightDark
import com.geeksville.mesh.model.BTScanModel
import com.geeksville.mesh.model.DeviceListEntry
@@ -28,7 +27,7 @@ import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.no_usb_devices
-import org.meshtastic.core.ui.component.TitledCard
+import org.meshtastic.core.strings.usb_devices
import org.meshtastic.core.ui.theme.AppTheme
@Composable
@@ -38,7 +37,7 @@ fun UsbDevices(
selectedDevice: String,
scanModel: BTScanModel,
) {
- UsbDevices(
+ UsbDevicesInternal(
connectionState = connectionState,
usbDevices = usbDevices,
selectedDevice = selectedDevice,
@@ -47,7 +46,7 @@ fun UsbDevices(
}
@Composable
-private fun UsbDevices(
+private fun UsbDevicesInternal(
connectionState: ConnectionState,
usbDevices: List,
selectedDevice: String,
@@ -58,17 +57,12 @@ private fun UsbDevices(
EmptyStateContent(imageVector = Icons.Rounded.UsbOff, text = stringResource(Res.string.no_usb_devices))
else ->
- TitledCard(title = null) {
- usbDevices.forEach { device ->
- DeviceListItem(
- connected =
- connectionState == ConnectionState.CONNECTED && device.fullAddress == selectedDevice,
- device = device,
- onSelect = { onDeviceSelected(device) },
- modifier = Modifier,
- )
- }
- }
+ usbDevices.DeviceListSection(
+ title = stringResource(Res.string.usb_devices),
+ connectionState = connectionState,
+ selectedDevice = selectedDevice,
+ onSelect = onDeviceSelected,
+ )
}
}
@@ -76,8 +70,8 @@ private fun UsbDevices(
@Composable
private fun UsbDevicesPreview() {
AppTheme {
- UsbDevices(
- connectionState = ConnectionState.CONNECTED,
+ UsbDevicesInternal(
+ connectionState = ConnectionState.Connected,
usbDevices = emptyList(),
selectedDevice = "",
onDeviceSelected = {},
diff --git a/app/src/main/java/com/geeksville/mesh/ui/sharing/Channel.kt b/app/src/main/java/com/geeksville/mesh/ui/sharing/Channel.kt
index fcbbeaf3c..094a3e4ea 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/sharing/Channel.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/sharing/Channel.kt
@@ -153,7 +153,7 @@ fun ChannelScreen(
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
val radioConfigState by radioConfigViewModel.radioConfigState.collectAsStateWithLifecycle()
- val enabled = connectionState == ConnectionState.CONNECTED && !viewModel.isManaged
+ val enabled = connectionState == ConnectionState.Connected && !viewModel.isManaged
val channels by viewModel.channels.collectAsStateWithLifecycle()
var channelSet by remember(channels) { mutableStateOf(channels) }
diff --git a/core/service/src/main/kotlin/org/meshtastic/core/service/ConnectionState.kt b/core/service/src/main/kotlin/org/meshtastic/core/service/ConnectionState.kt
index 394c760da..0e8beedae 100644
--- a/core/service/src/main/kotlin/org/meshtastic/core/service/ConnectionState.kt
+++ b/core/service/src/main/kotlin/org/meshtastic/core/service/ConnectionState.kt
@@ -17,17 +17,24 @@
package org.meshtastic.core.service
-enum class ConnectionState {
+sealed class ConnectionState {
/** We are disconnected from the device, and we should be trying to reconnect. */
- DISCONNECTED,
+ data object Disconnected : ConnectionState()
+
+ /** We are currently attempting to connect to the device. */
+ data object Connecting : ConnectionState()
/** We are connected to the device and communicating normally. */
- CONNECTED,
+ data object Connected : ConnectionState()
/** The device is in a light sleep state, and we are waiting for it to wake up and reconnect to us. */
- DEVICE_SLEEP,
+ data object DeviceSleep : ConnectionState()
- ;
+ fun isConnected() = this == Connected
- fun isConnected() = this != DISCONNECTED
+ fun isConnecting() = this == Connecting
+
+ fun isDisconnected() = this == Disconnected
+
+ fun isDeviceSleep() = this == DeviceSleep
}
diff --git a/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceRepository.kt b/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceRepository.kt
index 03041c4d6..08864a28c 100644
--- a/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceRepository.kt
+++ b/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceRepository.kt
@@ -41,7 +41,7 @@ class ServiceRepository @Inject constructor() {
}
// Connection state to our radio device
- private val _connectionState = MutableStateFlow(ConnectionState.DISCONNECTED)
+ private val _connectionState: MutableStateFlow = MutableStateFlow(ConnectionState.Disconnected)
val connectionState: StateFlow
get() = _connectionState
@@ -81,7 +81,7 @@ class ServiceRepository @Inject constructor() {
get() = _statusMessage
fun setStatusMessage(text: String) {
- if (connectionState.value != ConnectionState.CONNECTED) {
+ if (connectionState.value != ConnectionState.Connected) {
_statusMessage.value = text
}
}
diff --git a/core/strings/src/commonMain/composeResources/values/strings.xml b/core/strings/src/commonMain/composeResources/values/strings.xml
index 1eec5b1ab..a46b1541a 100644
--- a/core/strings/src/commonMain/composeResources/values/strings.xml
+++ b/core/strings/src/commonMain/composeResources/values/strings.xml
@@ -210,6 +210,7 @@
Current connections:
Wifi IP:
Ethernet IP:
+ Connecting
Not connected
Connected to radio, but it is sleeping
Application update required
@@ -953,4 +954,5 @@
Unset - 0
Relayed by: %1$s
Preserve Favorites?
+ USB Devices
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeItem.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeItem.kt
index c7d97869c..66720af7d 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeItem.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeItem.kt
@@ -49,6 +49,7 @@ import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.database.model.isUnmessageableRole
import org.meshtastic.core.model.util.toDistanceString
+import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.elevation_suffix
import org.meshtastic.core.strings.unknown_username
@@ -72,7 +73,7 @@ fun NodeItem(
onClick: () -> Unit = {},
onLongClick: (() -> Unit)? = null,
currentTimeMillis: Long,
- isConnected: Boolean = false,
+ connectionState: ConnectionState,
) {
val isFavorite = remember(thatNode) { thatNode.isFavorite }
val isIgnored = thatNode.isIgnored
@@ -140,7 +141,7 @@ fun NodeItem(
isThisNode = isThisNode,
isFavorite = isFavorite,
isUnmessageable = unmessageable,
- isConnected = isConnected,
+ connectionState = connectionState,
)
}
@@ -221,7 +222,14 @@ fun NodeInfoSimplePreview() {
AppTheme {
val thisNode = NodePreviewParameterProvider().values.first()
val thatNode = NodePreviewParameterProvider().values.last()
- NodeItem(thisNode = thisNode, thatNode = thatNode, 0, true, currentTimeMillis = System.currentTimeMillis())
+ NodeItem(
+ thisNode = thisNode,
+ thatNode = thatNode,
+ 0,
+ true,
+ currentTimeMillis = System.currentTimeMillis(),
+ connectionState = ConnectionState.Connected,
+ )
}
}
@@ -236,6 +244,7 @@ fun NodeInfoPreview(@PreviewParameter(NodePreviewParameterProvider::class) thatN
distanceUnits = 1,
tempInFahrenheit = true,
currentTimeMillis = System.currentTimeMillis(),
+ connectionState = ConnectionState.Connected,
)
}
}
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeStatusIcons.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeStatusIcons.kt
index fe4370abf..bfdaf6bc4 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeStatusIcons.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeStatusIcons.kt
@@ -23,8 +23,10 @@ import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.NoCell
import androidx.compose.material.icons.rounded.Star
+import androidx.compose.material.icons.twotone.Cloud
import androidx.compose.material.icons.twotone.CloudDone
import androidx.compose.material.icons.twotone.CloudOff
+import androidx.compose.material.icons.twotone.CloudSync
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -40,21 +42,29 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.connected
+import org.meshtastic.core.strings.connecting
+import org.meshtastic.core.strings.device_sleeping
import org.meshtastic.core.strings.disconnected
import org.meshtastic.core.strings.favorite
-import org.meshtastic.core.strings.not_connected
import org.meshtastic.core.strings.unmessageable
import org.meshtastic.core.strings.unmonitored_or_infrastructure
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
+import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
import org.meshtastic.core.ui.theme.StatusColors.StatusYellow
@Suppress("LongMethod")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
-fun NodeStatusIcons(isThisNode: Boolean, isUnmessageable: Boolean, isFavorite: Boolean, isConnected: Boolean) {
+fun NodeStatusIcons(
+ isThisNode: Boolean,
+ isUnmessageable: Boolean,
+ isFavorite: Boolean,
+ connectionState: ConnectionState,
+) {
Row(modifier = Modifier.padding(4.dp)) {
if (isThisNode) {
TooltipBox(
@@ -63,10 +73,11 @@ fun NodeStatusIcons(isThisNode: Boolean, isUnmessageable: Boolean, isFavorite: B
PlainTooltip {
Text(
stringResource(
- if (isConnected) {
- Res.string.connected
- } else {
- Res.string.disconnected
+ when (connectionState) {
+ ConnectionState.Connected -> Res.string.connected
+ ConnectionState.Connecting -> Res.string.connecting
+ ConnectionState.Disconnected -> Res.string.disconnected
+ ConnectionState.DeviceSleep -> Res.string.device_sleeping
},
),
)
@@ -74,21 +85,39 @@ fun NodeStatusIcons(isThisNode: Boolean, isUnmessageable: Boolean, isFavorite: B
},
state = rememberTooltipState(),
) {
- if (isConnected) {
- @Suppress("MagicNumber")
- Icon(
- imageVector = Icons.TwoTone.CloudDone,
- contentDescription = stringResource(Res.string.connected),
- modifier = Modifier.size(24.dp), // Smaller size for badge
- tint = MaterialTheme.colorScheme.StatusGreen,
- )
- } else {
- Icon(
- imageVector = Icons.TwoTone.CloudOff,
- contentDescription = stringResource(Res.string.not_connected),
- modifier = Modifier.size(24.dp), // Smaller size for badge
- tint = MaterialTheme.colorScheme.StatusRed,
- )
+ when (connectionState) {
+ ConnectionState.Connected -> {
+ Icon(
+ imageVector = Icons.TwoTone.CloudDone,
+ contentDescription = stringResource(Res.string.connected),
+ modifier = Modifier.size(24.dp), // Smaller size for badge
+ tint = MaterialTheme.colorScheme.StatusGreen,
+ )
+ }
+ ConnectionState.Connecting -> {
+ Icon(
+ imageVector = Icons.TwoTone.CloudSync,
+ contentDescription = stringResource(Res.string.connecting),
+ modifier = Modifier.size(24.dp), // Smaller size for badge
+ tint = MaterialTheme.colorScheme.StatusOrange,
+ )
+ }
+ ConnectionState.Disconnected -> {
+ Icon(
+ imageVector = Icons.TwoTone.CloudOff,
+ contentDescription = stringResource(Res.string.connecting),
+ modifier = Modifier.size(24.dp), // Smaller size for badge
+ tint = MaterialTheme.colorScheme.StatusRed,
+ )
+ }
+ ConnectionState.DeviceSleep -> {
+ Icon(
+ imageVector = Icons.TwoTone.Cloud,
+ contentDescription = stringResource(Res.string.device_sleeping),
+ modifier = Modifier.size(24.dp), // Smaller size for badge
+ tint = MaterialTheme.colorScheme.StatusYellow,
+ )
+ }
}
}
}
@@ -130,5 +159,10 @@ fun NodeStatusIcons(isThisNode: Boolean, isUnmessageable: Boolean, isFavorite: B
@Preview
@Composable
private fun StatusIconsPreview() {
- NodeStatusIcons(isThisNode = true, isUnmessageable = true, isFavorite = true, isConnected = false)
+ NodeStatusIcons(
+ isThisNode = true,
+ isUnmessageable = true,
+ isFavorite = true,
+ connectionState = ConnectionState.Connected,
+ )
}
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt
index cc37bbe68..c17765093 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt
@@ -140,7 +140,7 @@ fun NodeListScreen(
sharedContact = sharedContact,
modifier =
Modifier.animateFloatingActionButton(
- visible = !isScrollInProgress && connectionState == ConnectionState.CONNECTED && shareCapable,
+ visible = !isScrollInProgress && connectionState == ConnectionState.Connected && shareCapable,
alignment = Alignment.BottomEnd,
),
onSharedContactRequested = { contact -> viewModel.setSharedContactRequested(contact) },
@@ -217,7 +217,7 @@ fun NodeListScreen(
onClick = { navigateToNodeDetails(node.num) },
onLongClick = longClick,
currentTimeMillis = currentTimeMillis,
- isConnected = connectionState.isConnected(),
+ connectionState = connectionState,
)
val isThisNode = remember(node) { ourNode?.num == node.num }
if (!isThisNode) {
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt
index 0b5e6f235..3b2bfeb4c 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt
@@ -169,7 +169,7 @@ constructor(
serviceRepository.meshPacketFlow.onEach(::processPacketResponse).launchIn(viewModelScope)
combine(serviceRepository.connectionState, radioConfigState) { connState, configState ->
- _radioConfigState.update { it.copy(connected = connState == ConnectionState.CONNECTED) }
+ _radioConfigState.update { it.copy(connected = connState == ConnectionState.Connected) }
}
.launchIn(viewModelScope)