mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-16 11:57:35 -04:00
feat(connections): Connecting state refactor (#3722)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
@@ -33,7 +33,6 @@
|
||||
<ID>FinalNewline:SerialInterfaceFactory.kt$com.geeksville.mesh.repository.radio.SerialInterfaceFactory.kt</ID>
|
||||
<ID>FinalNewline:TCPInterfaceFactory.kt$com.geeksville.mesh.repository.radio.TCPInterfaceFactory.kt</ID>
|
||||
<ID>FinalNewline:UsbRepositoryModule.kt$com.geeksville.mesh.repository.usb.UsbRepositoryModule.kt</ID>
|
||||
<ID>ImplicitDefaultLocale:MeshService.kt$MeshService$String.format("0x%02x", byte)</ID>
|
||||
<ID>LambdaParameterEventTrailing:Channel.kt$onConfirm</ID>
|
||||
<ID>LambdaParameterInRestartableEffect:Channel.kt$onConfirm</ID>
|
||||
<ID>LargeClass:MeshService.kt$MeshService : Service</ID>
|
||||
@@ -67,9 +66,8 @@
|
||||
<ID>MagicNumber:UIState.kt$4</ID>
|
||||
<ID>MatchingDeclarationName:MeshServiceStarter.kt$ServiceStarter : Worker</ID>
|
||||
<ID>MaxLineLength:MeshService.kt$MeshService$"Config complete id mismatch: received=$configCompleteId expected one of [$configOnlyNonce,$nodeInfoNonce]"</ID>
|
||||
<ID>MaxLineLength:MeshService.kt$MeshService$"Failed to parse radio packet (len=${bytes.size} contents=$packet). Not a valid FromRadio or LogRecord."</ID>
|
||||
<ID>MaxLineLength:MeshService.kt$MeshService$"setOwner Id: $id longName: ${longName.anonymize} shortName: $shortName isLicensed: $isLicensed isUnmessagable: $isUnmessagable"</ID>
|
||||
<ID>MaxLineLength:MeshService.kt$MeshService.<no name provided>$"sendData dest=${p.to}, id=${p.id} <- ${bytes.size} bytes (connectionState=${connectionStateHolder.getState()})"</ID>
|
||||
<ID>MaxLineLength:MeshService.kt$MeshService.<no name provided>$"sendData dest=${p.to}, id=${p.id} <- ${bytes.size} bytes (connectionState=${connectionStateHolder.connectionState.value})"</ID>
|
||||
<ID>MaxLineLength:NordicBleInterface.kt$NordicBleInterface$"[$address] Found fromNum: ${fromNumCharacteristic?.uuid}, ${fromNumCharacteristic?.instanceId}"</ID>
|
||||
<ID>MaxLineLength:NordicBleInterface.kt$NordicBleInterface$"[$address] Found fromRadio: ${fromRadioCharacteristic?.uuid}, ${fromRadioCharacteristic?.instanceId}"</ID>
|
||||
<ID>MaxLineLength:NordicBleInterface.kt$NordicBleInterface$"[$address] Found logRadio: ${logRadioCharacteristic?.uuid}, ${logRadioCharacteristic?.instanceId}"</ID>
|
||||
@@ -120,7 +118,6 @@
|
||||
<ID>SwallowedException:NsdManager.kt$ex: IllegalArgumentException</ID>
|
||||
<ID>SwallowedException:ServiceClient.kt$ServiceClient$ex: IllegalArgumentException</ID>
|
||||
<ID>SwallowedException:TCPInterface.kt$TCPInterface$ex: SocketTimeoutException</ID>
|
||||
<ID>TooGenericExceptionCaught:BTScanModel.kt$BTScanModel$ex: Throwable</ID>
|
||||
<ID>TooGenericExceptionCaught:Exceptions.kt$ex: Throwable</ID>
|
||||
<ID>TooGenericExceptionCaught:MQTTRepository.kt$MQTTRepository$ex: Exception</ID>
|
||||
<ID>TooGenericExceptionCaught:MeshService.kt$MeshService$ex: Exception</ID>
|
||||
|
||||
@@ -77,7 +77,7 @@ constructor(
|
||||
private val analytics: PlatformAnalytics,
|
||||
) {
|
||||
|
||||
private val _connectionState = MutableStateFlow(ConnectionState.DISCONNECTED)
|
||||
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
|
||||
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
|
||||
|
||||
private val _receivedData = MutableSharedFlow<ByteArray>()
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>(ConnectionState.Disconnected)
|
||||
val connectionState = _connectionState.asStateFlow()
|
||||
|
||||
fun setState(state: ConnectionState) {
|
||||
connectionState = state
|
||||
_connectionState.value = state
|
||||
}
|
||||
|
||||
fun getState() = connectionState
|
||||
}
|
||||
|
||||
@@ -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<Boolean>()
|
||||
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}")
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<DeviceListEntry>.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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ConnectionState> {
|
||||
override val values: Sequence<ConnectionState> =
|
||||
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<ImageVector, ImageVector?> =
|
||||
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<DeviceType> {
|
||||
override val values: Sequence<DeviceType> = 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)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<DeviceListEntry>.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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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<DeviceListEntry>,
|
||||
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 = {},
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ class ServiceRepository @Inject constructor() {
|
||||
}
|
||||
|
||||
// Connection state to our radio device
|
||||
private val _connectionState = MutableStateFlow(ConnectionState.DISCONNECTED)
|
||||
private val _connectionState: MutableStateFlow<ConnectionState> = MutableStateFlow(ConnectionState.Disconnected)
|
||||
val connectionState: StateFlow<ConnectionState>
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,6 +210,7 @@
|
||||
<string name="connection_status">Current connections:</string>
|
||||
<string name="wifi_ip">Wifi IP:</string>
|
||||
<string name="ethernet_ip">Ethernet IP:</string>
|
||||
<string name="connecting">Connecting</string>
|
||||
<string name="not_connected">Not connected</string>
|
||||
<string name="connected_sleeping">Connected to radio, but it is sleeping</string>
|
||||
<string name="app_too_old">Application update required</string>
|
||||
@@ -953,4 +954,5 @@
|
||||
<string name="unset">Unset - 0</string>
|
||||
<string name="relayed_by">Relayed by: %1$s</string>
|
||||
<string name="preserve_favorites">Preserve Favorites?</string>
|
||||
<string name="usb_devices">USB Devices</string>
|
||||
</resources>
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user