feat(service)!: refactor configuration, nodedb, and connection states (#2661)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2025-08-08 16:59:54 -05:00 committed by GitHub
parent 4548a3ec7b
commit 4a7e3e35e0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1263 additions and 1387 deletions

View file

@ -241,7 +241,7 @@ class MainActivity :
errormsg("Failed to start service from activity - but ignoring because bind will work ${ex.message}")
}
mesh.connect(this, MeshService.createIntent(), BIND_AUTO_CREATE + BIND_ABOVE_CLIENT)
mesh.connect(this, MeshService.createIntent(this), BIND_AUTO_CREATE + BIND_ABOVE_CLIENT)
}
override fun onStart() {

View file

@ -118,10 +118,10 @@ constructor(
suspend fun upsert(node: NodeEntity) = withContext(dispatchers.io) { nodeInfoDao.upsert(node) }
suspend fun installNodeDB(mi: MyNodeEntity, nodes: List<NodeEntity>) = withContext(dispatchers.io) {
suspend fun installMyNodeInfo(mi: MyNodeEntity) = withContext(dispatchers.io) {
nodeInfoDao.clearMyNodeInfo()
nodeInfoDao.setMyNodeInfo(mi) // set MyNodeEntity first
nodeInfoDao.putAll(nodes)
nodeInfoDao.setMyNodeInfo(mi)
nodeInfoDao.clearNodeInfo()
}
suspend fun clearNodeDB() = withContext(dispatchers.io) { nodeInfoDao.clearNodeInfo() }

View file

@ -24,7 +24,6 @@ import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Upsert
import com.geeksville.mesh.android.BuildUtils.warn
import com.geeksville.mesh.database.entity.MetadataEntity
import com.geeksville.mesh.database.entity.MyNodeEntity
import com.geeksville.mesh.database.entity.NodeEntity
@ -36,52 +35,66 @@ import kotlinx.coroutines.flow.Flow
@Dao
interface NodeInfoDao {
// Helper function to contain all validation logic
private fun getVerifiedNodeForUpsert(node: NodeEntity): NodeEntity? {
// Populate the new publicKey field for lazy migration
node.publicKey = node.user.publicKey
/**
* Verifies a [NodeEntity] before an upsert operation. It handles populating the publicKey for lazy migration,
* checks for public key conflicts with new nodes, and manages updates to existing nodes, particularly in cases of
* public key mismatches to prevent potential impersonation or data corruption.
*
* @param incomingNode The node entity to be verified.
* @return A [NodeEntity] that is safe to upsert, or null if the upsert should be aborted (e.g., due to an
* impersonation attempt, though this logic is currently commented out).
*/
private fun getVerifiedNodeForUpsert(incomingNode: NodeEntity): NodeEntity {
// Populate the NodeEntity.publicKey field from the User.publicKey for consistency
// and to support lazy migration.
incomingNode.publicKey = incomingNode.user.publicKey
val existingNode = getNodeByNum(node.num)?.node
val existingNodeEntity = getNodeByNum(incomingNode.num)?.node
return if (existingNode == null) {
// This is a new node. We must check if its public key is already claimed by another node.
if (node.publicKey != null && node.publicKey?.isEmpty == false) {
val nodeWithSamePK = findNodeByPublicKey(node.publicKey)
if (nodeWithSamePK != null && nodeWithSamePK.num != node.num) {
// This is the impersonation attempt we want to block.
@Suppress("MaxLineLength")
warn(
"NodeInfoDao: Blocking new node #${node.num} because its public key is already used by #${nodeWithSamePK.num}.",
)
return null // ABORT
}
}
// If we're here, the new node is safe to add.
node
return if (existingNodeEntity == null) {
handleNewNodeUpsertValidation(incomingNode)
} else {
// This is an update to an existing node.
val keyMatch = existingNode.user.publicKey == node.user.publicKey || existingNode.user.publicKey.isEmpty
if (keyMatch) {
// Keys match, trust the incoming node completely.
// This allows for legit nodeId changes etc.
node
} else {
// Keys do NOT match. This is a potential attack.
// Log it, and create a NEW entity based on the EXISTING trusted one,
// only updating dynamic data and setting the public key to EMPTY to signal a conflict.
@Suppress("MaxLineLength")
warn(
"NodeInfoDao: Received packet for #${node.num} with non-matching public key. Identity data ignored, key set to EMPTY.",
)
existingNode.copy(
lastHeard = node.lastHeard,
snr = node.snr,
position = node.position,
user = existingNode.user.toBuilder().setPublicKey(ByteString.EMPTY).build(),
publicKey = ByteString.EMPTY,
)
handleExistingNodeUpsertValidation(existingNodeEntity, incomingNode)
}
}
/** Validates a new node before it is inserted into the database. */
private fun handleNewNodeUpsertValidation(newNode: NodeEntity): NodeEntity {
// Check if the new node's public key (if present and not empty)
// is already claimed by another existing node.
if (newNode.publicKey?.isEmpty == false) {
val nodeWithSamePK = findNodeByPublicKey(newNode.publicKey)
if (nodeWithSamePK != null && nodeWithSamePK.num != newNode.num) {
// This is a potential impersonation attempt.
return nodeWithSamePK
}
}
// If no conflicting public key is found, or if the impersonation check is not active,
// the new node is considered safe to add.
return newNode
}
private fun handleExistingNodeUpsertValidation(existingNode: NodeEntity, incomingNode: NodeEntity): NodeEntity {
// A public key is considered matching if the incoming key equals the existing key,
// OR if the existing key is empty (allowing a new key to be set or an update to proceed).
val isPublicKeyMatchingOrExistingIsEmpty =
existingNode.user.publicKey == incomingNode.publicKey || existingNode.user.publicKey.isEmpty
return if (isPublicKeyMatchingOrExistingIsEmpty) {
// Keys match or existing key was empty: trust the incoming node data completely.
// This allows for legitimate updates to user info and other fields.
incomingNode
} else {
existingNode.copy(
lastHeard = incomingNode.lastHeard,
snr = incomingNode.snr,
position = incomingNode.position,
// Preserve the existing user object, but update its internal public key to EMPTY
// to reflect the conflict state.
user = existingNode.user.toBuilder().setPublicKey(ByteString.EMPTY).build(),
publicKey = ByteString.EMPTY,
)
}
}
@Query("SELECT * FROM my_node")
@ -167,20 +180,6 @@ interface NodeInfoDao {
lastHeardMin: Int,
): Flow<List<NodeWithRelations>>
@Transaction
fun upsert(node: NodeEntity) {
getVerifiedNodeForUpsert(node)?.let { doUpsert(it) }
}
@Suppress("NestedBlockDepth")
@Transaction
fun putAll(nodes: List<NodeEntity>) {
val safeNodes = nodes.mapNotNull { getVerifiedNodeForUpsert(it) }
if (safeNodes.isNotEmpty()) {
doPutAll(safeNodes)
}
}
@Query("DELETE FROM nodes")
fun clearNodeInfo()
@ -210,6 +209,11 @@ interface NodeInfoDao {
@Upsert fun doUpsert(node: NodeEntity)
fun upsert(node: NodeEntity) {
val verifiedNode = getVerifiedNodeForUpsert(node)
doUpsert(verifiedNode)
}
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun doPutAll(nodes: List<NodeEntity>)
fun putAll(nodes: List<NodeEntity>)
}

View file

@ -62,7 +62,6 @@ import com.geeksville.mesh.repository.datastore.RadioConfigRepository
import com.geeksville.mesh.repository.location.LocationRepository
import com.geeksville.mesh.repository.radio.MeshActivity
import com.geeksville.mesh.repository.radio.RadioInterfaceService
import com.geeksville.mesh.service.MeshService
import com.geeksville.mesh.service.MeshServiceNotifications
import com.geeksville.mesh.service.ServiceAction
import com.geeksville.mesh.ui.map.MAP_STYLE_ID
@ -748,9 +747,10 @@ constructor(
val connectionState
get() = radioConfigRepository.connectionState
fun isConnected() = connectionState.value != MeshService.ConnectionState.DISCONNECTED
fun isConnected() = connectionState.value != com.geeksville.mesh.service.ConnectionState.DISCONNECTED
val isConnected = radioConfigRepository.connectionState.map { it != MeshService.ConnectionState.DISCONNECTED }
val isConnected =
radioConfigRepository.connectionState.map { it != com.geeksville.mesh.service.ConnectionState.DISCONNECTED }
private val _requestChannelSet = MutableStateFlow<AppOnlyProtos.ChannelSet?>(null)
val requestChannelSet: StateFlow<AppOnlyProtos.ChannelSet?>

View file

@ -36,7 +36,7 @@ import com.geeksville.mesh.database.entity.NodeEntity
import com.geeksville.mesh.deviceProfile
import com.geeksville.mesh.model.Node
import com.geeksville.mesh.model.getChannelUrl
import com.geeksville.mesh.service.MeshService.ConnectionState
import com.geeksville.mesh.service.ConnectionState
import com.geeksville.mesh.service.ServiceAction
import com.geeksville.mesh.service.ServiceRepository
import kotlinx.coroutines.coroutineScope
@ -48,43 +48,47 @@ import kotlinx.coroutines.flow.first
import javax.inject.Inject
/**
* Class responsible for radio configuration data.
* Combines access to [nodeDB], [ChannelSet], [LocalConfig] & [LocalModuleConfig].
* Class responsible for radio configuration data. Combines access to [nodeDB], [ChannelSet], [LocalConfig] &
* [LocalModuleConfig].
*/
class RadioConfigRepository @Inject constructor(
class RadioConfigRepository
@Inject
constructor(
private val serviceRepository: ServiceRepository,
private val nodeDB: NodeRepository,
private val channelSetRepository: ChannelSetRepository,
private val localConfigRepository: LocalConfigRepository,
private val moduleConfigRepository: ModuleConfigRepository,
) {
val meshService: IMeshService? get() = serviceRepository.meshService
val meshService: IMeshService?
get() = serviceRepository.meshService
// Connection state to our radio device
val connectionState get() = serviceRepository.connectionState
val connectionState
get() = serviceRepository.connectionState
fun setConnectionState(state: ConnectionState) = serviceRepository.setConnectionState(state)
/**
* Flow representing the unique userId of our node.
*/
val myId: StateFlow<String?> get() = nodeDB.myId
/** Flow representing the unique userId of our node. */
val myId: StateFlow<String?>
get() = nodeDB.myId
/**
* Flow representing the [MyNodeEntity] database.
*/
val myNodeInfo: StateFlow<MyNodeEntity?> get() = nodeDB.myNodeInfo
/** Flow representing the [MyNodeEntity] database. */
val myNodeInfo: StateFlow<MyNodeEntity?>
get() = nodeDB.myNodeInfo
/**
* Flow representing the [Node] database.
*/
val nodeDBbyNum: StateFlow<Map<Int, Node>> get() = nodeDB.nodeDBbyNum
/** Flow representing the [Node] database. */
val nodeDBbyNum: StateFlow<Map<Int, Node>>
get() = nodeDB.nodeDBbyNum
fun getUser(nodeNum: Int) = nodeDB.getUser(nodeNum)
suspend fun getNodeDBbyNum() = nodeDB.getNodeDBbyNum().first()
suspend fun upsert(node: NodeEntity) = nodeDB.upsert(node)
suspend fun installNodeDB(mi: MyNodeEntity, nodes: List<NodeEntity>) {
nodeDB.installNodeDB(mi, nodes)
suspend fun installMyNodeInfo(mi: MyNodeEntity) {
nodeDB.installMyNodeInfo(mi)
}
suspend fun insertMetadata(fromNum: Int, metadata: DeviceMetadata) {
@ -95,50 +99,40 @@ class RadioConfigRepository @Inject constructor(
nodeDB.clearNodeDB()
}
/**
* Flow representing the [ChannelSet] data store.
*/
/** Flow representing the [ChannelSet] data store. */
val channelSetFlow: Flow<ChannelSet> = channelSetRepository.channelSetFlow
/**
* Clears the [ChannelSet] data in the data store.
*/
/** Clears the [ChannelSet] data in the data store. */
suspend fun clearChannelSet() {
channelSetRepository.clearChannelSet()
}
/**
* Replaces the [ChannelSettings] list with a new [settingsList].
*/
/** Replaces the [ChannelSettings] list with a new [settingsList]. */
suspend fun replaceAllSettings(settingsList: List<ChannelSettings>) {
channelSetRepository.clearSettings()
channelSetRepository.addAllSettings(settingsList)
}
/**
* Updates the [ChannelSettings] list with the provided channel and returns the index of the
* admin channel after the update (if not found, returns 0).
* Updates the [ChannelSettings] list with the provided channel and returns the index of the admin channel after the
* update (if not found, returns 0).
*
* @param channel The [Channel] provided.
* @return the index of the admin channel after the update (if not found, returns 0).
*/
suspend fun updateChannelSettings(channel: Channel) {
return channelSetRepository.updateChannelSettings(channel)
}
suspend fun updateChannelSettings(channel: Channel) = channelSetRepository.updateChannelSettings(channel)
/**
* Flow representing the [LocalConfig] data store.
*/
/** Flow representing the [LocalConfig] data store. */
val localConfigFlow: Flow<LocalConfig> = localConfigRepository.localConfigFlow
/**
* Clears the [LocalConfig] data in the data store.
*/
/** Clears the [LocalConfig] data in the data store. */
suspend fun clearLocalConfig() {
localConfigRepository.clearLocalConfig()
}
/**
* Updates [LocalConfig] from each [Config] oneOf.
*
* @param config The [Config] to be set.
*/
suspend fun setLocalConfig(config: Config) {
@ -146,48 +140,44 @@ class RadioConfigRepository @Inject constructor(
if (config.hasLora()) channelSetRepository.setLoraConfig(config.lora)
}
/**
* Flow representing the [LocalModuleConfig] data store.
*/
/** Flow representing the [LocalModuleConfig] data store. */
val moduleConfigFlow: Flow<LocalModuleConfig> = moduleConfigRepository.moduleConfigFlow
/**
* Clears the [LocalModuleConfig] data in the data store.
*/
/** Clears the [LocalModuleConfig] data in the data store. */
suspend fun clearLocalModuleConfig() {
moduleConfigRepository.clearLocalModuleConfig()
}
/**
* Updates [LocalModuleConfig] from each [ModuleConfig] oneOf.
*
* @param config The [ModuleConfig] to be set.
*/
suspend fun setLocalModuleConfig(config: ModuleConfig) {
moduleConfigRepository.setLocalModuleConfig(config)
}
/**
* Flow representing the combined [DeviceProfile] protobuf.
*/
val deviceProfileFlow: Flow<DeviceProfile> = combine(
nodeDB.ourNodeInfo,
channelSetFlow,
localConfigFlow,
moduleConfigFlow,
) { node, channels, localConfig, localModuleConfig ->
deviceProfile {
node?.user?.let {
longName = it.longName
shortName = it.shortName
}
channelUrl = channels.getChannelUrl().toString()
config = localConfig
moduleConfig = localModuleConfig
if (node != null && localConfig.position.fixedPosition) {
fixedPosition = node.position
/** Flow representing the combined [DeviceProfile] protobuf. */
val deviceProfileFlow: Flow<DeviceProfile> =
combine(nodeDB.ourNodeInfo, channelSetFlow, localConfigFlow, moduleConfigFlow) {
node,
channels,
localConfig,
localModuleConfig,
->
deviceProfile {
node?.user?.let {
longName = it.longName
shortName = it.shortName
}
channelUrl = channels.getChannelUrl().toString()
config = localConfig
moduleConfig = localModuleConfig
if (node != null && localConfig.position.fixedPosition) {
fixedPosition = node.position
}
}
}
}
val clientNotification = serviceRepository.clientNotification
@ -199,7 +189,8 @@ class RadioConfigRepository @Inject constructor(
serviceRepository.clearClientNotification()
}
val errorMessage: StateFlow<String?> get() = serviceRepository.errorMessage
val errorMessage: StateFlow<String?>
get() = serviceRepository.errorMessage
fun setErrorMessage(text: String) {
serviceRepository.setErrorMessage(text)
@ -213,19 +204,18 @@ class RadioConfigRepository @Inject constructor(
serviceRepository.setStatusMessage(text)
}
val meshPacketFlow: SharedFlow<MeshPacket> get() = serviceRepository.meshPacketFlow
val meshPacketFlow: SharedFlow<MeshPacket>
get() = serviceRepository.meshPacketFlow
suspend fun emitMeshPacket(packet: MeshPacket) = coroutineScope {
serviceRepository.emitMeshPacket(packet)
}
suspend fun emitMeshPacket(packet: MeshPacket) = coroutineScope { serviceRepository.emitMeshPacket(packet) }
val serviceAction: Flow<ServiceAction> get() = serviceRepository.serviceAction
val serviceAction: Flow<ServiceAction>
get() = serviceRepository.serviceAction
suspend fun onServiceAction(action: ServiceAction) = coroutineScope {
serviceRepository.onServiceAction(action)
}
suspend fun onServiceAction(action: ServiceAction) = coroutineScope { serviceRepository.onServiceAction(action) }
val tracerouteResponse: StateFlow<String?> get() = serviceRepository.tracerouteResponse
val tracerouteResponse: StateFlow<String?>
get() = serviceRepository.tracerouteResponse
fun setTracerouteResponse(value: String?) {
serviceRepository.setTracerouteResponse(value)

View file

@ -135,7 +135,7 @@ constructor(
private var lastHeartbeatMillis = 0L
private fun keepAlive(now: Long) {
fun keepAlive(now: Long = System.currentTimeMillis()) {
if (now - lastHeartbeatMillis > HEARTBEAT_INTERVAL_MILLIS) {
info("Sending ToRadio heartbeat")
val heartbeat =

View file

@ -0,0 +1,117 @@
/*
* 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.service
import com.geeksville.mesh.CoroutineDispatchers
import com.geeksville.mesh.LocalOnlyProtos
import com.geeksville.mesh.android.BuildUtils.warn
import com.geeksville.mesh.repository.datastore.RadioConfigRepository
import com.geeksville.mesh.repository.radio.RadioInterfaceService
import com.geeksville.mesh.repository.radio.RadioServiceConnectionState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
@Singleton
class ConnectionRouter
@Inject
constructor(
private val radioInterface: RadioInterfaceService,
private val radioConfigRepository: RadioConfigRepository,
private val dispatchers: CoroutineDispatchers,
) {
private val _connectionState = MutableStateFlow(ConnectionState.DISCONNECTED)
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
private val routerJob = Job()
private val routerScope = CoroutineScope(dispatchers.io + routerJob)
private var sleepTimeout: Job? = null
private var localConfig: LocalOnlyProtos.LocalConfig = LocalOnlyProtos.LocalConfig.getDefaultInstance()
init {
// We need to keep our local radio config up to date
radioConfigRepository.localConfigFlow.onEach { localConfig = it }.launchIn(routerScope)
}
fun start() {
// This is where we will start listening to the radio interface
radioInterface.connectionState.onEach(::onRadioConnectionState).launchIn(routerScope)
}
fun stop() {
routerJob.cancel()
}
fun setDeviceAddress(address: String?): Boolean {
_connectionState.value = ConnectionState.CONNECTING
return radioInterface.setDeviceAddress(address)
}
private fun onRadioConnectionState(state: RadioServiceConnectionState) {
// sleep now disabled by default on ESP32, permanent is true unless light sleep enabled
val isRouter = localConfig.device.role == com.geeksville.mesh.ConfigProtos.Config.DeviceConfig.Role.ROUTER
val lsEnabled = localConfig.power.isPowerSaving || isRouter
val connected = state.isConnected
val permanent = state.isPermanent || !lsEnabled
onConnectionChanged(
when {
connected -> ConnectionState.CONNECTED
permanent -> ConnectionState.DISCONNECTED
else -> ConnectionState.DEVICE_SLEEP
},
)
}
private fun onConnectionChanged(c: ConnectionState) {
// Cancel any existing timeouts
sleepTimeout?.cancel()
sleepTimeout = null
_connectionState.value = c
if (c == ConnectionState.DEVICE_SLEEP) {
// Have our timeout fire in the appropriate number of seconds
sleepTimeout =
routerScope.launch {
try {
// If we have a valid timeout, wait that long (+30 seconds) otherwise, just wait 30 seconds
val timeout = (localConfig.power?.lsSecs ?: 0).milliseconds + 30.seconds
// Log.d(TAG, "Waiting for sleeping device, timeout=$timeout secs")
delay(timeout)
// Log.w(TAG, "Device timeout out, setting disconnected")
onConnectionChanged(ConnectionState.DISCONNECTED)
} catch (ex: CancellationException) {
warn("Sleep timeout cancelled: ${ex.message}")
}
}
}
}
}

View file

@ -0,0 +1,38 @@
/*
* 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.service
enum class ConnectionState {
/** We are disconnected from the device, and we should be trying to reconnect. */
DISCONNECTED,
/** We are currently attempting to connect to the device. */
CONNECTING,
/** We are connected to the device and communicating normally. */
CONNECTED,
/** The device is in a light sleep state, and we are waiting for it to wake up and reconnect to us. */
DEVICE_SLEEP,
;
fun isConnected() = this == CONNECTED
fun isDisconnected() = this == DISCONNECTED
}

File diff suppressed because it is too large Load diff

View file

@ -27,20 +27,11 @@ import com.geeksville.mesh.NodeInfo
class MeshServiceBroadcasts(
private val context: Context,
private val clientPackages: MutableMap<String, String>,
private val getConnectionState: () -> MeshService.ConnectionState
private val getConnectionState: () -> ConnectionState,
) {
/**
* Broadcast some received data
* Payload will be a DataPacket
*/
/** Broadcast some received data Payload will be a DataPacket */
fun broadcastReceivedData(payload: DataPacket) {
explicitBroadcast(
Intent(MeshService.actionReceived(payload.dataType)).putExtra(
EXTRA_PAYLOAD,
payload
)
)
explicitBroadcast(Intent(MeshService.actionReceived(payload.dataType)).putExtra(EXTRA_PAYLOAD, payload))
}
fun broadcastNodeChange(info: NodeInfo) {
@ -57,22 +48,19 @@ class MeshServiceBroadcasts(
} else {
// Do not log, contains PII possibly
// MeshService.debug("Broadcasting message status $p")
val intent = Intent(MeshService.ACTION_MESSAGE_STATUS).apply {
putExtra(EXTRA_PACKET_ID, id)
putExtra(EXTRA_STATUS, status as Parcelable)
}
val intent =
Intent(MeshService.ACTION_MESSAGE_STATUS).apply {
putExtra(EXTRA_PACKET_ID, id)
putExtra(EXTRA_STATUS, status as Parcelable)
}
explicitBroadcast(intent)
}
}
/**
* Broadcast our current connection status
*/
/** Broadcast our current connection status */
fun broadcastConnection() {
val intent = Intent(MeshService.ACTION_MESH_CONNECTED).putExtra(
EXTRA_CONNECTED,
getConnectionState().toString()
)
val intent =
Intent(MeshService.ACTION_MESH_CONNECTED).putExtra(EXTRA_CONNECTED, getConnectionState().toString())
explicitBroadcast(intent)
}
@ -86,7 +74,9 @@ class MeshServiceBroadcasts(
* because it implies we have assembled a valid node db.
*/
private fun explicitBroadcast(intent: Intent) {
context.sendBroadcast(intent) // We also do a regular (not explicit broadcast) so any context-registered rceivers will work
context.sendBroadcast(
intent,
) // We also do a regular (not explicit broadcast) so any context-registered rceivers will work
clientPackages.forEach {
intent.setClassName(it.value, it.key)
context.sendBroadcast(intent)

View file

@ -28,13 +28,8 @@ import androidx.work.WorkerParameters
import com.geeksville.mesh.BuildConfig
import java.util.concurrent.TimeUnit
/**
* Helper that calls MeshService.startService()
*/
class ServiceStarter(
appContext: Context,
workerParams: WorkerParameters
) : Worker(appContext, workerParams) {
/** Helper that calls MeshService.startService() */
class ServiceStarter(appContext: Context, workerParams: WorkerParameters) : Worker(appContext, workerParams) {
override fun doWork(): Result = try {
MeshService.startService(this.applicationContext)
@ -48,23 +43,23 @@ class ServiceStarter(
}
/**
* Just after boot the android OS is super busy, so if we call startForegroundService then, our
* thread might be stalled long enough to expose this Google/Samsung bug:
* https://issuetracker.google.com/issues/76112072#comment56
* Just after boot the android OS is super busy, so if we call startForegroundService then, our thread might be stalled
* long enough to expose this Google/Samsung bug: https://issuetracker.google.com/issues/76112072#comment56
*/
fun MeshService.Companion.startServiceLater(context: Context) {
// No point in even starting the service if the user doesn't have a device bonded
info("Received boot complete announcement, starting mesh service in two minutes")
val delayRequest = OneTimeWorkRequestBuilder<ServiceStarter>()
.setInitialDelay(2, TimeUnit.MINUTES)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 2, TimeUnit.MINUTES)
.addTag("startLater")
.build()
val delayRequest =
OneTimeWorkRequestBuilder<ServiceStarter>()
.setInitialDelay(2, TimeUnit.MINUTES)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 2, TimeUnit.MINUTES)
.addTag("startLater")
.build()
WorkManager.getInstance(context).enqueue(delayRequest)
}
/// Helper function to start running our service
// / Helper function to start running our service
fun MeshService.Companion.startService(context: Context) {
// Bind to our service using the same mechanism an external client would use (for testing coverage)
// The following would work for us, but not external users:
@ -76,18 +71,14 @@ fun MeshService.Companion.startService(context: Context) {
// to Signal or whatever.
info("Trying to start service debug=${BuildConfig.DEBUG}")
val intent = createIntent()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
try {
context.startForegroundService(intent)
} catch (ex: ForegroundServiceStartNotAllowedException) {
errormsg("Unable to start service: ${ex.message}")
}
} else {
val intent = createIntent(context)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
try {
context.startForegroundService(intent)
} catch (ex: ForegroundServiceStartNotAllowedException) {
errormsg("Unable to start service: ${ex.message}")
}
} else {
context.startService(intent)
context.startForegroundService(intent)
}
}

View file

@ -30,9 +30,7 @@ import kotlinx.coroutines.flow.receiveAsFlow
import javax.inject.Inject
import javax.inject.Singleton
/**
* Repository class for managing the [IMeshService] instance and connection state
*/
/** Repository class for managing the [IMeshService] instance and connection state */
@Suppress("TooManyFunctions")
@Singleton
class ServiceRepository @Inject constructor() : Logging {
@ -44,15 +42,18 @@ class ServiceRepository @Inject constructor() : Logging {
}
// Connection state to our radio device
private val _connectionState = MutableStateFlow(MeshService.ConnectionState.DISCONNECTED)
val connectionState: StateFlow<MeshService.ConnectionState> get() = _connectionState
private val _connectionState = MutableStateFlow(ConnectionState.DISCONNECTED)
val connectionState: StateFlow<ConnectionState>
get() = _connectionState
fun setConnectionState(connectionState: MeshService.ConnectionState) {
fun setConnectionState(connectionState: ConnectionState) {
_connectionState.value = connectionState
}
private val _clientNotification = MutableStateFlow<MeshProtos.ClientNotification?>(null)
val clientNotification: StateFlow<MeshProtos.ClientNotification?> get() = _clientNotification
val clientNotification: StateFlow<MeshProtos.ClientNotification?>
get() = _clientNotification
fun setClientNotification(notification: MeshProtos.ClientNotification?) {
errormsg(notification?.message.orEmpty())
@ -64,7 +65,8 @@ class ServiceRepository @Inject constructor() : Logging {
}
private val _errorMessage = MutableStateFlow<String?>(null)
val errorMessage: StateFlow<String?> get() = _errorMessage
val errorMessage: StateFlow<String?>
get() = _errorMessage
fun setErrorMessage(text: String) {
errormsg(text)
@ -76,23 +78,26 @@ class ServiceRepository @Inject constructor() : Logging {
}
private val _statusMessage = MutableStateFlow<String?>(null)
val statusMessage: StateFlow<String?> get() = _statusMessage
val statusMessage: StateFlow<String?>
get() = _statusMessage
fun setStatusMessage(text: String) {
if (connectionState.value != MeshService.ConnectionState.CONNECTED) {
if (connectionState.value != ConnectionState.CONNECTED) {
_statusMessage.value = text
}
}
private val _meshPacketFlow = MutableSharedFlow<MeshPacket>()
val meshPacketFlow: SharedFlow<MeshPacket> get() = _meshPacketFlow
val meshPacketFlow: SharedFlow<MeshPacket>
get() = _meshPacketFlow
suspend fun emitMeshPacket(packet: MeshPacket) {
_meshPacketFlow.emit(packet)
}
private val _tracerouteResponse = MutableStateFlow<String?>(null)
val tracerouteResponse: StateFlow<String?> get() = _tracerouteResponse
val tracerouteResponse: StateFlow<String?>
get() = _tracerouteResponse
fun setTracerouteResponse(value: String?) {
_tracerouteResponse.value = value

View file

@ -107,6 +107,7 @@ import com.geeksville.mesh.navigation.RadioConfigRoutes
import com.geeksville.mesh.navigation.Route
import com.geeksville.mesh.navigation.showLongNameTitle
import com.geeksville.mesh.repository.radio.MeshActivity
import com.geeksville.mesh.service.ConnectionState
import com.geeksville.mesh.service.MeshService
import com.geeksville.mesh.ui.TopLevelDestination.Companion.isTopLevel
import com.geeksville.mesh.ui.common.components.MultipleChoiceAlertDialog
@ -166,7 +167,7 @@ fun MainScreen(
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val notificationPermissionState = rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS)
LaunchedEffect(connectionState, notificationPermissionState) {
if (connectionState.isConnected() && !notificationPermissionState.status.isGranted) {
if (connectionState == ConnectionState.CONNECTED && !notificationPermissionState.status.isGranted) {
notificationPermissionState.launchPermissionRequest()
}
}
@ -174,7 +175,7 @@ fun MainScreen(
AddNavigationTracking(navController)
if (connectionState.isConnected()) {
if (connectionState == ConnectionState.CONNECTED) {
requestChannelSet?.let { newChannelSet -> ScannedQrCodeDialog(uIViewModel, newChannelSet) }
}
@ -387,7 +388,7 @@ fun MainScreen(
}
@Composable
private fun TopLevelNavIcon(destination: TopLevelDestination, connectionState: MeshService.ConnectionState) {
private fun TopLevelNavIcon(destination: TopLevelDestination, connectionState: ConnectionState) {
val iconTint =
when {
destination == TopLevelDestination.Connections -> connectionState.getConnectionColor()
@ -412,6 +413,8 @@ private fun VersionChecks(viewModel: UIViewModel) {
val myNodeInfo by viewModel.myNodeInfo.collectAsStateWithLifecycle()
val context = LocalContext.current
val myFirmwareVersion = myNodeInfo?.firmwareVersion
val firmwareEdition by viewModel.firmwareEdition.collectAsStateWithLifecycle(null)
val currentFirmwareVersion by viewModel.firmwareVersion.collectAsStateWithLifecycle(null)
@ -421,7 +424,7 @@ private fun VersionChecks(viewModel: UIViewModel) {
val latestStableFirmwareRelease by
viewModel.latestStableFirmwareRelease.collectAsStateWithLifecycle(DeviceVersion("2.6.4"))
LaunchedEffect(connectionState, firmwareEdition) {
if (connectionState == MeshService.ConnectionState.CONNECTED) {
if (connectionState == ConnectionState.CONNECTED) {
firmwareEdition?.let { edition ->
debug("FirmwareEdition: ${edition.name}")
when (edition) {
@ -438,7 +441,7 @@ private fun VersionChecks(viewModel: UIViewModel) {
}
LaunchedEffect(connectionState, currentFirmwareVersion, currentDeviceHardware) {
if (connectionState == MeshService.ConnectionState.CONNECTED) {
if (connectionState == ConnectionState.CONNECTED) {
if (currentDeviceHardware != null && currentFirmwareVersion != null) {
setAttributes(currentFirmwareVersion!!, currentDeviceHardware!!)
}
@ -447,10 +450,9 @@ private fun VersionChecks(viewModel: UIViewModel) {
// Check if the device is running an old app version or firmware version
LaunchedEffect(connectionState, myNodeInfo) {
if (connectionState == MeshService.ConnectionState.CONNECTED) {
if (connectionState == ConnectionState.CONNECTED) {
myNodeInfo?.let { info ->
val isOld = info.minAppVersion > BuildConfig.VERSION_CODE
val curVer = DeviceVersion(info.firmwareVersion ?: "0.0.0")
if (isOld) {
viewModel.showAlert(
context.getString(R.string.app_too_old),
@ -461,22 +463,28 @@ private fun VersionChecks(viewModel: UIViewModel) {
MeshService.changeDeviceAddress(context, service, "n")
},
)
} else if (curVer < MeshService.absoluteMinDeviceVersion) {
val title = context.getString(R.string.firmware_too_old)
val message = context.getString(R.string.firmware_old)
viewModel.showAlert(
title = title,
html = message,
dismissable = false,
onConfirm = {
val service = viewModel.meshService ?: return@showAlert
MeshService.changeDeviceAddress(context, service, "n")
},
)
} else if (curVer < MeshService.minDeviceVersion) {
val title = context.getString(R.string.should_update_firmware)
val message = context.getString(R.string.should_update, latestStableFirmwareRelease.asString)
viewModel.showAlert(title = title, message = message, dismissable = false, onConfirm = {})
} else {
myFirmwareVersion?.let {
val curVer = DeviceVersion(it)
if (curVer < MeshService.absoluteMinDeviceVersion) {
val title = context.getString(R.string.firmware_too_old)
val message = context.getString(R.string.firmware_old)
viewModel.showAlert(
title = title,
html = message,
dismissable = false,
onConfirm = {
val service = viewModel.meshService ?: return@showAlert
MeshService.changeDeviceAddress(context, service, "n")
},
)
} else if (curVer < MeshService.minDeviceVersion) {
val title = context.getString(R.string.should_update_firmware)
val message =
context.getString(R.string.should_update, latestStableFirmwareRelease.asString)
viewModel.showAlert(title = title, message = message, dismissable = false, onConfirm = {})
}
}
}
}
}
@ -631,21 +639,24 @@ private fun MainMenuActions(isManaged: Boolean, onAction: (MainMenuAction) -> Un
}
@Composable
private fun MeshService.ConnectionState.getConnectionColor(): Color = when (this) {
MeshService.ConnectionState.CONNECTED -> colorScheme.StatusGreen
MeshService.ConnectionState.DEVICE_SLEEP -> colorScheme.StatusYellow
MeshService.ConnectionState.DISCONNECTED -> colorScheme.StatusRed
private fun ConnectionState.getConnectionColor(): Color = when (this) {
ConnectionState.CONNECTED -> colorScheme.StatusGreen
ConnectionState.DEVICE_SLEEP -> colorScheme.StatusYellow
ConnectionState.DISCONNECTED -> colorScheme.StatusRed
ConnectionState.CONNECTING -> colorScheme.StatusYellow
}
private fun MeshService.ConnectionState.getConnectionIcon(): ImageVector = when (this) {
MeshService.ConnectionState.CONNECTED -> Icons.TwoTone.CloudDone
MeshService.ConnectionState.DEVICE_SLEEP -> Icons.TwoTone.CloudUpload
MeshService.ConnectionState.DISCONNECTED -> Icons.TwoTone.CloudOff
private fun ConnectionState.getConnectionIcon(): ImageVector = when (this) {
ConnectionState.CONNECTED -> Icons.TwoTone.CloudDone
ConnectionState.DEVICE_SLEEP -> Icons.TwoTone.CloudUpload
ConnectionState.DISCONNECTED -> Icons.TwoTone.CloudOff
ConnectionState.CONNECTING -> Icons.TwoTone.CloudUpload
}
@Composable
private fun MeshService.ConnectionState.getTooltipString(): String = when (this) {
MeshService.ConnectionState.CONNECTED -> stringResource(R.string.connected)
MeshService.ConnectionState.DEVICE_SLEEP -> stringResource(R.string.device_sleeping)
MeshService.ConnectionState.DISCONNECTED -> stringResource(R.string.disconnected)
private fun ConnectionState.getTooltipString(): String = when (this) {
ConnectionState.CONNECTED -> stringResource(R.string.connected)
ConnectionState.DEVICE_SLEEP -> stringResource(R.string.device_sleeping)
ConnectionState.DISCONNECTED -> stringResource(R.string.disconnected)
ConnectionState.CONNECTING -> stringResource(R.string.connecting_to_device)
}

View file

@ -96,7 +96,7 @@ import com.geeksville.mesh.navigation.ConfigRoute
import com.geeksville.mesh.navigation.RadioConfigRoutes
import com.geeksville.mesh.navigation.Route
import com.geeksville.mesh.navigation.getNavRouteFrom
import com.geeksville.mesh.service.MeshService
import com.geeksville.mesh.service.ConnectionState
import com.geeksville.mesh.ui.connections.components.BLEDevices
import com.geeksville.mesh.ui.connections.components.NetworkDevices
import com.geeksville.mesh.ui.connections.components.UsbDevices
@ -138,7 +138,7 @@ fun ConnectionsScreen(
val currentRegion = config.lora.region
val scrollState = rememberScrollState()
val scanStatusText by scanModel.errorText.observeAsState("")
val connectionState by uiViewModel.connectionState.collectAsState(MeshService.ConnectionState.DISCONNECTED)
val connectionState by uiViewModel.connectionState.collectAsState(ConnectionState.DISCONNECTED)
val scanning by scanModel.spinner.collectAsStateWithLifecycle(false)
val receivingLocationUpdates by uiViewModel.receivingLocationUpdates.collectAsState(false)
val context = LocalContext.current
@ -147,8 +147,7 @@ fun ConnectionsScreen(
val selectedDevice by scanModel.selectedNotNullFlow.collectAsStateWithLifecycle()
val bluetoothEnabled by bluetoothViewModel.enabled.collectAsStateWithLifecycle(false)
val regionUnset =
currentRegion == ConfigProtos.Config.LoRaConfig.RegionCode.UNSET &&
connectionState == MeshService.ConnectionState.CONNECTED
currentRegion == ConfigProtos.Config.LoRaConfig.RegionCode.UNSET && connectionState == ConnectionState.CONNECTED
val bleDevices by scanModel.bleDevicesForUi.collectAsStateWithLifecycle()
val discoveredTcpDevices by scanModel.discoveredTcpDevicesForUi.collectAsStateWithLifecycle()
@ -209,12 +208,13 @@ fun ConnectionsScreen(
LaunchedEffect(connectionState, regionUnset) {
when (connectionState) {
MeshService.ConnectionState.CONNECTED -> {
ConnectionState.CONNECTED -> {
if (regionUnset) R.string.must_set_region else R.string.connected_to
}
MeshService.ConnectionState.DISCONNECTED -> R.string.not_connected
MeshService.ConnectionState.DEVICE_SLEEP -> R.string.connected_sleeping
ConnectionState.DISCONNECTED -> R.string.not_connected
ConnectionState.DEVICE_SLEEP -> R.string.connected_sleeping
ConnectionState.CONNECTING -> R.string.connecting_to_device
}.let {
val firmwareString = info?.firmwareString ?: context.getString(R.string.unknown)
scanModel.setErrorText(context.getString(it, firmwareString))

View file

@ -44,7 +44,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.R
import com.geeksville.mesh.model.BTScanModel
import com.geeksville.mesh.model.DeviceListEntry
import com.geeksville.mesh.service.MeshService
import com.geeksville.mesh.service.ConnectionState
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.rememberMultiplePermissionsState
@ -61,7 +61,7 @@ import com.google.accompanist.permissions.rememberMultiplePermissionsState
@Suppress("LongMethod")
@Composable
fun BLEDevices(
connectionState: MeshService.ConnectionState,
connectionState: ConnectionState,
btDevices: List<DeviceListEntry>,
selectedDevice: String,
scanModel: BTScanModel,

View file

@ -38,14 +38,14 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.geeksville.mesh.R
import com.geeksville.mesh.model.DeviceListEntry
import com.geeksville.mesh.service.MeshService
import com.geeksville.mesh.service.ConnectionState
import com.geeksville.mesh.ui.common.theme.StatusColors.StatusGreen
import com.geeksville.mesh.ui.common.theme.StatusColors.StatusRed
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun DeviceListItem(
connectionState: MeshService.ConnectionState,
connectionState: ConnectionState,
device: DeviceListEntry,
selected: Boolean,
onSelect: () -> Unit,
@ -88,8 +88,8 @@ fun DeviceListItem(
leadingIconColor = MaterialTheme.colorScheme.onPrimaryContainer,
trailingIconColor =
when (connectionState) {
MeshService.ConnectionState.CONNECTED -> MaterialTheme.colorScheme.StatusGreen
MeshService.ConnectionState.DISCONNECTED -> MaterialTheme.colorScheme.StatusRed
ConnectionState.CONNECTED -> MaterialTheme.colorScheme.StatusGreen
ConnectionState.DISCONNECTED -> MaterialTheme.colorScheme.StatusRed
else ->
MaterialTheme.colorScheme
.onPrimaryContainer // Fallback for other states (e.g. connecting)
@ -125,7 +125,7 @@ fun DeviceListItem(
trailingContent = {
if (device is DeviceListEntry.Disconnect) {
Icon(imageVector = Icons.Default.CloudOff, contentDescription = stringResource(R.string.disconnect))
} else if (connectionState == MeshService.ConnectionState.CONNECTED) {
} else if (connectionState == ConnectionState.CONNECTED) {
Icon(imageVector = Icons.Default.CloudDone, contentDescription = stringResource(R.string.connected))
} else {
Icon(

View file

@ -54,14 +54,14 @@ import com.geeksville.mesh.R
import com.geeksville.mesh.model.BTScanModel
import com.geeksville.mesh.model.DeviceListEntry
import com.geeksville.mesh.repository.network.NetworkRepository
import com.geeksville.mesh.service.MeshService
import com.geeksville.mesh.service.ConnectionState
import com.geeksville.mesh.ui.connections.isIPAddress
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalFoundationApi::class)
@Suppress("MagicNumber", "LongMethod")
@Composable
fun NetworkDevices(
connectionState: MeshService.ConnectionState,
connectionState: ConnectionState,
discoveredNetworkDevices: List<DeviceListEntry>,
recentNetworkDevices: List<DeviceListEntry>,
selectedDevice: String,

View file

@ -34,11 +34,11 @@ import androidx.compose.ui.unit.dp
import com.geeksville.mesh.R
import com.geeksville.mesh.model.BTScanModel
import com.geeksville.mesh.model.DeviceListEntry
import com.geeksville.mesh.service.MeshService
import com.geeksville.mesh.service.ConnectionState
@Composable
fun UsbDevices(
connectionState: MeshService.ConnectionState,
connectionState: ConnectionState,
usbDevices: List<DeviceListEntry>,
selectedDevice: String,
scanModel: BTScanModel,

View file

@ -45,6 +45,7 @@ import com.geeksville.mesh.DataPacket
import com.geeksville.mesh.model.DeviceVersion
import com.geeksville.mesh.model.Node
import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.service.ConnectionState
import com.geeksville.mesh.ui.common.components.rememberTimeTickWithLifecycle
import com.geeksville.mesh.ui.node.components.NodeFilterTextField
import com.geeksville.mesh.ui.node.components.NodeItem
@ -75,32 +76,19 @@ fun NodeScreen(
var showSharedContact: Node? by remember { mutableStateOf(null) }
if (showSharedContact != null) {
SharedContactDialog(
contact = showSharedContact,
onDismiss = { showSharedContact = null }
)
SharedContactDialog(contact = showSharedContact, onDismiss = { showSharedContact = null })
}
val isScrollInProgress by remember {
derivedStateOf { listState.isScrollInProgress }
}
val isScrollInProgress by remember { derivedStateOf { listState.isScrollInProgress } }
Box(
modifier = Modifier
.fillMaxSize()
) {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
) {
Box(modifier = Modifier.fillMaxSize()) {
LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
stickyHeader {
val animatedAlpha by animateFloatAsState(
targetValue = if (!isScrollInProgress) 1.0f else 0f,
label = "alpha"
)
val animatedAlpha by
animateFloatAsState(targetValue = if (!isScrollInProgress) 1.0f else 0f, label = "alpha")
NodeFilterTextField(
modifier = Modifier
.fillMaxWidth()
modifier =
Modifier.fillMaxWidth()
.graphicsLayer(alpha = animatedAlpha)
.background(MaterialTheme.colorScheme.surfaceDim)
.padding(8.dp),
@ -137,8 +125,7 @@ fun NodeScreen(
is NodeMenuAction.Favorite -> model.favoriteNode(node)
is NodeMenuAction.DirectMessage -> {
val hasPKC = model.ourNodeInfo.value?.hasPKC == true && node.hasPKC
val channel =
if (hasPKC) DataPacket.PKC_CHANNEL_INDEX else node.channel
val channel = if (hasPKC) DataPacket.PKC_CHANNEL_INDEX else node.channel
navigateToMessages("$channel${node.user.id}")
}
@ -161,19 +148,10 @@ fun NodeScreen(
AnimatedVisibility(
modifier = Modifier.align(Alignment.BottomEnd),
visible = !isScrollInProgress &&
connectionState.isConnected() &&
shareCapable
visible = !isScrollInProgress && connectionState == ConnectionState.CONNECTED && shareCapable,
) {
@Suppress("NewApi")
(
AddContactFAB(
model = model,
onSharedContactImport = { contact ->
model.addSharedContact(contact)
}
)
)
(AddContactFAB(model = model, onSharedContactImport = { contact -> model.addSharedContact(contact) }))
}
}
}

View file

@ -58,7 +58,7 @@ import com.geeksville.mesh.navigation.ModuleRoute
import com.geeksville.mesh.navigation.RadioConfigRoutes
import com.geeksville.mesh.repository.datastore.RadioConfigRepository
import com.geeksville.mesh.repository.location.LocationRepository
import com.geeksville.mesh.service.MeshService.ConnectionState
import com.geeksville.mesh.service.ConnectionState
import com.geeksville.mesh.util.UiText
import com.google.protobuf.MessageLite
import dagger.hilt.android.lifecycle.HiltViewModel
@ -145,7 +145,7 @@ constructor(
combine(radioConfigRepository.connectionState, radioConfigState) { connState, configState ->
_radioConfigState.update { it.copy(connected = connState == ConnectionState.CONNECTED) }
if (connState.isDisconnected() && configState.responseState.isWaiting()) {
if (connState == ConnectionState.DISCONNECTED && configState.responseState.isWaiting()) {
sendError(R.string.disconnected)
}
}

View file

@ -101,7 +101,7 @@ import com.geeksville.mesh.model.toChannelSet
import com.geeksville.mesh.navigation.ConfigRoute
import com.geeksville.mesh.navigation.Route
import com.geeksville.mesh.navigation.getNavRouteFrom
import com.geeksville.mesh.service.MeshService
import com.geeksville.mesh.service.ConnectionState
import com.geeksville.mesh.ui.common.components.AdaptiveTwoPane
import com.geeksville.mesh.ui.common.components.PreferenceFooter
import com.geeksville.mesh.ui.radioconfig.RadioConfigViewModel
@ -131,7 +131,7 @@ fun ChannelScreen(
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
val radioConfigState by radioConfigViewModel.radioConfigState.collectAsStateWithLifecycle()
val enabled = connectionState == MeshService.ConnectionState.CONNECTED && !viewModel.isManaged
val enabled = connectionState == ConnectionState.CONNECTED && !viewModel.isManaged
val channels by viewModel.channels.collectAsStateWithLifecycle()
var channelSet by remember(channels) { mutableStateOf(channels) }