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)