mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
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:
parent
4548a3ec7b
commit
4a7e3e35e0
22 changed files with 1263 additions and 1387 deletions
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
|
|
|
|||
|
|
@ -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>)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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?>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@
|
|||
<string name="new_node_seen">New Node Seen: %s</string>
|
||||
<string name="disconnected">Disconnected</string>
|
||||
<string name="device_sleeping">Device sleeping</string>
|
||||
<string name="connected_count">Connected: %1$s online</string>
|
||||
<string name="connected_count">Connected: %1$d online</string>
|
||||
<string name="ip_address">IP Address:</string>
|
||||
<string name="ip_port">Port:</string>
|
||||
<string name="connected">Connected to radio</string>
|
||||
|
|
@ -762,4 +762,5 @@
|
|||
<string name="grant_permissions_and_scan">Grant Permissions and Scan</string>
|
||||
<string name="nodes_queued_for_deletion">%d nodes queued for deletion:</string>
|
||||
<string name="clean_node_database_description">Caution: This removes nodes from in-app and on-device databases.\nSelections are additive.</string>
|
||||
<string name="connecting_to_device">Connecting to device</string>
|
||||
</resources>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue