From 4a7e3e35e0ff27f29a8de74a706a44c2b51023a9 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Fri, 8 Aug 2025 16:59:54 -0500 Subject: [PATCH] feat(service)!: refactor configuration, nodedb, and connection states (#2661) Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- .../java/com/geeksville/mesh/MainActivity.kt | 2 +- .../mesh/database/NodeRepository.kt | 6 +- .../mesh/database/dao/NodeInfoDao.kt | 118 +- .../java/com/geeksville/mesh/model/UIState.kt | 6 +- .../datastore/RadioConfigRepository.kt | 142 +- .../repository/radio/RadioInterfaceService.kt | 2 +- .../mesh/service/ConnectionRouter.kt | 117 + .../mesh/service/ConnectionState.kt | 38 + .../geeksville/mesh/service/MeshService.kt | 1935 +++++++---------- .../mesh/service/MeshServiceBroadcasts.kt | 38 +- .../mesh/service/MeshServiceStarter.kt | 43 +- .../mesh/service/ServiceRepository.kt | 29 +- .../main/java/com/geeksville/mesh/ui/Main.kt | 81 +- .../mesh/ui/connections/Connections.kt | 14 +- .../ui/connections/components/BLEDevices.kt | 4 +- .../connections/components/DeviceListItem.kt | 10 +- .../connections/components/NetworkDevices.kt | 4 +- .../ui/connections/components/UsbDevices.kt | 4 +- .../com/geeksville/mesh/ui/node/NodeScreen.kt | 46 +- .../ui/radioconfig/RadioConfigViewModel.kt | 4 +- .../com/geeksville/mesh/ui/sharing/Channel.kt | 4 +- app/src/main/res/values/strings.xml | 3 +- 22 files changed, 1263 insertions(+), 1387 deletions(-) create mode 100644 app/src/main/java/com/geeksville/mesh/service/ConnectionRouter.kt create mode 100644 app/src/main/java/com/geeksville/mesh/service/ConnectionState.kt diff --git a/app/src/main/java/com/geeksville/mesh/MainActivity.kt b/app/src/main/java/com/geeksville/mesh/MainActivity.kt index 3a96b0908..d8e859a77 100644 --- a/app/src/main/java/com/geeksville/mesh/MainActivity.kt +++ b/app/src/main/java/com/geeksville/mesh/MainActivity.kt @@ -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() { diff --git a/app/src/main/java/com/geeksville/mesh/database/NodeRepository.kt b/app/src/main/java/com/geeksville/mesh/database/NodeRepository.kt index 220f6648b..44dd0c766 100644 --- a/app/src/main/java/com/geeksville/mesh/database/NodeRepository.kt +++ b/app/src/main/java/com/geeksville/mesh/database/NodeRepository.kt @@ -118,10 +118,10 @@ constructor( suspend fun upsert(node: NodeEntity) = withContext(dispatchers.io) { nodeInfoDao.upsert(node) } - suspend fun installNodeDB(mi: MyNodeEntity, nodes: List) = 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() } diff --git a/app/src/main/java/com/geeksville/mesh/database/dao/NodeInfoDao.kt b/app/src/main/java/com/geeksville/mesh/database/dao/NodeInfoDao.kt index 12826cb7d..494d04524 100644 --- a/app/src/main/java/com/geeksville/mesh/database/dao/NodeInfoDao.kt +++ b/app/src/main/java/com/geeksville/mesh/database/dao/NodeInfoDao.kt @@ -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> - @Transaction - fun upsert(node: NodeEntity) { - getVerifiedNodeForUpsert(node)?.let { doUpsert(it) } - } - - @Suppress("NestedBlockDepth") - @Transaction - fun putAll(nodes: List) { - 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) + fun putAll(nodes: List) } diff --git a/app/src/main/java/com/geeksville/mesh/model/UIState.kt b/app/src/main/java/com/geeksville/mesh/model/UIState.kt index 1a0a40eb0..afa680f8e 100644 --- a/app/src/main/java/com/geeksville/mesh/model/UIState.kt +++ b/app/src/main/java/com/geeksville/mesh/model/UIState.kt @@ -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(null) val requestChannelSet: StateFlow diff --git a/app/src/main/java/com/geeksville/mesh/repository/datastore/RadioConfigRepository.kt b/app/src/main/java/com/geeksville/mesh/repository/datastore/RadioConfigRepository.kt index 3382d1c43..7d9ca1ece 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/datastore/RadioConfigRepository.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/datastore/RadioConfigRepository.kt @@ -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 get() = nodeDB.myId + /** Flow representing the unique userId of our node. */ + val myId: StateFlow + get() = nodeDB.myId - /** - * Flow representing the [MyNodeEntity] database. - */ - val myNodeInfo: StateFlow get() = nodeDB.myNodeInfo + /** Flow representing the [MyNodeEntity] database. */ + val myNodeInfo: StateFlow + get() = nodeDB.myNodeInfo - /** - * Flow representing the [Node] database. - */ - val nodeDBbyNum: StateFlow> get() = nodeDB.nodeDBbyNum + /** Flow representing the [Node] database. */ + val nodeDBbyNum: StateFlow> + 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) { - 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 = 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) { 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 = 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 = 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 = 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 = + 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 get() = serviceRepository.errorMessage + val errorMessage: StateFlow + get() = serviceRepository.errorMessage fun setErrorMessage(text: String) { serviceRepository.setErrorMessage(text) @@ -213,19 +204,18 @@ class RadioConfigRepository @Inject constructor( serviceRepository.setStatusMessage(text) } - val meshPacketFlow: SharedFlow get() = serviceRepository.meshPacketFlow + val meshPacketFlow: SharedFlow + get() = serviceRepository.meshPacketFlow - suspend fun emitMeshPacket(packet: MeshPacket) = coroutineScope { - serviceRepository.emitMeshPacket(packet) - } + suspend fun emitMeshPacket(packet: MeshPacket) = coroutineScope { serviceRepository.emitMeshPacket(packet) } - val serviceAction: Flow get() = serviceRepository.serviceAction + val serviceAction: Flow + get() = serviceRepository.serviceAction - suspend fun onServiceAction(action: ServiceAction) = coroutineScope { - serviceRepository.onServiceAction(action) - } + suspend fun onServiceAction(action: ServiceAction) = coroutineScope { serviceRepository.onServiceAction(action) } - val tracerouteResponse: StateFlow get() = serviceRepository.tracerouteResponse + val tracerouteResponse: StateFlow + get() = serviceRepository.tracerouteResponse fun setTracerouteResponse(value: String?) { serviceRepository.setTracerouteResponse(value) diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt index 4c75379d6..f0bbdcb81 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt @@ -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 = diff --git a/app/src/main/java/com/geeksville/mesh/service/ConnectionRouter.kt b/app/src/main/java/com/geeksville/mesh/service/ConnectionRouter.kt new file mode 100644 index 000000000..b7d9b7d92 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/service/ConnectionRouter.kt @@ -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 . + */ + +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.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}") + } + } + } + } +} diff --git a/app/src/main/java/com/geeksville/mesh/service/ConnectionState.kt b/app/src/main/java/com/geeksville/mesh/service/ConnectionState.kt new file mode 100644 index 000000000..2643e898b --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/service/ConnectionState.kt @@ -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 . + */ + +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 +} diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt index e1722aa15..1ae64375e 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt @@ -17,6 +17,7 @@ package com.geeksville.mesh.service +import android.Manifest import android.annotation.SuppressLint import android.app.Service import android.content.Context @@ -26,6 +27,7 @@ import android.content.pm.ServiceInfo import android.os.Build import android.os.IBinder import android.os.RemoteException +import androidx.annotation.RequiresPermission import androidx.core.app.ServiceCompat import androidx.core.content.edit import androidx.core.location.LocationCompat @@ -78,7 +80,6 @@ import com.geeksville.mesh.repository.datastore.RadioConfigRepository import com.geeksville.mesh.repository.location.LocationRepository import com.geeksville.mesh.repository.network.MQTTRepository import com.geeksville.mesh.repository.radio.RadioInterfaceService -import com.geeksville.mesh.repository.radio.RadioServiceConnectionState import com.geeksville.mesh.telemetry import com.geeksville.mesh.user import com.geeksville.mesh.util.anonymize @@ -91,7 +92,6 @@ import com.google.protobuf.InvalidProtocolBufferException import dagger.Lazy import dagger.hilt.android.AndroidEntryPoint import java8.util.concurrent.CompletableFuture -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -102,6 +102,8 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull import java.util.Random import java.util.UUID @@ -109,6 +111,7 @@ import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentLinkedQueue import java.util.concurrent.TimeUnit import java.util.concurrent.TimeoutException +import java.util.concurrent.atomic.AtomicLong import javax.inject.Inject import kotlin.math.absoluteValue @@ -125,11 +128,13 @@ sealed class ServiceAction { } /** - * Handles all the communication with android apps. Also keeps an internal model of the network state. + * Handles all communication with android apps and the Meshtastic device. It maintains an internal model of the network + * state, manages device configurations, and processes incoming/outgoing packets. * - * Note: this service will go away once all clients are unbound from it. Warning: do not override toString, it causes - * infinite recursion on some androids (because contextWrapper.getResources calls to string + * Note: This service will go away once all clients are unbound from it. Warning: Do not override toString, it causes + * infinite recursion on some Android versions (because contextWrapper.getResources calls toString). */ +@Suppress("MagicNumber") @AndroidEntryPoint class MeshService : Service(), @@ -150,18 +155,20 @@ class MeshService : @Inject lateinit var serviceNotifications: MeshServiceNotifications + @Inject lateinit var connectionRouter: ConnectionRouter + companion object : Logging { + private const val MESH_PREFS_NAME = "mesh-prefs" + private const val DEVICE_ADDRESS_KEY = "device_address" + private const val ADMIN_CHANNEL_NAME = "admin" // Intents broadcast by MeshService - private fun actionReceived(portNum: String) = "$prefix.RECEIVED.$portNum" - // generate a RECEIVED action filter string that includes either the portnumber as an int, or preferably a - // symbolic name from portnums.proto + /** Generates a RECEIVED action filter string for a given port number. */ fun actionReceived(portNum: Int): String { val portType = Portnums.PortNum.forNumber(portNum) val portStr = portType?.toString() ?: portNum.toString() - return actionReceived(portStr) } @@ -180,71 +187,56 @@ class MeshService : class NoDeviceConfigException(message: String = "No radio settings received (is our app too old?)") : RadioNotConnectedException(message) - /** - * Talk to our running service and try to set a new device address. And then immediately call start on the - * service to possibly promote our service to be a foreground service. - */ + /** Initiates a device address change and starts the service. */ fun changeDeviceAddress(context: Context, service: IMeshService, address: String?) { service.setDeviceAddress(address) - startService(context) + startService(context) // Ensure service is started/foregrounded if needed } - fun createIntent() = Intent().setClassName("com.geeksville.mesh", "com.geeksville.mesh.service.MeshService") + fun createIntent(context: Context): Intent = Intent(context, MeshService::class.java) - /** - * The minimum firmware version we know how to talk to. We'll still be able to talk to 2.0 firmwares but only - * well enough to ask them to firmware update. - */ val minDeviceVersion = DeviceVersion(BuildConfig.MIN_FW_VERSION) val absoluteMinDeviceVersion = DeviceVersion(BuildConfig.ABS_MIN_FW_VERSION) - } - enum class ConnectionState { - DISCONNECTED, - CONNECTED, - DEVICE_SLEEP, // device is in LS sleep state, it will reconnected to us over bluetooth once it has data - ; - - fun isConnected() = this == CONNECTED - - fun isDisconnected() = this == DISCONNECTED + private const val CONFIG_ONLY_NONCE = 69420 + private const val NODE_INFO_ONLY_NONCE = 69421 } private var previousSummary: String? = null private var previousStats: LocalStats? = null - // A mapping of receiver class name to package name - used for explicit broadcasts - private val clientPackages = mutableMapOf() - private val serviceBroadcasts = + private val clientPackages = ConcurrentHashMap() + private val serviceBroadcasts by lazy { MeshServiceBroadcasts(this, clientPackages) { - connectionState.also { radioConfigRepository.setConnectionState(it) } + connectionRouter.connectionState.value.also { radioConfigRepository.setConnectionState(it) } } + } private val serviceJob = Job() private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob) - private var connectionState = ConnectionState.DISCONNECTED private var locationFlow: Job? = null private var mqttMessageFlow: Job? = null + // Battery thresholds and cooldowns private val batteryPercentUnsupported = 0.0 private val batteryPercentLowThreshold = 20 private val batteryPercentLowDivisor = 5 private val batteryPercentCriticalThreshold = 5 - private val batteryPercentCooldownSeconds = 1500 - private val batteryPercentCooldowns: HashMap = HashMap() + private val batteryPercentCooldownSeconds = 1500L + private val batteryPercentCooldowns = ConcurrentHashMap() private fun getSenderName(packet: DataPacket?): String { - val name = nodeDBbyID[packet?.from]?.user?.longName - return name ?: getString(R.string.unknown_username) + val nodeId = packet?.from ?: return getString(R.string.unknown_username) + return nodeDBbyID[nodeId]?.user?.longName ?: getString(R.string.unknown_username) } - private val notificationSummary + private val notificationSummary: String get() = - when (connectionState) { - ConnectionState.CONNECTED -> getString(R.string.connected_count).format(numOnlineNodes) - + when (connectionRouter.connectionState.value) { + ConnectionState.CONNECTED -> getString(R.string.connected_count, numOnlineNodes) ConnectionState.DISCONNECTED -> getString(R.string.disconnected) ConnectionState.DEVICE_SLEEP -> getString(R.string.device_sleeping) + ConnectionState.CONNECTING -> getString(R.string.connecting_to_device) } private var localStatsTelemetry: TelemetryProtos.Telemetry? = null @@ -254,75 +246,68 @@ class MeshService : private val localStatsUpdatedAtMillis: Long? get() = localStatsTelemetry?.time?.let { it * 1000L } - /** start our location requests (if they weren't already running) */ + /** Starts location requests if permissions are granted and not already active. */ + @RequiresPermission(allOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION]) private fun startLocationRequests() { - // If we're already observing updates, don't register again if (locationFlow?.isActive == true) return - @SuppressLint("MissingPermission") if (hasLocationPermission()) { locationFlow = locationRepository .getLocations() .onEach { location -> - sendPosition( - position { - latitudeI = Position.degI(location.latitude) - longitudeI = Position.degI(location.longitude) - if (LocationCompat.hasMslAltitude(location)) { - altitude = LocationCompat.getMslAltitudeMeters(location).toInt() - } - altitudeHae = location.altitude.toInt() - time = (location.time / 1000).toInt() - groundSpeed = location.speed.toInt() - groundTrack = location.bearing.toInt() - locationSource = MeshProtos.Position.LocSource.LOC_EXTERNAL - }, - ) + val positionBuilder = position { + latitudeI = Position.degI(location.latitude) + longitudeI = Position.degI(location.longitude) + if (LocationCompat.hasMslAltitude(location)) { + altitude = LocationCompat.getMslAltitudeMeters(location).toInt() + } + altitudeHae = location.altitude.toInt() + time = (location.time / 1000).toInt() + groundSpeed = location.speed.toInt() + groundTrack = location.bearing.toInt() + locationSource = MeshProtos.Position.LocSource.LOC_EXTERNAL + } + sendPosition(positionBuilder) } .launchIn(serviceScope) } } private fun stopLocationRequests() { - if (locationFlow?.isActive == true) { - info("Stopping location requests") - locationFlow?.cancel() - locationFlow = null - } + locationFlow + ?.takeIf { it.isActive } + ?.let { + info("Stopping location requests") + it.cancel() + locationFlow = null + } } - /** - * Send a command/packet to our radio. But cope with the possibility that we might start up before we are fully - * bound to the RadioInterfaceService - */ - private fun sendToRadio(p: ToRadio.Builder) { - val built = p.build() - debug("Sending to radio ${built.toPIIString()}") - val b = built.toByteArray() + private fun sendToRadio(toRadioBuilder: ToRadio.Builder) { + val builtProto = toRadioBuilder.build() + debug("Sending to radio: ${builtProto.toPIIString()}") + radioInterfaceService.sendToRadio(builtProto.toByteArray()) - radioInterfaceService.sendToRadio(b) - changeStatus(p.packet.id, MessageStatus.ENROUTE) - - if (p.packet.hasDecoded()) { - val packetToSave = - MeshLog( - uuid = UUID.randomUUID().toString(), - message_type = "Packet", - received_date = System.currentTimeMillis(), - raw_message = p.packet.toString(), - fromNum = p.packet.from, - portNum = p.packet.decoded.portnumValue, - fromRadio = fromRadio { packet = p.packet }, + if (toRadioBuilder.hasPacket()) { + val packet = toRadioBuilder.packet + changeStatus(packet.id, MessageStatus.ENROUTE) + if (packet.hasDecoded()) { + insertMeshLog( + MeshLog( + uuid = UUID.randomUUID().toString(), + message_type = "PacketSent", // Clarified type + received_date = System.currentTimeMillis(), + raw_message = packet.toString(), + fromNum = myNodeNum, // Correctly use myNodeNum for sent packets + portNum = packet.decoded.portnumValue, + fromRadio = fromRadio { this.packet = packet }, + ), ) - insertMeshLog(packetToSave) + } } } - /** - * Send a mesh packet to the radio, if the radio is not currently connected this function will throw - * NotConnectedException - */ private fun sendToRadio(packet: MeshPacket) { queuedPackets.add(packet) startPacketQueue() @@ -339,10 +324,9 @@ class MeshService : private fun updateMessageNotification(contactKey: String, dataPacket: DataPacket) { val message: String = when (dataPacket.dataType) { - Portnums.PortNum.TEXT_MESSAGE_APP_VALUE -> dataPacket.text!! - Portnums.PortNum.WAYPOINT_APP_VALUE -> { - getString(R.string.waypoint_received, dataPacket.waypoint!!.name) - } + Portnums.PortNum.TEXT_MESSAGE_APP_VALUE -> dataPacket.text ?: return + Portnums.PortNum.WAYPOINT_APP_VALUE -> + getString(R.string.waypoint_received, dataPacket.waypoint?.name ?: "") else -> return } @@ -356,81 +340,68 @@ class MeshService : override fun onCreate() { super.onCreate() - sharedPreferences = getSharedPreferences("mesh-prefs", Context.MODE_PRIVATE) - _lastAddress.value = sharedPreferences.getString("device_address", null) ?: NO_DEVICE_SELECTED + sharedPreferences = getSharedPreferences(MESH_PREFS_NAME, Context.MODE_PRIVATE) + _lastAddress.value = sharedPreferences.getString(DEVICE_ADDRESS_KEY, null) ?: NO_DEVICE_SELECTED info("Creating mesh service") serviceNotifications.initChannels() - // Switch to the IO thread + connectionRouter.start() + serviceScope.handledLaunch { radioInterfaceService.connect() } - radioInterfaceService.connectionState.onEach(::onRadioConnectionState).launchIn(serviceScope) + + connectionRouter.connectionState + .onEach { state -> + when (state) { + ConnectionState.CONNECTED -> startConnect() + ConnectionState.DEVICE_SLEEP -> startDeviceSleep() + ConnectionState.DISCONNECTED -> startDisconnect() + else -> Unit + } + } + .launchIn(serviceScope) + radioInterfaceService.receivedData.onEach(::onReceiveFromRadio).launchIn(serviceScope) radioConfigRepository.localConfigFlow.onEach { localConfig = it }.launchIn(serviceScope) radioConfigRepository.moduleConfigFlow.onEach { moduleConfig = it }.launchIn(serviceScope) radioConfigRepository.channelSetFlow.onEach { channelSet = it }.launchIn(serviceScope) radioConfigRepository.serviceAction.onEach(::onServiceAction).launchIn(serviceScope) - loadSettings() // Load our last known node DB - - // the rest of our init will happen once we are in radioConnection.onServiceConnected + loadSettings() } - /** If someone binds to us, this will be called after on create */ override fun onBind(intent: Intent?): IBinder = binder - /** - * Called when the service is started or restarted. This method manages the foreground state of the service. - * - * It attempts to start the service in the foreground with a notification. If `startForeground` fails, for example, - * due to a `SecurityException` on Android 13+ because the `POST_NOTIFICATIONS` permission is missing, it logs an - * error* and returns `START_NOT_STICKY` to prevent the service from becoming sticky in a broken state. - * - * If the service is not intended to be in the foreground (e.g., no device is connected), it stops the foreground - * state and returns `START_NOT_STICKY`. Otherwise, it returns `START_STICKY`. - * - * @param intent The Intent supplied to `startService(Intent)`, as modified by the system. - * @param flags Additional data about this start request. - * @param startId A unique integer representing this specific request to start. - * @return The return value indicates what semantics the system should use for the service's current started state. - * See [Service.onStartCommand] for details. - */ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - val a = radioInterfaceService.getBondedDeviceAddress() - val wantForeground = a != null && a != NO_DEVICE_SELECTED + val deviceAddress = radioInterfaceService.getBondedDeviceAddress() + val wantForeground = deviceAddress != null && deviceAddress != NO_DEVICE_SELECTED - info("Requesting foreground service=$wantForeground") + info("Requesting foreground service: $wantForeground") - // We always start foreground because that's how our service is always started (if we didn't then android would - // kill us) - // but if we don't really need foreground we immediately stop it. val notification = serviceNotifications.createServiceStateNotification(notificationSummary) + val foregroundServiceType = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + if (hasLocationPermission()) { + ServiceInfo.FOREGROUND_SERVICE_TYPE_MANIFEST + } else { + ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE + } + } else { + 0 + } try { - ServiceCompat.startForeground( - this, - serviceNotifications.notifyId, - notification, - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) { - if (hasLocationPermission()) { - ServiceInfo.FOREGROUND_SERVICE_TYPE_MANIFEST - } else { - ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE - } - } else { - 0 - }, - ) + ServiceCompat.startForeground(this, serviceNotifications.notifyId, notification, foregroundServiceType) } catch (ex: SecurityException) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - errormsg( - "startForeground failed, likely due to missing POST_NOTIFICATIONS permission on Android 13+", - ex, - ) - } else { - errormsg("startForeground failed", ex) - } - return START_NOT_STICKY + val errorMessage = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + "startForeground failed, likely due to missing POST_NOTIFICATIONS permission on Android 13+" + } else { + "startForeground failed" + } + errormsg(errorMessage, ex) + return START_NOT_STICKY // Prevent service becoming sticky in a broken state } + return if (!wantForeground) { ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) START_NOT_STICKY @@ -441,125 +412,128 @@ class MeshService : override fun onDestroy() { info("Destroying mesh service") - - // Make sure we aren't using the notification first ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) - super.onDestroy() serviceJob.cancel() + connectionRouter.stop() } - // - // BEGINNING OF MODEL - FIXME, move elsewhere - // - + // Node Database and Model Management private fun loadSettings() = serviceScope.handledLaunch { - discardNodeDB() // Get rid of any old state + resetState() // Clear previous state myNodeInfo = radioConfigRepository.myNodeInfo.value - nodeDBbyNodeNum.putAll(radioConfigRepository.getNodeDBbyNum()) - // Note: we do not haveNodeDB = true because that means we've got a valid db from a real device (rather than - // this possibly stale hint) + val nodesFromDb = radioConfigRepository.getNodeDBbyNum() + nodeDBbyNodeNum.putAll(nodesFromDb) + nodesFromDb.values.forEach { nodeEntity -> + if (nodeEntity.user.id.isNotEmpty()) { + _nodeDBbyID[nodeEntity.user.id] = nodeEntity + } + } } - /** discard entire node db & message state - used when downloading a new db from the device */ - private fun discardNodeDB() { - debug("Discarding NodeDB") + /** + * Resets all relevant service state variables to their defaults or clears collections. This is crucial when + * switching to a new device connection to prevent state from a previous session from affecting the new one. It + * ensures a clean slate for node information, configurations, pending operations, and cached data. + */ + private fun resetState() = serviceScope.launch { + debug("Discarding NodeDB and resetting all service state for new device connection") + clearDatabases() + // Core Node and Config data myNodeInfo = null + rawMyNodeInfo = null + nodeDBbyNodeNum.clear() - haveNodeDB = false + _nodeDBbyID.clear() + + localStatsTelemetry = null + sessionPasskey = ByteString.EMPTY + + currentPacketId = Random(System.currentTimeMillis()).nextLong().absoluteValue + packetIdGenerator.set(Random(System.currentTimeMillis()).nextLong().absoluteValue) + + offlineSentPackets.clear() + stopPacketQueue() + + connectTimeMsec = 0L + + stopLocationRequests() + stopMqttClientProxy() + + previousSummary = null + previousStats = null + + batteryPercentCooldowns.clear() + + info("MeshService state has been reset for a new device session.") } private var myNodeInfo: MyNodeEntity? = null - + private var rawMyNodeInfo: MeshProtos.MyNodeInfo? = null + private var currentPacketId = Random(System.currentTimeMillis()).nextLong().absoluteValue private val configTotal by lazy { ConfigProtos.Config.getDescriptor().fields.size } private val moduleTotal by lazy { ModuleConfigProtos.ModuleConfig.getDescriptor().fields.size } private var sessionPasskey: ByteString = ByteString.EMPTY - private var localConfig: LocalConfig = LocalConfig.getDefaultInstance() private var moduleConfig: LocalModuleConfig = LocalModuleConfig.getDefaultInstance() private var channelSet: AppOnlyProtos.ChannelSet = AppOnlyProtos.ChannelSet.getDefaultInstance() - // True after we've done our initial node db init - @Volatile private var haveNodeDB = false - - // The database of active nodes, index is the node number private val nodeDBbyNodeNum = ConcurrentHashMap() + private val _nodeDBbyID = ConcurrentHashMap() // Cached map for ID lookups + val nodeDBbyID: Map + get() = _nodeDBbyID // Expose immutable view if needed externally - // The database of active nodes, index is the node user ID string - // NOTE: some NodeInfos might be in only nodeDBbyNodeNum (because we don't yet know an ID). - private val nodeDBbyID - get() = nodeDBbyNodeNum.mapKeys { it.value.user.id } + private fun toNodeInfo(nodeNum: Int): NodeEntity = + nodeDBbyNodeNum[nodeNum] ?: throw NodeNumNotFoundException(nodeNum) - // - // END OF MODEL - // - - @Suppress("UnusedPrivateMember") - private val deviceVersion - get() = DeviceVersion(myNodeInfo?.firmwareVersion ?: "") - - @Suppress("UnusedPrivateMember") - private val appVersion - get() = BuildConfig.VERSION_CODE - - private val minAppVersion - get() = myNodeInfo?.minAppVersion ?: 0 - - // Map a nodenum to a node, or throw an exception if not found - private fun toNodeInfo(n: Int) = nodeDBbyNodeNum[n] ?: throw NodeNumNotFoundException(n) - - /** - * Map a nodeNum to the nodeId string If we have a NodeInfo for this ID we prefer to return the string ID inside the - * user record. but some nodes might not have a user record at all (because not yet received), in that case, we - * return a hex version of the ID just based on the number - */ - private fun toNodeID(n: Int): String = if (n == DataPacket.NODENUM_BROADCAST) { - DataPacket.ID_BROADCAST - } else { - nodeDBbyNodeNum[n]?.user?.id ?: DataPacket.nodeNumToDefaultId(n) + private fun toNodeID(nodeNum: Int): String = when (nodeNum) { + DataPacket.NODENUM_BROADCAST -> DataPacket.ID_BROADCAST + else -> nodeDBbyNodeNum[nodeNum]?.user?.id ?: DataPacket.nodeNumToDefaultId(nodeNum) } - // given a nodeNum, return a db entry - creating if necessary - private fun getOrCreateNodeInfo(n: Int, channel: Int = 0) = nodeDBbyNodeNum.getOrPut(n) { - val userId = DataPacket.nodeNumToDefaultId(n) + private fun getOrCreateNodeInfo(nodeNum: Int, channel: Int = 0): NodeEntity = nodeDBbyNodeNum.getOrPut(nodeNum) { + val userId = DataPacket.nodeNumToDefaultId(nodeNum) val defaultUser = user { id = userId - longName = "Meshtastic ${userId.takeLast(n = 4)}" - shortName = userId.takeLast(n = 4) + longName = "Meshtastic ${userId.takeLast(4)}" + shortName = userId.takeLast(4) hwModel = MeshProtos.HardwareModel.UNSET } - - NodeEntity(num = n, user = defaultUser, longName = defaultUser.longName, channel = channel) + NodeEntity( + num = nodeNum, + user = defaultUser, + longName = defaultUser.longName, + channel = channel, + ).also { newEntity -> + if (newEntity.user.id.isNotEmpty()) { + _nodeDBbyID[newEntity.user.id] = newEntity + } + } } private val hexIdRegex = """\!([0-9A-Fa-f]+)""".toRegex() - // Map a userid to a node/ node num, or throw an exception if not found - // We prefer to find nodes based on their assigned IDs, but if no ID has been assigned to a node, we can also find - // it based on node number - private fun toNodeInfo(id: String): NodeEntity { - // If this is a valid hexaddr will be !null - val hexStr = hexIdRegex.matchEntire(id)?.groups?.get(1)?.value - - return nodeDBbyID[id] - ?: when { + private fun toNodeInfo(id: String): NodeEntity = _nodeDBbyID[id] + ?: run { + val hexStr = hexIdRegex.matchEntire(id)?.groups?.get(1)?.value + when { id == DataPacket.ID_LOCAL -> toNodeInfo(myNodeNum) hexStr != null -> { - val n = hexStr.toLong(16).toInt() - nodeDBbyNodeNum[n] ?: throw IdNotFoundException(id) + val nodeNum = hexStr.toLong(16).toInt() + nodeDBbyNodeNum[nodeNum] ?: throw IdNotFoundException(id) } else -> throw InvalidNodeIdException(id) } - } + } - private fun getUserName(num: Int): String = with(radioConfigRepository.getUser(num)) { "$longName ($shortName)" } + private fun getUserName(num: Int): String = + radioConfigRepository.getUser(num).let { "${it.longName} (${it.shortName})" } - private val numNodes + private val numNodes: Int get() = nodeDBbyNodeNum.size - /** How many nodes are currently online (including our local node) */ - private val numOnlineNodes + private val numOnlineNodes: Int get() = nodeDBbyNodeNum.values.count { it.isOnline } private fun toNodeNum(id: String): Int = when (id) { @@ -568,7 +542,6 @@ class MeshService : else -> toNodeInfo(id).num } - // A helper function that makes it easy to update node info objects private inline fun updateNodeInfo( nodeNum: Int, withBroadcast: Boolean = true, @@ -576,9 +549,19 @@ class MeshService : crossinline updateFn: (NodeEntity) -> Unit, ) { val info = getOrCreateNodeInfo(nodeNum, channel) + val oldUserId = info.user.id + updateFn(info) - if (info.user.id.isNotEmpty() && haveNodeDB) { + val newUserId = info.user.id + if (oldUserId.isNotEmpty() && oldUserId != newUserId) { + _nodeDBbyID.remove(oldUserId) + } + if (newUserId.isNotEmpty()) { + _nodeDBbyID[newUserId] = info + } + + if (info.user.id.isNotEmpty()) { serviceScope.handledLaunch { radioConfigRepository.upsert(info) } } @@ -587,48 +570,35 @@ class MeshService : } } - // My node num - private val myNodeNum - get() = myNodeInfo?.myNodeNum ?: throw RadioNotConnectedException("We don't yet have our myNodeInfo") + private val myNodeNum: Int + get() = myNodeInfo?.myNodeNum ?: throw RadioNotConnectedException("Local node information not yet available") - // My node ID string - private val myNodeID + private val myNodeID: String get() = toNodeID(myNodeNum) - // Admin channel index private val MeshPacket.Builder.adminChannelIndex: Int get() = when { - myNodeNum == to -> 0 + myNodeNum == to -> 0 // Admin channel to self is 0 nodeDBbyNodeNum[myNodeNum]?.hasPKC == true && nodeDBbyNodeNum[to]?.hasPKC == true -> DataPacket.PKC_CHANNEL_INDEX else -> - channelSet.settingsList.indexOfFirst { it.name.equals("admin", ignoreCase = true) }.coerceAtLeast(0) + channelSet.settingsList + .indexOfFirst { it.name.equals(ADMIN_CHANNEL_NAME, ignoreCase = true) } + .coerceAtLeast(0) } - // Generate a new mesh packet builder with our node as the sender, and the specified node num - private fun newMeshPacketTo(idNum: Int) = MeshPacket.newBuilder().apply { - if (myNodeInfo == null) { - throw RadioNotConnectedException() - } - - from = 0 // don't add myNodeNum - - to = idNum + private fun newMeshPacketTo(nodeNum: Int): MeshPacket.Builder = MeshPacket.newBuilder().apply { + from = 0 // Device sets this to myNodeNum + to = nodeNum } - /** - * Generate a new mesh packet builder with our node as the sender, and the specified recipient - * - * If id is null we assume a broadcast message - */ - private fun newMeshPacketTo(id: String) = newMeshPacketTo(toNodeNum(id)) + private fun newMeshPacketTo(id: String): MeshPacket.Builder = newMeshPacketTo(toNodeNum(id)) - /** Helper to make it easy to build a subpacket in the proper protobufs */ private fun MeshPacket.Builder.buildMeshPacket( wantAck: Boolean = false, - id: Int = generatePacketId(), // always assign a packet ID if we didn't already have one + id: Int = generatePacketId(), hopLimit: Int = localConfig.lora.hopLimit, channel: Int = 0, priority: MeshPacket.Priority = MeshPacket.Priority.UNSET, @@ -638,44 +608,38 @@ class MeshService : this.id = id this.hopLimit = hopLimit this.priority = priority - decoded = MeshProtos.Data.newBuilder().also { initFn(it) }.build() + this.decoded = MeshProtos.Data.newBuilder().apply(initFn).build() if (channel == DataPacket.PKC_CHANNEL_INDEX) { pkiEncrypted = true - nodeDBbyNodeNum[to]?.user?.publicKey?.let { publicKey -> this.publicKey = publicKey } + nodeDBbyNodeNum[to]?.user?.publicKey?.let { this.publicKey = it } } else { this.channel = channel } - return build() } - /** Helper to make it easy to build a subpacket in the proper protobufs */ private fun MeshPacket.Builder.buildAdminPacket( - id: Int = generatePacketId(), // always assign a packet ID if we didn't already have one + id: Int = generatePacketId(), wantResponse: Boolean = false, initFn: AdminProtos.AdminMessage.Builder.() -> Unit, ): MeshPacket = buildMeshPacket(id = id, wantAck = true, channel = adminChannelIndex, priority = MeshPacket.Priority.RELIABLE) { this.wantResponse = wantResponse - portnumValue = Portnums.PortNum.ADMIN_APP_VALUE - payload = + this.portnumValue = Portnums.PortNum.ADMIN_APP_VALUE + this.payload = AdminProtos.AdminMessage.newBuilder() - .also { - initFn(it) - it.sessionPasskey = sessionPasskey + .apply { + initFn(this) + this.sessionPasskey = this@MeshService.sessionPasskey } .build() .toByteString() } - // Generate a DataPacket from a MeshPacket, or null if we didn't have enough data to do so - private fun toDataPacket(packet: MeshPacket): DataPacket? = if (!packet.hasDecoded()) { - // We never convert packets that are not DataPackets - null - } else { + private fun toDataPacket(packet: MeshPacket): DataPacket? { + if (!packet.hasDecoded()) return null val data = packet.decoded - - DataPacket( + return DataPacket( from = toNodeID(packet.from), to = toNodeID(packet.to), time = packet.rxTime * 1000L, @@ -692,20 +656,18 @@ class MeshService : ) } - private fun toMeshPacket(p: DataPacket): MeshPacket = newMeshPacketTo(p.to!!).buildMeshPacket( - id = p.id, - wantAck = p.wantAck, - hopLimit = p.hopLimit, - channel = p.channel, + private fun toMeshPacket(dataPacket: DataPacket): MeshPacket = newMeshPacketTo(dataPacket.to!!).buildMeshPacket( + id = dataPacket.id, + wantAck = dataPacket.wantAck, + hopLimit = dataPacket.hopLimit, + channel = dataPacket.channel, ) { - portnumValue = p.dataType - payload = ByteString.copyFrom(p.bytes) - if (p.replyId != null && p.replyId != 0) { - this.replyId = p.replyId!! - } + portnumValue = dataPacket.dataType + payload = ByteString.copyFrom(dataPacket.bytes) + dataPacket.replyId?.takeIf { it != 0 }?.let { this.replyId = it } } - private val rememberDataType = + private val rememberableDataTypes = setOf( Portnums.PortNum.TEXT_MESSAGE_APP_VALUE, Portnums.PortNum.ALERT_APP_VALUE, @@ -724,12 +686,11 @@ class MeshService : } private fun rememberDataPacket(dataPacket: DataPacket, updateNotification: Boolean = true) { - if (dataPacket.dataType !in rememberDataType) return + if (dataPacket.dataType !in rememberableDataTypes) return + val fromLocal = dataPacket.from == DataPacket.ID_LOCAL val toBroadcast = dataPacket.to == DataPacket.ID_BROADCAST val contactId = if (fromLocal || toBroadcast) dataPacket.to else dataPacket.from - - // contactKey: unique contact key filter (channel)+(nodeId) val contactKey = "${dataPacket.channel}$contactId" val packetToSave = @@ -760,152 +721,172 @@ class MeshService : } } - // Update our model and resend as needed for a MeshPacket we just received from the radio + // region Received Data Handlers private fun handleReceivedData(packet: MeshPacket) { - myNodeInfo?.let { myInfo -> - val data = packet.decoded - val bytes = data.payload.toByteArray() - val fromId = toNodeID(packet.from) - val dataPacket = toDataPacket(packet) + val currentMyNodeInfo = myNodeInfo ?: return // Early exit if no local node info - if (dataPacket != null) { - // We ignore most messages that we sent - val fromUs = myInfo.myNodeNum == packet.from + val decodedData = packet.decoded + val fromNodeId = toNodeID(packet.from) + val appDataPacket = toDataPacket(packet) ?: return // Not a processable data packet - debug("Received data from $fromId, portnum=${data.portnum} ${bytes.size} bytes") + val fromThisDevice = currentMyNodeInfo.myNodeNum == packet.from + debug("Received data from $fromNodeId, portnum=${decodedData.portnum} ${decodedData.payload.size()} bytes") + appDataPacket.status = MessageStatus.RECEIVED - dataPacket.status = MessageStatus.RECEIVED + var shouldBroadcastToClients = !fromThisDevice - // if (p.hasUser()) handleReceivedUser(fromNum, p.user) + when (decodedData.portnumValue) { + Portnums.PortNum.TEXT_MESSAGE_APP_VALUE -> handleReceivedText(packet, appDataPacket, fromNodeId) - // We tell other apps about most message types, but some may have sensitive data, so that is not shared' - var shouldBroadcast = !fromUs + Portnums.PortNum.ALERT_APP_VALUE -> handleReceivedAlert(appDataPacket, fromNodeId) + Portnums.PortNum.WAYPOINT_APP_VALUE -> handleReceivedWaypoint(packet, appDataPacket) + Portnums.PortNum.POSITION_APP_VALUE -> handleReceivedPositionApp(packet, decodedData, appDataPacket) - when (data.portnumValue) { - Portnums.PortNum.TEXT_MESSAGE_APP_VALUE -> { - if (data.replyId != 0 && data.emoji == 0) { - debug("Received REPLY from $fromId") - rememberDataPacket(dataPacket) - } else if (data.replyId != 0 && data.emoji != 0) { - debug("Received EMOJI from $fromId") - rememberReaction(packet) - } else { - debug("Received CLEAR_TEXT from $fromId") - rememberDataPacket(dataPacket) - } - } + Portnums.PortNum.NODEINFO_APP_VALUE -> if (!fromThisDevice) handleReceivedNodeInfoApp(packet, decodedData) - Portnums.PortNum.ALERT_APP_VALUE -> { - debug("Received ALERT_APP from $fromId") - rememberDataPacket(dataPacket) - } + Portnums.PortNum.TELEMETRY_APP_VALUE -> handleReceivedTelemetryApp(packet, decodedData, appDataPacket) - Portnums.PortNum.WAYPOINT_APP_VALUE -> { - val u = MeshProtos.Waypoint.parseFrom(data.payload) - // Validate locked Waypoints from the original sender - if (u.lockedTo != 0 && u.lockedTo != packet.from) return - rememberDataPacket(dataPacket, u.expire > currentSecond()) - } + Portnums.PortNum.ROUTING_APP_VALUE -> { + shouldBroadcastToClients = true + handleReceivedRoutingApp(decodedData, fromNodeId) + } - Portnums.PortNum.POSITION_APP_VALUE -> { - val u = MeshProtos.Position.parseFrom(data.payload) - // debug("position_app ${packet.from} ${u.toOneLineString()}") - if (data.wantResponse && u.latitudeI == 0 && u.longitudeI == 0) { - debug("Ignoring nop position update from position request") - } else { - handleReceivedPosition(packet.from, u, dataPacket.time) - } - } + Portnums.PortNum.ADMIN_APP_VALUE -> { + handleReceivedAdmin(packet.from, AdminProtos.AdminMessage.parseFrom(decodedData.payload)) + shouldBroadcastToClients = false + } - Portnums.PortNum.NODEINFO_APP_VALUE -> - if (!fromUs) { - val u = - MeshProtos.User.parseFrom(data.payload).copy { - if (isLicensed) clearPublicKey() - if (packet.viaMqtt) longName = "$longName (MQTT)" - } - handleReceivedUser(packet.from, u, packet.channel) - } + Portnums.PortNum.PAXCOUNTER_APP_VALUE -> { + handleReceivedPaxcounter(packet.from, PaxcountProtos.Paxcount.parseFrom(decodedData.payload)) + shouldBroadcastToClients = false + } - // Handle new telemetry info - Portnums.PortNum.TELEMETRY_APP_VALUE -> { - val u = - TelemetryProtos.Telemetry.parseFrom(data.payload).copy { - if (time == 0) time = (dataPacket.time / 1000L).toInt() - } - handleReceivedTelemetry(packet.from, u) - } - - Portnums.PortNum.ROUTING_APP_VALUE -> { - // We always send ACKs to other apps, because they might care about the messages they sent - shouldBroadcast = true - val u = MeshProtos.Routing.parseFrom(data.payload) - - if (u.errorReason == MeshProtos.Routing.Error.DUTY_CYCLE_LIMIT) { - radioConfigRepository.setErrorMessage(getString(R.string.error_duty_cycle)) - } - - handleAckNak(data.requestId, fromId, u.errorReasonValue) - queueResponse.remove(data.requestId)?.complete(true) - } - - Portnums.PortNum.ADMIN_APP_VALUE -> { - val u = AdminProtos.AdminMessage.parseFrom(data.payload) - handleReceivedAdmin(packet.from, u) - shouldBroadcast = false - } - - Portnums.PortNum.PAXCOUNTER_APP_VALUE -> { - val p = PaxcountProtos.Paxcount.parseFrom(data.payload) - handleReceivedPaxcounter(packet.from, p) - shouldBroadcast = false - } - - Portnums.PortNum.STORE_FORWARD_APP_VALUE -> { - val u = StoreAndForwardProtos.StoreAndForward.parseFrom(data.payload) - handleReceivedStoreAndForward(dataPacket, u) - shouldBroadcast = false - } - - Portnums.PortNum.RANGE_TEST_APP_VALUE -> { - if (!moduleConfig.rangeTest.enabled) return - val u = dataPacket.copy(dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE) - rememberDataPacket(u) - } - - Portnums.PortNum.DETECTION_SENSOR_APP_VALUE -> { - val u = dataPacket.copy(dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE) - rememberDataPacket(u) - } - - Portnums.PortNum.TRACEROUTE_APP_VALUE -> { - radioConfigRepository.setTracerouteResponse(packet.getTracerouteResponse(::getUserName)) - } - - else -> debug("No custom processing needed for ${data.portnumValue}") - } - - // We always tell other apps when new data packets arrive - if (shouldBroadcast) { - serviceBroadcasts.broadcastReceivedData(dataPacket) - } - - GeeksvilleApplication.analytics.track("num_data_receive", DataPair(1)) - - GeeksvilleApplication.analytics.track( - "data_receive", - DataPair("num_bytes", bytes.size), - DataPair("type", data.portnumValue), + Portnums.PortNum.STORE_FORWARD_APP_VALUE -> { + handleReceivedStoreAndForward( + appDataPacket, + StoreAndForwardProtos.StoreAndForward.parseFrom(decodedData.payload), ) + shouldBroadcastToClients = false + } + + Portnums.PortNum.RANGE_TEST_APP_VALUE -> handleReceivedRangeTest(appDataPacket) + Portnums.PortNum.DETECTION_SENSOR_APP_VALUE -> handleReceivedDetectionSensor(appDataPacket) + + Portnums.PortNum.TRACEROUTE_APP_VALUE -> + radioConfigRepository.setTracerouteResponse(packet.getTracerouteResponse(::getUserName)) + + else -> debug("No custom processing needed for ${decodedData.portnumValue}") + } + + if (shouldBroadcastToClients) { + serviceBroadcasts.broadcastReceivedData(appDataPacket) + } + trackDataReceptionAnalytics(decodedData.portnumValue, decodedData.payload.size()) + } + + private fun handleReceivedText(meshPacket: MeshPacket, dataPacket: DataPacket, fromId: String) { + val decodedPayload = meshPacket.decoded + when { + decodedPayload.replyId != 0 && decodedPayload.emoji == 0 -> { // Text reply + debug("Received REPLY from $fromId") + rememberDataPacket(dataPacket) + } + + decodedPayload.replyId != 0 && decodedPayload.emoji != 0 -> { // Emoji reaction + debug("Received EMOJI from $fromId") + rememberReaction(meshPacket) + } + + else -> { // Standard text message + debug("Received CLEAR_TEXT from $fromId") + rememberDataPacket(dataPacket) } } } - private fun handleReceivedAdmin(fromNodeNum: Int, a: AdminProtos.AdminMessage) { - when (a.payloadVariantCase) { + private fun handleReceivedAlert(dataPacket: DataPacket, fromId: String) { + debug("Received ALERT_APP from $fromId") + rememberDataPacket(dataPacket) + } + + private fun handleReceivedWaypoint(meshPacket: MeshPacket, dataPacket: DataPacket) { + val waypointProto = MeshProtos.Waypoint.parseFrom(meshPacket.decoded.payload) + // Validate locked Waypoints from the original sender + if (waypointProto.lockedTo != 0 && waypointProto.lockedTo != meshPacket.from) return + rememberDataPacket(dataPacket, waypointProto.expire > currentSecond()) + } + + private fun handleReceivedPositionApp( + meshPacket: MeshPacket, + decodedData: MeshProtos.Data, + dataPacket: DataPacket, + ) { + val positionProto = MeshProtos.Position.parseFrom(decodedData.payload) + if (decodedData.wantResponse && positionProto.latitudeI == 0 && positionProto.longitudeI == 0) { + debug("Ignoring nop position update from position request") + } else { + handleReceivedPosition(meshPacket.from, positionProto, dataPacket.time) + } + } + + private fun handleReceivedNodeInfoApp(meshPacket: MeshPacket, decodedData: MeshProtos.Data) { + val userProto = + MeshProtos.User.parseFrom(decodedData.payload).copy { + if (isLicensed) clearPublicKey() + if (meshPacket.viaMqtt) longName = "$longName (MQTT)" + } + handleReceivedUser(meshPacket.from, userProto, meshPacket.channel) + } + + private fun handleReceivedTelemetryApp( + meshPacket: MeshPacket, + decodedData: MeshProtos.Data, + dataPacket: DataPacket, + ) { + val telemetryProto = + TelemetryProtos.Telemetry.parseFrom(decodedData.payload).copy { + if (time == 0) time = (dataPacket.time / 1000L).toInt() + } + handleReceivedTelemetry(meshPacket.from, telemetryProto) + } + + private fun handleReceivedRoutingApp(decodedData: MeshProtos.Data, fromId: String) { + val routingProto = MeshProtos.Routing.parseFrom(decodedData.payload) + if (routingProto.errorReason == MeshProtos.Routing.Error.DUTY_CYCLE_LIMIT) { + radioConfigRepository.setErrorMessage(getString(R.string.error_duty_cycle)) + } + handleAckNak(decodedData.requestId, fromId, routingProto.errorReasonValue) + queueResponse.remove(decodedData.requestId)?.complete(true) + } + + private fun handleReceivedRangeTest(dataPacket: DataPacket) { + if (!moduleConfig.rangeTest.enabled) return + val textDataPacket = dataPacket.copy(dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE) + rememberDataPacket(textDataPacket) + } + + private fun handleReceivedDetectionSensor(dataPacket: DataPacket) { + val textDataPacket = dataPacket.copy(dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE) + rememberDataPacket(textDataPacket) + } + + private fun trackDataReceptionAnalytics(portNum: Int, bytesSize: Int) { + GeeksvilleApplication.analytics.track("num_data_receive", DataPair(1)) + GeeksvilleApplication.analytics.track( + "data_receive", + DataPair("num_bytes", bytesSize), + DataPair("type", portNum), + ) + } + + // endregion + + @Suppress("NestedBlockDepth") + private fun handleReceivedAdmin(fromNodeNum: Int, adminMessage: AdminProtos.AdminMessage) { + when (adminMessage.payloadVariantCase) { AdminProtos.AdminMessage.PayloadVariantCase.GET_CONFIG_RESPONSE -> { if (fromNodeNum == myNodeNum) { - val response = a.getConfigResponse + val response = adminMessage.getConfigResponse debug("Admin: received config ${response.payloadVariantCase}") setLocalConfig(response) } @@ -913,12 +894,10 @@ class MeshService : AdminProtos.AdminMessage.PayloadVariantCase.GET_CHANNEL_RESPONSE -> { if (fromNodeNum == myNodeNum) { - val mi = myNodeInfo - if (mi != null) { - val ch = a.getChannelResponse + myNodeInfo?.let { + val ch = adminMessage.getChannelResponse debug("Admin: Received channel ${ch.index}") - - if (ch.index + 1 < mi.maxChannels) { + if (ch.index + 1 < it.maxChannels) { handleChannel(ch) } } @@ -928,208 +907,208 @@ class MeshService : AdminProtos.AdminMessage.PayloadVariantCase.GET_DEVICE_METADATA_RESPONSE -> { debug("Admin: received DeviceMetadata from $fromNodeNum") serviceScope.handledLaunch { - radioConfigRepository.insertMetadata(fromNodeNum, a.getDeviceMetadataResponse) + radioConfigRepository.insertMetadata(fromNodeNum, adminMessage.getDeviceMetadataResponse) } } - else -> warn("No special processing needed for ${a.payloadVariantCase}") + AdminProtos.AdminMessage.PayloadVariantCase.PAYLOADVARIANT_NOT_SET, + null, + -> warn("Received admin message with no payload variant set.") + + else -> warn("No special processing needed for admin payload ${adminMessage.payloadVariantCase}") } debug("Admin: Received session_passkey from $fromNodeNum") - sessionPasskey = a.sessionPasskey + sessionPasskey = adminMessage.sessionPasskey } - // Update our DB of users based on someone sending out a User subpacket - private fun handleReceivedUser(fromNum: Int, p: MeshProtos.User, channel: Int = 0) { - updateNodeInfo(fromNum) { - val newNode = (it.isUnknownUser && p.hwModel != MeshProtos.HardwareModel.UNSET) + private fun handleReceivedUser(fromNum: Int, userProto: MeshProtos.User, channel: Int = 0) { + updateNodeInfo(fromNum, channel = channel) { nodeEntity -> + val isNewNode = (nodeEntity.isUnknownUser && userProto.hwModel != MeshProtos.HardwareModel.UNSET) + val keyMatch = !nodeEntity.hasPKC || nodeEntity.user.publicKey == userProto.publicKey - val keyMatch = !it.hasPKC || it.user.publicKey == p.publicKey - it.user = + nodeEntity.user = if (keyMatch) { - p + userProto } else { - p.copy { - warn("Public key mismatch from $longName ($shortName)") + userProto.copy { + warn("Public key mismatch from ${userProto.longName} (${userProto.shortName})") publicKey = NodeEntity.ERROR_BYTE_STRING } } - it.longName = p.longName - it.shortName = p.shortName - it.channel = channel - if (newNode) { - serviceNotifications.showNewNodeSeenNotification(it) + nodeEntity.longName = userProto.longName + nodeEntity.shortName = userProto.shortName + if (isNewNode) { + serviceNotifications.showNewNodeSeenNotification(nodeEntity) } } } - /** - * Update our DB of users based on someone sending out a Position subpacket - * - * @param defaultTime in msecs since 1970 - */ private fun handleReceivedPosition( fromNum: Int, - p: MeshProtos.Position, - defaultTime: Long = System.currentTimeMillis(), + positionProto: MeshProtos.Position, + defaultTimeMillis: Long = System.currentTimeMillis(), ) { - // Nodes periodically send out position updates, but those updates might not contain a lat & lon (because no GPS - // lock) - // We like to look at the local node to see if it has been sending out valid lat/lon, so for the LOCAL node - // (only) - // we don't record these nop position updates - if (myNodeNum == fromNum && p.latitudeI == 0 && p.longitudeI == 0) { + if (myNodeNum == fromNum && positionProto.latitudeI == 0 && positionProto.longitudeI == 0) { debug("Ignoring nop position update for the local node") - } else { - updateNodeInfo(fromNum) { - debug("update position: ${it.longName?.toPIIString()} with ${p.toPIIString()}") - it.setPosition(p, (defaultTime / 1000L).toInt()) - } + return + } + updateNodeInfo(fromNum) { + debug("update position: ${it.longName?.toPIIString()} with ${positionProto.toPIIString()}") + it.setPosition(positionProto, (defaultTimeMillis / 1000L).toInt()) } } - // Update our DB of users based on someone sending out a Telemetry subpacket - private fun handleReceivedTelemetry(fromNum: Int, t: TelemetryProtos.Telemetry) { + private fun handleReceivedTelemetry(fromNum: Int, telemetryProto: TelemetryProtos.Telemetry) { val isRemote = (fromNum != myNodeNum) - if (!isRemote && t.hasLocalStats()) { - localStatsTelemetry = t + if (!isRemote && telemetryProto.hasLocalStats()) { + localStatsTelemetry = telemetryProto maybeUpdateServiceStatusNotification() } - updateNodeInfo(fromNum) { + updateNodeInfo(fromNum) { nodeEntity -> when { - t.hasDeviceMetrics() -> { - it.deviceTelemetry = t - if (fromNum == myNodeNum || (isRemote && it.isFavorite)) { + telemetryProto.hasDeviceMetrics() -> { + nodeEntity.deviceTelemetry = telemetryProto + if (fromNum == myNodeNum || (isRemote && nodeEntity.isFavorite)) { + val metrics = telemetryProto.deviceMetrics if ( - t.deviceMetrics.voltage > batteryPercentUnsupported && - t.deviceMetrics.batteryLevel <= batteryPercentLowThreshold + metrics.voltage > batteryPercentUnsupported && + metrics.batteryLevel <= batteryPercentLowThreshold ) { - if (shouldBatteryNotificationShow(fromNum, t)) { - serviceNotifications.showOrUpdateLowBatteryNotification(it, isRemote) + if (shouldBatteryNotificationShow(fromNum, telemetryProto)) { + serviceNotifications.showOrUpdateLowBatteryNotification(nodeEntity, isRemote) } } else { - if (batteryPercentCooldowns.containsKey(fromNum)) { - batteryPercentCooldowns.remove(fromNum) - } - serviceNotifications.cancelLowBatteryNotification(it) + batteryPercentCooldowns.remove(fromNum) + serviceNotifications.cancelLowBatteryNotification(nodeEntity) } } } - t.hasEnvironmentMetrics() -> it.environmentTelemetry = t - t.hasPowerMetrics() -> it.powerTelemetry = t + telemetryProto.hasEnvironmentMetrics() -> nodeEntity.environmentTelemetry = telemetryProto + + telemetryProto.hasPowerMetrics() -> nodeEntity.powerTelemetry = telemetryProto } } } - private fun shouldBatteryNotificationShow(fromNum: Int, t: TelemetryProtos.Telemetry): Boolean { + private fun shouldBatteryNotificationShow(fromNum: Int, telemetry: TelemetryProtos.Telemetry): Boolean { val isRemote = (fromNum != myNodeNum) + val batteryLevel = telemetry.deviceMetrics.batteryLevel var shouldDisplay = false var forceDisplay = false + when { - t.deviceMetrics.batteryLevel <= batteryPercentCriticalThreshold -> { + batteryLevel <= batteryPercentCriticalThreshold -> { shouldDisplay = true forceDisplay = true } - t.deviceMetrics.batteryLevel == batteryPercentLowThreshold -> shouldDisplay = true - t.deviceMetrics.batteryLevel.mod(batteryPercentLowDivisor) == 0 && !isRemote -> shouldDisplay = true - - isRemote -> shouldDisplay = true + batteryLevel == batteryPercentLowThreshold -> shouldDisplay = true + batteryLevel % batteryPercentLowDivisor == 0 && !isRemote -> shouldDisplay = true + isRemote -> shouldDisplay = true // For remote favorites, show if low } + if (shouldDisplay) { - val now = System.currentTimeMillis() / 1000 - if (!batteryPercentCooldowns.containsKey(fromNum)) batteryPercentCooldowns[fromNum] = 0 - if ((now - batteryPercentCooldowns[fromNum]!!) >= batteryPercentCooldownSeconds || forceDisplay) { - batteryPercentCooldowns[fromNum] = now + val nowSeconds = System.currentTimeMillis() / 1000 + val lastNotificationTime = batteryPercentCooldowns[fromNum] ?: 0L + if ((nowSeconds - lastNotificationTime) >= batteryPercentCooldownSeconds || forceDisplay) { + batteryPercentCooldowns[fromNum] = nowSeconds return true } } return false } - private fun handleReceivedPaxcounter(fromNum: Int, p: PaxcountProtos.Paxcount) { - updateNodeInfo(fromNum) { it.paxcounter = p } + private fun handleReceivedPaxcounter(fromNum: Int, paxcountProto: PaxcountProtos.Paxcount) { + updateNodeInfo(fromNum) { it.paxcounter = paxcountProto } } - private fun handleReceivedStoreAndForward(dataPacket: DataPacket, s: StoreAndForwardProtos.StoreAndForward) { - debug("StoreAndForward: ${s.variantCase} ${s.rr} from ${dataPacket.from}") - when (s.variantCase) { + private fun handleReceivedStoreAndForward( + dataPacket: DataPacket, + storeAndForwardProto: StoreAndForwardProtos.StoreAndForward, + ) { + debug("StoreAndForward: ${storeAndForwardProto.variantCase} ${storeAndForwardProto.rr} from ${dataPacket.from}") + when (storeAndForwardProto.variantCase) { StoreAndForwardProtos.StoreAndForward.VariantCase.STATS -> { - val u = + val textPacket = dataPacket.copy( - bytes = s.stats.toString().encodeToByteArray(), + bytes = storeAndForwardProto.stats.toString().encodeToByteArray(), dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE, ) - rememberDataPacket(u) + rememberDataPacket(textPacket) } StoreAndForwardProtos.StoreAndForward.VariantCase.HISTORY -> { val text = """ - Total messages: ${s.history.historyMessages} - History window: ${s.history.window / 60000} min - Last request: ${s.history.lastRequest} + Total messages: ${storeAndForwardProto.history.historyMessages} + History window: ${storeAndForwardProto.history.window / 60000} min + Last request: ${storeAndForwardProto.history.lastRequest} """ .trimIndent() - val u = + val textPacket = dataPacket.copy( bytes = text.encodeToByteArray(), dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE, ) - rememberDataPacket(u) + rememberDataPacket(textPacket) } StoreAndForwardProtos.StoreAndForward.VariantCase.TEXT -> { - if (s.rr == StoreAndForwardProtos.StoreAndForward.RequestResponse.ROUTER_TEXT_BROADCAST) { - dataPacket.to = DataPacket.ID_BROADCAST + var actualTo = dataPacket.to + if ( + storeAndForwardProto.rr == + StoreAndForwardProtos.StoreAndForward.RequestResponse.ROUTER_TEXT_BROADCAST + ) { + actualTo = DataPacket.ID_BROADCAST } - val u = - dataPacket.copy(bytes = s.text.toByteArray(), dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE) - rememberDataPacket(u) + val textPacket = + dataPacket.copy( + to = actualTo, + bytes = storeAndForwardProto.text.toByteArray(), + dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE, + ) + rememberDataPacket(textPacket) } - else -> {} + StoreAndForwardProtos.StoreAndForward.VariantCase.VARIANT_NOT_SET, + null, + -> Unit + + StoreAndForwardProtos.StoreAndForward.VariantCase.HEARTBEAT -> {} } } - // If apps try to send packets when our radio is sleeping, we queue them here instead private val offlineSentPackets = mutableListOf() - // Update our model and resend as needed for a MeshPacket we just received from the radio private fun handleReceivedMeshPacket(packet: MeshPacket) { - if (haveNodeDB) { - processReceivedMeshPacket( - packet - .toBuilder() - .apply { - // If the rxTime was not set by the device, update with current time - if (packet.rxTime == 0) setRxTime(currentSecond()) - } - .build(), - ) - onNodeDBChanged() - } else { - warn("Ignoring early received packet: ${packet.toOneLineString()}") - // earlyReceivedPackets.add(packet) - // logAssert(earlyReceivedPackets.size < 128) // The max should normally be about 32, but if the device is - // messed up it might try to send forever - } + val processedPacket = + packet + .toBuilder() + .apply { + if (rxTime == 0) setRxTime(currentSecond()) // Ensure rxTime is set + } + .build() + processReceivedMeshPacketInternal(processedPacket) + onNodeDBChanged() } private val queuedPackets = ConcurrentLinkedQueue() - private val queueResponse = mutableMapOf>() + private val queueResponse = ConcurrentHashMap>() private var queueJob: Job? = null private fun sendPacket(packet: MeshPacket): CompletableFuture { - // send the packet to the radio and return a CompletableFuture that will be completed with the result val future = CompletableFuture() queueResponse[packet.id] = future try { - if (connectionState != ConnectionState.CONNECTED) throw RadioNotConnectedException() - sendToRadio(ToRadio.newBuilder().apply { this.packet = packet }) + if (connectionRouter.connectionState.value != ConnectionState.CONNECTED) { + throw RadioNotConnectedException("Cannot send packet, radio not connected.") + } + sendToRadio(ToRadio.newBuilder().setPacket(packet)) } catch (ex: Exception) { errormsg("sendToRadio error:", ex) - future.complete(false) + queueResponse.remove(packet.id) // Clean up if send failed immediately + future.completeExceptionally(ex) // Complete with exception } return future } @@ -1138,156 +1117,137 @@ class MeshService : if (queueJob?.isActive == true) return queueJob = serviceScope.handledLaunch { - debug("packet queueJob started") - while (connectionState == ConnectionState.CONNECTED) { - // take the first packet from the queue head - val packet = queuedPackets.poll() ?: break + debug("Packet queueJob started") + while ( + connectionRouter.connectionState.value == ConnectionState.CONNECTED && queuedPackets.isNotEmpty() + ) { + val packet = queuedPackets.poll() ?: break // Should not be null if loop condition met try { - // send packet to the radio and wait for response - val response = sendPacket(packet) - debug("queueJob packet id=${packet.id.toUInt()} waiting") - val success = response.get(2, TimeUnit.MINUTES) - debug("queueJob packet id=${packet.id.toUInt()} success $success") + debug("Queue: Sending packet id=${packet.id.toUInt()}") + val success = sendPacket(packet).get(2, TimeUnit.MINUTES) + debug("Queue: Packet id=${packet.id.toUInt()} sent, success=$success") } catch (e: TimeoutException) { - debug("queueJob packet id=${packet.id.toUInt()} timeout") + debug("Queue: Packet id=${packet.id.toUInt()} timed out: ${e.message}") + queueResponse.remove(packet.id)?.complete(false) } catch (e: Exception) { - debug("queueJob packet id=${packet.id.toUInt()} failed") + debug("Queue: Packet id=${packet.id.toUInt()} failed: ${e.message}") + queueResponse.remove(packet.id)?.complete(false) } } + debug("Packet queueJob finished or radio disconnected") } } private fun stopPacketQueue() { - if (queueJob?.isActive == true) { - info("Stopping packet queueJob") - queueJob?.cancel() - queueJob = null - queuedPackets.clear() - queueResponse.entries.lastOrNull { !it.value.isDone }?.value?.complete(false) - queueResponse.clear() - } + queueJob + ?.takeIf { it.isActive } + ?.let { + info("Stopping packet queueJob") + it.cancel() + queueJob = null + queuedPackets.clear() + queueResponse.values.forEach { future -> if (!future.isDone) future.complete(false) } + queueResponse.clear() + } } - private fun sendNow(p: DataPacket) { - val packet = toMeshPacket(p) - p.time = System.currentTimeMillis() // update time to the actual time we started sending - // debug("Sending to radio: ${packet.toPIIString()}") - sendToRadio(packet) + private fun sendNow(dataPacket: DataPacket) { + val meshPacket = toMeshPacket(dataPacket) + dataPacket.time = System.currentTimeMillis() // Update time to actual send time + sendToRadio(meshPacket) } private fun processQueuedPackets() { - val sentPackets = mutableListOf() - offlineSentPackets.forEach { p -> + val packetsToSend = ArrayList(offlineSentPackets) // Avoid ConcurrentModificationException + offlineSentPackets.clear() + + packetsToSend.forEach { p -> try { sendNow(p) - sentPackets.add(p) } catch (ex: Exception) { - errormsg("Error sending queued message:", ex) + errormsg("Error sending queued message, re-queuing:", ex) + offlineSentPackets.add(p) // Re-queue if sending failed } } - offlineSentPackets.removeAll(sentPackets) } - private suspend fun getDataPacketById(packetId: Int): DataPacket? = withTimeoutOrNull(1000) { + private suspend fun getDataPacketById(packetId: Int): DataPacket? = withTimeoutOrNull(1000L) { var dataPacket: DataPacket? = null - while (dataPacket == null) { + while (dataPacket == null && isActive) { // check coroutine isActive dataPacket = packetRepository.get().getPacketById(packetId)?.data - if (dataPacket == null) delay(100) + if (dataPacket == null) delay(100L) } dataPacket } - /** Change the status on a DataPacket and update watchers */ - private fun changeStatus(packetId: Int, m: MessageStatus) = serviceScope.handledLaunch { - if (packetId != 0) { - getDataPacketById(packetId)?.let { p -> - if (p.status == m) return@handledLaunch - packetRepository.get().updateMessageStatus(p, m) - serviceBroadcasts.broadcastMessageStatus(packetId, m) - } + private fun changeStatus(packetId: Int, status: MessageStatus) = serviceScope.handledLaunch { + if (packetId == 0) return@handledLaunch // Ignore packets with no ID + + getDataPacketById(packetId)?.let { p -> + if (p.status == status) return@handledLaunch + packetRepository.get().updateMessageStatus(p, status) + serviceBroadcasts.broadcastMessageStatus(packetId, status) } } - /** Handle an ack/nak packet by updating sent message status */ private fun handleAckNak(requestId: Int, fromId: String, routingError: Int) { serviceScope.handledLaunch { val isAck = routingError == MeshProtos.Routing.Error.NONE_VALUE - val p = packetRepository.get().getPacketById(requestId) - // distinguish real ACKs coming from the intended receiver - val m = - when { - isAck && fromId == p?.data?.to -> MessageStatus.RECEIVED - isAck -> MessageStatus.DELIVERED - else -> MessageStatus.ERROR - } - if (p != null && p.data.status != MessageStatus.RECEIVED) { - p.data.status = m - p.routingError = routingError - packetRepository.get().update(p) - } - serviceBroadcasts.broadcastMessageStatus(requestId, m) - } - } + val packetEntity = packetRepository.get().getPacketById(requestId) - // Update our model and resend as needed for a MeshPacket we just received from the radio - private fun processReceivedMeshPacket(packet: MeshPacket) { - val fromNum = packet.from - - // FIXME, perhaps we could learn our node ID by looking at any to packets the radio - // decided to pass through to us (except for broadcast packets) - // val toNum = packet.to - - // debug("Received: $packet") - if (packet.hasDecoded()) { - val packetToSave = - MeshLog( - uuid = UUID.randomUUID().toString(), - message_type = "Packet", - received_date = System.currentTimeMillis(), - raw_message = packet.toString(), - fromNum = packet.from, - portNum = packet.decoded.portnumValue, - fromRadio = fromRadio { this.packet = packet }, - ) - insertMeshLog(packetToSave) - - serviceScope.handledLaunch { radioConfigRepository.emitMeshPacket(packet) } - - // Update last seen for the node that sent the packet, but also for _our node_ because anytime a packet - // passes - // through our node on the way to the phone that means that local node is also alive in the mesh - - val isOtherNode = myNodeNum != fromNum - updateNodeInfo(myNodeNum, withBroadcast = isOtherNode) { it.lastHeard = currentSecond() } - - // Do not generate redundant broadcasts of node change for this bookkeeping updateNodeInfo call - // because apps really only care about important updates of node state - which handledReceivedData will give - // them - updateNodeInfo(fromNum, withBroadcast = false, channel = packet.channel) { - // Update our last seen based on any valid timestamps. If the device didn't provide a timestamp make - // one - it.lastHeard = packet.rxTime - it.snr = packet.rxSnr - it.rssi = packet.rxRssi - - // Generate our own hopsAway, comparing hopStart to hopLimit. - it.hopsAway = - if (packet.hopStart == 0 || packet.hopLimit > packet.hopStart) { - -1 - } else { - packet.hopStart - packet.hopLimit + packetEntity?.data?.let { dataPacket -> + // Distinguish real ACKs coming from the intended receiver + val newStatus = + when { + isAck && fromId == dataPacket.to -> MessageStatus.RECEIVED + isAck -> MessageStatus.DELIVERED + else -> MessageStatus.ERROR } + if (dataPacket.status != MessageStatus.RECEIVED) { // Don't override final RECEIVED + dataPacket.status = newStatus + packetRepository.get().update(packetEntity.copy(routingError = routingError, data = dataPacket)) + } + serviceBroadcasts.broadcastMessageStatus(requestId, newStatus) } - handleReceivedData(packet) } } - private fun insertMeshLog(packetToSave: MeshLog) { - serviceScope.handledLaunch { - // Do not log, because might contain PII - // info("insert: ${packetToSave.message_type} = ${packetToSave.raw_message.toOneLineString()}") - meshLogRepository.get().insert(packetToSave) + private fun processReceivedMeshPacketInternal(packet: MeshPacket) { + if (!packet.hasDecoded()) return + + insertMeshLog( + MeshLog( + uuid = UUID.randomUUID().toString(), + message_type = "PacketReceived", // Clarified type + received_date = System.currentTimeMillis(), + raw_message = packet.toString(), + fromNum = packet.from, + portNum = packet.decoded.portnumValue, + fromRadio = fromRadio { this.packet = packet }, + ), + ) + serviceScope.handledLaunch { radioConfigRepository.emitMeshPacket(packet) } + + val isOtherNode = myNodeNum != packet.from + // Update our own node's lastHeard as we are clearly active to receive this + updateNodeInfo(myNodeNum, withBroadcast = isOtherNode) { it.lastHeard = currentSecond() } + + updateNodeInfo(packet.from, withBroadcast = false, channel = packet.channel) { + it.lastHeard = packet.rxTime + it.snr = packet.rxSnr + it.rssi = packet.rxRssi + it.hopsAway = + if (packet.hopStart == 0 || packet.hopLimit > packet.hopStart) { + -1 // Unknown or direct + } else { + packet.hopStart - packet.hopLimit + } } + handleReceivedData(packet) + } + + private fun insertMeshLog(meshLog: MeshLog) { + serviceScope.handledLaunch { meshLogRepository.get().insert(meshLog) } } private fun setLocalConfig(config: ConfigProtos.Config) { @@ -1298,17 +1258,15 @@ class MeshService : serviceScope.handledLaunch { radioConfigRepository.setLocalModuleConfig(config) } } - private fun updateChannelSettings(ch: ChannelProtos.Channel) = - serviceScope.handledLaunch { radioConfigRepository.updateChannelSettings(ch) } + private fun updateChannelSettings(channel: ChannelProtos.Channel) = + serviceScope.handledLaunch { radioConfigRepository.updateChannelSettings(channel) } private fun currentSecond() = (System.currentTimeMillis() / 1000).toInt() - // If we just changed our nodedb, we might want to do somethings private fun onNodeDBChanged() { maybeUpdateServiceStatusNotification() } - /** Send in analytics about mesh connection */ private fun reportConnection() { val radioModel = DataPair("radio_model", myNodeInfo?.model ?: "unknown") GeeksvilleApplication.analytics.track( @@ -1317,124 +1275,67 @@ class MeshService : DataPair("num_online", numOnlineNodes), radioModel, ) - - // Once someone connects to hardware start tracking the approximate number of nodes in their mesh - // this allows us to collect stats on what typical mesh size is and to tell difference between users who just - // downloaded the app, vs has connected it to some hardware. GeeksvilleApplication.analytics.setUserInfo(DataPair("num_nodes", numNodes), radioModel) } - private var sleepTimeout: Job? = null - - // msecs since 1970 we started this connection private var connectTimeMsec = 0L - // Called when we gain/lose connection to our radio - private fun onConnectionChanged(c: ConnectionState) { - debug("onConnectionChanged: $connectionState -> $c") - - // Perform all the steps needed once we start waiting for device sleep to complete - fun startDeviceSleep() { - stopPacketQueue() - stopLocationRequests() - stopMqttClientProxy() - - if (connectTimeMsec != 0L) { - val now = System.currentTimeMillis() - connectTimeMsec = 0L - - GeeksvilleApplication.analytics.track("connected_seconds", DataPair((now - connectTimeMsec) / 1000.0)) - } - - // Have our timeout fire in the appropriate number of seconds - sleepTimeout = - serviceScope.handledLaunch { - try { - // If we have a valid timeout, wait that long (+30 seconds) otherwise, just wait 30 seconds - val timeout = (localConfig.power?.lsSecs ?: 0) + 30 - - debug("Waiting for sleeping device, timeout=$timeout secs") - delay(timeout * 1000L) - warn("Device timeout out, setting disconnected") - onConnectionChanged(ConnectionState.DISCONNECTED) - } catch (ex: CancellationException) { - debug("device sleep timeout cancelled") - } + private fun startConnect() { + try { + connectTimeMsec = System.currentTimeMillis() + sendConfigOnlyRequest() + } catch (ex: Exception) { + when (ex) { + is InvalidProtocolBufferException, + is RadioNotConnectedException, + is RemoteException, + -> { + errormsg("Failed to start connection sequence: ${ex.message}", ex) } - // broadcast an intent with our new connection state - serviceBroadcasts.broadcastConnection() - } - - fun startDisconnect() { - stopPacketQueue() - stopLocationRequests() - stopMqttClientProxy() - - GeeksvilleApplication.analytics.track( - "mesh_disconnect", - DataPair("num_nodes", numNodes), - DataPair("num_online", numOnlineNodes), - ) - GeeksvilleApplication.analytics.track("num_nodes", DataPair(numNodes)) - - // broadcast an intent with our new connection state - serviceBroadcasts.broadcastConnection() - } - - fun startConnect() { - // Do our startup init - try { - connectTimeMsec = System.currentTimeMillis() - startConfigOnly() - } catch (ex: InvalidProtocolBufferException) { - errormsg("Invalid protocol buffer sent by device - update device software and try again", ex) - } catch (ex: RadioNotConnectedException) { - // note: no need to call startDeviceSleep(), because this exception could only have reached us if it was - // already called - errormsg("Lost connection to radio during init - waiting for reconnect ${ex.message}") - } catch (ex: RemoteException) { - // It seems that when the ESP32 goes offline it can briefly come back for a 100ms ish which - // causes the phone to try and reconnect. If we fail downloading our initial radio state we don't want - // to - // claim we have a valid connection still - connectionState = ConnectionState.DEVICE_SLEEP - startDeviceSleep() - throw ex // Important to rethrow so that we don't tell the app all is well + else -> throw ex } } + } - // Cancel any existing timeouts - sleepTimeout?.let { - it.cancel() - sleepTimeout = null + private fun startDeviceSleep() { + stopPacketQueue() + stopLocationRequests() + stopMqttClientProxy() + + if (connectTimeMsec != 0L) { + val now = System.currentTimeMillis() + GeeksvilleApplication.analytics.track("connected_seconds", DataPair((now - connectTimeMsec) / 1000.0)) + connectTimeMsec = 0L } + serviceBroadcasts.broadcastConnection() + } - connectionState = c - when (c) { - ConnectionState.CONNECTED -> startConnect() - ConnectionState.DEVICE_SLEEP -> startDeviceSleep() - ConnectionState.DISCONNECTED -> startDisconnect() - } + private fun startDisconnect() { + stopPacketQueue() + stopLocationRequests() + stopMqttClientProxy() - // Update the android notification in the status bar - maybeUpdateServiceStatusNotification() + GeeksvilleApplication.analytics.track( + "mesh_disconnect", + DataPair("num_nodes", numNodes), + DataPair("num_online", numOnlineNodes), + ) + GeeksvilleApplication.analytics.track("num_nodes", DataPair(numNodes)) + serviceBroadcasts.broadcastConnection() } private fun maybeUpdateServiceStatusNotification() { - var update = false val currentSummary = notificationSummary val currentStats = localStats val currentStatsUpdatedAtMillis = localStatsUpdatedAtMillis - if (!currentSummary.isNullOrBlank() && (previousSummary == null || !previousSummary.equals(currentSummary))) { + + val summaryChanged = currentSummary.isNotBlank() && previousSummary != currentSummary + val statsChanged = currentStats != null && previousStats != currentStats + + if (summaryChanged || statsChanged) { previousSummary = currentSummary - update = true - } - if (currentStats != null && (previousStats == null || !(previousStats?.equals(currentStats) ?: false))) { previousStats = currentStats - update = true - } - if (update) { serviceNotifications.updateServiceStateNotification( summaryString = currentSummary, localStats = currentStats, @@ -1443,75 +1344,56 @@ class MeshService : } } - private fun onRadioConnectionState(state: RadioServiceConnectionState) { - // sleep now disabled by default on ESP32, permanent is true unless light sleep enabled - val isRouter = localConfig.device.role == 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 - }, - ) - } - + @SuppressLint("CheckResult") @Suppress("CyclomaticComplexMethod") private fun onReceiveFromRadio(bytes: ByteArray) { try { val proto = MeshProtos.FromRadio.parseFrom(bytes) - // info("Received from radio service: ${proto.toOneLineString()}") - when (proto.payloadVariantCase.number) { - MeshProtos.FromRadio.PACKET_FIELD_NUMBER -> handleReceivedMeshPacket(proto.packet) - MeshProtos.FromRadio.CONFIG_COMPLETE_ID_FIELD_NUMBER -> handleConfigComplete(proto.configCompleteId) - MeshProtos.FromRadio.MY_INFO_FIELD_NUMBER -> handleMyInfo(proto.myInfo) - MeshProtos.FromRadio.NODE_INFO_FIELD_NUMBER -> handleNodeInfo(proto.nodeInfo) - MeshProtos.FromRadio.CHANNEL_FIELD_NUMBER -> handleChannel(proto.channel) - MeshProtos.FromRadio.CONFIG_FIELD_NUMBER -> handleDeviceConfig(proto.config) - MeshProtos.FromRadio.MODULECONFIG_FIELD_NUMBER -> handleModuleConfig(proto.moduleConfig) - MeshProtos.FromRadio.QUEUESTATUS_FIELD_NUMBER -> handleQueueStatus(proto.queueStatus) - MeshProtos.FromRadio.METADATA_FIELD_NUMBER -> handleMetadata(proto.metadata) - MeshProtos.FromRadio.MQTTCLIENTPROXYMESSAGE_FIELD_NUMBER -> + when (proto.payloadVariantCase) { + MeshProtos.FromRadio.PayloadVariantCase.PACKET -> handleReceivedMeshPacket(proto.packet) + MeshProtos.FromRadio.PayloadVariantCase.CONFIG_COMPLETE_ID -> + handleConfigComplete(proto.configCompleteId) + + MeshProtos.FromRadio.PayloadVariantCase.MY_INFO -> handleMyInfo(proto.myInfo) + MeshProtos.FromRadio.PayloadVariantCase.NODE_INFO -> handleNodeInfo(proto.nodeInfo) + MeshProtos.FromRadio.PayloadVariantCase.CHANNEL -> handleChannel(proto.channel) + MeshProtos.FromRadio.PayloadVariantCase.CONFIG -> handleDeviceConfig(proto.config) + MeshProtos.FromRadio.PayloadVariantCase.MODULECONFIG -> handleModuleConfig(proto.moduleConfig) + MeshProtos.FromRadio.PayloadVariantCase.QUEUESTATUS -> handleQueueStatus(proto.queueStatus) + MeshProtos.FromRadio.PayloadVariantCase.METADATA -> handleMetadata(proto.metadata) + MeshProtos.FromRadio.PayloadVariantCase.MQTTCLIENTPROXYMESSAGE -> handleMqttProxyMessage(proto.mqttClientProxyMessage) - MeshProtos.FromRadio.CLIENTNOTIFICATION_FIELD_NUMBER -> { + MeshProtos.FromRadio.PayloadVariantCase.DEVICEUICONFIG -> handleDeviceUiConfig(proto.deviceuiConfig) + + MeshProtos.FromRadio.PayloadVariantCase.FILEINFO -> handleFileInfo(proto.fileInfo) + + MeshProtos.FromRadio.PayloadVariantCase.CLIENTNOTIFICATION -> handleClientNotification(proto.clientNotification) - } - MeshProtos.FromRadio.DEVICEUICONFIG_FIELD_NUMBER -> handleDevicUiConfig(proto.deviceuiConfig) - - MeshProtos.FromRadio.FILEINFO_FIELD_NUMBER -> handleFileInfo(proto.fileInfo) - - else -> errormsg("Unexpected FromRadio variant") + MeshProtos.FromRadio.PayloadVariantCase.LOG_RECORD -> {} + MeshProtos.FromRadio.PayloadVariantCase.REBOOTED -> {} + MeshProtos.FromRadio.PayloadVariantCase.XMODEMPACKET -> {} + MeshProtos.FromRadio.PayloadVariantCase.PAYLOADVARIANT_NOT_SET, + null, + -> errormsg("Unexpected FromRadio variant") } } catch (ex: InvalidProtocolBufferException) { errormsg("Invalid Protobuf from radio, len=${bytes.size}", ex) } } - // A provisional MyNodeInfo that we will install if all of our node config downloads go okay - private var newMyNodeInfo: MyNodeEntity? = null - - // provisional NodeInfos we will install if all goes well - private val newNodes = mutableListOf() - - // Used to make sure we never get foold by old BLE packets - private var configOnlyNonce = 69420 - private var nodeInfoNonce = 69421 - private fun handleDeviceConfig(config: ConfigProtos.Config) { debug("Received config ${config.toOneLineString()}") - val packetToSave = + insertMeshLog( MeshLog( uuid = UUID.randomUUID().toString(), message_type = "Config ${config.payloadVariantCase}", received_date = System.currentTimeMillis(), raw_message = config.toString(), fromRadio = fromRadio { this.config = config }, - ) - insertMeshLog(packetToSave) + ), + ) setLocalConfig(config) val configCount = localConfig.allFields.size radioConfigRepository.setStatusMessage("Device config ($configCount / $configTotal)") @@ -1519,15 +1401,15 @@ class MeshService : private fun handleModuleConfig(config: ModuleConfigProtos.ModuleConfig) { debug("Received moduleConfig ${config.toOneLineString()}") - val packetToSave = + insertMeshLog( MeshLog( uuid = UUID.randomUUID().toString(), message_type = "ModuleConfig ${config.payloadVariantCase}", received_date = System.currentTimeMillis(), raw_message = config.toString(), - fromRadio = fromRadio { moduleConfig = config }, - ) - insertMeshLog(packetToSave) + fromRadio = fromRadio { this.moduleConfig = config }, + ), + ) setLocalModuleConfig(config) val moduleCount = moduleConfig.allFields.size radioConfigRepository.setStatusMessage("Module config ($moduleCount / $moduleTotal)") @@ -1536,33 +1418,36 @@ class MeshService : private fun handleQueueStatus(queueStatus: MeshProtos.QueueStatus) { debug("queueStatus ${queueStatus.toOneLineString()}") val (success, isFull, requestId) = with(queueStatus) { Triple(res == 0, free == 0, meshPacketId) } - if (success && isFull) return // Queue is full, wait for free != 0 - if (requestId != 0) { - queueResponse.remove(requestId)?.complete(success) - } else { - queueResponse.entries.lastOrNull { !it.value.isDone }?.value?.complete(success) - } + if (success && isFull) return // Queue is full, wait for next update + + val future = + if (requestId != 0) { + queueResponse.remove(requestId) + } else { + // This is a bit of a guess, but for now we assume it's for the last request that isn't done. + // A more robust solution would involve matching something other than packetId. + queueResponse.entries.lastOrNull { !it.value.isDone }?.also { queueResponse.remove(it.key) }?.value + } + future?.complete(success) } private fun handleChannel(ch: ChannelProtos.Channel) { debug("Received channel ${ch.index}") - val packetToSave = + insertMeshLog( MeshLog( uuid = UUID.randomUUID().toString(), message_type = "Channel", received_date = System.currentTimeMillis(), raw_message = ch.toString(), fromRadio = fromRadio { channel = ch }, - ) - insertMeshLog(packetToSave) + ), + ) if (ch.role != ChannelProtos.Channel.Role.DISABLED) updateChannelSettings(ch) val maxChannels = myNodeInfo?.maxChannels ?: 8 radioConfigRepository.setStatusMessage("Channels (${ch.index + 1} / $maxChannels)") } - /** Convert a protobuf NodeInfo into our model objects and update our node DB */ private fun installNodeInfo(info: MeshProtos.NodeInfo) { - // Just replace/add any entry updateNodeInfo(info.num) { if (info.hasUser()) { it.user = @@ -1573,29 +1458,18 @@ class MeshService : it.longName = it.user.longName it.shortName = it.user.shortName } - if (info.hasPosition()) { it.position = info.position it.latitude = Position.degD(info.position.latitudeI) it.longitude = Position.degD(info.position.longitudeI) } - it.lastHeard = info.lastHeard - if (info.hasDeviceMetrics()) { it.deviceTelemetry = telemetry { deviceMetrics = info.deviceMetrics } } - it.channel = info.channel it.viaMqtt = info.viaMqtt - - // hopsAway should be nullable/optional from the proto, but explicitly checking it's existence first - it.hopsAway = - if (info.hasHopsAway()) { - info.hopsAway - } else { - -1 - } + it.hopsAway = if (info.hasHopsAway()) info.hopsAway else -1 it.isFavorite = info.isFavorite it.isIgnored = info.isIgnored } @@ -1603,32 +1477,24 @@ class MeshService : private fun handleNodeInfo(info: MeshProtos.NodeInfo) { debug( - "Received nodeinfo num=${info.num}," + - " hasUser=${info.hasUser()}," + - " hasPosition=${info.hasPosition()}," + - " hasDeviceMetrics=${info.hasDeviceMetrics()}", + "Received nodeinfo num=${info.num}, hasUser=${info.hasUser()}, " + + "hasPosition=${info.hasPosition()}, hasDeviceMetrics=${info.hasDeviceMetrics()}", ) - - val packetToSave = + insertMeshLog( MeshLog( uuid = UUID.randomUUID().toString(), message_type = "NodeInfo", received_date = System.currentTimeMillis(), raw_message = info.toString(), fromRadio = fromRadio { nodeInfo = info }, - ) - insertMeshLog(packetToSave) + ), + ) - newNodes.add(info) - radioConfigRepository.setStatusMessage("Nodes (${newNodes.size})") + installNodeInfo(info) + onNodeDBChanged() + radioConfigRepository.setStatusMessage("Nodes ($numNodes)") } - private var rawMyNodeInfo: MeshProtos.MyNodeInfo? = null - - /** - * Regenerate the myNodeInfo model. We call this twice. Once after we receive myNodeInfo from the device and again - * after we have the node DB (which might allow us a better notion of our HwModel. - */ private fun regenMyNodeInfo(metadata: MeshProtos.DeviceMetadata) { val myInfo = rawMyNodeInfo if (myInfo != null) { @@ -1655,38 +1521,35 @@ class MeshService : deviceId = deviceId.toStringUtf8(), ) } - serviceScope.handledLaunch { radioConfigRepository.insertMetadata(mi.myNodeNum, metadata) } - newMyNodeInfo = mi + serviceScope.handledLaunch { + radioConfigRepository.installMyNodeInfo(mi) + radioConfigRepository.insertMetadata(mi.myNodeNum, metadata) + } + myNodeInfo = mi + onConnected() } } private fun sendAnalytics() { - val myInfo = rawMyNodeInfo - val mi = myNodeInfo - if (myInfo != null && mi != null) { - // Track types of devices and firmware versions in use + myNodeInfo?.let { GeeksvilleApplication.analytics.setUserInfo( - DataPair("firmware", mi.firmwareVersion), - DataPair("hw_model", mi.model), + DataPair("firmware", it.firmwareVersion), + DataPair("hw_model", it.model), ) } } - /** Update MyNodeInfo (called from either new API version or the old one) */ private fun handleMyInfo(myInfo: MeshProtos.MyNodeInfo) { - val packetToSave = + insertMeshLog( MeshLog( uuid = UUID.randomUUID().toString(), message_type = "MyNodeInfo", received_date = System.currentTimeMillis(), raw_message = myInfo.toString(), fromRadio = fromRadio { this.myInfo = myInfo }, - ) - insertMeshLog(packetToSave) - + ), + ) rawMyNodeInfo = myInfo - - // We'll need to get a new set of channels and settings now serviceScope.handledLaunch { radioConfigRepository.clearChannelSet() radioConfigRepository.clearLocalConfig() @@ -1694,7 +1557,7 @@ class MeshService : } } - private fun handleDevicUiConfig(deviceuiConfig: DeviceUIProtos.DeviceUIConfig) { + private fun handleDeviceUiConfig(deviceuiConfig: DeviceUIProtos.DeviceUIConfig) { debug("Received DeviceUIConfig ${deviceuiConfig.toOneLineString()}") val packetToSave = MeshLog( @@ -1720,35 +1583,30 @@ class MeshService : insertMeshLog(packetToSave) } - /** Update our DeviceMetadata */ private fun handleMetadata(metadata: MeshProtos.DeviceMetadata) { debug("Received deviceMetadata ${metadata.toOneLineString()}") - val packetToSave = + insertMeshLog( MeshLog( uuid = UUID.randomUUID().toString(), message_type = "DeviceMetadata", received_date = System.currentTimeMillis(), raw_message = metadata.toString(), fromRadio = fromRadio { this.metadata = metadata }, - ) - insertMeshLog(packetToSave) - + ), + ) regenMyNodeInfo(metadata) } - /** Publish MqttClientProxyMessage (fromRadio) */ private fun handleMqttProxyMessage(message: MeshProtos.MqttClientProxyMessage) { with(message) { when (payloadVariantCase) { - MeshProtos.MqttClientProxyMessage.PayloadVariantCase.TEXT -> { + MeshProtos.MqttClientProxyMessage.PayloadVariantCase.TEXT -> mqttRepository.publish(topic, text.encodeToByteArray(), retained) - } - MeshProtos.MqttClientProxyMessage.PayloadVariantCase.DATA -> { + MeshProtos.MqttClientProxyMessage.PayloadVariantCase.DATA -> mqttRepository.publish(topic, data.toByteArray(), retained) - } - else -> {} + else -> Unit } } } @@ -1757,143 +1615,103 @@ class MeshService : debug("Received clientNotification ${notification.toOneLineString()}") radioConfigRepository.setClientNotification(notification) serviceNotifications.showClientNotification(notification) - // if the future for the originating request is still in the queue, complete as unsuccessful for now queueResponse.remove(notification.replyId)?.complete(false) } - /** Connect, subscribe and receive Flow of MqttClientProxyMessage (toRadio) */ private fun startMqttClientProxy() { if (mqttMessageFlow?.isActive == true) return if (moduleConfig.mqtt.enabled && moduleConfig.mqtt.proxyToClientEnabled) { mqttMessageFlow = mqttRepository.proxyMessageFlow - .onEach { message -> sendToRadio(ToRadio.newBuilder().apply { mqttClientProxyMessage = message }) } + .onEach { message -> sendToRadio(ToRadio.newBuilder().setMqttClientProxyMessage(message)) } .catch { throwable -> radioConfigRepository.setErrorMessage("MqttClientProxy failed: $throwable") } .launchIn(serviceScope) } } private fun stopMqttClientProxy() { - if (mqttMessageFlow?.isActive == true) { - info("Stopping MqttClientProxy") - mqttMessageFlow?.cancel() - mqttMessageFlow = null - } + mqttMessageFlow + ?.takeIf { it.isActive } + ?.let { + info("Stopping MqttClientProxy") + it.cancel() + mqttMessageFlow = null + } } - // If we've received our initial config, our radio settings and all of our channels, send any queued packets and - // broadcast connected to clients - private fun onHasSettings() { - processQueuedPackets() // send any packets that were queued up + private fun onConnected() { + // Start sending queued packets and other tasks + processQueuedPackets() startMqttClientProxy() + onNodeDBChanged() serviceBroadcasts.broadcastConnection() - sendToRadio(newMeshPacketTo(myNodeNum).buildAdminPacket { setTimeOnly = currentSecond() }) sendAnalytics() reportConnection() + sendToRadio(newMeshPacketTo(myNodeNum).buildAdminPacket { setTimeOnly = currentSecond() }) } private fun handleConfigComplete(configCompleteId: Int) { - if (configCompleteId == configOnlyNonce) { - debug("Received config complete for config-only nonce $configOnlyNonce") - handleConfigOnlyComplete() - } else if (configCompleteId == nodeInfoNonce) { - debug("Received node info complete for nonce $nodeInfoNonce") - handleNodeInfoComplete() - } else { - warn("Received unexpected config complete id $configCompleteId") + when (configCompleteId) { + CONFIG_ONLY_NONCE -> handleConfigOnlyNonceResponse() + NODE_INFO_ONLY_NONCE -> handleNodeInfoNonceResponse() + else -> warn("Received unexpected config complete id $configCompleteId") } } - private fun handleConfigOnlyComplete() { - debug("Received config only complete for nonce $configOnlyNonce") - val packetToSave = + private fun handleConfigOnlyNonceResponse() { + debug("Received config only complete for nonce $CONFIG_ONLY_NONCE") + insertMeshLog( MeshLog( uuid = UUID.randomUUID().toString(), message_type = "ConfigOnlyComplete", received_date = System.currentTimeMillis(), - raw_message = configOnlyNonce.toString(), - fromRadio = fromRadio { this.configCompleteId = configOnlyNonce }, - ) - insertMeshLog(packetToSave) - - // This was our config request - if (newMyNodeInfo == null) { - errormsg("Did not receive a valid config") - } else { - myNodeInfo = newMyNodeInfo - } - startNodeInfoOnly() - onHasSettings() + raw_message = CONFIG_ONLY_NONCE.toString(), + fromRadio = fromRadio { this.configCompleteId = CONFIG_ONLY_NONCE }, + ), + ) + // we have recieved the response to our ConfigOnly request + // send a heartbeat, then request NodeInfoOnly to get the nodeDb from the radio + serviceScope.handledLaunch { radioInterfaceService.keepAlive() } + sendNodeInfoOnlyRequest() } - private fun handleNodeInfoComplete() { - debug("Received node info complete for nonce $nodeInfoNonce") - val packetToSave = + private fun handleNodeInfoNonceResponse() { + debug("Received node info complete for nonce $NODE_INFO_ONLY_NONCE") + insertMeshLog( MeshLog( uuid = UUID.randomUUID().toString(), message_type = "NodeInfoComplete", received_date = System.currentTimeMillis(), - raw_message = nodeInfoNonce.toString(), - fromRadio = fromRadio { this.configCompleteId = nodeInfoNonce }, - ) - insertMeshLog(packetToSave) - - // This was our config request - if (newNodes.isEmpty()) { - errormsg("Did not receive a valid node info") - } else { - newNodes.forEach(::installNodeInfo) - newNodes.clear() // Just to save RAM ;-) - - serviceScope.handledLaunch { - radioConfigRepository.installNodeDB(myNodeInfo!!, nodeDBbyNodeNum.values.toList()) - } - - haveNodeDB = true // we now have nodes from real hardware - - sendAnalytics() - onNodeDBChanged() - } + raw_message = NODE_INFO_ONLY_NONCE.toString(), + fromRadio = fromRadio { this.configCompleteId = NODE_INFO_ONLY_NONCE }, + ), + ) } - /** Start the modern (REV2) API configuration flow */ - private fun startConfigOnly() { - newMyNodeInfo = null - - debug("Starting config only nonce=$configOnlyNonce") - - sendToRadio(ToRadio.newBuilder().apply { this.wantConfigId = configOnlyNonce }) + private fun sendConfigOnlyRequest() { + resetState() + debug("Starting config only with nonce=$CONFIG_ONLY_NONCE") + sendToRadio(ToRadio.newBuilder().setWantConfigId(CONFIG_ONLY_NONCE)) } - private fun startNodeInfoOnly() { - newNodes.clear() - - debug("Starting node info nonce=$nodeInfoNonce") - - sendToRadio(ToRadio.newBuilder().apply { this.wantConfigId = nodeInfoNonce }) + private fun sendNodeInfoOnlyRequest() { + debug("Starting node info with nonce=$NODE_INFO_ONLY_NONCE") + sendToRadio(ToRadio.newBuilder().setWantConfigId(NODE_INFO_ONLY_NONCE)) } - /** Send a position (typically from our built in GPS) into the mesh. */ private fun sendPosition(position: MeshProtos.Position, destNum: Int? = null, wantResponse: Boolean = false) { try { - val mi = myNodeInfo - if (mi != null) { - val idNum = destNum ?: mi.myNodeNum // when null we just send to the local node - debug("Sending our position/time to=$idNum ${Position(position)}") + myNodeInfo?.let { mi -> + val targetNodeNum = destNum ?: mi.myNodeNum + debug("Sending our position/time to=$targetNodeNum ${Position(position)}") - // Also update our own map for our nodeNum, by handling the packet just like packets from other users if (!localConfig.position.fixedPosition) { handleReceivedPosition(mi.myNodeNum, position) } sendToRadio( - newMeshPacketTo(idNum).buildMeshPacket( - channel = - if (destNum == null) { - 0 - } else { - nodeDBbyNodeNum[destNum]?.channel ?: 0 - }, + newMeshPacketTo(targetNodeNum).buildMeshPacket( + channel = if (destNum == null) 0 else (nodeDBbyNodeNum[destNum]?.channel ?: 0), priority = MeshPacket.Priority.BACKGROUND, ) { portnumValue = Portnums.PortNum.POSITION_APP_VALUE @@ -1903,51 +1721,32 @@ class MeshService : ) } } catch (ex: BLEException) { - warn("Ignoring disconnected radio during gps location update") + warn("Ignoring disconnected radio during gps location update: ${ex.message}") } } - /** Send setOwner admin packet with [MeshProtos.User] protobuf */ - private fun setOwner(packetId: Int, user: MeshProtos.User) = with(user) { - val dest = nodeDBbyID[id] ?: throw Exception("Can't set user without a NodeInfo") // this shouldn't happen - val old = dest.user - - @Suppress("ComplexCondition") - if (user == old) { + private fun setOwner(packetId: Int, user: MeshProtos.User) { + val dest = nodeDBbyID[user.id] ?: throw Exception("Can't set user without a NodeInfo") + if (user == dest.user) { debug("Ignoring nop owner change") - } else { - debug( - "setOwner Id: $id longName: ${longName.anonymize}" + - " shortName: $shortName isLicensed: $isLicensed" + - " isUnmessagable: $isUnmessagable", - ) - - // Also update our own map for our nodeNum, by handling the packet just like packets from other users - handleReceivedUser(dest.num, user) - - // encapsulate our payload in the proper protobuf and fire it off - sendToRadio(newMeshPacketTo(dest.num).buildAdminPacket(id = packetId) { setOwner = user }) + return } + + debug("setOwner Id: ${user.id} longName: ${user.longName.anonymize} shortName: ${user.shortName}") + handleReceivedUser(dest.num, user) + sendToRadio(newMeshPacketTo(dest.num).buildAdminPacket(id = packetId) { setOwner = user }) } - // Do not use directly, instead call generatePacketId() - private var currentPacketId = Random(System.currentTimeMillis()).nextLong().absoluteValue + private val packetIdGenerator = AtomicLong(Random().nextLong()) - /** Generate a unique packet ID (if we know enough to do so - otherwise return 0 so the device will do it) */ - @Synchronized private fun generatePacketId(): Int { - val numPacketIds = ((1L shl 32) - 1) // A mask for only the valid packet ID bits, either 255 or maxint - - currentPacketId++ - - currentPacketId = currentPacketId and 0xffffffff // keep from exceeding 32 bits - - // Use modulus and +1 to ensure we skip 0 on any values we return - return ((currentPacketId % numPacketIds) + 1L).toInt() + // We need a 32 bit unsigned integer, but since Java doesn't have unsigned, + // we can use a long and mask it. To ensure it's never 0, we add 1 after masking. + return (packetIdGenerator.incrementAndGet() and 0xFFFFFFFFL).toInt().let { if (it == 0) 1 else it } } private fun enqueueForSending(p: DataPacket) { - if (p.dataType in rememberDataType) { + if (p.dataType in rememberableDataTypes) { offlineSentPackets.add(p) } } @@ -2004,19 +1803,18 @@ class MeshService : } private fun sendReaction(reaction: ServiceAction.Reaction) = toRemoteExceptions { - // contactKey: unique contact key filter (channel)+(nodeId) val channel = reaction.contactKey[0].digitToInt() - val destNum = reaction.contactKey.substring(1) + val destId = reaction.contactKey.substring(1) val packet = - newMeshPacketTo(destNum).buildMeshPacket(channel = channel, priority = MeshPacket.Priority.BACKGROUND) { + newMeshPacketTo(destId).buildMeshPacket(channel = channel, priority = MeshPacket.Priority.BACKGROUND) { emoji = 1 replyId = reaction.replyId portnumValue = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE payload = ByteString.copyFrom(reaction.emoji.encodeToByteArray()) } sendToRadio(packet) - rememberReaction(packet.copy { from = myNodeNum }) + rememberReaction(packet.toBuilder().setFrom(myNodeNum).build()) } private val _lastAddress: MutableStateFlow = MutableStateFlow(null) @@ -2031,29 +1829,15 @@ class MeshService : } private fun updateLastAddress(deviceAddr: String?) { - debug("setDeviceAddress: Passing through device change to radio service: ${deviceAddr.anonymize}") - when (deviceAddr) { - null, - "", - -> { - debug("SetDeviceAddress: No previous device address, setting new one") - _lastAddress.value = deviceAddr - sharedPreferences.edit { putString("device_address", deviceAddr) } - } + val currentAddr = lastAddress.value + debug("setDeviceAddress: New: ${deviceAddr.anonymize}, Old: ${currentAddr.anonymize}") - lastAddress.value, - NO_DEVICE_SELECTED, - -> { - debug("SetDeviceAddress: Device address is the none or same, ignoring") - } - - else -> { - debug("SetDeviceAddress: Device address changed from $lastAddress to $deviceAddr") - _lastAddress.value = deviceAddr - sharedPreferences.edit { putString("device_address", deviceAddr) } - clearDatabases() - clearNotifications() - } + if (deviceAddr != currentAddr) { + _lastAddress.value = deviceAddr ?: NO_DEVICE_SELECTED + sharedPreferences.edit { putString(DEVICE_ADDRESS_KEY, deviceAddr) } + clearNotifications() + clearDatabases() + resetState() } } @@ -2063,36 +1847,26 @@ class MeshService : private val binder = object : IMeshService.Stub() { - override fun setDeviceAddress(deviceAddr: String?) = toRemoteExceptions { debug("Passing through device change to radio service: ${deviceAddr.anonymize}") updateLastAddress(deviceAddr) - val res = radioInterfaceService.setDeviceAddress(deviceAddr) - if (res) { - discardNodeDB() - } else { - serviceBroadcasts.broadcastConnection() - } - res + sharedPreferences.edit { putString("device_address", deviceAddr) } + connectionRouter.setDeviceAddress(deviceAddr) } - // Note: bound methods don't get properly exception caught/logged, so do that with a wrapper - // per https://blog.classycode.com/dealing-with-exceptions-in-aidl-9ba904c6d63 override fun subscribeReceiver(packageName: String, receiverName: String) = toRemoteExceptions { clientPackages[receiverName] = packageName } - override fun getUpdateStatus(): Int = -4 // ProgressNotStarted + override fun getUpdateStatus(): Int = -4 // ProgressNotStarted (DEPRECATED) - override fun startFirmwareUpdate() = toRemoteExceptions { - // TODO reimplement this after we have a new firmware update mechanism - } + override fun startFirmwareUpdate() = toRemoteExceptions {} override fun getMyNodeInfo(): MyNodeInfo? = this@MeshService.myNodeInfo?.toMyNodeInfo() - override fun getMyId() = toRemoteExceptions { myNodeID } + override fun getMyId(): String = toRemoteExceptions { myNodeID } - override fun getPacketId() = toRemoteExceptions { generatePacketId() } + override fun getPacketId(): Int = toRemoteExceptions { generatePacketId() } override fun setOwner(user: MeshUser) = toRemoteExceptions { setOwner( @@ -2120,24 +1894,20 @@ class MeshService : override fun send(p: DataPacket) { toRemoteExceptions { if (p.id == 0) p.id = generatePacketId() - info( - "sendData dest=${p.to}, id=${p.id} <- ${p.bytes!!.size} bytes" + - " (connectionState=$connectionState)", + "sendData dest=${p.to}, id=${p.id} <- ${p.bytes?.size} bytes " + + "(connectionState=${connectionRouter.connectionState.value})", ) - if (p.dataType == 0) { - throw Exception("Port numbers must be non-zero!") // we are now more strict - } - - if (p.bytes.size >= MeshProtos.Constants.DATA_PAYLOAD_LEN.number) { + if (p.dataType == 0) throw InvalidProtocolBufferException("Port numbers must be non-zero") + if ((p.bytes?.size ?: 0) >= MeshProtos.Constants.DATA_PAYLOAD_LEN_VALUE) { p.status = MessageStatus.ERROR throw RemoteException("Message too long") } else { p.status = MessageStatus.QUEUED } - if (connectionState == ConnectionState.CONNECTED) { + if (connectionRouter.connectionState.value == ConnectionState.CONNECTED) { try { sendNow(p) } catch (ex: Exception) { @@ -2148,16 +1918,13 @@ class MeshService : enqueueForSending(p) } serviceBroadcasts.broadcastMessageStatus(p) - - // Keep a record of DataPackets, so GUIs can show proper chat history rememberDataPacket(p, false) GeeksvilleApplication.analytics.track( "data_send", - DataPair("num_bytes", p.bytes.size), + DataPair("num_bytes", p.bytes?.size), DataPair("type", p.dataType), ) - GeeksvilleApplication.analytics.track("num_data_sent", DataPair(1)) } } @@ -2166,7 +1933,6 @@ class MeshService : this@MeshService.localConfig.toByteArray() ?: throw NoDeviceConfigException() } - /** Send our current radio config to the device */ override fun setConfig(payload: ByteArray) = toRemoteExceptions { setRemoteConfig(generatePacketId(), myNodeNum, payload) } @@ -2175,27 +1941,22 @@ class MeshService : debug("Setting new radio config!") val config = ConfigProtos.Config.parseFrom(payload) sendToRadio(newMeshPacketTo(num).buildAdminPacket(id = id) { setConfig = config }) - if (num == myNodeNum) setLocalConfig(config) // Update our local copy + if (num == myNodeNum) setLocalConfig(config) } override fun getRemoteConfig(id: Int, destNum: Int, config: Int) = toRemoteExceptions { sendToRadio( newMeshPacketTo(destNum).buildAdminPacket(id = id, wantResponse = true) { - if (config == AdminProtos.AdminMessage.ConfigType.SESSIONKEY_CONFIG_VALUE) { - getDeviceMetadataRequest = true - } else { - getConfigRequestValue = config - } + getConfigRequestValue = config }, ) } - /** Send our current module config to the device */ override fun setModuleConfig(id: Int, num: Int, payload: ByteArray) = toRemoteExceptions { debug("Setting new module config!") val config = ModuleConfigProtos.ModuleConfig.parseFrom(payload) sendToRadio(newMeshPacketTo(num).buildAdminPacket(id = id) { setModuleConfig = config }) - if (num == myNodeNum) setLocalModuleConfig(config) // Update our local copy + if (num == myNodeNum) setLocalModuleConfig(config) } override fun getModuleConfig(id: Int, destNum: Int, config: Int) = toRemoteExceptions { @@ -2206,35 +1967,27 @@ class MeshService : ) } - override fun setRingtone(destNum: Int, ringtone: String) = toRemoteExceptions { - sendToRadio(newMeshPacketTo(destNum).buildAdminPacket { setRingtoneMessage = ringtone }) + override fun setRingtone(destNum: Int, ringtone: String?) { + TODO("Not yet implemented") } - override fun getRingtone(id: Int, destNum: Int) = toRemoteExceptions { - sendToRadio( - newMeshPacketTo(destNum).buildAdminPacket(id = id, wantResponse = true) { - getRingtoneRequest = true - }, - ) + override fun getRingtone(requestId: Int, destNum: Int) { + TODO("Not yet implemented") } - override fun setCannedMessages(destNum: Int, messages: String) = toRemoteExceptions { - sendToRadio(newMeshPacketTo(destNum).buildAdminPacket { setCannedMessageModuleMessages = messages }) + override fun setCannedMessages(destNum: Int, messages: String?) { + TODO("Not yet implemented") } - override fun getCannedMessages(id: Int, destNum: Int) = toRemoteExceptions { - sendToRadio( - newMeshPacketTo(destNum).buildAdminPacket(id = id, wantResponse = true) { - getCannedMessageModuleMessagesRequest = true - }, - ) + override fun getCannedMessages(requestId: Int, destNum: Int) { + TODO("Not yet implemented") } - override fun setChannel(payload: ByteArray?) = toRemoteExceptions { + override fun setChannel(payload: ByteArray) = toRemoteExceptions { setRemoteChannel(generatePacketId(), myNodeNum, payload) } - override fun setRemoteChannel(id: Int, num: Int, payload: ByteArray?) = toRemoteExceptions { + override fun setRemoteChannel(id: Int, num: Int, payload: ByteArray) = toRemoteExceptions { val channel = ChannelProtos.Channel.parseFrom(payload) sendToRadio(newMeshPacketTo(num).buildAdminPacket(id = id) { setChannel = channel }) } @@ -2242,7 +1995,7 @@ class MeshService : override fun getRemoteChannel(id: Int, destNum: Int, index: Int) = toRemoteExceptions { sendToRadio( newMeshPacketTo(destNum).buildAdminPacket(id = id, wantResponse = true) { - getChannelRequest = index + 1 + getChannelRequest = index + 1 // API is 1-based }, ) } @@ -2258,25 +2011,27 @@ class MeshService : override fun getChannelSet(): ByteArray = toRemoteExceptions { this@MeshService.channelSet.toByteArray() } override fun getNodes(): MutableList = toRemoteExceptions { - val r = nodeDBbyNodeNum.values.map { it.toNodeInfo() }.toMutableList() - info("in getOnline, count=${r.size}") - // return arrayOf("+16508675309") - r + nodeDBbyNodeNum.values.map { it.toNodeInfo() }.toMutableList() } override fun connectionState(): String = toRemoteExceptions { - val r = this@MeshService.connectionState - info("in connectionState=$r") - r.toString() + this@MeshService.connectionRouter.connectionState.value.toString() } - override fun startProvideLocation() = toRemoteExceptions { startLocationRequests() } + override fun startProvideLocation() = toRemoteExceptions { + @SuppressLint("MissingPermission") + startLocationRequests() + } override fun stopProvideLocation() = toRemoteExceptions { stopLocationRequests() } override fun removeByNodenum(requestId: Int, nodeNum: Int) = toRemoteExceptions { - nodeDBbyNodeNum.remove(nodeNum) - sendToRadio(newMeshPacketTo(myNodeNum).buildAdminPacket { removeByNodenum = nodeNum }) + nodeDBbyNodeNum.remove(nodeNum)?.let { removedNode -> + if (removedNode.user.id.isNotEmpty()) { + _nodeDBbyID.remove(removedNode.user.id) + } + } + sendToRadio(newMeshPacketTo(myNodeNum).buildAdminPacket { this.removeByNodenum = nodeNum }) } override fun requestUserInfo(destNum: Int) = toRemoteExceptions { @@ -2285,48 +2040,44 @@ class MeshService : newMeshPacketTo(destNum).buildMeshPacket(channel = nodeDBbyNodeNum[destNum]?.channel ?: 0) { portnumValue = Portnums.PortNum.NODEINFO_APP_VALUE wantResponse = true - payload = nodeDBbyNodeNum[myNodeNum]!!.user.toByteString() + payload = nodeDBbyNodeNum[myNodeNum]?.user?.toByteString() ?: ByteString.EMPTY }, ) } } override fun requestPosition(destNum: Int, position: Position) = toRemoteExceptions { - if (destNum != myNodeNum) { - // Determine the best position to send based on user preferences and available data - val provideLocation = sharedPreferences.getBoolean("provide-location-$myNodeNum", false) - val currentPosition = - when { - // Use provided position if valid and user allows phone location sharing - provideLocation && position.isValid() -> position - // Otherwise use the last valid position from nodeDB (node GPS or static) - else -> nodeDBbyNodeNum[myNodeNum]?.position?.let { Position(it) }?.takeIf { it.isValid() } - } + if (destNum == myNodeNum) return@toRemoteExceptions - if (currentPosition == null) { - debug("Position request skipped - no valid position available") - return@toRemoteExceptions + val provideLocation = sharedPreferences.getBoolean("provide-location-$myNodeNum", false) + val currentPosition = + when { + provideLocation && position.isValid() -> position + else -> nodeDBbyNodeNum[myNodeNum]?.position?.let { Position(it) }?.takeIf { it.isValid() } } - // Convert Position to MeshProtos.Position for the payload - val meshPosition = position { - latitudeI = Position.degI(currentPosition.latitude) - longitudeI = Position.degI(currentPosition.longitude) - altitude = currentPosition.altitude - time = currentSecond() - } - - sendToRadio( - newMeshPacketTo(destNum).buildMeshPacket( - channel = nodeDBbyNodeNum[destNum]?.channel ?: 0, - priority = MeshPacket.Priority.BACKGROUND, - ) { - portnumValue = Portnums.PortNum.POSITION_APP_VALUE - payload = meshPosition.toByteString() - wantResponse = true - }, - ) + if (currentPosition == null) { + debug("Position request skipped - no valid position available") + return@toRemoteExceptions } + + val meshPosition = position { + latitudeI = Position.degI(currentPosition.latitude) + longitudeI = Position.degI(currentPosition.longitude) + altitude = currentPosition.altitude + time = currentSecond() + } + + sendToRadio( + newMeshPacketTo(destNum).buildMeshPacket( + channel = nodeDBbyNodeNum[destNum]?.channel ?: 0, + priority = MeshPacket.Priority.BACKGROUND, + ) { + portnumValue = Portnums.PortNum.POSITION_APP_VALUE + payload = meshPosition.toByteString() + wantResponse = true + }, + ) } override fun setFixedPosition(destNum: Int, position: Position) = toRemoteExceptions { @@ -2337,7 +2088,7 @@ class MeshService : } sendToRadio( newMeshPacketTo(destNum).buildAdminPacket { - if (position != Position(0.0, 0.0, 0)) { + if (position.latitude != 0.0 || position.longitude != 0.0 || position.altitude != 0) { setFixedPosition = pos } else { removeFixedPosition = true diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshServiceBroadcasts.kt b/app/src/main/java/com/geeksville/mesh/service/MeshServiceBroadcasts.kt index cc62b7300..d1ff92ef3 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshServiceBroadcasts.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshServiceBroadcasts.kt @@ -27,20 +27,11 @@ import com.geeksville.mesh.NodeInfo class MeshServiceBroadcasts( private val context: Context, private val clientPackages: MutableMap, - 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) diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshServiceStarter.kt b/app/src/main/java/com/geeksville/mesh/service/MeshServiceStarter.kt index b6351e26f..3306b3032 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshServiceStarter.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshServiceStarter.kt @@ -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() - .setInitialDelay(2, TimeUnit.MINUTES) - .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 2, TimeUnit.MINUTES) - .addTag("startLater") - .build() + val delayRequest = + OneTimeWorkRequestBuilder() + .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) } } diff --git a/app/src/main/java/com/geeksville/mesh/service/ServiceRepository.kt b/app/src/main/java/com/geeksville/mesh/service/ServiceRepository.kt index 69d9dc940..11d3e5f54 100644 --- a/app/src/main/java/com/geeksville/mesh/service/ServiceRepository.kt +++ b/app/src/main/java/com/geeksville/mesh/service/ServiceRepository.kt @@ -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 get() = _connectionState + private val _connectionState = MutableStateFlow(ConnectionState.DISCONNECTED) + val connectionState: StateFlow + get() = _connectionState - fun setConnectionState(connectionState: MeshService.ConnectionState) { + fun setConnectionState(connectionState: ConnectionState) { _connectionState.value = connectionState } private val _clientNotification = MutableStateFlow(null) - val clientNotification: StateFlow get() = _clientNotification + val clientNotification: StateFlow + get() = _clientNotification + fun setClientNotification(notification: MeshProtos.ClientNotification?) { errormsg(notification?.message.orEmpty()) @@ -64,7 +65,8 @@ class ServiceRepository @Inject constructor() : Logging { } private val _errorMessage = MutableStateFlow(null) - val errorMessage: StateFlow get() = _errorMessage + val errorMessage: StateFlow + get() = _errorMessage fun setErrorMessage(text: String) { errormsg(text) @@ -76,23 +78,26 @@ class ServiceRepository @Inject constructor() : Logging { } private val _statusMessage = MutableStateFlow(null) - val statusMessage: StateFlow get() = _statusMessage + val statusMessage: StateFlow + get() = _statusMessage fun setStatusMessage(text: String) { - if (connectionState.value != MeshService.ConnectionState.CONNECTED) { + if (connectionState.value != ConnectionState.CONNECTED) { _statusMessage.value = text } } private val _meshPacketFlow = MutableSharedFlow() - val meshPacketFlow: SharedFlow get() = _meshPacketFlow + val meshPacketFlow: SharedFlow + get() = _meshPacketFlow suspend fun emitMeshPacket(packet: MeshPacket) { _meshPacketFlow.emit(packet) } private val _tracerouteResponse = MutableStateFlow(null) - val tracerouteResponse: StateFlow get() = _tracerouteResponse + val tracerouteResponse: StateFlow + get() = _tracerouteResponse fun setTracerouteResponse(value: String?) { _tracerouteResponse.value = value diff --git a/app/src/main/java/com/geeksville/mesh/ui/Main.kt b/app/src/main/java/com/geeksville/mesh/ui/Main.kt index e6d5f208d..ddfba0441 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/Main.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/Main.kt @@ -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) } diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/Connections.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/Connections.kt index 6854ed773..09d9d5141 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/connections/Connections.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/connections/Connections.kt @@ -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)) diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/components/BLEDevices.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/components/BLEDevices.kt index 013907ebf..9e0cd86bd 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/connections/components/BLEDevices.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/connections/components/BLEDevices.kt @@ -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, selectedDevice: String, scanModel: BTScanModel, diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/components/DeviceListItem.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/components/DeviceListItem.kt index f33d0ac1f..89b8a2a3e 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/connections/components/DeviceListItem.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/connections/components/DeviceListItem.kt @@ -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( diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/components/NetworkDevices.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/components/NetworkDevices.kt index 73bf48e19..014add1e1 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/connections/components/NetworkDevices.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/connections/components/NetworkDevices.kt @@ -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, recentNetworkDevices: List, selectedDevice: String, diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/components/UsbDevices.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/components/UsbDevices.kt index 25856fd41..736d1390a 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/connections/components/UsbDevices.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/connections/components/UsbDevices.kt @@ -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, selectedDevice: String, scanModel: BTScanModel, diff --git a/app/src/main/java/com/geeksville/mesh/ui/node/NodeScreen.kt b/app/src/main/java/com/geeksville/mesh/ui/node/NodeScreen.kt index a10c097ad..bdf68ccda 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/node/NodeScreen.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/node/NodeScreen.kt @@ -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) })) } } } diff --git a/app/src/main/java/com/geeksville/mesh/ui/radioconfig/RadioConfigViewModel.kt b/app/src/main/java/com/geeksville/mesh/ui/radioconfig/RadioConfigViewModel.kt index 870112d7b..6614a13b6 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/radioconfig/RadioConfigViewModel.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/radioconfig/RadioConfigViewModel.kt @@ -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) } } diff --git a/app/src/main/java/com/geeksville/mesh/ui/sharing/Channel.kt b/app/src/main/java/com/geeksville/mesh/ui/sharing/Channel.kt index a786c1d07..66c1b7d88 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/sharing/Channel.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/sharing/Channel.kt @@ -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) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 47fac8301..465c47776 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -117,7 +117,7 @@ New Node Seen: %s Disconnected Device sleeping - Connected: %1$s online + Connected: %1$d online IP Address: Port: Connected to radio @@ -762,4 +762,5 @@ Grant Permissions and Scan %d nodes queued for deletion: Caution: This removes nodes from in-app and on-device databases.\nSelections are additive. + Connecting to device