diff --git a/app/src/main/java/com/geeksville/mesh/service/BLEException.kt b/app/src/main/java/com/geeksville/mesh/service/BLEException.kt
deleted file mode 100644
index f434231a1..000000000
--- a/app/src/main/java/com/geeksville/mesh/service/BLEException.kt
+++ /dev/null
@@ -1,29 +0,0 @@
-/*
- * 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 android.os.RemoteException
-import java.util.UUID
-
-open class BLEException(msg: String) : RemoteException(msg)
-
-open class BLECharacteristicNotFoundException(uuid: UUID) :
- BLEException("Can't get characteristic $uuid")
-
-/// Our interface is being shut down
-open class BLEConnectionClosing : BLEException("Connection closing ")
\ No newline at end of file
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshServiceConnectionStateHolder.kt b/app/src/main/java/com/geeksville/mesh/service/ConnectionStateHandler.kt
similarity index 94%
rename from app/src/main/java/com/geeksville/mesh/service/MeshServiceConnectionStateHolder.kt
rename to app/src/main/java/com/geeksville/mesh/service/ConnectionStateHandler.kt
index 34b269d50..577dea881 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshServiceConnectionStateHolder.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/ConnectionStateHandler.kt
@@ -24,7 +24,7 @@ import javax.inject.Inject
import javax.inject.Singleton
@Singleton
-class MeshServiceConnectionStateHolder @Inject constructor() {
+class ConnectionStateHandler @Inject constructor() {
private val _connectionState = MutableStateFlow(ConnectionState.Disconnected)
val connectionState = _connectionState.asStateFlow()
diff --git a/app/src/main/java/com/geeksville/mesh/service/Constants.kt b/app/src/main/java/com/geeksville/mesh/service/Constants.kt
index 06e18531b..f84cc4079 100644
--- a/app/src/main/java/com/geeksville/mesh/service/Constants.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/Constants.kt
@@ -17,21 +17,26 @@
package com.geeksville.mesh.service
-const val prefix = "com.geeksville.mesh"
+const val PREFIX = "com.geeksville.mesh"
+const val ACTION_NODE_CHANGE = "$PREFIX.NODE_CHANGE"
+const val ACTION_MESH_CONNECTED = "$PREFIX.MESH_CONNECTED"
+const val ACTION_MESSAGE_STATUS = "$PREFIX.MESSAGE_STATUS"
+
+fun actionReceived(portNum: String) = "$PREFIX.RECEIVED.$portNum"
//
// standard EXTRA bundle definitions
//
// a bool true means now connected, false means not
-const val EXTRA_CONNECTED = "$prefix.Connected"
-const val EXTRA_PROGRESS = "$prefix.Progress"
+const val EXTRA_CONNECTED = "$PREFIX.Connected"
+const val EXTRA_PROGRESS = "$PREFIX.Progress"
-/// a bool true means we expect this condition to continue until, false means device might come back
-const val EXTRA_PERMANENT = "$prefix.Permanent"
+// / a bool true means we expect this condition to continue until, false means device might come back
+const val EXTRA_PERMANENT = "$PREFIX.Permanent"
-const val EXTRA_PAYLOAD = "$prefix.Payload"
-const val EXTRA_NODEINFO = "$prefix.NodeInfo"
-const val EXTRA_PACKET_ID = "$prefix.PacketId"
-const val EXTRA_STATUS = "$prefix.Status"
+const val EXTRA_PAYLOAD = "$PREFIX.Payload"
+const val EXTRA_NODEINFO = "$PREFIX.NodeInfo"
+const val EXTRA_PACKET_ID = "$PREFIX.PacketId"
+const val EXTRA_STATUS = "$PREFIX.Status"
diff --git a/app/src/main/java/com/geeksville/mesh/service/FromRadioPacketHandler.kt b/app/src/main/java/com/geeksville/mesh/service/FromRadioPacketHandler.kt
new file mode 100644
index 000000000..f5321e367
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/service/FromRadioPacketHandler.kt
@@ -0,0 +1,76 @@
+/*
+ * 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 co.touchlab.kermit.Logger
+import org.meshtastic.core.service.ServiceRepository
+import org.meshtastic.proto.MeshProtos
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Dispatches non-packet [MeshProtos.FromRadio] variants to their respective handlers. This class is stateless and
+ * handles routing for config, metadata, and specialized system messages.
+ */
+@Singleton
+class FromRadioPacketHandler
+@Inject
+constructor(
+ private val serviceRepository: ServiceRepository,
+ private val router: MeshRouter,
+ private val mqttManager: MeshMqttManager,
+ private val packetHandler: PacketHandler,
+) {
+ @Suppress("CyclomaticComplexMethod")
+ fun handleFromRadio(proto: MeshProtos.FromRadio) {
+ when (proto.payloadVariantCase) {
+ MeshProtos.FromRadio.PayloadVariantCase.MY_INFO -> router.configFlowManager.handleMyInfo(proto.myInfo)
+ MeshProtos.FromRadio.PayloadVariantCase.METADATA ->
+ router.configFlowManager.handleLocalMetadata(proto.metadata)
+ MeshProtos.FromRadio.PayloadVariantCase.NODE_INFO -> {
+ router.configFlowManager.handleNodeInfo(proto.nodeInfo)
+ serviceRepository.setStatusMessage("Nodes (${router.configFlowManager.newNodeCount})")
+ }
+ MeshProtos.FromRadio.PayloadVariantCase.CONFIG_COMPLETE_ID ->
+ router.configFlowManager.handleConfigComplete(proto.configCompleteId)
+ MeshProtos.FromRadio.PayloadVariantCase.MQTTCLIENTPROXYMESSAGE ->
+ mqttManager.handleMqttProxyMessage(proto.mqttClientProxyMessage)
+ MeshProtos.FromRadio.PayloadVariantCase.QUEUESTATUS -> packetHandler.handleQueueStatus(proto.queueStatus)
+ MeshProtos.FromRadio.PayloadVariantCase.CONFIG -> router.configHandler.handleDeviceConfig(proto.config)
+ MeshProtos.FromRadio.PayloadVariantCase.MODULECONFIG ->
+ router.configHandler.handleModuleConfig(proto.moduleConfig)
+ MeshProtos.FromRadio.PayloadVariantCase.CHANNEL -> router.configHandler.handleChannel(proto.channel)
+ MeshProtos.FromRadio.PayloadVariantCase.CLIENTNOTIFICATION -> {
+ serviceRepository.setClientNotification(proto.clientNotification)
+ packetHandler.removeResponse(proto.clientNotification.replyId, complete = false)
+ }
+ // Logging-only variants are handled by MeshMessageProcessor before dispatching here
+ MeshProtos.FromRadio.PayloadVariantCase.PACKET,
+ MeshProtos.FromRadio.PayloadVariantCase.LOG_RECORD,
+ MeshProtos.FromRadio.PayloadVariantCase.REBOOTED,
+ MeshProtos.FromRadio.PayloadVariantCase.XMODEMPACKET,
+ MeshProtos.FromRadio.PayloadVariantCase.DEVICEUICONFIG,
+ MeshProtos.FromRadio.PayloadVariantCase.FILEINFO,
+ -> {
+ /* No specialized routing needed here */
+ }
+
+ else -> Logger.d { "Dispatcher ignoring ${proto.payloadVariantCase}" }
+ }
+ }
+}
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshActionHandler.kt b/app/src/main/java/com/geeksville/mesh/service/MeshActionHandler.kt
new file mode 100644
index 000000000..d7b8c75d3
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshActionHandler.kt
@@ -0,0 +1,309 @@
+/*
+ * 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.concurrent.handledLaunch
+import com.geeksville.mesh.util.ignoreException
+import dagger.Lazy
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import org.meshtastic.core.analytics.DataPair
+import org.meshtastic.core.analytics.platform.PlatformAnalytics
+import org.meshtastic.core.data.repository.PacketRepository
+import org.meshtastic.core.database.DatabaseManager
+import org.meshtastic.core.database.entity.ReactionEntity
+import org.meshtastic.core.model.DataPacket
+import org.meshtastic.core.model.Position
+import org.meshtastic.core.prefs.mesh.MeshPrefs
+import org.meshtastic.core.service.MeshServiceNotifications
+import org.meshtastic.core.service.ServiceAction
+import org.meshtastic.proto.AdminProtos
+import org.meshtastic.proto.ChannelProtos
+import org.meshtastic.proto.ConfigProtos
+import org.meshtastic.proto.MeshProtos
+import org.meshtastic.proto.ModuleConfigProtos
+import org.meshtastic.proto.Portnums
+import org.meshtastic.proto.user
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Suppress("LongParameterList", "TooManyFunctions")
+@Singleton
+class MeshActionHandler
+@Inject
+constructor(
+ private val nodeManager: MeshNodeManager,
+ private val commandSender: MeshCommandSender,
+ private val packetRepository: Lazy,
+ private val serviceBroadcasts: MeshServiceBroadcasts,
+ private val dataHandler: MeshDataHandler,
+ private val analytics: PlatformAnalytics,
+ private val meshPrefs: MeshPrefs,
+ private val databaseManager: DatabaseManager,
+ private val serviceNotifications: MeshServiceNotifications,
+) {
+ private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
+
+ fun start(scope: CoroutineScope) {
+ this.scope = scope
+ }
+
+ companion object {
+ private const val DEFAULT_REBOOT_DELAY = 5
+ }
+
+ fun onServiceAction(action: ServiceAction) {
+ ignoreException {
+ val myNodeNum = nodeManager.myNodeNum ?: return@ignoreException
+ when (action) {
+ is ServiceAction.Favorite -> {
+ val node = action.node
+ commandSender.sendAdmin(myNodeNum) {
+ if (node.isFavorite) removeFavoriteNode = node.num else setFavoriteNode = node.num
+ }
+ nodeManager.updateNodeInfo(node.num) { it.isFavorite = !node.isFavorite }
+ }
+ is ServiceAction.Ignore -> {
+ val node = action.node
+ commandSender.sendAdmin(myNodeNum) {
+ if (node.isIgnored) removeIgnoredNode = node.num else setIgnoredNode = node.num
+ }
+ nodeManager.updateNodeInfo(node.num) { it.isIgnored = !node.isIgnored }
+ }
+ is ServiceAction.Reaction -> {
+ val channel = action.contactKey[0].digitToInt()
+ val destId = action.contactKey.substring(1)
+ val dataPacket =
+ org.meshtastic.core.model.DataPacket(
+ to = destId,
+ dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE,
+ bytes = action.emoji.encodeToByteArray(),
+ channel = channel,
+ replyId = action.replyId,
+ wantAck = false,
+ )
+ commandSender.sendData(dataPacket)
+ rememberReaction(action)
+ }
+ is ServiceAction.ImportContact -> {
+ val verifiedContact = action.contact.toBuilder().setManuallyVerified(true).build()
+ commandSender.sendAdmin(myNodeNum) { addContact = verifiedContact }
+ nodeManager.handleReceivedUser(
+ verifiedContact.nodeNum,
+ verifiedContact.user,
+ manuallyVerified = true,
+ )
+ }
+ is ServiceAction.SendContact -> {
+ commandSender.sendAdmin(myNodeNum) { addContact = action.contact }
+ }
+ is ServiceAction.GetDeviceMetadata -> {
+ commandSender.sendAdmin(action.destNum, wantResponse = true) { getDeviceMetadataRequest = true }
+ }
+ }
+ }
+ }
+
+ private fun rememberReaction(action: ServiceAction.Reaction) {
+ scope.handledLaunch {
+ val reaction =
+ ReactionEntity(
+ replyId = action.replyId,
+ userId = DataPacket.ID_LOCAL,
+ emoji = action.emoji,
+ timestamp = System.currentTimeMillis(),
+ snr = 0f,
+ rssi = 0,
+ hopsAway = 0,
+ )
+ packetRepository.get().insertReaction(reaction)
+ }
+ }
+
+ fun handleSetOwner(u: org.meshtastic.core.model.MeshUser, myNodeNum: Int) {
+ commandSender.sendAdmin(myNodeNum) {
+ setOwner = user {
+ id = u.id
+ longName = u.longName
+ shortName = u.shortName
+ isLicensed = u.isLicensed
+ }
+ }
+ nodeManager.handleReceivedUser(
+ myNodeNum,
+ user {
+ id = u.id
+ longName = u.longName
+ shortName = u.shortName
+ isLicensed = u.isLicensed
+ },
+ )
+ }
+
+ fun handleSend(p: DataPacket, myNodeNum: Int) {
+ commandSender.sendData(p)
+ serviceBroadcasts.broadcastMessageStatus(p)
+ dataHandler.rememberDataPacket(p, myNodeNum, false)
+ val bytes = p.bytes ?: ByteArray(0)
+ analytics.track("data_send", DataPair("num_bytes", bytes.size), DataPair("type", p.dataType))
+ }
+
+ fun handleRequestPosition(destNum: Int, position: Position, myNodeNum: Int) {
+ if (destNum != myNodeNum) {
+ val provideLocation = meshPrefs.shouldProvideNodeLocation(myNodeNum)
+ val currentPosition =
+ when {
+ provideLocation && position.isValid() -> position
+ else ->
+ nodeManager.nodeDBbyNodeNum[myNodeNum]?.position?.let { Position(it) }?.takeIf { it.isValid() }
+ }
+ currentPosition?.let { commandSender.requestPosition(destNum, it) }
+ }
+ }
+
+ fun handleRemoveByNodenum(nodeNum: Int, requestId: Int, myNodeNum: Int) {
+ nodeManager.removeByNodenum(nodeNum)
+ commandSender.sendAdmin(myNodeNum, requestId) { removeByNodenum = nodeNum }
+ }
+
+ fun handleSetRemoteOwner(id: Int, payload: ByteArray, myNodeNum: Int) {
+ val u = MeshProtos.User.parseFrom(payload)
+ commandSender.sendAdmin(myNodeNum, id) { setOwner = u }
+ }
+
+ fun handleGetRemoteOwner(id: Int, destNum: Int) {
+ commandSender.sendAdmin(destNum, id, wantResponse = true) { getOwnerRequest = true }
+ }
+
+ fun handleSetConfig(payload: ByteArray, myNodeNum: Int) {
+ val c = ConfigProtos.Config.parseFrom(payload)
+ commandSender.sendAdmin(myNodeNum) { setConfig = c }
+ }
+
+ fun handleSetRemoteConfig(id: Int, num: Int, payload: ByteArray) {
+ val c = ConfigProtos.Config.parseFrom(payload)
+ commandSender.sendAdmin(num, id) { setConfig = c }
+ }
+
+ fun handleGetRemoteConfig(id: Int, destNum: Int, config: Int) {
+ commandSender.sendAdmin(destNum, id, wantResponse = true) {
+ if (config == AdminProtos.AdminMessage.ConfigType.SESSIONKEY_CONFIG_VALUE) {
+ getDeviceMetadataRequest = true
+ } else {
+ getConfigRequestValue = config
+ }
+ }
+ }
+
+ fun handleSetModuleConfig(id: Int, num: Int, payload: ByteArray) {
+ val c = ModuleConfigProtos.ModuleConfig.parseFrom(payload)
+ commandSender.sendAdmin(num, id) { setModuleConfig = c }
+ }
+
+ fun handleGetModuleConfig(id: Int, destNum: Int, config: Int) {
+ commandSender.sendAdmin(destNum, id, wantResponse = true) { getModuleConfigRequestValue = config }
+ }
+
+ fun handleSetRingtone(destNum: Int, ringtone: String) {
+ commandSender.sendAdmin(destNum) { setRingtoneMessage = ringtone }
+ }
+
+ fun handleGetRingtone(id: Int, destNum: Int) {
+ commandSender.sendAdmin(destNum, id, wantResponse = true) { getRingtoneRequest = true }
+ }
+
+ fun handleSetCannedMessages(destNum: Int, messages: String) {
+ commandSender.sendAdmin(destNum) { setCannedMessageModuleMessages = messages }
+ }
+
+ fun handleGetCannedMessages(id: Int, destNum: Int) {
+ commandSender.sendAdmin(destNum, id, wantResponse = true) { getCannedMessageModuleMessagesRequest = true }
+ }
+
+ fun handleSetChannel(payload: ByteArray?, myNodeNum: Int) {
+ if (payload != null) {
+ val c = ChannelProtos.Channel.parseFrom(payload)
+ commandSender.sendAdmin(myNodeNum) { setChannel = c }
+ }
+ }
+
+ fun handleSetRemoteChannel(id: Int, num: Int, payload: ByteArray?) {
+ if (payload != null) {
+ val c = ChannelProtos.Channel.parseFrom(payload)
+ commandSender.sendAdmin(num, id) { setChannel = c }
+ }
+ }
+
+ fun handleGetRemoteChannel(id: Int, destNum: Int, index: Int) {
+ commandSender.sendAdmin(destNum, id, wantResponse = true) { getChannelRequest = index + 1 }
+ }
+
+ fun handleRequestNeighborInfo(requestId: Int, destNum: Int) {
+ commandSender.requestNeighborInfo(requestId, destNum)
+ }
+
+ fun handleBeginEditSettings(myNodeNum: Int) {
+ commandSender.sendAdmin(myNodeNum) { beginEditSettings = true }
+ }
+
+ fun handleCommitEditSettings(myNodeNum: Int) {
+ commandSender.sendAdmin(myNodeNum) { commitEditSettings = true }
+ }
+
+ fun handleRebootToDfu(myNodeNum: Int) {
+ commandSender.sendAdmin(myNodeNum) { enterDfuModeRequest = true }
+ }
+
+ fun handleRequestTelemetry(requestId: Int, destNum: Int, type: Int) {
+ commandSender.requestTelemetry(requestId, destNum, type)
+ }
+
+ fun handleRequestShutdown(requestId: Int, destNum: Int) {
+ commandSender.sendAdmin(destNum, requestId) { shutdownSeconds = DEFAULT_REBOOT_DELAY }
+ }
+
+ fun handleRequestReboot(requestId: Int, destNum: Int) {
+ commandSender.sendAdmin(destNum, requestId) { rebootSeconds = DEFAULT_REBOOT_DELAY }
+ }
+
+ fun handleRequestFactoryReset(requestId: Int, destNum: Int) {
+ commandSender.sendAdmin(destNum, requestId) { factoryResetDevice = 1 }
+ }
+
+ fun handleRequestNodedbReset(requestId: Int, destNum: Int, preserveFavorites: Boolean) {
+ commandSender.sendAdmin(destNum, requestId) { nodedbReset = preserveFavorites }
+ }
+
+ fun handleGetDeviceConnectionStatus(requestId: Int, destNum: Int) {
+ commandSender.sendAdmin(destNum, requestId, wantResponse = true) { getDeviceConnectionStatusRequest = true }
+ }
+
+ fun handleUpdateLastAddress(deviceAddr: String?) {
+ val currentAddr = meshPrefs.deviceAddress
+ if (deviceAddr != currentAddr) {
+ meshPrefs.deviceAddress = deviceAddr
+ scope.handledLaunch {
+ nodeManager.clear()
+ databaseManager.switchActiveDatabase(deviceAddr)
+ serviceNotifications.clearNotifications()
+ nodeManager.loadCachedNodeDB()
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshCommandSender.kt b/app/src/main/java/com/geeksville/mesh/service/MeshCommandSender.kt
new file mode 100644
index 000000000..44083ad4d
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshCommandSender.kt
@@ -0,0 +1,426 @@
+/*
+ * 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 android.os.RemoteException
+import androidx.annotation.VisibleForTesting
+import co.touchlab.kermit.Logger
+import com.google.protobuf.ByteString
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import org.meshtastic.core.data.repository.RadioConfigRepository
+import org.meshtastic.core.model.DataPacket
+import org.meshtastic.core.model.MessageStatus
+import org.meshtastic.core.model.Position
+import org.meshtastic.core.model.TelemetryType
+import org.meshtastic.core.service.ConnectionState
+import org.meshtastic.proto.AdminProtos
+import org.meshtastic.proto.AppOnlyProtos.ChannelSet
+import org.meshtastic.proto.LocalOnlyProtos.LocalConfig
+import org.meshtastic.proto.MeshProtos
+import org.meshtastic.proto.MeshProtos.MeshPacket
+import org.meshtastic.proto.Portnums
+import org.meshtastic.proto.TelemetryProtos
+import org.meshtastic.proto.position
+import org.meshtastic.proto.telemetry
+import java.io.IOException
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.CopyOnWriteArrayList
+import java.util.concurrent.atomic.AtomicLong
+import java.util.concurrent.atomic.AtomicReference
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlin.math.absoluteValue
+import kotlin.time.Duration.Companion.hours
+
+@Suppress("TooManyFunctions")
+@Singleton
+class MeshCommandSender
+@Inject
+constructor(
+ private val packetHandler: PacketHandler?,
+ private val nodeManager: MeshNodeManager?,
+ private val connectionStateHolder: ConnectionStateHandler?,
+ private val radioConfigRepository: RadioConfigRepository?,
+) {
+ private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
+ private val currentPacketId = AtomicLong(java.util.Random(System.currentTimeMillis()).nextLong().absoluteValue)
+ private val sessionPasskey = AtomicReference(ByteString.EMPTY)
+ private val offlineSentPackets = CopyOnWriteArrayList()
+ val tracerouteStartTimes = ConcurrentHashMap()
+ val neighborInfoStartTimes = ConcurrentHashMap()
+
+ private val localConfig = MutableStateFlow(LocalConfig.getDefaultInstance())
+ private val channelSet = MutableStateFlow(ChannelSet.getDefaultInstance())
+
+ @Volatile var lastNeighborInfo: MeshProtos.NeighborInfo? = null
+
+ private val rememberDataType =
+ setOf(
+ Portnums.PortNum.TEXT_MESSAGE_APP_VALUE,
+ Portnums.PortNum.ALERT_APP_VALUE,
+ Portnums.PortNum.WAYPOINT_APP_VALUE,
+ )
+
+ fun start(scope: CoroutineScope) {
+ this.scope = scope
+ radioConfigRepository?.localConfigFlow?.onEach { localConfig.value = it }?.launchIn(scope)
+ radioConfigRepository?.channelSetFlow?.onEach { channelSet.value = it }?.launchIn(scope)
+ }
+
+ @VisibleForTesting internal constructor() : this(null, null, null, null)
+
+ fun getCurrentPacketId(): Long = currentPacketId.get()
+
+ fun generatePacketId(): Int {
+ val numPacketIds = ((1L shl PACKET_ID_SHIFT_BITS) - 1)
+ val next = currentPacketId.incrementAndGet() and PACKET_ID_MASK
+ return ((next % numPacketIds) + 1L).toInt()
+ }
+
+ fun setSessionPasskey(key: ByteString) {
+ sessionPasskey.set(key)
+ }
+
+ private fun getHopLimit(): Int = localConfig.value.lora.hopLimit
+
+ private fun getAdminChannelIndex(toNum: Int): Int {
+ val myNum = nodeManager?.myNodeNum ?: return 0
+ val myNode = nodeManager.nodeDBbyNodeNum[myNum]
+ val destNode = nodeManager.nodeDBbyNodeNum[toNum]
+
+ val adminChannelIndex =
+ when {
+ myNum == toNum -> 0
+ myNode?.hasPKC == true && destNode?.hasPKC == true -> DataPacket.PKC_CHANNEL_INDEX
+ else ->
+ channelSet.value.settingsList
+ .indexOfFirst { it.name.equals(ADMIN_CHANNEL_NAME, ignoreCase = true) }
+ .coerceAtLeast(0)
+ }
+ return adminChannelIndex
+ }
+
+ fun sendData(p: DataPacket) {
+ if (p.id == 0) p.id = generatePacketId()
+ val bytes = p.bytes ?: ByteArray(0)
+ if (bytes.size >= MeshProtos.Constants.DATA_PAYLOAD_LEN_VALUE) {
+ p.status = MessageStatus.ERROR
+ throw RemoteException("Message too long")
+ } else {
+ p.status = MessageStatus.QUEUED
+ }
+
+ if (connectionStateHolder?.connectionState?.value == ConnectionState.Connected) {
+ try {
+ sendNow(p)
+ } catch (ex: IOException) {
+ Logger.e(ex) { "Error sending message, so enqueueing" }
+ enqueueForSending(p)
+ }
+ } else {
+ enqueueForSending(p)
+ }
+ }
+
+ private fun sendNow(p: DataPacket) {
+ val meshPacket =
+ newMeshPacketTo(p.to ?: DataPacket.ID_BROADCAST).buildMeshPacket(
+ id = p.id,
+ wantAck = p.wantAck,
+ hopLimit = if (p.hopLimit > 0) p.hopLimit else getHopLimit(),
+ channel = p.channel,
+ ) {
+ portnumValue = p.dataType
+ payload = ByteString.copyFrom(p.bytes ?: ByteArray(0))
+ p.replyId?.let { if (it != 0) replyId = it }
+ }
+ p.time = System.currentTimeMillis()
+ packetHandler?.sendToRadio(meshPacket)
+ }
+
+ private fun enqueueForSending(p: DataPacket) {
+ if (p.dataType in rememberDataType) {
+ offlineSentPackets.add(p)
+ }
+ }
+
+ fun processQueuedPackets() {
+ val sentPackets = mutableListOf()
+ offlineSentPackets.forEach { p ->
+ try {
+ sendNow(p)
+ sentPackets.add(p)
+ } catch (ex: IOException) {
+ Logger.e(ex) { "Error sending queued message:" }
+ }
+ }
+ offlineSentPackets.removeAll(sentPackets)
+ }
+
+ fun sendAdmin(
+ destNum: Int,
+ requestId: Int = generatePacketId(),
+ wantResponse: Boolean = false,
+ initFn: AdminProtos.AdminMessage.Builder.() -> Unit,
+ ) {
+ val packet =
+ newMeshPacketTo(destNum).buildAdminPacket(id = requestId, wantResponse = wantResponse, initFn = initFn)
+ packetHandler?.sendToRadio(packet)
+ }
+
+ fun sendPosition(pos: MeshProtos.Position, destNum: Int? = null, wantResponse: Boolean = false) {
+ val myNum = nodeManager?.myNodeNum ?: return
+ val idNum = destNum ?: myNum
+ Logger.d { "Sending our position/time to=$idNum ${Position(pos)}" }
+
+ if (!localConfig.value.position.fixedPosition) {
+ nodeManager.handleReceivedPosition(myNum, myNum, pos)
+ }
+
+ packetHandler?.sendToRadio(
+ newMeshPacketTo(idNum).buildMeshPacket(
+ channel = if (destNum == null) 0 else nodeManager.nodeDBbyNodeNum[destNum]?.channel ?: 0,
+ priority = MeshPacket.Priority.BACKGROUND,
+ ) {
+ portnumValue = Portnums.PortNum.POSITION_APP_VALUE
+ payload = pos.toByteString()
+ this.wantResponse = wantResponse
+ },
+ )
+ }
+
+ fun requestPosition(destNum: Int, currentPosition: Position) {
+ val meshPosition = position {
+ latitudeI = Position.degI(currentPosition.latitude)
+ longitudeI = Position.degI(currentPosition.longitude)
+ altitude = currentPosition.altitude
+ time = (System.currentTimeMillis() / TIME_MS_TO_S).toInt()
+ }
+ packetHandler?.sendToRadio(
+ newMeshPacketTo(destNum).buildMeshPacket(
+ channel = nodeManager?.nodeDBbyNodeNum?.get(destNum)?.channel ?: 0,
+ priority = MeshPacket.Priority.BACKGROUND,
+ ) {
+ portnumValue = Portnums.PortNum.POSITION_APP_VALUE
+ payload = meshPosition.toByteString()
+ wantResponse = true
+ },
+ )
+ }
+
+ fun setFixedPosition(destNum: Int, pos: Position) {
+ val meshPos = position {
+ latitudeI = Position.degI(pos.latitude)
+ longitudeI = Position.degI(pos.longitude)
+ altitude = pos.altitude
+ }
+ sendAdmin(destNum) {
+ if (pos != Position(0.0, 0.0, 0)) {
+ setFixedPosition = meshPos
+ } else {
+ removeFixedPosition = true
+ }
+ }
+ nodeManager?.handleReceivedPosition(destNum, nodeManager.myNodeNum ?: 0, meshPos)
+ }
+
+ fun requestUserInfo(destNum: Int) {
+ val myNum = nodeManager?.myNodeNum ?: return
+ val myNode = nodeManager.getOrCreateNodeInfo(myNum)
+ packetHandler?.sendToRadio(
+ newMeshPacketTo(destNum).buildMeshPacket(channel = nodeManager.nodeDBbyNodeNum[destNum]?.channel ?: 0) {
+ portnumValue = Portnums.PortNum.NODEINFO_APP_VALUE
+ wantResponse = true
+ payload = myNode.user.toByteString()
+ },
+ )
+ }
+
+ fun requestTraceroute(requestId: Int, destNum: Int) {
+ tracerouteStartTimes[requestId] = System.currentTimeMillis()
+ packetHandler?.sendToRadio(
+ newMeshPacketTo(destNum).buildMeshPacket(
+ wantAck = true,
+ id = requestId,
+ channel = nodeManager?.nodeDBbyNodeNum?.get(destNum)?.channel ?: 0,
+ ) {
+ portnumValue = Portnums.PortNum.TRACEROUTE_APP_VALUE
+ wantResponse = true
+ },
+ )
+ }
+
+ fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int) {
+ val type = TelemetryType.entries.getOrNull(typeValue) ?: TelemetryType.DEVICE
+ val telemetryRequest = telemetry {
+ when (type) {
+ TelemetryType.ENVIRONMENT ->
+ environmentMetrics = TelemetryProtos.EnvironmentMetrics.getDefaultInstance()
+ TelemetryType.AIR_QUALITY -> airQualityMetrics = TelemetryProtos.AirQualityMetrics.getDefaultInstance()
+ TelemetryType.POWER -> powerMetrics = TelemetryProtos.PowerMetrics.getDefaultInstance()
+ TelemetryType.LOCAL_STATS -> localStats = TelemetryProtos.LocalStats.getDefaultInstance()
+ TelemetryType.DEVICE -> deviceMetrics = TelemetryProtos.DeviceMetrics.getDefaultInstance()
+ }
+ }
+ packetHandler?.sendToRadio(
+ newMeshPacketTo(destNum).buildMeshPacket(
+ id = requestId,
+ channel = nodeManager?.nodeDBbyNodeNum?.get(destNum)?.channel ?: 0,
+ ) {
+ portnumValue = Portnums.PortNum.TELEMETRY_APP_VALUE
+ payload = telemetryRequest.toByteString()
+ wantResponse = true
+ },
+ )
+ }
+
+ fun requestNeighborInfo(requestId: Int, destNum: Int) {
+ neighborInfoStartTimes[requestId] = System.currentTimeMillis()
+ val myNum = nodeManager?.myNodeNum ?: 0
+ if (destNum == myNum) {
+ val neighborInfoToSend =
+ lastNeighborInfo
+ ?: run {
+ val oneHour = 1.hours.inWholeMinutes.toInt()
+ Logger.d { "No stored neighbor info from connected radio, sending dummy data" }
+ MeshProtos.NeighborInfo.newBuilder()
+ .setNodeId(myNum)
+ .setLastSentById(myNum)
+ .setNodeBroadcastIntervalSecs(oneHour)
+ .addNeighbors(
+ MeshProtos.Neighbor.newBuilder()
+ .setNodeId(0) // Dummy node ID that can be intercepted
+ .setSnr(0f)
+ .setLastRxTime((System.currentTimeMillis() / TIME_MS_TO_S).toInt())
+ .setNodeBroadcastIntervalSecs(oneHour)
+ .build(),
+ )
+ .build()
+ }
+
+ // Send the neighbor info from our connected radio to ourselves (simulated)
+ packetHandler?.sendToRadio(
+ newMeshPacketTo(destNum).buildMeshPacket(
+ wantAck = true,
+ id = requestId,
+ channel = nodeManager?.nodeDBbyNodeNum?.get(destNum)?.channel ?: 0,
+ ) {
+ portnumValue = Portnums.PortNum.NEIGHBORINFO_APP_VALUE
+ payload = neighborInfoToSend.toByteString()
+ wantResponse = true
+ },
+ )
+ } else {
+ // Send request to remote
+ packetHandler?.sendToRadio(
+ newMeshPacketTo(destNum).buildMeshPacket(
+ wantAck = true,
+ id = requestId,
+ channel = nodeManager?.nodeDBbyNodeNum?.get(destNum)?.channel ?: 0,
+ ) {
+ portnumValue = Portnums.PortNum.NEIGHBORINFO_APP_VALUE
+ wantResponse = true
+ },
+ )
+ }
+ }
+
+ @VisibleForTesting
+ internal fun resolveNodeNum(toId: String): Int = when (toId) {
+ DataPacket.ID_BROADCAST -> DataPacket.NODENUM_BROADCAST
+ else -> {
+ val numericNum =
+ if (toId.startsWith(NODE_ID_PREFIX)) {
+ toId.substring(NODE_ID_START_INDEX).toLongOrNull(HEX_RADIX)?.toInt()
+ } else {
+ null
+ }
+ numericNum
+ ?: nodeManager?.nodeDBbyID?.get(toId)?.num
+ ?: throw IllegalArgumentException("Unknown node ID $toId")
+ }
+ }
+
+ private fun newMeshPacketTo(toId: String): MeshPacket.Builder {
+ val destNum = resolveNodeNum(toId)
+ return newMeshPacketTo(destNum)
+ }
+
+ private fun newMeshPacketTo(destNum: Int): MeshPacket.Builder = MeshPacket.newBuilder().apply { to = destNum }
+
+ private fun MeshPacket.Builder.buildMeshPacket(
+ wantAck: Boolean = false,
+ id: Int = generatePacketId(), // always assign a packet ID if we didn't already have one
+ hopLimit: Int = 0,
+ channel: Int = 0,
+ priority: MeshPacket.Priority = MeshPacket.Priority.UNSET,
+ initFn: MeshProtos.Data.Builder.() -> Unit,
+ ): MeshPacket {
+ this.id = id
+ this.wantAck = wantAck
+ this.hopLimit = if (hopLimit > 0) hopLimit else getHopLimit()
+ this.priority = priority
+
+ if (channel == DataPacket.PKC_CHANNEL_INDEX) {
+ pkiEncrypted = true
+ nodeManager?.nodeDBbyNodeNum?.get(to)?.user?.publicKey?.let { publicKey = it }
+ } else {
+ this.channel = channel
+ }
+
+ this.decoded = MeshProtos.Data.newBuilder().apply(initFn).build()
+ return build()
+ }
+
+ private fun MeshPacket.Builder.buildAdminPacket(
+ id: Int = generatePacketId(), // always assign a packet ID if we didn't already have one
+ wantResponse: Boolean = false,
+ initFn: AdminProtos.AdminMessage.Builder.() -> Unit,
+ ): MeshPacket =
+ buildMeshPacket(
+ id = id,
+ wantAck = true,
+ channel = getAdminChannelIndex(to),
+ priority = MeshPacket.Priority.RELIABLE,
+ ) {
+ this.wantResponse = wantResponse
+ portnumValue = Portnums.PortNum.ADMIN_APP_VALUE
+ payload =
+ AdminProtos.AdminMessage.newBuilder()
+ .apply(initFn)
+ .setSessionPasskey(sessionPasskey.get())
+ .build()
+ .toByteString()
+ }
+
+ companion object {
+ private const val PACKET_ID_MASK = 0xffffffffL
+ private const val PACKET_ID_SHIFT_BITS = 32
+ private const val TIME_MS_TO_S = 1000L
+
+ private const val ADMIN_CHANNEL_NAME = "admin"
+ private const val NODE_ID_PREFIX = "!"
+ private const val NODE_ID_START_INDEX = 1
+ private const val HEX_RADIX = 16
+ }
+}
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshConfigFlowManager.kt b/app/src/main/java/com/geeksville/mesh/service/MeshConfigFlowManager.kt
new file mode 100644
index 000000000..6cc34ea8b
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshConfigFlowManager.kt
@@ -0,0 +1,184 @@
+/*
+ * 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 co.touchlab.kermit.Logger
+import com.geeksville.mesh.concurrent.handledLaunch
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.delay
+import org.meshtastic.core.analytics.platform.PlatformAnalytics
+import org.meshtastic.core.data.repository.NodeRepository
+import org.meshtastic.core.data.repository.RadioConfigRepository
+import org.meshtastic.core.database.entity.MetadataEntity
+import org.meshtastic.core.database.entity.MyNodeEntity
+import org.meshtastic.core.service.ConnectionState
+import org.meshtastic.proto.MeshProtos
+import java.io.IOException
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Suppress("LongParameterList")
+@Singleton
+class MeshConfigFlowManager
+@Inject
+constructor(
+ private val nodeManager: MeshNodeManager,
+ private val connectionManager: MeshConnectionManager,
+ private val nodeRepository: NodeRepository,
+ private val radioConfigRepository: RadioConfigRepository,
+ private val connectionStateHolder: ConnectionStateHandler,
+ private val serviceBroadcasts: MeshServiceBroadcasts,
+ private val analytics: PlatformAnalytics,
+ private val commandSender: MeshCommandSender,
+ private val packetHandler: PacketHandler,
+) {
+ private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
+ private val configOnlyNonce = 69420
+ private val nodeInfoNonce = 69421
+ private val wantConfigDelay = 100L
+
+ fun start(scope: CoroutineScope) {
+ this.scope = scope
+ }
+
+ private val newNodes = mutableListOf()
+ val newNodeCount: Int
+ get() = newNodes.size
+
+ private var rawMyNodeInfo: MeshProtos.MyNodeInfo? = null
+ private var newMyNodeInfo: MyNodeEntity? = null
+ private var myNodeInfo: MyNodeEntity? = null
+
+ fun handleConfigComplete(configCompleteId: Int) {
+ when (configCompleteId) {
+ configOnlyNonce -> handleConfigOnlyComplete()
+ nodeInfoNonce -> handleNodeInfoComplete()
+ else -> Logger.w { "Config complete id mismatch: $configCompleteId" }
+ }
+ }
+
+ private fun handleConfigOnlyComplete() {
+ Logger.i { "Config-only complete" }
+ if (newMyNodeInfo == null) {
+ Logger.e { "Did not receive a valid config - newMyNodeInfo is null" }
+ } else {
+ myNodeInfo = newMyNodeInfo
+ Logger.i { "myNodeInfo committed successfully" }
+ }
+
+ scope.handledLaunch {
+ delay(wantConfigDelay)
+ sendHeartbeat()
+ delay(wantConfigDelay)
+ connectionManager.startNodeInfoOnly()
+ }
+ }
+
+ private fun sendHeartbeat() {
+ try {
+ packetHandler.sendToRadio(
+ MeshProtos.ToRadio.newBuilder().apply { heartbeat = MeshProtos.Heartbeat.getDefaultInstance() },
+ )
+ Logger.d { "Heartbeat sent between nonce stages" }
+ } catch (ex: IOException) {
+ Logger.w(ex) { "Failed to send heartbeat; proceeding with node-info stage" }
+ }
+ }
+
+ private fun handleNodeInfoComplete() {
+ Logger.i { "NodeInfo complete" }
+ val entities =
+ newNodes.map { info ->
+ nodeManager.installNodeInfo(info, withBroadcast = false)
+ nodeManager.nodeDBbyNodeNum[info.num]!!
+ }
+ newNodes.clear()
+
+ scope.handledLaunch {
+ myNodeInfo?.let {
+ nodeRepository.installConfig(it, entities)
+ sendAnalytics(it)
+ }
+ nodeManager.isNodeDbReady.value = true
+ nodeManager.allowNodeDbWrites.value = true
+ connectionStateHolder.setState(ConnectionState.Connected)
+ serviceBroadcasts.broadcastConnection()
+ connectionManager.onHasSettings()
+ }
+ }
+
+ private fun sendAnalytics(mi: MyNodeEntity) {
+ analytics.setDeviceAttributes(mi.firmwareVersion ?: "unknown", mi.model ?: "unknown")
+ }
+
+ fun handleMyInfo(myInfo: MeshProtos.MyNodeInfo) {
+ Logger.i { "MyNodeInfo received: ${myInfo.myNodeNum}" }
+ rawMyNodeInfo = myInfo
+ nodeManager.myNodeNum = myInfo.myNodeNum
+ regenMyNodeInfo()
+
+ scope.handledLaunch {
+ radioConfigRepository.clearChannelSet()
+ radioConfigRepository.clearLocalConfig()
+ radioConfigRepository.clearLocalModuleConfig()
+ }
+ }
+
+ fun handleLocalMetadata(metadata: MeshProtos.DeviceMetadata) {
+ Logger.i { "Local Metadata received" }
+ regenMyNodeInfo(metadata)
+ }
+
+ fun handleNodeInfo(info: MeshProtos.NodeInfo) {
+ newNodes.add(info)
+ }
+
+ private fun regenMyNodeInfo(metadata: MeshProtos.DeviceMetadata? = MeshProtos.DeviceMetadata.getDefaultInstance()) {
+ val myInfo = rawMyNodeInfo
+ if (myInfo != null) {
+ val mi =
+ with(myInfo) {
+ MyNodeEntity(
+ myNodeNum = myNodeNum,
+ model =
+ when (val hwModel = metadata?.hwModel) {
+ null,
+ MeshProtos.HardwareModel.UNSET,
+ -> null
+ else -> hwModel.name.replace('_', '-').replace('p', '.').lowercase()
+ },
+ firmwareVersion = metadata?.firmwareVersion,
+ couldUpdate = false,
+ shouldUpdate = false,
+ currentPacketId = commandSender.getCurrentPacketId() and 0xffffffffL,
+ messageTimeoutMsec = 300000,
+ minAppVersion = minAppVersion,
+ maxChannels = 8,
+ hasWifi = metadata?.hasWifi == true,
+ deviceId = deviceId.toStringUtf8(),
+ )
+ }
+ if (metadata != null && metadata != MeshProtos.DeviceMetadata.getDefaultInstance()) {
+ scope.handledLaunch { nodeRepository.insertMetadata(MetadataEntity(mi.myNodeNum, metadata)) }
+ }
+ newMyNodeInfo = mi
+ }
+ }
+}
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshConfigHandler.kt b/app/src/main/java/com/geeksville/mesh/service/MeshConfigHandler.kt
new file mode 100644
index 000000000..a975a4c7a
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshConfigHandler.kt
@@ -0,0 +1,88 @@
+/*
+ * 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.concurrent.handledLaunch
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import org.meshtastic.core.data.repository.RadioConfigRepository
+import org.meshtastic.core.service.ServiceRepository
+import org.meshtastic.proto.ChannelProtos
+import org.meshtastic.proto.ConfigProtos
+import org.meshtastic.proto.LocalOnlyProtos.LocalConfig
+import org.meshtastic.proto.LocalOnlyProtos.LocalModuleConfig
+import org.meshtastic.proto.ModuleConfigProtos
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class MeshConfigHandler
+@Inject
+constructor(
+ private val radioConfigRepository: RadioConfigRepository,
+ private val serviceRepository: ServiceRepository,
+ private val nodeManager: MeshNodeManager,
+) {
+ private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
+
+ private val _localConfig = MutableStateFlow(LocalConfig.getDefaultInstance())
+ val localConfig = _localConfig.asStateFlow()
+
+ private val _moduleConfig = MutableStateFlow(LocalModuleConfig.getDefaultInstance())
+ val moduleConfig = _moduleConfig.asStateFlow()
+
+ private val configTotal = ConfigProtos.Config.getDescriptor().fields.size
+ private val moduleTotal = ModuleConfigProtos.ModuleConfig.getDescriptor().fields.size
+
+ fun start(scope: CoroutineScope) {
+ this.scope = scope
+ radioConfigRepository.localConfigFlow.onEach { _localConfig.value = it }.launchIn(scope)
+
+ radioConfigRepository.moduleConfigFlow.onEach { _moduleConfig.value = it }.launchIn(scope)
+ }
+
+ fun handleDeviceConfig(config: ConfigProtos.Config) {
+ scope.handledLaunch { radioConfigRepository.setLocalConfig(config) }
+ val configCount = _localConfig.value.allFields.size
+ serviceRepository.setStatusMessage("Device config ($configCount / $configTotal)")
+ }
+
+ fun handleModuleConfig(config: ModuleConfigProtos.ModuleConfig) {
+ scope.handledLaunch { radioConfigRepository.setLocalModuleConfig(config) }
+ val moduleCount = _moduleConfig.value.allFields.size
+ serviceRepository.setStatusMessage("Module config ($moduleCount / $moduleTotal)")
+ }
+
+ fun handleChannel(ch: ChannelProtos.Channel) {
+ // We always want to save channel settings we receive from the radio
+ scope.handledLaunch { radioConfigRepository.updateChannelSettings(ch) }
+
+ // Update status message if we have node info, otherwise use a generic one
+ val mi = nodeManager.getMyNodeInfo()
+ if (mi != null) {
+ serviceRepository.setStatusMessage("Channels (${ch.index + 1} / ${mi.maxChannels})")
+ } else {
+ serviceRepository.setStatusMessage("Channels (${ch.index + 1})")
+ }
+ }
+}
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshConnectionManager.kt b/app/src/main/java/com/geeksville/mesh/service/MeshConnectionManager.kt
new file mode 100644
index 000000000..1b57e6aec
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshConnectionManager.kt
@@ -0,0 +1,268 @@
+/*
+ * 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 android.app.Notification
+import co.touchlab.kermit.Logger
+import com.geeksville.mesh.concurrent.handledLaunch
+import com.geeksville.mesh.repository.radio.RadioInterfaceService
+import com.meshtastic.core.strings.getString
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import org.meshtastic.core.analytics.DataPair
+import org.meshtastic.core.analytics.platform.PlatformAnalytics
+import org.meshtastic.core.data.repository.NodeRepository
+import org.meshtastic.core.data.repository.RadioConfigRepository
+import org.meshtastic.core.prefs.ui.UiPrefs
+import org.meshtastic.core.service.ConnectionState
+import org.meshtastic.core.service.MeshServiceNotifications
+import org.meshtastic.core.strings.Res
+import org.meshtastic.core.strings.connected_count
+import org.meshtastic.core.strings.connecting
+import org.meshtastic.core.strings.device_sleeping
+import org.meshtastic.core.strings.disconnected
+import org.meshtastic.proto.ConfigProtos
+import org.meshtastic.proto.MeshProtos.ToRadio
+import org.meshtastic.proto.TelemetryProtos
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlin.time.Duration.Companion.seconds
+
+@Suppress("LongParameterList", "TooManyFunctions")
+@Singleton
+class MeshConnectionManager
+@Inject
+constructor(
+ private val radioInterfaceService: RadioInterfaceService,
+ private val connectionStateHolder: ConnectionStateHandler,
+ private val serviceBroadcasts: MeshServiceBroadcasts,
+ private val serviceNotifications: MeshServiceNotifications,
+ private val uiPrefs: UiPrefs,
+ private val packetHandler: PacketHandler,
+ private val nodeRepository: NodeRepository,
+ private val locationManager: MeshLocationManager,
+ private val mqttManager: MeshMqttManager,
+ private val historyManager: MeshHistoryManager,
+ private val radioConfigRepository: RadioConfigRepository,
+ private val commandSender: MeshCommandSender,
+ private val nodeManager: MeshNodeManager,
+ private val analytics: PlatformAnalytics,
+) {
+ private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
+ private var sleepTimeout: Job? = null
+ private var locationRequestsJob: Job? = null
+ private var connectTimeMsec = 0L
+
+ fun start(scope: CoroutineScope) {
+ this.scope = scope
+ radioInterfaceService.connectionState.onEach(::onRadioConnectionState).launchIn(scope)
+
+ nodeRepository.myNodeInfo
+ .onEach { myNodeEntity ->
+ locationRequestsJob?.cancel()
+ if (myNodeEntity != null) {
+ locationRequestsJob =
+ uiPrefs
+ .shouldProvideNodeLocation(myNodeEntity.myNodeNum)
+ .onEach { shouldProvide ->
+ if (shouldProvide) {
+ locationManager.start(scope) { pos -> commandSender.sendPosition(pos) }
+ } else {
+ locationManager.stop()
+ }
+ }
+ .launchIn(scope)
+ }
+ }
+ .launchIn(scope)
+ }
+
+ private fun onRadioConnectionState(newState: ConnectionState) {
+ scope.handledLaunch {
+ val localConfig = radioConfigRepository.localConfigFlow.first()
+ val isRouter = localConfig.device.role == ConfigProtos.Config.DeviceConfig.Role.ROUTER
+ val lsEnabled = localConfig.power.isPowerSaving || isRouter
+
+ val effectiveState =
+ when (newState) {
+ is ConnectionState.Connected -> ConnectionState.Connected
+ is ConnectionState.DeviceSleep ->
+ if (lsEnabled) ConnectionState.DeviceSleep else ConnectionState.Disconnected
+ is ConnectionState.Connecting -> ConnectionState.Connecting
+ is ConnectionState.Disconnected -> ConnectionState.Disconnected
+ }
+ onConnectionChanged(effectiveState)
+ }
+ }
+
+ private fun onConnectionChanged(c: ConnectionState) {
+ if (connectionStateHolder.connectionState.value == c && c !is ConnectionState.Connected) return
+ Logger.d { "onConnectionChanged: ${connectionStateHolder.connectionState.value} -> $c" }
+
+ sleepTimeout?.cancel()
+ sleepTimeout = null
+
+ when (c) {
+ is ConnectionState.Connecting -> connectionStateHolder.setState(ConnectionState.Connecting)
+ is ConnectionState.Connected -> handleConnected()
+ is ConnectionState.DeviceSleep -> handleDeviceSleep()
+ is ConnectionState.Disconnected -> handleDisconnected()
+ }
+ updateStatusNotification()
+ }
+
+ private fun handleConnected() {
+ connectionStateHolder.setState(ConnectionState.Connecting)
+ serviceBroadcasts.broadcastConnection()
+ Logger.d { "Starting connect" }
+ connectTimeMsec = System.currentTimeMillis()
+ startConfigOnly()
+ }
+
+ private fun handleDeviceSleep() {
+ connectionStateHolder.setState(ConnectionState.DeviceSleep)
+ packetHandler.stopPacketQueue()
+ locationManager.stop()
+ mqttManager.stop()
+
+ if (connectTimeMsec != 0L) {
+ val now = System.currentTimeMillis()
+ val duration = now - connectTimeMsec
+ connectTimeMsec = 0L
+ analytics.track(
+ EVENT_CONNECTED_SECONDS,
+ DataPair(EVENT_CONNECTED_SECONDS, duration / MILLISECONDS_IN_SECOND),
+ )
+ }
+
+ sleepTimeout =
+ scope.handledLaunch {
+ try {
+ val localConfig = radioConfigRepository.localConfigFlow.first()
+ val timeout = (localConfig.power?.lsSecs ?: 0) + DEVICE_SLEEP_TIMEOUT_SECONDS
+ Logger.d { "Waiting for sleeping device, timeout=$timeout secs" }
+ delay(timeout.seconds)
+ Logger.w { "Device timeout out, setting disconnected" }
+ onConnectionChanged(ConnectionState.Disconnected)
+ } catch (_: CancellationException) {
+ Logger.d { "device sleep timeout cancelled" }
+ }
+ }
+
+ serviceBroadcasts.broadcastConnection()
+ }
+
+ private fun handleDisconnected() {
+ connectionStateHolder.setState(ConnectionState.Disconnected)
+ packetHandler.stopPacketQueue()
+ locationManager.stop()
+ mqttManager.stop()
+
+ analytics.track(
+ EVENT_MESH_DISCONNECT,
+ DataPair(KEY_NUM_NODES, nodeManager.nodeDBbyNodeNum.size),
+ DataPair(KEY_NUM_ONLINE, nodeManager.nodeDBbyNodeNum.values.count { it.isOnline }),
+ )
+ analytics.track(EVENT_NUM_NODES, DataPair(KEY_NUM_NODES, nodeManager.nodeDBbyNodeNum.size))
+
+ serviceBroadcasts.broadcastConnection()
+ }
+
+ fun startConfigOnly() {
+ packetHandler.sendToRadio(ToRadio.newBuilder().apply { wantConfigId = CONFIG_ONLY_NONCE })
+ }
+
+ fun startNodeInfoOnly() {
+ packetHandler.sendToRadio(ToRadio.newBuilder().apply { wantConfigId = NODE_INFO_NONCE })
+ }
+
+ fun onHasSettings() {
+ commandSender.processQueuedPackets()
+
+ // Start MQTT if enabled
+ scope.handledLaunch {
+ val moduleConfig = radioConfigRepository.moduleConfigFlow.first()
+ mqttManager.start(scope, moduleConfig.mqtt.enabled, moduleConfig.mqtt.proxyToClientEnabled)
+ }
+
+ reportConnection()
+
+ val myNodeNum = nodeManager.myNodeNum ?: 0
+ // Request history
+ scope.handledLaunch {
+ val moduleConfig = radioConfigRepository.moduleConfigFlow.first()
+ historyManager.requestHistoryReplay("onHasSettings", myNodeNum, moduleConfig.storeForward, "Unknown")
+ }
+
+ // Set time
+ commandSender.sendAdmin(myNodeNum) {
+ setTimeOnly = (System.currentTimeMillis() / MILLISECONDS_IN_SECOND).toInt()
+ }
+ }
+
+ private fun reportConnection() {
+ val myNode = nodeManager.getMyNodeInfo()
+ val radioModel = DataPair(KEY_RADIO_MODEL, myNode?.model ?: "unknown")
+ analytics.track(
+ EVENT_MESH_CONNECT,
+ DataPair(KEY_NUM_NODES, nodeManager.nodeDBbyNodeNum.size),
+ DataPair(KEY_NUM_ONLINE, nodeManager.nodeDBbyNodeNum.values.count { it.isOnline }),
+ radioModel,
+ )
+ }
+
+ fun updateTelemetry(telemetry: TelemetryProtos.Telemetry) {
+ updateStatusNotification(telemetry)
+ }
+
+ fun updateStatusNotification(telemetry: TelemetryProtos.Telemetry? = null): Notification {
+ val summary =
+ when (connectionStateHolder.connectionState.value) {
+ is ConnectionState.Connected ->
+ getString(Res.string.connected_count)
+ .format(nodeManager.nodeDBbyNodeNum.values.count { it.isOnline })
+ is ConnectionState.Disconnected -> getString(Res.string.disconnected)
+ is ConnectionState.DeviceSleep -> getString(Res.string.device_sleeping)
+ is ConnectionState.Connecting -> getString(Res.string.connecting)
+ }
+ return serviceNotifications.updateServiceStateNotification(summary, telemetry = telemetry)
+ }
+
+ companion object {
+ private const val CONFIG_ONLY_NONCE = 69420
+ private const val NODE_INFO_NONCE = 69421
+ private const val MILLISECONDS_IN_SECOND = 1000.0
+ private const val DEVICE_SLEEP_TIMEOUT_SECONDS = 30
+
+ private const val EVENT_CONNECTED_SECONDS = "connected_seconds"
+ private const val EVENT_MESH_DISCONNECT = "mesh_disconnect"
+ private const val EVENT_NUM_NODES = "num_nodes"
+ private const val EVENT_MESH_CONNECT = "mesh_connect"
+
+ private const val KEY_NUM_NODES = "num_nodes"
+ private const val KEY_NUM_ONLINE = "num_online"
+ private const val KEY_RADIO_MODEL = "radio_model"
+ }
+}
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt b/app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt
new file mode 100644
index 000000000..b9cc7419b
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt
@@ -0,0 +1,542 @@
+/*
+ * 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 android.util.Log
+import co.touchlab.kermit.Logger
+import com.geeksville.mesh.BuildConfig
+import com.geeksville.mesh.concurrent.handledLaunch
+import com.geeksville.mesh.repository.radio.InterfaceId
+import com.meshtastic.core.strings.getString
+import dagger.Lazy
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.first
+import org.meshtastic.core.analytics.DataPair
+import org.meshtastic.core.analytics.platform.PlatformAnalytics
+import org.meshtastic.core.data.repository.PacketRepository
+import org.meshtastic.core.data.repository.RadioConfigRepository
+import org.meshtastic.core.database.entity.Packet
+import org.meshtastic.core.database.entity.ReactionEntity
+import org.meshtastic.core.model.DataPacket
+import org.meshtastic.core.model.MessageStatus
+import org.meshtastic.core.prefs.mesh.MeshPrefs
+import org.meshtastic.core.service.MeshServiceNotifications
+import org.meshtastic.core.service.ServiceRepository
+import org.meshtastic.core.strings.Res
+import org.meshtastic.core.strings.critical_alert
+import org.meshtastic.core.strings.error_duty_cycle
+import org.meshtastic.core.strings.unknown_username
+import org.meshtastic.core.strings.waypoint_received
+import org.meshtastic.proto.AdminProtos
+import org.meshtastic.proto.MeshProtos
+import org.meshtastic.proto.MeshProtos.MeshPacket
+import org.meshtastic.proto.PaxcountProtos
+import org.meshtastic.proto.Portnums
+import org.meshtastic.proto.StoreAndForwardProtos
+import org.meshtastic.proto.TelemetryProtos
+import org.meshtastic.proto.copy
+import java.util.concurrent.ConcurrentHashMap
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlin.time.Duration.Companion.milliseconds
+
+@Suppress("LongParameterList", "TooManyFunctions")
+@Singleton
+class MeshDataHandler
+@Inject
+constructor(
+ private val nodeManager: MeshNodeManager,
+ private val packetHandler: PacketHandler,
+ private val serviceRepository: ServiceRepository,
+ private val packetRepository: Lazy,
+ private val serviceBroadcasts: MeshServiceBroadcasts,
+ private val serviceNotifications: MeshServiceNotifications,
+ private val analytics: PlatformAnalytics,
+ private val dataMapper: MeshDataMapper,
+ private val configHandler: MeshConfigHandler,
+ private val configFlowManager: MeshConfigFlowManager,
+ private val commandSender: MeshCommandSender,
+ private val historyManager: MeshHistoryManager,
+ private val meshPrefs: MeshPrefs,
+ private val connectionManager: MeshConnectionManager,
+ private val tracerouteHandler: MeshTracerouteHandler,
+ private val neighborInfoHandler: MeshNeighborInfoHandler,
+ private val radioConfigRepository: RadioConfigRepository,
+) {
+ private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
+
+ fun start(scope: CoroutineScope) {
+ this.scope = scope
+ }
+
+ private val rememberDataType =
+ setOf(
+ Portnums.PortNum.TEXT_MESSAGE_APP_VALUE,
+ Portnums.PortNum.ALERT_APP_VALUE,
+ Portnums.PortNum.WAYPOINT_APP_VALUE,
+ )
+
+ fun handleReceivedData(packet: MeshPacket, myNodeNum: Int, logUuid: String? = null, logInsertJob: Job? = null) {
+ val dataPacket = dataMapper.toDataPacket(packet) ?: return
+ val fromUs = myNodeNum == packet.from
+ dataPacket.status = MessageStatus.RECEIVED
+
+ val shouldBroadcast = handleDataPacket(packet, dataPacket, myNodeNum, fromUs, logUuid, logInsertJob)
+
+ if (shouldBroadcast) {
+ serviceBroadcasts.broadcastReceivedData(dataPacket)
+ }
+ analytics.track("num_data_receive", DataPair("num_data_receive", 1))
+ }
+
+ private fun handleDataPacket(
+ packet: MeshPacket,
+ dataPacket: DataPacket,
+ myNodeNum: Int,
+ fromUs: Boolean,
+ logUuid: String?,
+ logInsertJob: Job?,
+ ): Boolean {
+ var shouldBroadcast = !fromUs
+ when (packet.decoded.portnumValue) {
+ Portnums.PortNum.TEXT_MESSAGE_APP_VALUE -> handleTextMessage(packet, dataPacket, myNodeNum)
+ Portnums.PortNum.ALERT_APP_VALUE -> rememberDataPacket(dataPacket, myNodeNum)
+ Portnums.PortNum.WAYPOINT_APP_VALUE -> handleWaypoint(packet, dataPacket, myNodeNum)
+ Portnums.PortNum.POSITION_APP_VALUE -> handlePosition(packet, dataPacket, myNodeNum)
+ Portnums.PortNum.NODEINFO_APP_VALUE -> if (!fromUs) handleNodeInfo(packet)
+ Portnums.PortNum.TELEMETRY_APP_VALUE -> handleTelemetry(packet, dataPacket, myNodeNum)
+ else -> shouldBroadcast = handleSpecializedDataPacket(packet, dataPacket, myNodeNum, logUuid, logInsertJob)
+ }
+ return shouldBroadcast
+ }
+
+ private fun handleSpecializedDataPacket(
+ packet: MeshPacket,
+ dataPacket: DataPacket,
+ myNodeNum: Int,
+ logUuid: String?,
+ logInsertJob: Job?,
+ ): Boolean {
+ var shouldBroadcast = false
+ when (packet.decoded.portnumValue) {
+ Portnums.PortNum.TRACEROUTE_APP_VALUE -> {
+ tracerouteHandler.handleTraceroute(packet, logUuid, logInsertJob)
+ shouldBroadcast = false
+ }
+ Portnums.PortNum.ROUTING_APP_VALUE -> {
+ handleRouting(packet, dataPacket)
+ shouldBroadcast = true
+ }
+
+ Portnums.PortNum.PAXCOUNTER_APP_VALUE -> {
+ handlePaxCounter(packet)
+ shouldBroadcast = false
+ }
+
+ Portnums.PortNum.STORE_FORWARD_APP_VALUE -> {
+ handleStoreAndForward(packet, dataPacket, myNodeNum)
+ shouldBroadcast = false
+ }
+
+ Portnums.PortNum.ADMIN_APP_VALUE -> {
+ handleAdminMessage(packet, myNodeNum)
+ shouldBroadcast = false
+ }
+
+ Portnums.PortNum.NEIGHBORINFO_APP_VALUE -> {
+ neighborInfoHandler.handleNeighborInfo(packet)
+ shouldBroadcast = true
+ }
+
+ Portnums.PortNum.RANGE_TEST_APP_VALUE,
+ Portnums.PortNum.DETECTION_SENSOR_APP_VALUE,
+ -> {
+ handleRangeTest(dataPacket, myNodeNum)
+ shouldBroadcast = false
+ }
+ }
+ return shouldBroadcast
+ }
+
+ private fun handleRangeTest(dataPacket: DataPacket, myNodeNum: Int) {
+ val u = dataPacket.copy(dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE)
+ rememberDataPacket(u, myNodeNum)
+ }
+
+ private fun handleStoreAndForward(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) {
+ val u = StoreAndForwardProtos.StoreAndForward.parseFrom(packet.decoded.payload)
+ handleReceivedStoreAndForward(dataPacket, u, myNodeNum)
+ }
+
+ private fun handlePaxCounter(packet: MeshPacket) {
+ val p = PaxcountProtos.Paxcount.parseFrom(packet.decoded.payload)
+ nodeManager.handleReceivedPaxcounter(packet.from, p)
+ }
+
+ private fun handlePosition(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) {
+ val p = MeshProtos.Position.parseFrom(packet.decoded.payload)
+ nodeManager.handleReceivedPosition(packet.from, myNodeNum, p, dataPacket.time)
+ }
+
+ private fun handleWaypoint(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) {
+ val u = MeshProtos.Waypoint.parseFrom(packet.decoded.payload)
+ if (u.lockedTo != 0 && u.lockedTo != packet.from) return
+ val currentSecond = (System.currentTimeMillis() / MILLISECONDS_IN_SECOND).toInt()
+ rememberDataPacket(dataPacket, myNodeNum, updateNotification = u.expire > currentSecond)
+ }
+
+ private fun handleAdminMessage(packet: MeshPacket, myNodeNum: Int) {
+ val u = AdminProtos.AdminMessage.parseFrom(packet.decoded.payload)
+ commandSender.setSessionPasskey(u.sessionPasskey)
+
+ if (packet.from == myNodeNum) {
+ when (u.payloadVariantCase) {
+ AdminProtos.AdminMessage.PayloadVariantCase.GET_CONFIG_RESPONSE ->
+ configHandler.handleDeviceConfig(u.getConfigResponse)
+
+ AdminProtos.AdminMessage.PayloadVariantCase.GET_CHANNEL_RESPONSE ->
+ configHandler.handleChannel(u.getChannelResponse)
+
+ else -> {}
+ }
+ }
+
+ if (u.payloadVariantCase == AdminProtos.AdminMessage.PayloadVariantCase.GET_DEVICE_METADATA_RESPONSE) {
+ if (packet.from == myNodeNum) {
+ configFlowManager.handleLocalMetadata(u.getDeviceMetadataResponse)
+ } else {
+ nodeManager.insertMetadata(packet.from, u.getDeviceMetadataResponse)
+ }
+ }
+ }
+
+ private fun handleTextMessage(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) {
+ if (packet.decoded.replyId != 0 && packet.decoded.emoji != 0) {
+ rememberReaction(packet)
+ } else {
+ rememberDataPacket(dataPacket, myNodeNum)
+ }
+ }
+
+ private fun handleNodeInfo(packet: MeshPacket) {
+ val u =
+ MeshProtos.User.parseFrom(packet.decoded.payload).copy {
+ if (isLicensed) clearPublicKey()
+ if (packet.viaMqtt) longName = "$longName (MQTT)"
+ }
+ nodeManager.handleReceivedUser(packet.from, u, packet.channel)
+ }
+
+ private fun handleTelemetry(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) {
+ val t =
+ TelemetryProtos.Telemetry.parseFrom(packet.decoded.payload).copy {
+ if (time == 0) time = (dataPacket.time.milliseconds.inWholeSeconds).toInt()
+ }
+ val fromNum = packet.from
+ val isRemote = (fromNum != myNodeNum)
+ if (!isRemote) {
+ connectionManager.updateTelemetry(t)
+ }
+
+ nodeManager.updateNodeInfo(fromNum) { nodeEntity ->
+ when {
+ t.hasDeviceMetrics() -> {
+ nodeEntity.deviceTelemetry = t
+ if (fromNum == myNodeNum || (isRemote && nodeEntity.isFavorite)) {
+ if (
+ t.deviceMetrics.voltage > BATTERY_PERCENT_UNSUPPORTED &&
+ t.deviceMetrics.batteryLevel <= BATTERY_PERCENT_LOW_THRESHOLD
+ ) {
+ if (shouldBatteryNotificationShow(fromNum, t, myNodeNum)) {
+ serviceNotifications.showOrUpdateLowBatteryNotification(nodeEntity, isRemote)
+ }
+ } else {
+ if (batteryPercentCooldowns.containsKey(fromNum)) {
+ batteryPercentCooldowns.remove(fromNum)
+ }
+ serviceNotifications.cancelLowBatteryNotification(nodeEntity)
+ }
+ }
+ }
+
+ t.hasEnvironmentMetrics() -> nodeEntity.environmentTelemetry = t
+ t.hasPowerMetrics() -> nodeEntity.powerTelemetry = t
+ }
+ }
+ }
+
+ private fun shouldBatteryNotificationShow(fromNum: Int, t: TelemetryProtos.Telemetry, myNodeNum: Int): Boolean {
+ val isRemote = (fromNum != myNodeNum)
+ var shouldDisplay = false
+ var forceDisplay = false
+ when {
+ t.deviceMetrics.batteryLevel <= BATTERY_PERCENT_CRITICAL_THRESHOLD -> {
+ shouldDisplay = true
+ forceDisplay = true
+ }
+
+ t.deviceMetrics.batteryLevel == BATTERY_PERCENT_LOW_THRESHOLD -> shouldDisplay = true
+ t.deviceMetrics.batteryLevel.mod(BATTERY_PERCENT_LOW_DIVISOR) == 0 && !isRemote -> shouldDisplay = true
+
+ isRemote -> shouldDisplay = true
+ }
+ if (shouldDisplay) {
+ val now = System.currentTimeMillis() / MILLISECONDS_IN_SECOND
+ if (!batteryPercentCooldowns.containsKey(fromNum)) batteryPercentCooldowns[fromNum] = 0
+ if ((now - batteryPercentCooldowns[fromNum]!!) >= BATTERY_PERCENT_COOLDOWN_SECONDS || forceDisplay) {
+ batteryPercentCooldowns[fromNum] = now
+ return true
+ }
+ }
+ return false
+ }
+
+ private fun handleRouting(packet: MeshPacket, dataPacket: DataPacket) {
+ val r = MeshProtos.Routing.parseFrom(packet.decoded.payload)
+ if (r.errorReason == MeshProtos.Routing.Error.DUTY_CYCLE_LIMIT) {
+ serviceRepository.setErrorMessage(getString(Res.string.error_duty_cycle))
+ }
+ handleAckNak(
+ packet.decoded.requestId,
+ dataMapper.toNodeID(packet.from),
+ r.errorReasonValue,
+ dataPacket.relayNode,
+ )
+ packetHandler.removeResponse(packet.decoded.requestId, complete = true)
+ }
+
+ private fun handleAckNak(requestId: Int, fromId: String, routingError: Int, relayNode: Int?) {
+ scope.handledLaunch {
+ val isAck = routingError == MeshProtos.Routing.Error.NONE_VALUE
+ val p = packetRepository.get().getPacketById(requestId)
+ 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
+ p.data.relayNode = relayNode
+ if (isAck) {
+ p.data.relays += 1
+ }
+ packetRepository.get().update(p)
+ }
+ serviceBroadcasts.broadcastMessageStatus(requestId, m)
+ }
+ }
+
+ private fun handleReceivedStoreAndForward(
+ dataPacket: DataPacket,
+ s: StoreAndForwardProtos.StoreAndForward,
+ myNodeNum: Int,
+ ) {
+ Logger.d { "StoreAndForward: ${s.variantCase} ${s.rr} from ${dataPacket.from}" }
+ val transport = currentTransport()
+ val lastRequest =
+ if (s.variantCase == StoreAndForwardProtos.StoreAndForward.VariantCase.HISTORY) {
+ s.history.lastRequest
+ } else {
+ 0
+ }
+ val baseContext = "transport=$transport from=${dataPacket.from}"
+ historyLog { "rxStoreForward $baseContext variant=${s.variantCase} rr=${s.rr} lastRequest=$lastRequest" }
+ when (s.variantCase) {
+ StoreAndForwardProtos.StoreAndForward.VariantCase.STATS -> {
+ val u =
+ dataPacket.copy(
+ bytes = s.stats.toString().encodeToByteArray(),
+ dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE,
+ )
+ rememberDataPacket(u, myNodeNum)
+ }
+
+ StoreAndForwardProtos.StoreAndForward.VariantCase.HISTORY -> {
+ val history = s.history
+ val historySummary =
+ "routerHistory $baseContext messages=${history.historyMessages} " +
+ "window=${history.window} lastRequest=${history.lastRequest}"
+ historyLog(Log.DEBUG) { historySummary }
+ val text =
+ """
+ Total messages: ${s.history.historyMessages}
+ History window: ${s.history.window.milliseconds.inWholeMinutes} min
+ Last request: ${s.history.lastRequest}
+ """
+ .trimIndent()
+ val u =
+ dataPacket.copy(
+ bytes = text.encodeToByteArray(),
+ dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE,
+ )
+ rememberDataPacket(u, myNodeNum)
+ historyManager.updateStoreForwardLastRequest("router_history", s.history.lastRequest, transport)
+ }
+
+ StoreAndForwardProtos.StoreAndForward.VariantCase.TEXT -> {
+ if (s.rr == StoreAndForwardProtos.StoreAndForward.RequestResponse.ROUTER_TEXT_BROADCAST) {
+ dataPacket.to = DataPacket.ID_BROADCAST
+ }
+ val textLog =
+ "rxText $baseContext id=${dataPacket.id} ts=${dataPacket.time} " +
+ "to=${dataPacket.to} decision=remember"
+ historyLog(Log.DEBUG) { textLog }
+ val u =
+ dataPacket.copy(bytes = s.text.toByteArray(), dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE)
+ rememberDataPacket(u, myNodeNum)
+ }
+
+ else -> {}
+ }
+ }
+
+ fun rememberDataPacket(dataPacket: DataPacket, myNodeNum: Int, updateNotification: Boolean = true) {
+ if (dataPacket.dataType !in rememberDataType) 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 =
+ Packet(
+ uuid = 0L,
+ myNodeNum = myNodeNum,
+ packetId = dataPacket.id,
+ port_num = dataPacket.dataType,
+ contact_key = contactKey,
+ received_time = System.currentTimeMillis(),
+ read = fromLocal,
+ data = dataPacket,
+ snr = dataPacket.snr,
+ rssi = dataPacket.rssi,
+ hopsAway = dataPacket.hopsAway,
+ replyId = dataPacket.replyId ?: 0,
+ )
+ scope.handledLaunch {
+ packetRepository.get().apply {
+ insert(packetToSave)
+ val isMuted = getContactSettings(contactKey).isMuted
+ if (!isMuted) {
+ if (packetToSave.port_num == Portnums.PortNum.ALERT_APP_VALUE) {
+ serviceNotifications.showAlertNotification(
+ contactKey,
+ getSenderName(dataPacket),
+ dataPacket.alert ?: getString(Res.string.critical_alert),
+ )
+ } else if (updateNotification) {
+ scope.handledLaunch { updateMessageNotification(contactKey, dataPacket) }
+ }
+ }
+ }
+ }
+ }
+
+ private fun getSenderName(packet: DataPacket): String =
+ nodeManager.nodeDBbyID[packet.from]?.user?.longName ?: getString(Res.string.unknown_username)
+
+ private suspend fun updateMessageNotification(contactKey: String, dataPacket: DataPacket) {
+ val message =
+ when (dataPacket.dataType) {
+ Portnums.PortNum.TEXT_MESSAGE_APP_VALUE -> dataPacket.text!!
+ Portnums.PortNum.WAYPOINT_APP_VALUE ->
+ getString(Res.string.waypoint_received, dataPacket.waypoint!!.name)
+
+ else -> return
+ }
+
+ val channelName =
+ if (dataPacket.to == DataPacket.ID_BROADCAST) {
+ radioConfigRepository.channelSetFlow.first().settingsList.getOrNull(dataPacket.channel)?.name
+ } else {
+ null
+ }
+
+ serviceNotifications.updateMessageNotification(
+ contactKey,
+ getSenderName(dataPacket),
+ message,
+ dataPacket.to == DataPacket.ID_BROADCAST,
+ channelName,
+ )
+ }
+
+ private fun rememberReaction(packet: MeshPacket) = scope.handledLaunch {
+ val reaction =
+ ReactionEntity(
+ replyId = packet.decoded.replyId,
+ userId = dataMapper.toNodeID(packet.from),
+ emoji = packet.decoded.payload.toByteArray().decodeToString(),
+ timestamp = System.currentTimeMillis(),
+ snr = packet.rxSnr,
+ rssi = packet.rxRssi,
+ hopsAway =
+ if (packet.hopStart == 0 || packet.hopLimit > packet.hopStart) {
+ HOPS_AWAY_UNAVAILABLE
+ } else {
+ packet.hopStart - packet.hopLimit
+ },
+ )
+ packetRepository.get().insertReaction(reaction)
+ }
+
+ private fun currentTransport(address: String? = meshPrefs.deviceAddress): String = when (address?.firstOrNull()) {
+ InterfaceId.BLUETOOTH.id -> "BLE"
+ InterfaceId.TCP.id -> "TCP"
+ InterfaceId.SERIAL.id -> "Serial"
+ InterfaceId.MOCK.id -> "Mock"
+ InterfaceId.NOP.id -> "NOP"
+ else -> "Unknown"
+ }
+
+ private inline fun historyLog(
+ priority: Int = Log.INFO,
+ throwable: Throwable? = null,
+ crossinline message: () -> String,
+ ) {
+ if (!BuildConfig.DEBUG) return
+ val logger = Logger.withTag("HistoryReplay")
+ val msg = message()
+ when (priority) {
+ Log.VERBOSE -> logger.v(throwable) { msg }
+ Log.DEBUG -> logger.d(throwable) { msg }
+ Log.INFO -> logger.i(throwable) { msg }
+ Log.WARN -> logger.w(throwable) { msg }
+ Log.ERROR -> logger.e(throwable) { msg }
+ else -> logger.i(throwable) { msg }
+ }
+ }
+
+ companion object {
+ private const val MILLISECONDS_IN_SECOND = 1000L
+ private const val HOPS_AWAY_UNAVAILABLE = -1
+
+ private const val BATTERY_PERCENT_UNSUPPORTED = 0.0
+ private const val BATTERY_PERCENT_LOW_THRESHOLD = 20
+ private const val BATTERY_PERCENT_LOW_DIVISOR = 5
+ private const val BATTERY_PERCENT_CRITICAL_THRESHOLD = 5
+ private const val BATTERY_PERCENT_COOLDOWN_SECONDS = 1500
+ private val batteryPercentCooldowns = ConcurrentHashMap()
+ }
+}
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshDataMapper.kt b/app/src/main/java/com/geeksville/mesh/service/MeshDataMapper.kt
new file mode 100644
index 000000000..c8a810ab1
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshDataMapper.kt
@@ -0,0 +1,55 @@
+/*
+ * 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 org.meshtastic.core.model.DataPacket
+import org.meshtastic.proto.MeshProtos.MeshPacket
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class MeshDataMapper @Inject constructor(private val nodeManager: MeshNodeManager) {
+ fun toNodeID(n: Int): String = if (n == DataPacket.NODENUM_BROADCAST) {
+ DataPacket.ID_BROADCAST
+ } else {
+ nodeManager.nodeDBbyNodeNum[n]?.user?.id ?: DataPacket.nodeNumToDefaultId(n)
+ }
+
+ fun toDataPacket(packet: MeshPacket): DataPacket? = if (!packet.hasDecoded()) {
+ null
+ } else {
+ val data = packet.decoded
+ DataPacket(
+ from = toNodeID(packet.from),
+ to = toNodeID(packet.to),
+ time = packet.rxTime * 1000L,
+ id = packet.id,
+ dataType = data.portnumValue,
+ bytes = data.payload.toByteArray(),
+ hopLimit = packet.hopLimit,
+ channel = if (packet.pkiEncrypted) DataPacket.PKC_CHANNEL_INDEX else packet.channel,
+ wantAck = packet.wantAck,
+ hopStart = packet.hopStart,
+ snr = packet.rxSnr,
+ rssi = packet.rxRssi,
+ replyId = data.replyId,
+ relayNode = packet.relayNode,
+ viaMqtt = packet.viaMqtt,
+ )
+ }
+}
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshHistoryManager.kt b/app/src/main/java/com/geeksville/mesh/service/MeshHistoryManager.kt
new file mode 100644
index 000000000..a7208e526
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshHistoryManager.kt
@@ -0,0 +1,146 @@
+/*
+ * 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 android.util.Log
+import androidx.annotation.VisibleForTesting
+import co.touchlab.kermit.Logger
+import com.geeksville.mesh.BuildConfig
+import com.geeksville.mesh.model.NO_DEVICE_SELECTED
+import com.google.protobuf.ByteString
+import org.meshtastic.core.prefs.mesh.MeshPrefs
+import org.meshtastic.proto.MeshProtos.MeshPacket
+import org.meshtastic.proto.ModuleConfigProtos
+import org.meshtastic.proto.Portnums
+import org.meshtastic.proto.StoreAndForwardProtos
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class MeshHistoryManager
+@Inject
+constructor(
+ private val meshPrefs: MeshPrefs,
+ private val packetHandler: PacketHandler,
+) {
+ companion object {
+ private const val HISTORY_TAG = "HistoryReplay"
+ private const val DEFAULT_HISTORY_RETURN_WINDOW_MINUTES = 60 * 24
+ private const val DEFAULT_HISTORY_RETURN_MAX_MESSAGES = 100
+
+ @VisibleForTesting
+ internal fun buildStoreForwardHistoryRequest(
+ lastRequest: Int,
+ historyReturnWindow: Int,
+ historyReturnMax: Int,
+ ): StoreAndForwardProtos.StoreAndForward {
+ val historyBuilder = StoreAndForwardProtos.StoreAndForward.History.newBuilder()
+ if (lastRequest > 0) historyBuilder.lastRequest = lastRequest
+ if (historyReturnWindow > 0) historyBuilder.window = historyReturnWindow
+ if (historyReturnMax > 0) historyBuilder.historyMessages = historyReturnMax
+ return StoreAndForwardProtos.StoreAndForward.newBuilder()
+ .setRr(StoreAndForwardProtos.StoreAndForward.RequestResponse.CLIENT_HISTORY)
+ .setHistory(historyBuilder)
+ .build()
+ }
+
+ @VisibleForTesting
+ internal fun resolveHistoryRequestParameters(window: Int, max: Int): Pair {
+ val resolvedWindow = if (window > 0) window else DEFAULT_HISTORY_RETURN_WINDOW_MINUTES
+ val resolvedMax = if (max > 0) max else DEFAULT_HISTORY_RETURN_MAX_MESSAGES
+ return resolvedWindow to resolvedMax
+ }
+ }
+
+ private fun historyLog(priority: Int = Log.INFO, throwable: Throwable? = null, message: () -> String) {
+ if (!BuildConfig.DEBUG) return
+ val logger = Logger.withTag(HISTORY_TAG)
+ val msg = message()
+ when (priority) {
+ Log.VERBOSE -> logger.v(throwable) { msg }
+ Log.DEBUG -> logger.d(throwable) { msg }
+ Log.INFO -> logger.i(throwable) { msg }
+ Log.WARN -> logger.w(throwable) { msg }
+ Log.ERROR -> logger.e(throwable) { msg }
+ else -> logger.i(throwable) { msg }
+ }
+ }
+
+ private fun activeDeviceAddress(): String? =
+ meshPrefs.deviceAddress?.takeIf { !it.equals(NO_DEVICE_SELECTED, ignoreCase = true) && it.isNotBlank() }
+
+ fun requestHistoryReplay(
+ trigger: String,
+ myNodeNum: Int?,
+ storeForwardConfig: ModuleConfigProtos.ModuleConfig.StoreForwardConfig?,
+ transport: String,
+ ) {
+ val address = activeDeviceAddress()
+ if (address == null || myNodeNum == null) {
+ val reason = if (address == null) "no_addr" else "no_my_node"
+ historyLog { "requestHistory skipped trigger=$trigger reason=$reason" }
+ return
+ }
+
+ val lastRequest = meshPrefs.getStoreForwardLastRequest(address)
+ val (window, max) =
+ resolveHistoryRequestParameters(
+ storeForwardConfig?.historyReturnWindow ?: 0,
+ storeForwardConfig?.historyReturnMax ?: 0,
+ )
+
+ val request = buildStoreForwardHistoryRequest(lastRequest, window, max)
+
+ historyLog {
+ "requestHistory trigger=$trigger transport=$transport addr=$address " +
+ "lastRequest=$lastRequest window=$window max=$max"
+ }
+
+ runCatching {
+ packetHandler.sendToRadio(
+ MeshPacket.newBuilder()
+ .apply {
+ to = myNodeNum
+ decoded =
+ org.meshtastic.proto.MeshProtos.Data.newBuilder()
+ .apply {
+ portnumValue = Portnums.PortNum.STORE_FORWARD_APP_VALUE
+ payload = ByteString.copyFrom(request.toByteArray())
+ }
+ .build()
+ priority = MeshPacket.Priority.BACKGROUND
+ }
+ .build(),
+ )
+ }
+ .onFailure { ex -> historyLog(Log.WARN, ex) { "requestHistory failed" } }
+ }
+
+ fun updateStoreForwardLastRequest(source: String, lastRequest: Int, transport: String) {
+ if (lastRequest <= 0) return
+ val address = activeDeviceAddress() ?: return
+ val current = meshPrefs.getStoreForwardLastRequest(address)
+ if (lastRequest != current) {
+ meshPrefs.setStoreForwardLastRequest(address, lastRequest)
+ historyLog {
+ "historyMarker updated source=$source transport=$transport " +
+ "addr=$address from=$current to=$lastRequest"
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshLocationManager.kt b/app/src/main/java/com/geeksville/mesh/service/MeshLocationManager.kt
new file mode 100644
index 000000000..026566f31
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshLocationManager.kt
@@ -0,0 +1,85 @@
+/*
+ * 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 android.annotation.SuppressLint
+import android.app.Application
+import androidx.core.location.LocationCompat
+import co.touchlab.kermit.Logger
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import org.meshtastic.core.common.hasLocationPermission
+import org.meshtastic.core.data.repository.LocationRepository
+import org.meshtastic.core.model.Position
+import org.meshtastic.proto.MeshProtos
+import org.meshtastic.proto.position
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlin.time.Duration.Companion.milliseconds
+
+@Singleton
+class MeshLocationManager
+@Inject
+constructor(
+ private val context: Application,
+ private val locationRepository: LocationRepository,
+) {
+ private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
+ private var locationFlow: Job? = null
+
+ @SuppressLint("MissingPermission")
+ fun start(scope: CoroutineScope, sendPositionFn: (MeshProtos.Position) -> Unit) {
+ this.scope = scope
+ if (locationFlow?.isActive == true) return
+
+ if (context.hasLocationPermission()) {
+ locationFlow =
+ locationRepository
+ .getLocations()
+ .onEach { location ->
+ sendPositionFn(
+ 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.milliseconds.inWholeSeconds).toInt()
+ groundSpeed = location.speed.toInt()
+ groundTrack = location.bearing.toInt()
+ locationSource = MeshProtos.Position.LocSource.LOC_EXTERNAL
+ },
+ )
+ }
+ .launchIn(scope)
+ }
+ }
+
+ fun stop() {
+ if (locationFlow?.isActive == true) {
+ Logger.i { "Stopping location requests" }
+ locationFlow?.cancel()
+ locationFlow = null
+ }
+ }
+}
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshMessageProcessor.kt b/app/src/main/java/com/geeksville/mesh/service/MeshMessageProcessor.kt
new file mode 100644
index 000000000..6b538fc6e
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshMessageProcessor.kt
@@ -0,0 +1,250 @@
+/*
+ * 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 android.util.Log
+import co.touchlab.kermit.Logger
+import com.geeksville.mesh.BuildConfig
+import com.geeksville.mesh.concurrent.handledLaunch
+import dagger.Lazy
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import org.meshtastic.core.data.repository.MeshLogRepository
+import org.meshtastic.core.database.entity.MeshLog
+import org.meshtastic.core.service.ServiceRepository
+import org.meshtastic.proto.MeshProtos
+import org.meshtastic.proto.MeshProtos.FromRadio.PayloadVariantCase
+import org.meshtastic.proto.MeshProtos.MeshPacket
+import org.meshtastic.proto.Portnums
+import org.meshtastic.proto.fromRadio
+import java.util.ArrayDeque
+import java.util.Locale
+import java.util.UUID
+import java.util.concurrent.ConcurrentHashMap
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlin.time.Duration.Companion.milliseconds
+
+@Singleton
+class MeshMessageProcessor
+@Inject
+constructor(
+ private val nodeManager: MeshNodeManager,
+ private val serviceRepository: ServiceRepository,
+ private val meshLogRepository: Lazy,
+ private val router: MeshRouter,
+ private val fromRadioDispatcher: FromRadioPacketHandler,
+) {
+ private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
+ private val logUuidByPacketId = ConcurrentHashMap()
+ private val logInsertJobByPacketId = ConcurrentHashMap()
+
+ private val earlyReceivedPackets = ArrayDeque()
+ private val maxEarlyPacketBuffer = 128
+
+ fun start(scope: CoroutineScope) {
+ this.scope = scope
+ nodeManager.isNodeDbReady
+ .onEach { ready ->
+ if (ready) {
+ flushEarlyReceivedPackets("dbReady")
+ }
+ }
+ .launchIn(scope)
+ }
+
+ fun handleFromRadio(bytes: ByteArray, myNodeNum: Int?) {
+ runCatching { MeshProtos.FromRadio.parseFrom(bytes) }
+ .onSuccess { proto ->
+ if (proto.payloadVariantCase == PayloadVariantCase.PAYLOADVARIANT_NOT_SET) {
+ Logger.w { "Received FromRadio with PAYLOADVARIANT_NOT_SET. rawBytes=${bytes.toHexString()}" }
+ }
+ processFromRadio(proto, myNodeNum)
+ }
+ .onFailure { primaryException ->
+ runCatching {
+ val logRecord = MeshProtos.LogRecord.parseFrom(bytes)
+ processFromRadio(fromRadio { this.logRecord = logRecord }, myNodeNum)
+ }
+ .onFailure { _ ->
+ Logger.e(primaryException) {
+ "Failed to parse radio packet (len=${bytes.size} contents=${bytes.toHexString()}). " +
+ "Not a valid FromRadio or LogRecord."
+ }
+ }
+ }
+ }
+
+ private fun processFromRadio(proto: MeshProtos.FromRadio, myNodeNum: Int?) {
+ // Audit log every incoming variant
+ logVariant(proto)
+
+ if (proto.payloadVariantCase == PayloadVariantCase.PACKET) {
+ handleReceivedMeshPacket(proto.packet, myNodeNum)
+ } else {
+ fromRadioDispatcher.handleFromRadio(proto)
+ }
+ }
+
+ private fun logVariant(proto: MeshProtos.FromRadio) {
+ val (type, message) =
+ when (proto.payloadVariantCase) {
+ PayloadVariantCase.LOG_RECORD -> "LogRecord" to proto.logRecord.toString()
+ PayloadVariantCase.REBOOTED -> "Rebooted" to proto.rebooted.toString()
+ PayloadVariantCase.XMODEMPACKET -> "XmodemPacket" to proto.xmodemPacket.toString()
+ PayloadVariantCase.DEVICEUICONFIG -> "DeviceUIConfig" to proto.deviceuiConfig.toString()
+ PayloadVariantCase.FILEINFO -> "FileInfo" to proto.fileInfo.toString()
+ else -> return // Other variants (Config, NodeInfo, etc.) are handled by dispatcher but not necessarily
+ // logged as raw strings here
+ }
+
+ insertMeshLog(
+ MeshLog(
+ uuid = UUID.randomUUID().toString(),
+ message_type = type,
+ received_date = System.currentTimeMillis(),
+ raw_message = message,
+ fromRadio = proto,
+ ),
+ )
+ }
+
+ fun handleReceivedMeshPacket(packet: MeshPacket, myNodeNum: Int?) {
+ val rxTime =
+ if (packet.rxTime == 0) (System.currentTimeMillis().milliseconds.inWholeSeconds).toInt() else packet.rxTime
+ val preparedPacket = packet.toBuilder().setRxTime(rxTime).build()
+
+ if (nodeManager.isNodeDbReady.value) {
+ processReceivedMeshPacket(preparedPacket, myNodeNum)
+ } else {
+ synchronized(earlyReceivedPackets) {
+ val queueSize = earlyReceivedPackets.size
+ if (queueSize >= maxEarlyPacketBuffer) {
+ val dropped = earlyReceivedPackets.removeFirst()
+ historyLog(Log.WARN) {
+ val portLabel =
+ if (dropped.hasDecoded()) {
+ Portnums.PortNum.forNumber(dropped.decoded.portnumValue)?.name
+ ?: dropped.decoded.portnumValue.toString()
+ } else {
+ "unknown"
+ }
+ "dropEarlyPacket bufferFull size=$queueSize id=${dropped.id} port=$portLabel"
+ }
+ }
+ earlyReceivedPackets.addLast(preparedPacket)
+ val portLabel =
+ if (preparedPacket.hasDecoded()) {
+ Portnums.PortNum.forNumber(preparedPacket.decoded.portnumValue)?.name
+ ?: preparedPacket.decoded.portnumValue.toString()
+ } else {
+ "unknown"
+ }
+ historyLog {
+ "queueEarlyPacket size=${earlyReceivedPackets.size} id=${preparedPacket.id} port=$portLabel"
+ }
+ }
+ }
+ }
+
+ private fun flushEarlyReceivedPackets(reason: String) {
+ val packets =
+ synchronized(earlyReceivedPackets) {
+ if (earlyReceivedPackets.isEmpty()) return
+ val list = earlyReceivedPackets.toList()
+ earlyReceivedPackets.clear()
+ list
+ }
+ historyLog { "replayEarlyPackets reason=$reason count=${packets.size}" }
+ val myNodeNum = nodeManager.myNodeNum
+ packets.forEach { processReceivedMeshPacket(it, myNodeNum) }
+ }
+
+ private fun processReceivedMeshPacket(packet: MeshPacket, myNodeNum: Int?) {
+ if (!packet.hasDecoded()) return
+ val log =
+ 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 },
+ )
+ val logJob = insertMeshLog(log)
+ logInsertJobByPacketId[packet.id] = logJob
+ logUuidByPacketId[packet.id] = log.uuid
+
+ scope.handledLaunch { serviceRepository.emitMeshPacket(packet) }
+
+ myNodeNum?.let { myNum ->
+ val isOtherNode = myNum != packet.from
+ nodeManager.updateNodeInfo(myNum, withBroadcast = isOtherNode) {
+ it.lastHeard = (System.currentTimeMillis().milliseconds.inWholeSeconds).toInt()
+ }
+ nodeManager.updateNodeInfo(packet.from, withBroadcast = false, channel = packet.channel) {
+ it.lastHeard = packet.rxTime
+ it.snr = packet.rxSnr
+ it.rssi = packet.rxRssi
+ it.hopsAway =
+ if (packet.decoded.portnumValue == Portnums.PortNum.RANGE_TEST_APP_VALUE) {
+ 0
+ } else if (packet.hopStart == 0 || packet.hopLimit > packet.hopStart) {
+ -1
+ } else {
+ packet.hopStart - packet.hopLimit
+ }
+ }
+
+ try {
+ router.dataHandler.handleReceivedData(packet, myNum, log.uuid, logJob)
+ } finally {
+ logUuidByPacketId.remove(packet.id)
+ logInsertJobByPacketId.remove(packet.id)
+ }
+ }
+ }
+
+ private fun insertMeshLog(log: MeshLog): Job = scope.handledLaunch { meshLogRepository.get().insert(log) }
+
+ private inline fun historyLog(
+ priority: Int = Log.INFO,
+ throwable: Throwable? = null,
+ crossinline message: () -> String,
+ ) {
+ if (!BuildConfig.DEBUG) return
+ val logger = Logger.withTag("HistoryReplay")
+ val msg = message()
+ when (priority) {
+ Log.VERBOSE -> logger.v(throwable) { msg }
+ Log.DEBUG -> logger.d(throwable) { msg }
+ Log.INFO -> logger.i(throwable) { msg }
+ Log.WARN -> logger.w(throwable) { msg }
+ Log.ERROR -> logger.e(throwable) { msg }
+ else -> logger.i(throwable) { msg }
+ }
+ }
+
+ private fun ByteArray.toHexString(): String =
+ this.joinToString(",") { byte -> String.format(Locale.US, "0x%02x", byte) }
+}
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshMqttManager.kt b/app/src/main/java/com/geeksville/mesh/service/MeshMqttManager.kt
new file mode 100644
index 000000000..01e679a6b
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshMqttManager.kt
@@ -0,0 +1,82 @@
+/*
+ * 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 co.touchlab.kermit.Logger
+import com.geeksville.mesh.repository.network.MQTTRepository
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import org.meshtastic.core.service.ServiceRepository
+import org.meshtastic.proto.MeshProtos
+import org.meshtastic.proto.MeshProtos.ToRadio
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class MeshMqttManager
+@Inject
+constructor(
+ private val mqttRepository: MQTTRepository,
+ private val packetHandler: PacketHandler,
+ private val serviceRepository: ServiceRepository,
+) {
+ private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
+ private var mqttMessageFlow: Job? = null
+
+ fun start(scope: CoroutineScope, enabled: Boolean, proxyToClientEnabled: Boolean) {
+ this.scope = scope
+ if (mqttMessageFlow?.isActive == true) return
+ if (enabled && proxyToClientEnabled) {
+ mqttMessageFlow =
+ mqttRepository.proxyMessageFlow
+ .onEach { message ->
+ packetHandler.sendToRadio(ToRadio.newBuilder().apply { mqttClientProxyMessage = message })
+ }
+ .catch { throwable -> serviceRepository.setErrorMessage("MqttClientProxy failed: $throwable") }
+ .launchIn(scope)
+ }
+ }
+
+ fun stop() {
+ if (mqttMessageFlow?.isActive == true) {
+ Logger.i { "Stopping MqttClientProxy" }
+ mqttMessageFlow?.cancel()
+ mqttMessageFlow = null
+ }
+ }
+
+ fun handleMqttProxyMessage(message: MeshProtos.MqttClientProxyMessage) {
+ Logger.d { "[mqttClientProxyMessage] ${message.topic}" }
+ with(message) {
+ when (payloadVariantCase) {
+ MeshProtos.MqttClientProxyMessage.PayloadVariantCase.TEXT -> {
+ mqttRepository.publish(topic, text.encodeToByteArray(), retained)
+ }
+ MeshProtos.MqttClientProxyMessage.PayloadVariantCase.DATA -> {
+ mqttRepository.publish(topic, data.toByteArray(), retained)
+ }
+ else -> {}
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshNeighborInfoHandler.kt b/app/src/main/java/com/geeksville/mesh/service/MeshNeighborInfoHandler.kt
new file mode 100644
index 000000000..8af302502
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshNeighborInfoHandler.kt
@@ -0,0 +1,90 @@
+/*
+ * 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 co.touchlab.kermit.Logger
+import com.meshtastic.core.strings.getString
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import org.meshtastic.core.service.ServiceRepository
+import org.meshtastic.core.strings.Res
+import org.meshtastic.core.strings.unknown_username
+import org.meshtastic.proto.MeshProtos
+import org.meshtastic.proto.MeshProtos.MeshPacket
+import java.util.Locale
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class MeshNeighborInfoHandler
+@Inject
+constructor(
+ private val nodeManager: MeshNodeManager,
+ private val serviceRepository: ServiceRepository,
+ private val commandSender: MeshCommandSender,
+ private val serviceBroadcasts: MeshServiceBroadcasts,
+) {
+ private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
+
+ fun start(scope: CoroutineScope) {
+ this.scope = scope
+ }
+
+ fun handleNeighborInfo(packet: MeshPacket) {
+ val ni = MeshProtos.NeighborInfo.parseFrom(packet.decoded.payload)
+
+ // Store the last neighbor info from our connected radio
+ if (packet.from == nodeManager.myNodeNum) {
+ commandSender.lastNeighborInfo = ni
+ Logger.d { "Stored last neighbor info from connected radio" }
+ }
+
+ // Update Node DB
+ nodeManager.nodeDBbyNodeNum[packet.from]?.let { serviceBroadcasts.broadcastNodeChange(it.toNodeInfo()) }
+
+ // Format for UI response
+ val requestId = packet.decoded.requestId
+ val start = commandSender.neighborInfoStartTimes.remove(requestId)
+
+ val neighbors =
+ ni.neighborsList.joinToString("\n") { n ->
+ val node = nodeManager.nodeDBbyNodeNum[n.nodeId]
+ val name = node?.let { "${it.longName} (${it.shortName})" } ?: getString(Res.string.unknown_username)
+ "• $name (SNR: ${n.snr})"
+ }
+
+ val formatted = "Neighbors of ${nodeManager.nodeDBbyNodeNum[packet.from]?.longName ?: "Unknown"}:\n$neighbors"
+
+ val responseText =
+ if (start != null) {
+ val elapsedMs = System.currentTimeMillis() - start
+ val seconds = elapsedMs / MILLIS_PER_SECOND
+ Logger.i { "Neighbor info $requestId complete in $seconds s" }
+ String.format(Locale.US, "%s\n\nDuration: %.1f s", formatted, seconds)
+ } else {
+ formatted
+ }
+
+ serviceRepository.setNeighborInfoResponse(responseText)
+ }
+
+ companion object {
+ private const val MILLIS_PER_SECOND = 1000.0
+ }
+}
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshNodeManager.kt b/app/src/main/java/com/geeksville/mesh/service/MeshNodeManager.kt
new file mode 100644
index 000000000..1d8bfbb9c
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshNodeManager.kt
@@ -0,0 +1,258 @@
+/*
+ * 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 androidx.annotation.VisibleForTesting
+import co.touchlab.kermit.Logger
+import com.geeksville.mesh.concurrent.handledLaunch
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.first
+import org.meshtastic.core.data.repository.NodeRepository
+import org.meshtastic.core.database.entity.MetadataEntity
+import org.meshtastic.core.database.entity.NodeEntity
+import org.meshtastic.core.model.DataPacket
+import org.meshtastic.core.model.MyNodeInfo
+import org.meshtastic.core.model.NodeInfo
+import org.meshtastic.core.model.Position
+import org.meshtastic.core.service.MeshServiceNotifications
+import org.meshtastic.proto.MeshProtos
+import org.meshtastic.proto.PaxcountProtos
+import org.meshtastic.proto.TelemetryProtos
+import org.meshtastic.proto.copy
+import org.meshtastic.proto.telemetry
+import org.meshtastic.proto.user
+import java.util.concurrent.ConcurrentHashMap
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Suppress("TooManyFunctions")
+@Singleton
+class MeshNodeManager
+@Inject
+constructor(
+ private val nodeRepository: NodeRepository?,
+ private val serviceBroadcasts: MeshServiceBroadcasts?,
+ private val serviceNotifications: MeshServiceNotifications?,
+) {
+ private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
+
+ val nodeDBbyNodeNum = ConcurrentHashMap()
+ val nodeDBbyID = ConcurrentHashMap()
+
+ fun start(scope: CoroutineScope) {
+ this.scope = scope
+ }
+
+ val isNodeDbReady = MutableStateFlow(false)
+ val allowNodeDbWrites = MutableStateFlow(false)
+
+ var myNodeNum: Int? = null
+
+ companion object {
+ private const val TIME_MS_TO_S = 1000L
+ }
+
+ @VisibleForTesting internal constructor() : this(null, null, null)
+
+ fun loadCachedNodeDB() {
+ scope.handledLaunch {
+ val nodes = nodeRepository?.getNodeDBbyNum()?.first() ?: emptyMap()
+ nodeDBbyNodeNum.putAll(nodes)
+ nodes.values.forEach { nodeDBbyID[it.user.id] = it }
+ myNodeNum = nodeRepository?.myNodeInfo?.value?.myNodeNum
+ }
+ }
+
+ fun clear() {
+ nodeDBbyNodeNum.clear()
+ nodeDBbyID.clear()
+ isNodeDbReady.value = false
+ allowNodeDbWrites.value = false
+ myNodeNum = null
+ }
+
+ fun getMyNodeInfo(): MyNodeInfo? {
+ val mi = nodeRepository?.myNodeInfo?.value ?: return null
+ val myNode = nodeDBbyNodeNum[mi.myNodeNum]
+ return MyNodeInfo(
+ myNodeNum = mi.myNodeNum,
+ hasGPS = myNode?.position?.latitudeI != 0,
+ model = mi.model ?: myNode?.user?.hwModel?.name,
+ firmwareVersion = mi.firmwareVersion,
+ couldUpdate = mi.couldUpdate,
+ shouldUpdate = mi.shouldUpdate,
+ currentPacketId = mi.currentPacketId,
+ messageTimeoutMsec = mi.messageTimeoutMsec,
+ minAppVersion = mi.minAppVersion,
+ maxChannels = mi.maxChannels,
+ hasWifi = mi.hasWifi,
+ channelUtilization = 0f,
+ airUtilTx = 0f,
+ deviceId = mi.deviceId ?: myNode?.user?.id,
+ )
+ }
+
+ fun getMyId(): String {
+ val num = myNodeNum ?: nodeRepository?.myNodeInfo?.value?.myNodeNum ?: return ""
+ return nodeDBbyNodeNum[num]?.user?.id ?: ""
+ }
+
+ fun getNodes(): List = nodeDBbyNodeNum.values.map { it.toNodeInfo() }
+
+ fun removeByNodenum(nodeNum: Int) {
+ nodeDBbyNodeNum.remove(nodeNum)?.let { nodeDBbyID.remove(it.user.id) }
+ }
+
+ fun getOrCreateNodeInfo(n: Int, channel: Int = 0): NodeEntity = nodeDBbyNodeNum.getOrPut(n) {
+ val userId = DataPacket.nodeNumToDefaultId(n)
+ val defaultUser = user {
+ id = userId
+ longName = "Meshtastic ${userId.takeLast(n = 4)}"
+ shortName = userId.takeLast(n = 4)
+ hwModel = MeshProtos.HardwareModel.UNSET
+ }
+
+ NodeEntity(
+ num = n,
+ user = defaultUser,
+ longName = defaultUser.longName,
+ shortName = defaultUser.shortName,
+ channel = channel,
+ )
+ }
+
+ fun updateNodeInfo(nodeNum: Int, withBroadcast: Boolean = true, channel: Int = 0, updateFn: (NodeEntity) -> Unit) {
+ val info = getOrCreateNodeInfo(nodeNum, channel)
+ updateFn(info)
+ if (info.user.id.isNotEmpty()) {
+ nodeDBbyID[info.user.id] = info
+ }
+
+ if (info.user.id.isNotEmpty() && isNodeDbReady.value) {
+ scope.handledLaunch { nodeRepository?.upsert(info) }
+ }
+
+ if (withBroadcast) {
+ serviceBroadcasts?.broadcastNodeChange(info.toNodeInfo())
+ }
+ }
+
+ fun insertMetadata(nodeNum: Int, metadata: MeshProtos.DeviceMetadata) {
+ scope.handledLaunch { nodeRepository?.insertMetadata(MetadataEntity(nodeNum, metadata)) }
+ }
+
+ fun handleReceivedUser(fromNum: Int, p: MeshProtos.User, channel: Int = 0, manuallyVerified: Boolean = false) {
+ updateNodeInfo(fromNum) {
+ val newNode = (it.isUnknownUser && p.hwModel != MeshProtos.HardwareModel.UNSET)
+ val shouldPreserve = shouldPreserveExistingUser(it.user, p)
+
+ if (shouldPreserve) {
+ it.longName = it.user.longName
+ it.shortName = it.user.shortName
+ it.channel = channel
+ it.manuallyVerified = manuallyVerified
+ } else {
+ val keyMatch = !it.hasPKC || it.user.publicKey == p.publicKey
+ it.user = if (keyMatch) p else p.copy { publicKey = NodeEntity.ERROR_BYTE_STRING }
+ it.longName = p.longName
+ it.shortName = p.shortName
+ it.channel = channel
+ it.manuallyVerified = manuallyVerified
+ if (newNode) {
+ serviceNotifications?.showNewNodeSeenNotification(it)
+ }
+ }
+ }
+ }
+
+ fun handleReceivedPosition(
+ fromNum: Int,
+ myNodeNum: Int,
+ p: MeshProtos.Position,
+ defaultTime: Long = System.currentTimeMillis(),
+ ) {
+ if (myNodeNum == fromNum && p.latitudeI == 0 && p.longitudeI == 0) {
+ Logger.d { "Ignoring nop position update for the local node" }
+ } else {
+ updateNodeInfo(fromNum) { it.setPosition(p, (defaultTime / TIME_MS_TO_S).toInt()) }
+ }
+ }
+
+ fun handleReceivedTelemetry(fromNum: Int, telemetry: TelemetryProtos.Telemetry) {
+ updateNodeInfo(fromNum) { nodeEntity ->
+ when {
+ telemetry.hasDeviceMetrics() -> nodeEntity.deviceTelemetry = telemetry
+ telemetry.hasEnvironmentMetrics() -> nodeEntity.environmentTelemetry = telemetry
+ telemetry.hasPowerMetrics() -> nodeEntity.powerTelemetry = telemetry
+ }
+ }
+ }
+
+ fun handleReceivedPaxcounter(fromNum: Int, p: PaxcountProtos.Paxcount) {
+ updateNodeInfo(fromNum) { it.paxcounter = p }
+ }
+
+ fun installNodeInfo(info: MeshProtos.NodeInfo, withBroadcast: Boolean = true) {
+ updateNodeInfo(info.num, withBroadcast = withBroadcast) { entity ->
+ if (info.hasUser()) {
+ if (shouldPreserveExistingUser(entity.user, info.user)) {
+ entity.longName = entity.user.longName
+ entity.shortName = entity.user.shortName
+ } else {
+ entity.user =
+ info.user.copy {
+ if (isLicensed) clearPublicKey()
+ if (info.viaMqtt) longName = "$longName (MQTT)"
+ }
+ entity.longName = entity.user.longName
+ entity.shortName = entity.user.shortName
+ }
+ }
+ if (info.hasPosition()) {
+ entity.position = info.position
+ entity.latitude = Position.degD(info.position.latitudeI)
+ entity.longitude = Position.degD(info.position.longitudeI)
+ }
+ entity.lastHeard = info.lastHeard
+ if (info.hasDeviceMetrics()) {
+ entity.deviceTelemetry = telemetry { deviceMetrics = info.deviceMetrics }
+ }
+ entity.channel = info.channel
+ entity.viaMqtt = info.viaMqtt
+ entity.hopsAway = if (info.hasHopsAway()) info.hopsAway else -1
+ entity.isFavorite = info.isFavorite
+ entity.isIgnored = info.isIgnored
+ }
+ }
+
+ private fun shouldPreserveExistingUser(existing: MeshProtos.User, incoming: MeshProtos.User): Boolean {
+ val isDefaultName = incoming.longName.matches(Regex("^Meshtastic [0-9a-fA-F]{4}$"))
+ val isDefaultHwModel = incoming.hwModel == MeshProtos.HardwareModel.UNSET
+ val hasExistingUser = existing.id.isNotEmpty() && existing.hwModel != MeshProtos.HardwareModel.UNSET
+ return hasExistingUser && isDefaultName && isDefaultHwModel
+ }
+
+ fun toNodeID(n: Int): String = if (n == DataPacket.NODENUM_BROADCAST) {
+ DataPacket.ID_BROADCAST
+ } else {
+ nodeDBbyNodeNum[n]?.user?.id ?: DataPacket.nodeNumToDefaultId(n)
+ }
+}
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshRouter.kt b/app/src/main/java/com/geeksville/mesh/service/MeshRouter.kt
new file mode 100644
index 000000000..0fc00ba34
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshRouter.kt
@@ -0,0 +1,49 @@
+/*
+ * 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 kotlinx.coroutines.CoroutineScope
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Orchestrates the specialized packet handlers for the [MeshService]. This class serves as a central registry and
+ * lifecycle manager for all routing sub-components.
+ */
+@Suppress("LongParameterList")
+@Singleton
+class MeshRouter
+@Inject
+constructor(
+ val dataHandler: MeshDataHandler,
+ val configHandler: MeshConfigHandler,
+ val tracerouteHandler: MeshTracerouteHandler,
+ val neighborInfoHandler: MeshNeighborInfoHandler,
+ val configFlowManager: MeshConfigFlowManager,
+ val mqttManager: MeshMqttManager,
+ val actionHandler: MeshActionHandler,
+) {
+ fun start(scope: CoroutineScope) {
+ dataHandler.start(scope)
+ configHandler.start(scope)
+ tracerouteHandler.start(scope)
+ neighborInfoHandler.start(scope)
+ configFlowManager.start(scope)
+ actionHandler.start(scope)
+ }
+}
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 0137c30ad..4a4df7c88 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt
@@ -17,460 +17,127 @@
package com.geeksville.mesh.service
-import android.annotation.SuppressLint
-import android.app.Notification
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
-import android.os.RemoteException
-import android.util.Log
-import androidx.annotation.VisibleForTesting
import androidx.core.app.ServiceCompat
-import androidx.core.location.LocationCompat
import co.touchlab.kermit.Logger
import com.geeksville.mesh.BuildConfig
import com.geeksville.mesh.concurrent.handledLaunch
import com.geeksville.mesh.model.NO_DEVICE_SELECTED
-import com.geeksville.mesh.repository.network.MQTTRepository
-import com.geeksville.mesh.repository.radio.InterfaceId
import com.geeksville.mesh.repository.radio.RadioInterfaceService
-import com.geeksville.mesh.util.ignoreException
import com.geeksville.mesh.util.toRemoteExceptions
-import com.google.protobuf.ByteString
-import com.google.protobuf.InvalidProtocolBufferException
-import com.meshtastic.core.strings.getString
-import dagger.Lazy
import dagger.hilt.android.AndroidEntryPoint
-import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first
-import kotlinx.coroutines.flow.flatMapLatest
-import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.launchIn
-import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach
-import kotlinx.coroutines.flow.onStart
-import org.meshtastic.core.analytics.DataPair
-import org.meshtastic.core.analytics.platform.PlatformAnalytics
+import kotlinx.coroutines.runBlocking
import org.meshtastic.core.common.hasLocationPermission
-import org.meshtastic.core.data.repository.LocationRepository
-import org.meshtastic.core.data.repository.MeshLogRepository
-import org.meshtastic.core.data.repository.NodeRepository
-import org.meshtastic.core.data.repository.PacketRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
-import org.meshtastic.core.data.repository.TracerouteSnapshotRepository
-import org.meshtastic.core.database.DatabaseManager
-import org.meshtastic.core.database.entity.MeshLog
-import org.meshtastic.core.database.entity.MetadataEntity
-import org.meshtastic.core.database.entity.MyNodeEntity
-import org.meshtastic.core.database.entity.NodeEntity
-import org.meshtastic.core.database.entity.Packet
-import org.meshtastic.core.database.entity.ReactionEntity
-import org.meshtastic.core.database.model.Node
-import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.DeviceVersion
import org.meshtastic.core.model.MeshUser
-import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.NodeInfo
import org.meshtastic.core.model.Position
-import org.meshtastic.core.model.TelemetryType
-import org.meshtastic.core.model.fullRouteDiscovery
-import org.meshtastic.core.model.getFullTracerouteResponse
-import org.meshtastic.core.model.util.anonymize
-import org.meshtastic.core.model.util.toOneLineString
-import org.meshtastic.core.model.util.toPIIString
-import org.meshtastic.core.prefs.mesh.MeshPrefs
-import org.meshtastic.core.prefs.ui.UiPrefs
-import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.service.IMeshService
import org.meshtastic.core.service.MeshServiceNotifications
import org.meshtastic.core.service.SERVICE_NOTIFY_ID
-import org.meshtastic.core.service.ServiceAction
import org.meshtastic.core.service.ServiceRepository
-import org.meshtastic.core.service.TracerouteResponse
-import org.meshtastic.core.strings.Res
-import org.meshtastic.core.strings.connected_count
-import org.meshtastic.core.strings.connecting
-import org.meshtastic.core.strings.critical_alert
-import org.meshtastic.core.strings.device_sleeping
-import org.meshtastic.core.strings.disconnected
-import org.meshtastic.core.strings.error_duty_cycle
-import org.meshtastic.core.strings.unknown_username
-import org.meshtastic.core.strings.waypoint_received
-import org.meshtastic.proto.AdminProtos
-import org.meshtastic.proto.AppOnlyProtos
-import org.meshtastic.proto.ChannelProtos
-import org.meshtastic.proto.ConfigProtos
-import org.meshtastic.proto.DeviceUIProtos
-import org.meshtastic.proto.LocalOnlyProtos.LocalConfig
-import org.meshtastic.proto.LocalOnlyProtos.LocalModuleConfig
-import org.meshtastic.proto.MeshProtos
-import org.meshtastic.proto.MeshProtos.FromRadio.PayloadVariantCase
-import org.meshtastic.proto.MeshProtos.MeshPacket
-import org.meshtastic.proto.MeshProtos.ToRadio
-import org.meshtastic.proto.ModuleConfigProtos
-import org.meshtastic.proto.PaxcountProtos
-import org.meshtastic.proto.Portnums
-import org.meshtastic.proto.StoreAndForwardProtos
-import org.meshtastic.proto.TelemetryProtos
-import org.meshtastic.proto.XmodemProtos
-import org.meshtastic.proto.copy
-import org.meshtastic.proto.fromRadio
-import org.meshtastic.proto.position
-import org.meshtastic.proto.telemetry
-import org.meshtastic.proto.user
-import java.util.ArrayDeque
-import java.util.Locale
-import java.util.UUID
-import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
-import kotlin.math.absoluteValue
-/**
- * Handles all the communication with android apps. Also keeps an internal model of the network state.
- *
- * 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
- */
@AndroidEntryPoint
class MeshService : Service() {
- @Inject lateinit var dispatchers: CoroutineDispatchers
-
- @Inject lateinit var packetRepository: Lazy
-
- @Inject lateinit var meshLogRepository: Lazy
-
- @Inject lateinit var tracerouteSnapshotRepository: TracerouteSnapshotRepository
@Inject lateinit var radioInterfaceService: RadioInterfaceService
- @Inject lateinit var locationRepository: LocationRepository
-
- @Inject lateinit var radioConfigRepository: RadioConfigRepository
-
@Inject lateinit var serviceRepository: ServiceRepository
- @Inject lateinit var nodeRepository: NodeRepository
-
- @Inject lateinit var databaseManager: DatabaseManager
-
- @Inject lateinit var mqttRepository: MQTTRepository
-
- @Inject lateinit var serviceNotifications: MeshServiceNotifications
-
- @Inject lateinit var meshPrefs: MeshPrefs
-
- @Inject lateinit var uiPrefs: UiPrefs
-
- @Inject lateinit var connectionStateHolder: MeshServiceConnectionStateHolder
+ @Inject lateinit var connectionStateHolder: ConnectionStateHandler
@Inject lateinit var packetHandler: PacketHandler
@Inject lateinit var serviceBroadcasts: MeshServiceBroadcasts
- @Inject lateinit var analytics: PlatformAnalytics
+ @Inject lateinit var nodeManager: MeshNodeManager
- private val tracerouteStartTimes = ConcurrentHashMap()
- private val neighborInfoStartTimes = ConcurrentHashMap()
+ @Inject lateinit var messageProcessor: MeshMessageProcessor
- @Volatile private var lastNeighborInfo: MeshProtos.NeighborInfo? = null
- private val logUuidByPacketId = ConcurrentHashMap()
- private val logInsertJobByPacketId = ConcurrentHashMap()
+ @Inject lateinit var commandSender: MeshCommandSender
- companion object {
+ @Inject lateinit var locationManager: MeshLocationManager
- // Intents broadcast by MeshService
+ @Inject lateinit var connectionManager: MeshConnectionManager
- private fun actionReceived(portNum: String) = "$prefix.RECEIVED.$portNum"
+ @Inject lateinit var serviceNotifications: MeshServiceNotifications
- // generate a RECEIVED action filter string that includes either the portnumber as an int,
- // or preferably a
- // symbolic name from portnums.proto
- fun actionReceived(portNum: Int): String {
- val portType = Portnums.PortNum.forNumber(portNum)
- val portStr = portType?.toString() ?: portNum.toString()
+ @Inject lateinit var radioConfigRepository: RadioConfigRepository
- return actionReceived(portStr)
- }
-
- const val ACTION_NODE_CHANGE = "$prefix.NODE_CHANGE"
- const val ACTION_MESH_CONNECTED = "$prefix.MESH_CONNECTED"
- const val ACTION_MESSAGE_STATUS = "$prefix.MESSAGE_STATUS"
-
- open class NodeNotFoundException(reason: String) : Exception(reason)
-
- class InvalidNodeIdException(id: String) : NodeNotFoundException("Invalid NodeId $id")
-
- class NodeNumNotFoundException(id: Int) : NodeNotFoundException("NodeNum not found $id")
-
- class IdNotFoundException(id: String) : NodeNotFoundException("ID not found $id")
-
- 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.
- */
- fun changeDeviceAddress(context: Context, service: IMeshService, address: String?) {
- service.setDeviceAddress(address)
- startService(context)
- }
-
- fun createIntent(context: Context) = 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)
-
- // Two-stage config flow nonces to avoid stale BLE packets, mirroring Meshtastic-Apple
- private const val DEFAULT_CONFIG_ONLY_NONCE = 69420
- private const val DEFAULT_NODE_INFO_NONCE = 69421
-
- private const val WANT_CONFIG_DELAY = 100L
- private const val HISTORY_TAG = "HistoryReplay"
- private const val DEFAULT_HISTORY_RETURN_WINDOW_MINUTES = 60 * 24
- private const val DEFAULT_HISTORY_RETURN_MAX_MESSAGES = 100
- private const val MAX_EARLY_PACKET_BUFFER = 128
-
- private const val NEIGHBOR_RQ_COOLDOWN = 3 * 60 * 1000L // ms
-
- @VisibleForTesting
- internal fun buildStoreForwardHistoryRequest(
- lastRequest: Int,
- historyReturnWindow: Int,
- historyReturnMax: Int,
- ): StoreAndForwardProtos.StoreAndForward {
- val historyBuilder = StoreAndForwardProtos.StoreAndForward.History.newBuilder()
- if (lastRequest > 0) historyBuilder.lastRequest = lastRequest
- if (historyReturnWindow > 0) historyBuilder.window = historyReturnWindow
- if (historyReturnMax > 0) historyBuilder.historyMessages = historyReturnMax
- return StoreAndForwardProtos.StoreAndForward.newBuilder()
- .setRr(StoreAndForwardProtos.StoreAndForward.RequestResponse.CLIENT_HISTORY)
- .setHistory(historyBuilder)
- .build()
- }
-
- @VisibleForTesting
- internal fun resolveHistoryRequestParameters(window: Int, max: Int): Pair {
- val resolvedWindow = if (window > 0) window else DEFAULT_HISTORY_RETURN_WINDOW_MINUTES
- val resolvedMax = if (max > 0) max else DEFAULT_HISTORY_RETURN_MAX_MESSAGES
- return resolvedWindow to resolvedMax
- }
- }
+ @Inject lateinit var router: MeshRouter
private val serviceJob = Job()
private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob)
- private inline fun historyLog(
- priority: Int = Log.INFO,
- throwable: Throwable? = null,
- crossinline message: () -> String,
- ) {
- if (!BuildConfig.DEBUG) return
- val severity =
- when (priority) {
- Log.VERBOSE -> co.touchlab.kermit.Severity.Verbose
- Log.DEBUG -> co.touchlab.kermit.Severity.Debug
- Log.INFO -> co.touchlab.kermit.Severity.Info
- Log.WARN -> co.touchlab.kermit.Severity.Warn
- Log.ERROR -> co.touchlab.kermit.Severity.Error
- else -> co.touchlab.kermit.Severity.Info
- }
- val msg = message()
- if (throwable != null) {
- Logger.log(severity, HISTORY_TAG, throwable, msg)
- } else {
- Logger.log(severity, HISTORY_TAG, null, msg)
+ private val myNodeNum: Int
+ get() = nodeManager.myNodeNum ?: throw RadioNotConnectedException()
+
+ companion object {
+ fun actionReceived(portNum: Int): String {
+ val portType = org.meshtastic.proto.Portnums.PortNum.forNumber(portNum)
+ val portStr = portType?.toString() ?: portNum.toString()
+ return com.geeksville.mesh.service.actionReceived(portStr)
}
- }
- private fun activeDeviceAddress(): String? =
- meshPrefs.deviceAddress?.takeIf { !it.equals(NO_DEVICE_SELECTED, ignoreCase = true) && it.isNotBlank() }
+ fun createIntent(context: Context) = Intent(context, MeshService::class.java)
- private fun currentTransport(address: String? = meshPrefs.deviceAddress): String = when (address?.firstOrNull()) {
- InterfaceId.BLUETOOTH.id -> "BLE"
- InterfaceId.TCP.id -> "TCP"
- InterfaceId.SERIAL.id -> "Serial"
- InterfaceId.MOCK.id -> "Mock"
- InterfaceId.NOP.id -> "NOP"
- else -> "Unknown"
- }
-
- private var locationFlow: Job? = null
- private var mqttMessageFlow: Job? = null
-
- 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 oneHour = 3600
-
- private fun getSenderName(packet: DataPacket?): String {
- val name = nodeDBbyID[packet?.from]?.user?.longName
- return name ?: getString(Res.string.unknown_username)
- }
-
- /** start our location requests (if they weren't already running) */
- 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
- },
- )
- }
- .launchIn(serviceScope)
- }
- }
-
- private fun stopLocationRequests() {
- if (locationFlow?.isActive == true) {
- Logger.i { "Stopping location requests" }
- locationFlow?.cancel()
- locationFlow = null
- }
- }
-
- private fun showAlertNotification(contactKey: String, dataPacket: DataPacket) {
- serviceNotifications.showAlertNotification(
- contactKey,
- getSenderName(dataPacket),
- dataPacket.alert ?: getString(Res.string.critical_alert),
- )
- }
-
- 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(Res.string.waypoint_received, dataPacket.waypoint!!.name)
- }
-
- else -> return
- }
-
- serviceNotifications.updateMessageNotification(
- contactKey,
- getSenderName(dataPacket),
- message,
- isBroadcast = dataPacket.to == DataPacket.ID_BROADCAST,
- channelName =
- if (dataPacket.to == DataPacket.ID_BROADCAST) {
- channelSet.settingsList[dataPacket.channel].name
+ fun changeDeviceAddress(context: Context, service: IMeshService, address: String?) {
+ service.setDeviceAddress(address)
+ val intent = Intent(context, MeshService::class.java)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ context.startForegroundService(intent)
} else {
- null
- },
- )
+ context.startService(intent)
+ }
+ }
+
+ val minDeviceVersion = DeviceVersion(BuildConfig.MIN_FW_VERSION)
+ val absoluteMinDeviceVersion = DeviceVersion(BuildConfig.ABS_MIN_FW_VERSION)
}
override fun onCreate() {
super.onCreate()
Logger.i { "Creating mesh service" }
serviceNotifications.initChannels()
- // Switch to the IO thread
+
+ packetHandler.start(serviceScope)
+ router.start(serviceScope)
+ nodeManager.start(serviceScope)
+ connectionManager.start(serviceScope)
+ messageProcessor.start(serviceScope)
+ commandSender.start(serviceScope)
+
serviceScope.handledLaunch { radioInterfaceService.connect() }
- radioInterfaceService.connectionState.onEach(::onRadioConnectionState).launchIn(serviceScope)
+
radioInterfaceService.receivedData
- .onStart {
- historyLog { "rxCollector START transport=${currentTransport()} scope=${serviceScope.hashCode()}" }
- }
- .onCompletion { cause ->
- historyLog(Log.WARN) {
- "rxCollector STOP transport=${currentTransport()} cause=${cause?.message ?: "completed"}"
- }
- }
- .onEach(::onReceiveFromRadio)
- .launchIn(serviceScope)
- radioInterfaceService.connectionError
- .onEach { error -> Logger.e { "BLE Connection Error: ${error.message}" } }
- .launchIn(serviceScope)
- radioConfigRepository.localConfigFlow.onEach { localConfig = it }.launchIn(serviceScope)
- radioConfigRepository.moduleConfigFlow.onEach { moduleConfig = it }.launchIn(serviceScope)
- radioConfigRepository.channelSetFlow.onEach { channelSet = it }.launchIn(serviceScope)
- serviceRepository.serviceAction.onEach(::onServiceAction).launchIn(serviceScope)
- nodeRepository.myNodeInfo
- .onEach { myNodeInfo = it }
- .flatMapLatest { myNodeEntity ->
- // When myNodeInfo changes, set up emissions for the "provide-location-nodeNum" pref.
- if (myNodeEntity == null) {
- flowOf(false)
- } else {
- uiPrefs.shouldProvideNodeLocation(myNodeEntity.myNodeNum)
- }
- }
- .onEach { shouldProvideNodeLocation ->
- if (shouldProvideNodeLocation) {
- startLocationRequests()
- } else {
- stopLocationRequests()
- }
- }
+ .onEach { bytes -> messageProcessor.handleFromRadio(bytes, nodeManager.myNodeNum) }
.launchIn(serviceScope)
- loadCachedNodeDB() // Load our last known node DB
+ serviceRepository.serviceAction.onEach(router.actionHandler::onServiceAction).launchIn(serviceScope)
- // the rest of our init will happen once we are in radioConnection.onServiceConnected
+ nodeManager.loadCachedNodeDB()
}
- /** 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
- Logger.i { "Requesting foreground service=$wantForeground" }
- val notification = updateServiceStatusNotification()
+ val notification = connectionManager.updateStatusNotification()
try {
ServiceCompat.startForeground(
@@ -484,7 +151,7 @@ class MeshService : Service() {
ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE
}
} else {
- 0 // No specific type needed for older Android versions
+ 0
},
)
} catch (ex: Exception) {
@@ -499,2480 +166,182 @@ class MeshService : Service() {
}
}
+ override fun onBind(intent: Intent?): IBinder = binder
+
override fun onDestroy() {
Logger.i { "Destroying mesh service" }
-
- // Make sure we aren't using the notification first
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
-
- super.onDestroy()
serviceJob.cancel()
- }
-
- //
- // BEGINNING OF MODEL - FIXME, move elsewhere
- //
-
- private fun loadCachedNodeDB() =
- serviceScope.handledLaunch { nodeDBbyNodeNum.putAll(nodeRepository.getNodeDBbyNum().first()) }
-
- /** discard entire node db & message state - used when downloading a new db from the device */
- private fun discardNodeDB() {
- Logger.d { "Discarding NodeDB" }
- myNodeInfo = null
- nodeDBbyNodeNum.clear()
- isNodeDbReady = false
- allowNodeDbWrites = false
- earlyReceivedPackets.clear()
- }
-
- private var myNodeInfo: MyNodeEntity? = null
-
- 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, signaling we can process packets immediately
- @Volatile private var isNodeDbReady = false
-
- // True when we are allowed to write node updates to the persistent database
- @Volatile private var allowNodeDbWrites = false
-
- // The database of active nodes, index is the node number
- private val nodeDBbyNodeNum = ConcurrentHashMap()
-
- // 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 }
-
- //
- // 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)
- }
-
- // 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)
- val defaultUser = user {
- id = userId
- longName = "Meshtastic ${userId.takeLast(n = 4)}"
- shortName = userId.takeLast(n = 4)
- hwModel = MeshProtos.HardwareModel.UNSET
- }
-
- NodeEntity(
- num = n,
- user = defaultUser,
- longName = defaultUser.longName,
- shortName = defaultUser.shortName,
- channel = channel,
- )
- }
-
- 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 {
- id == DataPacket.ID_LOCAL -> toNodeInfo(myNodeNum)
- hexStr != null -> {
- val n = hexStr.toLong(16).toInt()
- nodeDBbyNodeNum[n] ?: throw IdNotFoundException(id)
- }
-
- else -> throw InvalidNodeIdException(id)
- }
- }
-
- private fun getUserName(num: Int): String = with(nodeRepository.getUser(num)) { "$longName ($shortName)" }
-
- private val numNodes
- get() = nodeDBbyNodeNum.size
-
- /** How many nodes are currently online (including our local node) */
- private val numOnlineNodes
- get() = nodeDBbyNodeNum.values.count { it.isOnline }
-
- private fun toNodeNum(id: String): Int = when (id) {
- DataPacket.ID_BROADCAST -> DataPacket.NODENUM_BROADCAST
- DataPacket.ID_LOCAL -> myNodeNum
- 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,
- channel: Int = 0,
- crossinline updateFn: (NodeEntity) -> Unit,
- ) {
- val info = getOrCreateNodeInfo(nodeNum, channel)
- updateFn(info)
-
- if (info.user.id.isNotEmpty() && isNodeDbReady) {
- serviceScope.handledLaunch { nodeRepository.upsert(info) }
- }
-
- if (withBroadcast) {
- serviceBroadcasts.broadcastNodeChange(info.toNodeInfo())
- }
- }
-
- // My node num
- private val myNodeNum
- get() = myNodeInfo?.myNodeNum ?: throw RadioNotConnectedException("We don't yet have our myNodeInfo")
-
- // My node ID string
- private val myNodeID
- get() = toNodeID(myNodeNum)
-
- // Admin channel index
- private val MeshPacket.Builder.adminChannelIndex: Int
- get() =
- when {
- myNodeNum == to -> 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)
- }
-
- // 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
- }
-
- /**
- * 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))
-
- /** 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
- hopLimit: Int = localConfig.lora.hopLimit,
- channel: Int = 0,
- priority: MeshPacket.Priority = MeshPacket.Priority.UNSET,
- initFn: MeshProtos.Data.Builder.() -> Unit,
- ): MeshPacket {
- this.wantAck = wantAck
- this.id = id
- this.hopLimit = hopLimit
- this.priority = priority
- decoded = MeshProtos.Data.newBuilder().also { initFn(it) }.build()
- if (channel == DataPacket.PKC_CHANNEL_INDEX) {
- pkiEncrypted = true
- nodeDBbyNodeNum[to]?.user?.publicKey?.let { publicKey -> this.publicKey = publicKey }
- } 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
- 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 =
- AdminProtos.AdminMessage.newBuilder()
- .also {
- initFn(it)
- it.sessionPasskey = 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 {
- val data = packet.decoded
-
- DataPacket(
- from = toNodeID(packet.from),
- to = toNodeID(packet.to),
- time = packet.rxTime * 1000L,
- id = packet.id,
- dataType = data.portnumValue,
- bytes = data.payload.toByteArray(),
- hopLimit = packet.hopLimit,
- channel = if (packet.pkiEncrypted) DataPacket.PKC_CHANNEL_INDEX else packet.channel,
- wantAck = packet.wantAck,
- hopStart = packet.hopStart,
- snr = packet.rxSnr,
- rssi = packet.rxRssi,
- replyId = data.replyId,
- relayNode = packet.relayNode,
- viaMqtt = packet.viaMqtt,
- )
- }
-
- private fun toMeshPacket(p: DataPacket): MeshPacket = newMeshPacketTo(p.to!!).buildMeshPacket(
- id = p.id,
- wantAck = p.wantAck,
- hopLimit = p.hopLimit,
- channel = p.channel,
- ) {
- portnumValue = p.dataType
- payload = ByteString.copyFrom(p.bytes)
- if (p.replyId != null && p.replyId != 0) {
- this.replyId = p.replyId!!
- }
- }
-
- private val rememberDataType =
- setOf(
- Portnums.PortNum.TEXT_MESSAGE_APP_VALUE,
- Portnums.PortNum.ALERT_APP_VALUE,
- Portnums.PortNum.WAYPOINT_APP_VALUE,
- )
-
- private fun rememberReaction(packet: MeshPacket) = serviceScope.handledLaunch {
- val reaction =
- ReactionEntity(
- replyId = packet.decoded.replyId,
- userId = toNodeID(packet.from),
- emoji = packet.decoded.payload.toByteArray().decodeToString(),
- timestamp = System.currentTimeMillis(),
- snr = packet.rxSnr,
- rssi = packet.rxRssi,
- hopsAway = getHopsAwayForPacket(packet),
- )
- packetRepository.get().insertReaction(reaction)
- }
-
- private fun rememberDataPacket(dataPacket: DataPacket, updateNotification: Boolean = true) {
- if (dataPacket.dataType !in rememberDataType) 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 =
- Packet(
- uuid = 0L, // autoGenerated
- myNodeNum = myNodeNum,
- packetId = dataPacket.id,
- port_num = dataPacket.dataType,
- contact_key = contactKey,
- received_time = System.currentTimeMillis(),
- read = fromLocal,
- data = dataPacket,
- snr = dataPacket.snr,
- rssi = dataPacket.rssi,
- hopsAway = dataPacket.hopsAway,
- replyId = dataPacket.replyId ?: 0,
- )
- serviceScope.handledLaunch {
- packetRepository.get().apply {
- insert(packetToSave)
- val isMuted = getContactSettings(contactKey).isMuted
- if (packetToSave.port_num == Portnums.PortNum.ALERT_APP_VALUE && !isMuted) {
- showAlertNotification(contactKey, dataPacket)
- } else if (updateNotification && !isMuted) {
- updateMessageNotification(contactKey, dataPacket)
- }
- }
- }
- }
-
- // Update our model and resend as needed for a MeshPacket we just received from the radio
- 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)
-
- if (dataPacket != null) {
- // We ignore most messages that we sent
- val fromUs = myInfo.myNodeNum == packet.from
-
- Logger.d { "Received data from $fromId, portnum=${data.portnum} ${bytes.size} bytes" }
-
- dataPacket.status = MessageStatus.RECEIVED
-
- // if (p.hasUser()) handleReceivedUser(fromNum, p.user)
-
- // We tell other apps about most message types, but some may have sensitive data, so
- // that is not shared'
- var shouldBroadcast = !fromUs
-
- val logUuid = logUuidByPacketId[packet.id]
- val logInsertJob = logInsertJobByPacketId[packet.id]
- when (data.portnumValue) {
- Portnums.PortNum.TEXT_MESSAGE_APP_VALUE -> {
- if (data.replyId != 0 && data.emoji == 0) {
- Logger.d { "Received REPLY from $fromId" }
- rememberDataPacket(dataPacket)
- } else if (data.replyId != 0 && data.emoji != 0) {
- Logger.d { "Received EMOJI from $fromId" }
- rememberReaction(packet)
- } else {
- Logger.d { "Received CLEAR_TEXT from $fromId" }
- rememberDataPacket(dataPacket)
- }
- }
-
- Portnums.PortNum.ALERT_APP_VALUE -> {
- Logger.d { "Received ALERT_APP from $fromId" }
- rememberDataPacket(dataPacket)
- }
-
- Portnums.PortNum.WAYPOINT_APP_VALUE -> {
- Logger.d { "Received WAYPOINT_APP from $fromId" }
- 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.POSITION_APP_VALUE -> {
- Logger.d { "Received POSITION_APP from $fromId" }
- val u = MeshProtos.Position.parseFrom(data.payload)
- if (data.wantResponse && u.latitudeI == 0 && u.longitudeI == 0) {
- Logger.d { "Ignoring nop position update from position request" }
- } else {
- handleReceivedPosition(packet.from, u, dataPacket.time)
- }
- }
-
- Portnums.PortNum.NODEINFO_APP_VALUE ->
- if (!fromUs) {
- Logger.d { "Received NODEINFO_APP from $fromId" }
- val u =
- MeshProtos.User.parseFrom(data.payload).copy {
- if (isLicensed) clearPublicKey()
- if (packet.viaMqtt) longName = "$longName (MQTT)"
- }
- handleReceivedUser(packet.from, u, packet.channel)
- }
-
- // Handle new telemetry info
- Portnums.PortNum.TELEMETRY_APP_VALUE -> {
- Logger.d { "Received TELEMETRY_APP from $fromId" }
- 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 -> {
- Logger.d { "Received ROUTING_APP from $fromId" }
- // 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) {
- serviceRepository.setErrorMessage(getString(Res.string.error_duty_cycle))
- }
-
- handleAckNak(data.requestId, fromId, u.errorReasonValue, dataPacket.relayNode)
- packetHandler.removeResponse(data.requestId, complete = true)
- }
-
- Portnums.PortNum.ADMIN_APP_VALUE -> {
- Logger.d { "Received ADMIN_APP from $fromId" }
- val u = AdminProtos.AdminMessage.parseFrom(data.payload)
- handleReceivedAdmin(packet.from, u)
- shouldBroadcast = false
- }
-
- Portnums.PortNum.PAXCOUNTER_APP_VALUE -> {
- Logger.d { "Received PAXCOUNTER_APP from $fromId" }
- val p = PaxcountProtos.Paxcount.parseFrom(data.payload)
- handleReceivedPaxcounter(packet.from, p)
- shouldBroadcast = false
- }
-
- Portnums.PortNum.STORE_FORWARD_APP_VALUE -> {
- Logger.d { "Received STORE_FORWARD_APP from $fromId" }
- val u = StoreAndForwardProtos.StoreAndForward.parseFrom(data.payload)
- handleReceivedStoreAndForward(dataPacket, u)
- shouldBroadcast = false
- }
-
- Portnums.PortNum.RANGE_TEST_APP_VALUE -> {
- Logger.d { "Received RANGE_TEST_APP from $fromId" }
- if (!moduleConfig.rangeTest.enabled) return
- val u = dataPacket.copy(dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE)
- rememberDataPacket(u)
- }
-
- Portnums.PortNum.DETECTION_SENSOR_APP_VALUE -> {
- Logger.d { "Received DETECTION_SENSOR_APP from $fromId" }
- val u = dataPacket.copy(dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE)
- rememberDataPacket(u)
- }
-
- Portnums.PortNum.TRACEROUTE_APP_VALUE -> {
- Logger.d { "Received TRACEROUTE_APP from $fromId" }
- val routeDiscovery = packet.fullRouteDiscovery
- val full = packet.getFullTracerouteResponse(::getUserName)
- if (full != null) {
- val requestId = packet.decoded.requestId
- if (logUuid != null) {
- serviceScope.handledLaunch {
- logInsertJob?.join()
- val forwardRoute = routeDiscovery?.routeList.orEmpty()
- val returnRoute = routeDiscovery?.routeBackList.orEmpty()
- val routeNodeNums = (forwardRoute + returnRoute).distinct()
- val nodeDbByNum = nodeRepository.nodeDBbyNum.value
- val snapshotPositions =
- routeNodeNums
- .mapNotNull { nodeNum ->
- val position =
- nodeDbByNum[nodeNum]?.validPosition ?: return@mapNotNull null
- nodeNum to position
- }
- .toMap()
- tracerouteSnapshotRepository.upsertSnapshotPositions(
- logUuid = logUuid,
- requestId = requestId,
- positions = snapshotPositions,
- )
- }
- }
- val start = tracerouteStartTimes.remove(requestId)
- val responseText =
- if (start != null) {
- val elapsedMs = System.currentTimeMillis() - start
- val seconds = elapsedMs / 1000.0
- Logger.i { "Traceroute $requestId complete in $seconds s" }
- "$full\n\nDuration: ${"%.1f".format(seconds)} s"
- } else {
- full
- }
- val destination =
- routeDiscovery?.routeList?.firstOrNull()
- ?: routeDiscovery?.routeBackList?.lastOrNull()
- ?: 0
- serviceRepository.setTracerouteResponse(
- TracerouteResponse(
- message = responseText,
- destinationNodeNum = destination,
- requestId = requestId,
- forwardRoute = routeDiscovery?.routeList.orEmpty(),
- returnRoute = routeDiscovery?.routeBackList.orEmpty(),
- logUuid = logUuid,
- ),
- )
- }
- }
-
- Portnums.PortNum.NEIGHBORINFO_APP_VALUE -> {
- val requestId = packet.decoded.requestId
- Logger.d { "Processing NEIGHBORINFO_APP packet with requestId: $requestId" }
- val start = neighborInfoStartTimes.remove(requestId)
- Logger.d { "Found start time for requestId $requestId: $start" }
-
- val info =
- runCatching { MeshProtos.NeighborInfo.parseFrom(data.payload.toByteArray()) }.getOrNull()
-
- // Store the last neighbor info from our connected radio
- if (info != null && packet.from == myInfo.myNodeNum) {
- lastNeighborInfo = info
- Logger.d { "Stored last neighbor info from connected radio" }
- }
-
- // Only show response if packet is addressed to us and we sent a request in the last 3 minutes
- val isAddressedToUs = packet.to == myInfo.myNodeNum
- val isRecentRequest =
- start != null && (System.currentTimeMillis() - start) < NEIGHBOR_RQ_COOLDOWN
-
- if (isAddressedToUs && isRecentRequest) {
- val formatted =
- if (info != null) {
- val fmtNode: (Int) -> String = { nodeNum ->
- val user = nodeRepository.nodeDBbyNum.value[nodeNum]?.user
- val shortName = user?.shortName?.takeIf { it.isNotEmpty() } ?: ""
- val nodeId = "!%08x".format(nodeNum)
- if (shortName.isNotEmpty()) "$nodeId ($shortName)" else nodeId
- }
- buildString {
- appendLine("NeighborInfo:")
- appendLine("node_id: ${fmtNode(info.nodeId)}")
- appendLine("last_sent_by_id: ${fmtNode(info.lastSentById)}")
- appendLine("node_broadcast_interval_secs: ${info.nodeBroadcastIntervalSecs}")
- if (info.neighborsCount > 0) {
- appendLine("neighbors:")
- info.neighborsList.forEach { n ->
- appendLine(" - node_id: ${fmtNode(n.nodeId)} snr: ${n.snr}")
- }
- }
- }
- } else {
- // Fallback to raw string if parsing fails
- String(data.payload.toByteArray())
- }
-
- val response =
- if (start != null) {
- val elapsedMs = System.currentTimeMillis() - start
- val seconds = elapsedMs / 1000.0
- Logger.i { "Neighbor info $requestId complete in $seconds s" }
- "$formatted\n\nDuration: ${"%.1f".format(seconds)} s"
- } else {
- Logger.w { "No start time found for neighbor info requestId: $requestId" }
- formatted
- }
- serviceRepository.setNeighborInfoResponse(response)
- } else {
- Logger.d {
- "Neighbor info response filtered: ToUs=$isAddressedToUs, isRecentRequest=$isRecentRequest"
- }
- }
- }
-
- Portnums.PortNum.NEIGHBORINFO_APP_VALUE -> {
- val requestId = packet.decoded.requestId
- Logger.d { "Processing NEIGHBORINFO_APP packet with requestId: $requestId" }
- val start = neighborInfoStartTimes.remove(requestId)
- Logger.d { "Found start time for requestId $requestId: $start" }
-
- val info =
- runCatching { MeshProtos.NeighborInfo.parseFrom(data.payload.toByteArray()) }.getOrNull()
-
- // Store the last neighbor info from our connected radio
- if (info != null && packet.from == myInfo.myNodeNum) {
- lastNeighborInfo = info
- Logger.d { "Stored last neighbor info from connected radio" }
- }
-
- // Only show response if packet is addressed to us and we sent a request in the last 3 minutes
- val isAddressedToUs = packet.to == myInfo.myNodeNum
- val isRecentRequest =
- start != null && (System.currentTimeMillis() - start) < NEIGHBOR_RQ_COOLDOWN
-
- if (isAddressedToUs && isRecentRequest) {
- val formatted =
- if (info != null) {
- val fmtNode: (Int) -> String = { nodeNum ->
- val user = nodeRepository.nodeDBbyNum.value[nodeNum]?.user
- val shortName = user?.shortName?.takeIf { it.isNotEmpty() } ?: ""
- val nodeId = "!%08x".format(nodeNum)
- if (shortName.isNotEmpty()) "$nodeId ($shortName)" else nodeId
- }
- buildString {
- appendLine("NeighborInfo:")
- appendLine("node_id: ${fmtNode(info.nodeId)}")
- appendLine("last_sent_by_id: ${fmtNode(info.lastSentById)}")
- appendLine("node_broadcast_interval_secs: ${info.nodeBroadcastIntervalSecs}")
- if (info.neighborsCount > 0) {
- appendLine("neighbors:")
- info.neighborsList.forEach { n ->
- appendLine(" - node_id: ${fmtNode(n.nodeId)} snr: ${n.snr}")
- }
- }
- }
- } else {
- // Fallback to raw string if parsing fails
- String(data.payload.toByteArray())
- }
-
- val response =
- if (start != null) {
- val elapsedMs = System.currentTimeMillis() - start
- val seconds = elapsedMs / 1000.0
- Logger.i { "Neighbor info $requestId complete in $seconds s" }
- "$formatted\n\nDuration: ${"%.1f".format(seconds)} s"
- } else {
- Logger.w { "No start time found for neighbor info requestId: $requestId" }
- formatted
- }
- serviceRepository.setNeighborInfoResponse(response)
- } else {
- Logger.d {
- "Neighbor info response filtered: isToUs=$isAddressedToUs, isRecent=$isRecentRequest"
- }
- }
- }
-
- else -> Logger.d { "No custom processing needed for ${data.portnumValue} from $fromId" }
- }
-
- // We always tell other apps when new data packets arrive
- if (shouldBroadcast) {
- serviceBroadcasts.broadcastReceivedData(dataPacket)
- }
-
- analytics.track("num_data_receive", DataPair("num_data_receive", 1))
-
- analytics.track("data_receive", DataPair("num_bytes", bytes.size), DataPair("type", data.portnumValue))
- }
- }
- }
-
- private fun handleReceivedAdmin(fromNodeNum: Int, a: AdminProtos.AdminMessage) {
- when (a.payloadVariantCase) {
- AdminProtos.AdminMessage.PayloadVariantCase.GET_CONFIG_RESPONSE -> {
- if (fromNodeNum == myNodeNum) {
- val response = a.getConfigResponse
- Logger.d { "Admin: received config ${response.payloadVariantCase}" }
- setLocalConfig(response)
- }
- }
-
- AdminProtos.AdminMessage.PayloadVariantCase.GET_CHANNEL_RESPONSE -> {
- if (fromNodeNum == myNodeNum) {
- val mi = myNodeInfo
- if (mi != null) {
- val ch = a.getChannelResponse
- Logger.d { "Admin: Received channel ${ch.index}" }
-
- if (ch.index + 1 < mi.maxChannels) {
- handleChannel(ch)
- }
- }
- }
- }
-
- AdminProtos.AdminMessage.PayloadVariantCase.GET_DEVICE_METADATA_RESPONSE -> {
- Logger.d { "Admin: received DeviceMetadata from $fromNodeNum" }
- serviceScope.handledLaunch {
- nodeRepository.insertMetadata(MetadataEntity(fromNodeNum, a.getDeviceMetadataResponse))
- }
- }
-
- else -> Logger.w { "No special processing needed for ${a.payloadVariantCase}" }
- }
- Logger.d { "Admin: Received session_passkey from $fromNodeNum" }
- sessionPasskey = a.sessionPasskey
- }
-
- /**
- * Check if a User is a default/placeholder from firmware (node was evicted and re-created) and whether we should
- * preserve existing user data instead of overwriting it.
- */
- private fun shouldPreserveExistingUser(existing: MeshProtos.User, incoming: MeshProtos.User): Boolean {
- val isDefaultName = incoming.longName.matches(Regex("^Meshtastic [0-9a-fA-F]{4}$"))
- val isDefaultHwModel = incoming.hwModel == MeshProtos.HardwareModel.UNSET
- val hasExistingUser = existing.id.isNotEmpty() && existing.hwModel != MeshProtos.HardwareModel.UNSET
- return hasExistingUser && isDefaultName && isDefaultHwModel
- }
-
- private fun handleSharedContactImport(contact: AdminProtos.SharedContact) {
- handleReceivedUser(contact.nodeNum, contact.user, manuallyVerified = true)
- }
-
- // Update our DB of users based on someone sending out a User subpacket
- private fun handleReceivedUser(
- fromNum: Int,
- p: MeshProtos.User,
- channel: Int = 0,
- manuallyVerified: Boolean = false,
- ) {
- updateNodeInfo(fromNum) {
- val newNode = (it.isUnknownUser && p.hwModel != MeshProtos.HardwareModel.UNSET)
-
- // Check if this is a default/unknown user from firmware (node was evicted and re-created)
- val shouldPreserve = shouldPreserveExistingUser(it.user, p)
-
- if (shouldPreserve) {
- // Firmware sent us a placeholder - keep all our existing user data
- Logger.d {
- "Preserving existing user data for node $fromNum: " +
- "kept='${it.user.longName}' (hwModel=${it.user.hwModel}), " +
- "skipped default='${p.longName}' (hwModel=UNSET)"
- }
- // Ensure denormalized columns are updated from preserved user data
- it.longName = it.user.longName
- it.shortName = it.user.shortName
- // Still update channel and verification status
- it.channel = channel
- it.manuallyVerified = manuallyVerified
- } else {
- val keyMatch = !it.hasPKC || it.user.publicKey == p.publicKey
- it.user =
- if (keyMatch) {
- p
- } else {
- p.copy {
- Logger.w { "Public key mismatch from $longName ($shortName)" }
- publicKey = NodeEntity.ERROR_BYTE_STRING
- }
- }
- it.longName = p.longName
- it.shortName = p.shortName
- it.channel = channel
- it.manuallyVerified = manuallyVerified
- if (newNode) {
- serviceNotifications.showNewNodeSeenNotification(it)
- }
- }
- }
- }
-
- /**
- * 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(),
- ) {
- // 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) {
- Logger.d { "Ignoring nop position update for the local node" }
- } else {
- updateNodeInfo(fromNum) { it.setPosition(p, (defaultTime / 1000L).toInt()) }
- }
- }
-
- // Update our DB of users based on someone sending out a Telemetry subpacket
- private fun handleReceivedTelemetry(fromNum: Int, telemetry: TelemetryProtos.Telemetry) {
- val isRemote = (fromNum != myNodeNum)
- if (!isRemote) {
- updateServiceStatusNotification(telemetry = telemetry)
- }
- updateNodeInfo(fromNum) { nodeEntity ->
- when {
- telemetry.hasDeviceMetrics() -> {
- nodeEntity.deviceTelemetry = telemetry
- if (fromNum == myNodeNum || (isRemote && nodeEntity.isFavorite)) {
- if (
- telemetry.deviceMetrics.voltage > batteryPercentUnsupported &&
- telemetry.deviceMetrics.batteryLevel <= batteryPercentLowThreshold
- ) {
- if (shouldBatteryNotificationShow(fromNum, telemetry)) {
- serviceNotifications.showOrUpdateLowBatteryNotification(nodeEntity, isRemote)
- }
- } else {
- if (batteryPercentCooldowns.containsKey(fromNum)) {
- batteryPercentCooldowns.remove(fromNum)
- }
- serviceNotifications.cancelLowBatteryNotification(nodeEntity)
- }
- }
- }
-
- telemetry.hasEnvironmentMetrics() -> nodeEntity.environmentTelemetry = telemetry
- telemetry.hasPowerMetrics() -> nodeEntity.powerTelemetry = telemetry
- }
- }
- }
-
- private fun shouldBatteryNotificationShow(fromNum: Int, t: TelemetryProtos.Telemetry): Boolean {
- val isRemote = (fromNum != myNodeNum)
- var shouldDisplay = false
- var forceDisplay = false
- when {
- t.deviceMetrics.batteryLevel <= batteryPercentCriticalThreshold -> {
- shouldDisplay = true
- forceDisplay = true
- }
-
- t.deviceMetrics.batteryLevel == batteryPercentLowThreshold -> shouldDisplay = true
- t.deviceMetrics.batteryLevel.mod(batteryPercentLowDivisor) == 0 && !isRemote -> shouldDisplay = true
-
- isRemote -> shouldDisplay = true
- }
- if (shouldDisplay) {
- val now = System.currentTimeMillis() / 1000
- if (!batteryPercentCooldowns.containsKey(fromNum)) batteryPercentCooldowns[fromNum] = 0
- if ((now - batteryPercentCooldowns[fromNum]!!) >= batteryPercentCooldownSeconds || forceDisplay) {
- batteryPercentCooldowns[fromNum] = now
- return true
- }
- }
- return false
- }
-
- private fun handleReceivedPaxcounter(fromNum: Int, p: PaxcountProtos.Paxcount) {
- updateNodeInfo(fromNum) { it.paxcounter = p }
- }
-
- /**
- * Ask the connected radio to replay any packets it buffered while the client was offline.
- *
- * Radios deliver history via the Store & Forward protocol regardless of transport, so we piggyback on that
- * mechanism after BLE/Wi‑Fi reconnects.
- */
- private fun requestHistoryReplay(trigger: String) {
- val address = activeDeviceAddress()
- val failure =
- when {
- address == null -> "no_active_address"
- myNodeNum == null -> "no_my_node"
- else -> null
- }
- if (failure != null) {
- historyLog { "requestHistory skipped trigger=$trigger reason=$failure" }
- return
- }
-
- val safeAddress = address!!
- val myNum = myNodeNum!!
- val storeForwardConfig = moduleConfig.storeForward
- val lastRequest = meshPrefs.getStoreForwardLastRequest(safeAddress)
- val (window, max) =
- resolveHistoryRequestParameters(storeForwardConfig.historyReturnWindow, storeForwardConfig.historyReturnMax)
- val windowSource = if (storeForwardConfig.historyReturnWindow > 0) "config" else "default"
- val maxSource = if (storeForwardConfig.historyReturnMax > 0) "config" else "default"
- val sourceSummary = "window=$window($windowSource) max=$max($maxSource)"
- val request =
- buildStoreForwardHistoryRequest(
- lastRequest = lastRequest,
- historyReturnWindow = window,
- historyReturnMax = max,
- )
- val logContext = "trigger=$trigger transport=${currentTransport(safeAddress)} addr=$safeAddress"
- historyLog { "requestHistory $logContext lastRequest=$lastRequest $sourceSummary" }
-
- runCatching {
- packetHandler.sendToRadio(
- newMeshPacketTo(myNum).buildMeshPacket(priority = MeshPacket.Priority.BACKGROUND) {
- portnumValue = Portnums.PortNum.STORE_FORWARD_APP_VALUE
- payload = ByteString.copyFrom(request.toByteArray())
- },
- )
- }
- .onFailure { ex -> historyLog(Log.WARN, ex) { "requestHistory failed $logContext" } }
- }
-
- private fun updateStoreForwardLastRequest(source: String, lastRequest: Int) {
- if (lastRequest <= 0) return
- val address = activeDeviceAddress() ?: return
- val current = meshPrefs.getStoreForwardLastRequest(address)
- val transport = currentTransport(address)
- val logContext = "source=$source transport=$transport address=$address"
- if (lastRequest != current) {
- meshPrefs.setStoreForwardLastRequest(address, lastRequest)
- historyLog { "historyMarker updated $logContext from=$current to=$lastRequest" }
- } else {
- historyLog(Log.DEBUG) { "historyMarker unchanged $logContext value=$lastRequest" }
- }
- }
-
- private fun handleReceivedStoreAndForward(dataPacket: DataPacket, s: StoreAndForwardProtos.StoreAndForward) {
- Logger.d { "StoreAndForward: ${s.variantCase} ${s.rr} from ${dataPacket.from}" }
- val transport = currentTransport()
- val lastRequest =
- if (s.variantCase == StoreAndForwardProtos.StoreAndForward.VariantCase.HISTORY) {
- s.history.lastRequest
- } else {
- 0
- }
- val baseContext = "transport=$transport from=${dataPacket.from}"
- historyLog { "rxStoreForward $baseContext variant=${s.variantCase} rr=${s.rr} lastRequest=$lastRequest" }
- when (s.variantCase) {
- StoreAndForwardProtos.StoreAndForward.VariantCase.STATS -> {
- val u =
- dataPacket.copy(
- bytes = s.stats.toString().encodeToByteArray(),
- dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE,
- )
- rememberDataPacket(u)
- }
-
- StoreAndForwardProtos.StoreAndForward.VariantCase.HISTORY -> {
- val history = s.history
- val historySummary =
- "routerHistory $baseContext messages=${history.historyMessages} " +
- "window=${history.window} lastRequest=${history.lastRequest}"
- historyLog(Log.DEBUG) { historySummary }
- val text =
- """
- Total messages: ${s.history.historyMessages}
- History window: ${s.history.window / 60000} min
- Last request: ${s.history.lastRequest}
- """
- .trimIndent()
- val u =
- dataPacket.copy(
- bytes = text.encodeToByteArray(),
- dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE,
- )
- rememberDataPacket(u)
- updateStoreForwardLastRequest("router_history", s.history.lastRequest)
- }
-
- StoreAndForwardProtos.StoreAndForward.VariantCase.TEXT -> {
- if (s.rr == StoreAndForwardProtos.StoreAndForward.RequestResponse.ROUTER_TEXT_BROADCAST) {
- dataPacket.to = DataPacket.ID_BROADCAST
- }
- val textLog =
- "rxText $baseContext id=${dataPacket.id} ts=${dataPacket.time} " +
- "to=${dataPacket.to} decision=remember"
- historyLog(Log.DEBUG) { textLog }
- val u =
- dataPacket.copy(bytes = s.text.toByteArray(), dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE)
- rememberDataPacket(u)
- }
-
- else -> {}
- }
- }
-
- private val earlyReceivedPackets = ArrayDeque()
-
- // 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) {
- val preparedPacket =
- packet
- .toBuilder()
- .apply {
- // If the rxTime was not set by the device, update with current time
- if (packet.rxTime == 0) setRxTime(currentSecond())
- }
- .build()
- Logger.d { "[packet]: ${packet.toOneLineString()}" }
- if (isNodeDbReady) {
- processReceivedMeshPacket(preparedPacket)
- return
- }
-
- val queueSize = earlyReceivedPackets.size
- if (queueSize >= MAX_EARLY_PACKET_BUFFER) {
- val dropped = earlyReceivedPackets.removeFirst()
- historyLog(Log.WARN) {
- val portLabel =
- if (dropped.hasDecoded()) {
- Portnums.PortNum.forNumber(dropped.decoded.portnumValue)?.name
- ?: dropped.decoded.portnumValue.toString()
- } else {
- "unknown"
- }
- "dropEarlyPacket bufferFull size=$queueSize id=${dropped.id} port=$portLabel"
- }
- }
-
- earlyReceivedPackets.addLast(preparedPacket)
- val portLabel =
- if (preparedPacket.hasDecoded()) {
- Portnums.PortNum.forNumber(preparedPacket.decoded.portnumValue)?.name
- ?: preparedPacket.decoded.portnumValue.toString()
- } else {
- "unknown"
- }
- historyLog { "queueEarlyPacket size=${earlyReceivedPackets.size} id=${preparedPacket.id} port=$portLabel" }
- }
-
- private fun flushEarlyReceivedPackets(reason: String) {
- if (earlyReceivedPackets.isEmpty()) return
- val packets = earlyReceivedPackets.toList()
- earlyReceivedPackets.clear()
- historyLog { "replayEarlyPackets reason=$reason count=${packets.size}" }
- packets.forEach(::processReceivedMeshPacket)
- }
-
- private fun sendNow(p: DataPacket) {
- val packet = toMeshPacket(p)
- p.time = System.currentTimeMillis() // update time to the actual time we started sending
- // Logger.d { "Sending to radio: ${packet.toPIIString()}" }
- packetHandler.sendToRadio(packet)
- }
-
- private fun processQueuedPackets() {
- val sentPackets = mutableListOf()
- offlineSentPackets.forEach { p ->
- try {
- sendNow(p)
- sentPackets.add(p)
- } catch (ex: Exception) {
- Logger.e(ex) { "Error sending queued message:" }
- }
- }
- offlineSentPackets.removeAll(sentPackets)
- }
-
- /** Handle an ack/nak packet by updating sent message status */
- private fun handleAckNak(requestId: Int, fromId: String, routingError: Int, relayNode: Int? = null) {
- 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
- p.data.relayNode = relayNode
- if (isAck) {
- p.data.relays += 1
- }
- packetRepository.get().update(p)
- }
- serviceBroadcasts.broadcastMessageStatus(requestId, m)
- }
- }
-
- private fun getHopsAwayForPacket(packet: MeshPacket): Int =
- if (packet.decoded.portnumValue == Portnums.PortNum.RANGE_TEST_APP_VALUE) {
- 0 // These don't come with the .hop params, but do not propagate, so they must be 0
- } else if (packet.hopStart == 0 && !packet.decoded.hasBitfield()) {
- // Firmware prior to 2.3.0 doesn't set hopStart. The bitfield was added in 2.5.0. Its
- // absence is used to approximate firmware versions where hopStart is missing and when
- // the number of hops away cannot be calculated.
- -1
- } else if (packet.hopLimit > packet.hopStart) {
- -1 // Invalid hopStart/hopLimit.
- } else {
- packet.hopStart - packet.hopLimit
- }
-
- // 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
- 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 },
- )
- val logInsertJob = insertMeshLog(packetToSave)
-
- serviceScope.handledLaunch { serviceRepository.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 = getHopsAwayForPacket(packet)
- }
- logInsertJobByPacketId[packet.id] = logInsertJob
- logUuidByPacketId[packet.id] = packetToSave.uuid
- try {
- handleReceivedData(packet)
- } finally {
- logUuidByPacketId.remove(packet.id)
- logInsertJobByPacketId.remove(packet.id)
- }
- }
- }
-
- private fun insertMeshLog(packetToSave: MeshLog): Job = serviceScope.handledLaunch {
- // Do not log, because might contain PII
- // info("insert: ${packetToSave.message_type} =
- // ${packetToSave.raw_message.toOneLineString()}")
- meshLogRepository.get().insert(packetToSave)
- }
-
- private fun setLocalConfig(config: ConfigProtos.Config) {
- serviceScope.handledLaunch { radioConfigRepository.setLocalConfig(config) }
- }
-
- private fun setLocalModuleConfig(config: ModuleConfigProtos.ModuleConfig) {
- serviceScope.handledLaunch { radioConfigRepository.setLocalModuleConfig(config) }
- }
-
- private fun updateChannelSettings(ch: ChannelProtos.Channel) =
- serviceScope.handledLaunch { radioConfigRepository.updateChannelSettings(ch) }
-
- private fun currentSecond() = (System.currentTimeMillis() / 1000).toInt()
-
- /** Send in analytics about mesh connection */
- private fun reportConnection() {
- val radioModel = DataPair("radio_model", myNodeInfo?.model ?: "unknown")
- analytics.track(
- "mesh_connect",
- DataPair("num_nodes", numNodes),
- DataPair("num_online", numOnlineNodes),
- 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
- @Suppress("CyclomaticComplexMethod")
- private fun onConnectionChanged(c: ConnectionState) {
- if (connectionStateHolder.connectionState.value == c && c !is ConnectionState.Connected) return
- Logger.d { "onConnectionChanged: ${connectionStateHolder.connectionState.value} -> $c" }
-
- // Cancel any existing timeouts
- sleepTimeout?.cancel()
- sleepTimeout = null
-
- when (c) {
- is ConnectionState.Connecting -> {
- connectionStateHolder.setState(ConnectionState.Connecting)
- }
-
- is ConnectionState.Connected -> {
- handleConnected()
- }
-
- is ConnectionState.DeviceSleep -> {
- handleDeviceSleep()
- }
-
- is ConnectionState.Disconnected -> {
- handleDisconnected()
- }
- }
- updateServiceStatusNotification()
- }
-
- private fun handleDisconnected() {
- connectionStateHolder.setState(ConnectionState.Disconnected)
- Logger.d { "Starting disconnect" }
- packetHandler.stopPacketQueue()
- stopLocationRequests()
- stopMqttClientProxy()
-
- analytics.track("mesh_disconnect", DataPair("num_nodes", numNodes), DataPair("num_online", numOnlineNodes))
- analytics.track("num_nodes", DataPair("num_nodes", numNodes))
-
- // broadcast an intent with our new connection state
- serviceBroadcasts.broadcastConnection()
- }
-
- private fun handleDeviceSleep() {
- connectionStateHolder.setState(ConnectionState.DeviceSleep)
- packetHandler.stopPacketQueue()
- stopLocationRequests()
- stopMqttClientProxy()
-
- if (connectTimeMsec != 0L) {
- val now = System.currentTimeMillis()
- connectTimeMsec = 0L
-
- analytics.track("connected_seconds", DataPair("connected_seconds", (now - connectTimeMsec) / 1000.0))
- }
-
- // 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
-
- Logger.d { "Waiting for sleeping device, timeout=$timeout secs" }
- delay(timeout * 1000L)
- Logger.w { "Device timeout out, setting disconnected" }
- onConnectionChanged(ConnectionState.Disconnected)
- } catch (_: CancellationException) {
- Logger.d { "device sleep timeout cancelled" }
- }
- }
-
- // broadcast an intent with our new connection state
- serviceBroadcasts.broadcastConnection()
- }
-
- private fun handleConnected() {
- connectionStateHolder.setState(ConnectionState.Connecting)
- serviceBroadcasts.broadcastConnection()
- Logger.d { "Starting connect" }
- historyLog {
- val address = meshPrefs.deviceAddress ?: "null"
- "onReconnect transport=${currentTransport()} node=$address"
- }
- try {
- connectTimeMsec = System.currentTimeMillis()
- startConfigOnly()
- } catch (ex: InvalidProtocolBufferException) {
- Logger.e(ex) { "Invalid protocol buffer sent by device - update device software and try again" }
- } catch (ex: RadioNotConnectedException) {
- Logger.e { "Lost connection to radio during init - waiting for reconnect ${ex.message}" }
- } catch (ex: RemoteException) {
- onConnectionChanged(ConnectionState.DeviceSleep)
- throw ex
- }
- }
-
- private fun updateServiceStatusNotification(telemetry: TelemetryProtos.Telemetry? = null): Notification {
- val notificationSummary =
- when (connectionStateHolder.connectionState.value) {
- is ConnectionState.Connected -> getString(Res.string.connected_count).format(numOnlineNodes)
-
- is ConnectionState.Disconnected -> getString(Res.string.disconnected)
- is ConnectionState.DeviceSleep -> getString(Res.string.device_sleeping)
- is ConnectionState.Connecting -> getString(Res.string.connecting)
- }
- return serviceNotifications.updateServiceStateNotification(
- summaryString = notificationSummary,
- telemetry = telemetry,
- )
- }
-
- private fun onRadioConnectionState(newState: ConnectionState) {
- // Respect light sleep (lsEnabled) setting: if device reports sleep
- // but lsEnabled is false, treat as disconnected.
- val isRouter = localConfig.device.role == ConfigProtos.Config.DeviceConfig.Role.ROUTER
- val lsEnabled = localConfig.power.isPowerSaving || isRouter
-
- val effectiveState =
- when (newState) {
- is ConnectionState.Connected -> ConnectionState.Connected
- is ConnectionState.DeviceSleep ->
- if (lsEnabled) {
- ConnectionState.DeviceSleep
- } else {
- ConnectionState.Disconnected
- }
-
- is ConnectionState.Connecting -> ConnectionState.Connecting
- is ConnectionState.Disconnected -> ConnectionState.Disconnected
- }
- onConnectionChanged(effectiveState)
- }
-
- private val packetHandlers: Map Unit)> by lazy {
- PayloadVariantCase.entries.associateWith { variant: PayloadVariantCase ->
- when (variant) {
- PayloadVariantCase.PACKET -> { proto: MeshProtos.FromRadio -> handleReceivedMeshPacket(proto.packet) }
-
- PayloadVariantCase.CONFIG_COMPLETE_ID -> { proto: MeshProtos.FromRadio ->
- handleConfigComplete(proto.configCompleteId)
- }
-
- PayloadVariantCase.MY_INFO -> { proto: MeshProtos.FromRadio -> handleMyInfo(proto.myInfo) }
-
- PayloadVariantCase.NODE_INFO -> { proto: MeshProtos.FromRadio -> handleNodeInfo(proto.nodeInfo) }
-
- PayloadVariantCase.CHANNEL -> { proto: MeshProtos.FromRadio -> handleChannel(proto.channel) }
-
- PayloadVariantCase.CONFIG -> { proto: MeshProtos.FromRadio -> handleDeviceConfig(proto.config) }
-
- PayloadVariantCase.MODULECONFIG -> { proto: MeshProtos.FromRadio ->
- handleModuleConfig(proto.moduleConfig)
- }
-
- PayloadVariantCase.QUEUESTATUS -> { proto: MeshProtos.FromRadio ->
- packetHandler.handleQueueStatus((proto.queueStatus))
- }
-
- PayloadVariantCase.METADATA -> { proto: MeshProtos.FromRadio -> handleMetadata(proto.metadata) }
-
- PayloadVariantCase.MQTTCLIENTPROXYMESSAGE -> { proto: MeshProtos.FromRadio ->
- handleMqttProxyMessage(proto.mqttClientProxyMessage)
- }
-
- PayloadVariantCase.DEVICEUICONFIG -> { proto: MeshProtos.FromRadio ->
- handleDeviceUiConfig(proto.deviceuiConfig)
- }
-
- PayloadVariantCase.FILEINFO -> { proto: MeshProtos.FromRadio -> handleFileInfo(proto.fileInfo) }
-
- PayloadVariantCase.CLIENTNOTIFICATION -> { proto: MeshProtos.FromRadio ->
- handleClientNotification(proto.clientNotification)
- }
-
- PayloadVariantCase.LOG_RECORD -> { proto: MeshProtos.FromRadio -> handleLogRecord(proto.logRecord) }
-
- PayloadVariantCase.REBOOTED -> { proto: MeshProtos.FromRadio -> handleRebooted(proto.rebooted) }
-
- PayloadVariantCase.XMODEMPACKET -> { proto: MeshProtos.FromRadio ->
- handleXmodemPacket(proto.xmodemPacket)
- }
-
- // Explicitly handle default/unwanted cases to satisfy the exhaustive `when`
- PayloadVariantCase.PAYLOADVARIANT_NOT_SET -> { proto ->
- Logger.d {
- "Received variant PayloadVariantUnset: Full FromRadio proto: ${proto.toPIIString()}"
- }
- }
- }
- }
- }
-
- private fun MeshProtos.FromRadio.route() {
- packetHandlers[this.payloadVariantCase]?.invoke(this)
- }
-
- /**
- * Parses and routes incoming data from the radio.
- *
- * This function first attempts to parse the data as a `FromRadio` protobuf message. If that fails, it then tries to
- * parse it as a `LogRecord` for debugging purposes.
- */
- private fun onReceiveFromRadio(bytes: ByteArray) {
- runCatching { MeshProtos.FromRadio.parseFrom(bytes) }
- .onSuccess { proto ->
- if (proto.payloadVariantCase == PayloadVariantCase.PAYLOADVARIANT_NOT_SET) {
- Logger.w {
- "Received FromRadio with PAYLOADVARIANT_NOT_SET. rawBytes=${bytes.toHexString()} proto=$proto"
- }
- }
- proto.route()
- }
- .onFailure { primaryException ->
- runCatching {
- val logRecord = MeshProtos.LogRecord.parseFrom(bytes)
- handleLogRecord(logRecord)
- }
- .onFailure { _ ->
- Logger.e(primaryException) {
- "Failed to parse radio packet (len=${bytes.size} contents=${bytes.toHexString()}). " +
- "Not a valid FromRadio or LogRecord."
- }
- }
- }
- }
-
- /** Extension function to convert a ByteArray to a hex string for logging. Example output: "0x0a,0x1f,0x..." */
- private fun ByteArray.toHexString(): String =
- this.joinToString(",") { byte -> String.format(Locale.US, "0x%02x", byte) }
-
- // 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()
-
- // Nonces for two-stage config flow (match Meshtastic-Apple)
- private var configOnlyNonce: Int = DEFAULT_CONFIG_ONLY_NONCE
- private var nodeInfoNonce: Int = DEFAULT_NODE_INFO_NONCE
-
- private fun handleDeviceConfig(config: ConfigProtos.Config) {
- Logger.d { "[deviceConfig] ${config.toPIIString()}" }
- val packetToSave =
- 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
- serviceRepository.setStatusMessage("Device config ($configCount / $configTotal)")
- }
-
- private fun handleModuleConfig(config: ModuleConfigProtos.ModuleConfig) {
- Logger.d { "[moduleConfig] ${config.toPIIString()}" }
- val packetToSave =
- MeshLog(
- uuid = UUID.randomUUID().toString(),
- message_type = "ModuleConfig ${config.payloadVariantCase}",
- received_date = System.currentTimeMillis(),
- raw_message = config.toString(),
- fromRadio = fromRadio { moduleConfig = config },
- )
- insertMeshLog(packetToSave)
- setLocalModuleConfig(config)
- val moduleCount = moduleConfig.allFields.size
- serviceRepository.setStatusMessage("Module config ($moduleCount / $moduleTotal)")
- }
-
- private fun handleChannel(ch: ChannelProtos.Channel) {
- Logger.d { "[channel] ${ch.toPIIString()}" }
- val packetToSave =
- 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
- serviceRepository.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, withBroadcast: Boolean = true) {
- // Just replace/add any entry
- updateNodeInfo(info.num, withBroadcast = withBroadcast) {
- if (info.hasUser()) {
- // Check if this is a default/unknown user from firmware (node was evicted and re-created)
- val shouldPreserve = shouldPreserveExistingUser(it.user, info.user)
-
- if (shouldPreserve) {
- // Firmware sent us a placeholder - keep all our existing user data
- Logger.d {
- "Preserving existing user data for node ${info.num}: " +
- "kept='${it.user.longName}' (hwModel=${it.user.hwModel}), " +
- "skipped default='${info.user.longName}' (hwModel=UNSET)"
- }
- // Ensure denormalized columns are updated from preserved user data
- it.longName = it.user.longName
- it.shortName = it.user.shortName
- } else {
- it.user =
- info.user.copy {
- if (isLicensed) clearPublicKey()
- if (info.viaMqtt) longName = "$longName (MQTT)"
- }
- 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.isFavorite = info.isFavorite
- it.isIgnored = info.isIgnored
- }
- }
-
- private fun handleNodeInfo(info: MeshProtos.NodeInfo) {
- Logger.d { "[nodeInfo] ${info.toPIIString()}" }
- val packetToSave =
- 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)
- serviceRepository.setStatusMessage("Nodes (${newNodes.size})")
- }
-
- private fun handleNodeInfoComplete() {
- Logger.d { "NodeInfo complete for nonce $nodeInfoNonce" }
- val packetToSave =
- MeshLog(
- uuid = UUID.randomUUID().toString(),
- message_type = "NodeInfoComplete",
- received_date = System.currentTimeMillis(),
- raw_message = nodeInfoNonce.toString(),
- fromRadio = fromRadio { this.configCompleteId = nodeInfoNonce },
- )
- insertMeshLog(packetToSave)
- if (newNodes.isEmpty()) {
- Logger.e { "Did not receive a valid node info" }
- } else {
- // Batch update: Update in-memory models first without triggering individual DB writes
- val entities =
- newNodes.mapNotNull { info ->
- installNodeInfo(info, withBroadcast = false)
- nodeDBbyNodeNum[info.num]
- }
- newNodes.clear()
-
- // Perform a single batch DB transaction for all nodes + myNodeInfo
- serviceScope.handledLaunch { myNodeInfo?.let { nodeRepository.installConfig(it, entities) } }
-
- // Enable DB writes for future individual updates
- allowNodeDbWrites = true
- isNodeDbReady = true
- flushEarlyReceivedPackets("node_info_complete")
- sendAnalytics()
- onHasSettings()
- connectionStateHolder.setState(ConnectionState.Connected)
- serviceBroadcasts.broadcastConnection()
- updateServiceStatusNotification()
- }
- }
-
- 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? = MeshProtos.DeviceMetadata.getDefaultInstance()) {
- val myInfo = rawMyNodeInfo
- val hasMetadata = metadata != null && metadata != MeshProtos.DeviceMetadata.getDefaultInstance()
- Logger.i {
- "[MYNODE_REGEN] Called - " +
- "rawMyNodeInfo: ${if (myInfo != null) "present" else "null"}, " +
- "metadata: ${if (hasMetadata) "present" else "null/default"}, " +
- "firmwareVersion: ${metadata?.firmwareVersion ?: "null"}, " +
- "hasWifi: ${metadata?.hasWifi}"
- }
-
- if (myInfo != null) {
- val mi =
- with(myInfo) {
- MyNodeEntity(
- myNodeNum = myNodeNum,
- model =
- when (val hwModel = metadata?.hwModel) {
- null,
- MeshProtos.HardwareModel.UNSET,
- -> null
-
- else -> hwModel.name.replace('_', '-').replace('p', '.').lowercase()
- },
- firmwareVersion = metadata?.firmwareVersion,
- couldUpdate = false,
- shouldUpdate = false, // TODO add check after re-implementing firmware updates
- currentPacketId = currentPacketId and 0xffffffffL,
- messageTimeoutMsec = 5 * 60 * 1000, // constants from current firmware code
- minAppVersion = minAppVersion,
- maxChannels = 8,
- hasWifi = metadata?.hasWifi == true,
- deviceId = deviceId.toStringUtf8(),
- )
- }
-
- Logger.i {
- "[MYNODE_REGEN] Created MyNodeEntity - " +
- "nodeNum: ${mi.myNodeNum}, " +
- "model: ${mi.model}, " +
- "firmwareVersion: ${mi.firmwareVersion}, " +
- "hasWifi: ${mi.hasWifi}"
- }
-
- if (metadata != null && metadata != MeshProtos.DeviceMetadata.getDefaultInstance()) {
- serviceScope.handledLaunch { nodeRepository.insertMetadata(MetadataEntity(mi.myNodeNum, metadata)) }
- }
- newMyNodeInfo = mi
- Logger.i { "[MYNODE_REGEN] Set newMyNodeInfo (will be committed on configComplete)" }
- } else {
- Logger.w { "[MYNODE_REGEN] rawMyNodeInfo is null, cannot regenerate" }
- }
- }
-
- private fun sendAnalytics() {
- val myInfo = rawMyNodeInfo
- val mi = myNodeInfo
- if (myInfo != null && mi != null) {
- // Track types of devices and firmware versions in use
- analytics.setDeviceAttributes(mi.firmwareVersion ?: "unknown", mi.model ?: "unknown")
- }
- }
-
- /** Update MyNodeInfo (called from either new API version or the old one) */
- private fun handleMyInfo(myInfo: MeshProtos.MyNodeInfo) {
- Logger.i {
- "[MYINFO_RECEIVED] MyNodeInfo received - " +
- "nodeNum: ${myInfo.myNodeNum}, " +
- "minAppVersion: ${myInfo.minAppVersion}, " +
- "PII data: ${myInfo.toPIIString()}"
- }
- val packetToSave =
- 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
- Logger.i { "[MYINFO_RECEIVED] Set rawMyNodeInfo, calling regenMyNodeInfo()" }
- regenMyNodeInfo()
-
- // We'll need to get a new set of channels and settings now
- serviceScope.handledLaunch {
- radioConfigRepository.clearChannelSet()
- radioConfigRepository.clearLocalConfig()
- radioConfigRepository.clearLocalModuleConfig()
- }
- }
-
- /** Update our DeviceMetadata */
- private fun handleMetadata(metadata: MeshProtos.DeviceMetadata) {
- Logger.i {
- "[METADATA_RECEIVED] DeviceMetadata received - " +
- "firmwareVersion: ${metadata.firmwareVersion}, " +
- "hwModel: ${metadata.hwModel}, " +
- "hasWifi: ${metadata.hasWifi}, " +
- "hasBluetooth: ${metadata.hasBluetooth}, " +
- "PII data: ${metadata.toPIIString()}"
- }
- val packetToSave =
- MeshLog(
- uuid = UUID.randomUUID().toString(),
- message_type = "DeviceMetadata",
- received_date = System.currentTimeMillis(),
- raw_message = metadata.toString(),
- fromRadio = fromRadio { this.metadata = metadata },
- )
- insertMeshLog(packetToSave)
-
- Logger.i {
- "[METADATA_RECEIVED] Calling regenMyNodeInfo with metadata - " +
- "This will update newMyNodeInfo with firmwareVersion: ${metadata.firmwareVersion}"
- }
- regenMyNodeInfo(metadata)
- }
-
- /** Publish MqttClientProxyMessage (fromRadio) */
- private fun handleMqttProxyMessage(message: MeshProtos.MqttClientProxyMessage) {
- Logger.d { "[mqttClientProxyMessage] ${message.toPIIString()}" }
- with(message) {
- when (payloadVariantCase) {
- MeshProtos.MqttClientProxyMessage.PayloadVariantCase.TEXT -> {
- mqttRepository.publish(topic, text.encodeToByteArray(), retained)
- }
-
- MeshProtos.MqttClientProxyMessage.PayloadVariantCase.DATA -> {
- mqttRepository.publish(topic, data.toByteArray(), retained)
- }
-
- else -> {}
- }
- }
- }
-
- private fun handleClientNotification(notification: MeshProtos.ClientNotification) {
- Logger.d { "[clientNotification] ${notification.toPIIString()}" }
- serviceRepository.setClientNotification(notification)
- serviceNotifications.showClientNotification(notification)
- // if the future for the originating request is still in the queue, complete as unsuccessful
- // for now
- packetHandler.removeResponse(notification.replyId, complete = false)
- val packetToSave =
- MeshLog(
- uuid = UUID.randomUUID().toString(),
- message_type = "ClientNotification",
- received_date = System.currentTimeMillis(),
- raw_message = notification.toString(),
- fromRadio = fromRadio { this.clientNotification = notification },
- )
- insertMeshLog(packetToSave)
- }
-
- private fun handleFileInfo(fileInfo: MeshProtos.FileInfo) {
- Logger.d { "[fileInfo] ${fileInfo.toPIIString()}" }
- val packetToSave =
- MeshLog(
- uuid = UUID.randomUUID().toString(),
- message_type = "FileInfo",
- received_date = System.currentTimeMillis(),
- raw_message = fileInfo.toString(),
- fromRadio = fromRadio { this.fileInfo = fileInfo },
- )
- insertMeshLog(packetToSave)
- }
-
- private fun handleLogRecord(logRecord: MeshProtos.LogRecord) {
- Logger.d { "[logRecord] ${logRecord.toPIIString()}" }
- val packetToSave =
- MeshLog(
- uuid = UUID.randomUUID().toString(),
- message_type = "LogRecord",
- received_date = System.currentTimeMillis(),
- raw_message = logRecord.toString(),
- fromRadio = fromRadio { this.logRecord = logRecord },
- )
- insertMeshLog(packetToSave)
- }
-
- private fun handleRebooted(rebooted: Boolean) {
- Logger.d { "[rebooted] $rebooted" }
- val packetToSave =
- MeshLog(
- uuid = UUID.randomUUID().toString(),
- message_type = "Rebooted",
- received_date = System.currentTimeMillis(),
- raw_message = rebooted.toString(),
- fromRadio = fromRadio { this.rebooted = rebooted },
- )
- insertMeshLog(packetToSave)
- }
-
- private fun handleXmodemPacket(xmodemPacket: XmodemProtos.XModem) {
- Logger.d { "[xmodemPacket] ${xmodemPacket.toPIIString()}" }
- val packetToSave =
- MeshLog(
- uuid = UUID.randomUUID().toString(),
- message_type = "XmodemPacket",
- received_date = System.currentTimeMillis(),
- raw_message = xmodemPacket.toString(),
- fromRadio = fromRadio { this.xmodemPacket = xmodemPacket },
- )
- insertMeshLog(packetToSave)
- }
-
- private fun handleDeviceUiConfig(deviceuiConfig: DeviceUIProtos.DeviceUIConfig) {
- Logger.d { "[deviceuiConfig] ${deviceuiConfig.toPIIString()}" }
- val packetToSave =
- MeshLog(
- uuid = UUID.randomUUID().toString(),
- message_type = "DeviceUIConfig",
- received_date = System.currentTimeMillis(),
- raw_message = deviceuiConfig.toString(),
- fromRadio = fromRadio { this.deviceuiConfig = deviceuiConfig },
- )
- insertMeshLog(packetToSave)
- }
-
- /** 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 ->
- packetHandler.sendToRadio(ToRadio.newBuilder().apply { mqttClientProxyMessage = message })
- }
- .catch { throwable -> serviceRepository.setErrorMessage("MqttClientProxy failed: $throwable") }
- .launchIn(serviceScope)
- }
- }
-
- private fun stopMqttClientProxy() {
- if (mqttMessageFlow?.isActive == true) {
- Logger.i { "Stopping MqttClientProxy" }
- mqttMessageFlow?.cancel()
- mqttMessageFlow = null
- }
- }
-
- private fun onHasSettings() {
- processQueuedPackets()
- startMqttClientProxy()
- sendAnalytics()
- reportConnection()
- historyLog {
- val ports =
- rememberDataType.joinToString(",") { port -> Portnums.PortNum.forNumber(port)?.name ?: port.toString() }
- "subscribePorts afterReconnect ports=$ports"
- }
- requestHistoryReplay("onHasSettings")
- packetHandler.sendToRadio(newMeshPacketTo(myNodeNum).buildAdminPacket { setTimeOnly = currentSecond() })
- }
-
- private fun handleConfigComplete(configCompleteId: Int) {
- Logger.d { "[configCompleteId]: ${configCompleteId.toPIIString()}" }
- when (configCompleteId) {
- configOnlyNonce -> handleConfigOnlyComplete()
- nodeInfoNonce -> handleNodeInfoComplete()
- else ->
- Logger.w {
- "Config complete id mismatch: received=$configCompleteId expected one of [$configOnlyNonce,$nodeInfoNonce]"
- }
- }
- }
-
- private fun handleConfigOnlyComplete() {
- Logger.i { "[CONFIG_COMPLETE] Config-only complete for nonce $configOnlyNonce" }
- val packetToSave =
- MeshLog(
- uuid = UUID.randomUUID().toString(),
- message_type = "ConfigOnlyComplete",
- received_date = System.currentTimeMillis(),
- raw_message = configOnlyNonce.toString(),
- fromRadio = fromRadio { this.configCompleteId = configOnlyNonce },
- )
- insertMeshLog(packetToSave)
-
- if (newMyNodeInfo == null) {
- Logger.e { "[CONFIG_COMPLETE] Did not receive a valid config - newMyNodeInfo is null" }
- } else {
- Logger.i {
- "[CONFIG_COMPLETE] Committing newMyNodeInfo to myNodeInfo - " +
- "firmwareVersion: ${newMyNodeInfo?.firmwareVersion}, " +
- "hasWifi: ${newMyNodeInfo?.hasWifi}, " +
- "model: ${newMyNodeInfo?.model}"
- }
- myNodeInfo = newMyNodeInfo
- Logger.i { "[CONFIG_COMPLETE] myNodeInfo committed successfully" }
- }
- // Keep BLE awake and allow the firmware to settle before the node-info stage.
- serviceScope.handledLaunch {
- delay(WANT_CONFIG_DELAY)
- sendHeartbeat()
- delay(WANT_CONFIG_DELAY)
- startNodeInfoOnly()
- }
- }
-
- /** Send a ToRadio heartbeat to keep the link alive without producing mesh traffic. */
- private fun sendHeartbeat() {
- try {
- packetHandler.sendToRadio(
- ToRadio.newBuilder().apply { heartbeat = MeshProtos.Heartbeat.getDefaultInstance() },
- )
- Logger.d { "Heartbeat sent between nonce stages" }
- } catch (ex: Exception) {
- Logger.w(ex) { "Failed to send heartbeat; proceeding with node-info stage" }
- }
- }
-
- private fun startConfigOnly() {
- newMyNodeInfo = null
- Logger.d { "Starting config-only nonce=$configOnlyNonce" }
- packetHandler.sendToRadio(ToRadio.newBuilder().apply { this.wantConfigId = configOnlyNonce })
- }
-
- private fun startNodeInfoOnly() {
- newNodes.clear()
- Logger.d { "Starting node-info nonce=$nodeInfoNonce" }
- packetHandler.sendToRadio(ToRadio.newBuilder().apply { this.wantConfigId = nodeInfoNonce })
- }
-
- /** 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
- Logger.d { "Sending our position/time to=$idNum ${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)
- }
-
- packetHandler.sendToRadio(
- newMeshPacketTo(idNum).buildMeshPacket(
- channel = if (destNum == null) 0 else nodeDBbyNodeNum[destNum]?.channel ?: 0,
- priority = MeshPacket.Priority.BACKGROUND,
- ) {
- portnumValue = Portnums.PortNum.POSITION_APP_VALUE
- payload = position.toByteString()
- this.wantResponse = wantResponse
- },
- )
- }
- } catch (_: BLEException) {
- Logger.w { "Ignoring disconnected radio during gps location update" }
- }
- }
-
- /** 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) {
- Logger.d { "Ignoring nop owner change" }
- } else {
- Logger.d {
- "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
- packetHandler.sendToRadio(newMeshPacketTo(dest.num).buildAdminPacket(id = packetId) { setOwner = user })
- }
- }
-
- // Do not use directly, instead call generatePacketId()
- private var currentPacketId = java.util.Random(System.currentTimeMillis()).nextLong().absoluteValue
-
- /** 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)
- currentPacketId++
- currentPacketId = currentPacketId and 0xffffffff
- return ((currentPacketId % numPacketIds) + 1L).toInt()
- }
-
- private fun enqueueForSending(p: DataPacket) {
- if (p.dataType in rememberDataType) {
- offlineSentPackets.add(p)
- }
- }
-
- private fun onServiceAction(action: ServiceAction) {
- ignoreException {
- when (action) {
- is ServiceAction.GetDeviceMetadata -> getDeviceMetadata(action.destNum)
- is ServiceAction.Favorite -> favoriteNode(action.node)
- is ServiceAction.Ignore -> ignoreNode(action.node)
- is ServiceAction.Reaction -> sendReaction(action)
- is ServiceAction.ImportContact -> importContact(action.contact)
- is ServiceAction.SendContact -> sendContact(action.contact)
- }
- }
- }
-
- /**
- * Imports a manually shared contact.
- *
- * This function takes a [AdminProtos.SharedContact] proto, marks it as manually verified, sends it for further
- * processing, and then handles the import specific logic.
- *
- * @param contact The [AdminProtos.SharedContact] to be imported.
- */
- private fun importContact(contact: AdminProtos.SharedContact) {
- val verifiedContact = contact.copy { manuallyVerified = true }
- sendContact(verifiedContact)
- handleSharedContactImport(contact = verifiedContact)
- }
-
- /**
- * Sends a shared contact to the radio via [AdminProtos.AdminMessage]
- *
- * @param contact The contact to send.
- */
- private fun sendContact(contact: AdminProtos.SharedContact) {
- packetHandler.sendToRadio(newMeshPacketTo(myNodeNum).buildAdminPacket { addContact = contact })
- }
-
- private fun getDeviceMetadata(destNum: Int) = toRemoteExceptions {
- packetHandler.sendToRadio(
- newMeshPacketTo(destNum).buildAdminPacket(wantResponse = true) { getDeviceMetadataRequest = true },
- )
- }
-
- private fun favoriteNode(node: Node) = toRemoteExceptions {
- packetHandler.sendToRadio(
- newMeshPacketTo(myNodeNum).buildAdminPacket {
- if (node.isFavorite) {
- Logger.d { "removing node ${node.num} from favorite list" }
- removeFavoriteNode = node.num
- } else {
- Logger.d { "adding node ${node.num} to favorite list" }
- setFavoriteNode = node.num
- }
- },
- )
- updateNodeInfo(node.num) { it.isFavorite = !node.isFavorite }
- }
-
- private fun ignoreNode(node: Node) = toRemoteExceptions {
- packetHandler.sendToRadio(
- newMeshPacketTo(myNodeNum).buildAdminPacket {
- if (node.isIgnored) {
- Logger.d { "removing node ${node.num} from ignore list" }
- removeIgnoredNode = node.num
- } else {
- Logger.d { "adding node ${node.num} to ignore list" }
- setIgnoredNode = node.num
- }
- },
- )
- updateNodeInfo(node.num) { it.isIgnored = !node.isIgnored }
- }
-
- 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 packet =
- newMeshPacketTo(destNum).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())
- }
- packetHandler.sendToRadio(packet)
- rememberReaction(packet.copy { from = myNodeNum })
- }
-
- private fun updateLastAddress(deviceAddr: String?) {
- val currentAddr = meshPrefs.deviceAddress
- Logger.d { "setDeviceAddress: received request to change to: ${deviceAddr.anonymize}" }
- if (deviceAddr != currentAddr) {
- Logger.d {
- "SetDeviceAddress: Device address changed from ${currentAddr.anonymize} to ${deviceAddr.anonymize}"
- }
- val currentLabel = currentAddr ?: "null"
- val nextLabel = deviceAddr ?: "null"
- val nextTransport = currentTransport(deviceAddr)
- historyLog { "dbSwitch request current=$currentLabel next=$nextLabel transportNext=$nextTransport" }
- meshPrefs.deviceAddress = deviceAddr
- serviceScope.handledLaunch {
- // Clear only in-memory caches to avoid cross-device bleed
- discardNodeDB()
- // Switch active on-disk DB to device-specific database
- databaseManager.switchActiveDatabase(deviceAddr)
- val activeAddress = databaseManager.currentAddress.value
- val activeLabel = activeAddress ?: "null"
- val transportLabel = currentTransport()
- val meshAddress = meshPrefs.deviceAddress ?: "null"
- val nodeId = myNodeInfo?.myNodeNum?.toString() ?: "unknown"
- val dbSummary =
- "dbSwitch activeAddress=$activeLabel nodeId=$nodeId transport=$transportLabel addr=$meshAddress"
- historyLog { dbSummary }
- // Do not clear packet DB here; messages are per-device and should persist
- clearNotifications()
- // Reload nodes from the newly switched database
- loadCachedNodeDB()
- }
- } else {
- Logger.d { "SetDeviceAddress: Device address is unchanged, ignoring." }
- }
- }
-
- private fun clearNotifications() {
- serviceNotifications.clearNotifications()
+ super.onDestroy()
}
private val binder =
object : IMeshService.Stub() {
-
override fun setDeviceAddress(deviceAddr: String?) = toRemoteExceptions {
- Logger.d { "Passing through device change to radio service: ${deviceAddr.anonymize}" }
- updateLastAddress(deviceAddr)
+ Logger.d { "Passing through device change to radio service: ${deviceAddr?.take(8)}..." }
+ router.actionHandler.handleUpdateLastAddress(deviceAddr)
radioInterfaceService.setDeviceAddress(deviceAddr)
}
- override fun subscribeReceiver(packageName: String, receiverName: String) = toRemoteExceptions {
+ override fun subscribeReceiver(packageName: String, receiverName: String) {
serviceBroadcasts.subscribeReceiver(receiverName, packageName)
}
override fun getUpdateStatus(): Int = -4
- override fun startFirmwareUpdate() = toRemoteExceptions {}
+ override fun startFirmwareUpdate() {
+ // Not implemented yet
+ }
- override fun getMyNodeInfo(): MyNodeInfo? = this@MeshService.myNodeInfo?.toMyNodeInfo()
+ override fun getMyNodeInfo(): MyNodeInfo? = nodeManager.getMyNodeInfo()
- override fun getMyId() = toRemoteExceptions { myNodeID }
+ override fun getMyId(): String = nodeManager.getMyId()
- override fun getPacketId() = toRemoteExceptions { generatePacketId() }
+ override fun getPacketId(): Int = commandSender.generatePacketId()
- override fun setOwner(user: MeshUser) = toRemoteExceptions {
- setOwner(
- generatePacketId(),
- user {
- id = user.id
- longName = user.longName
- shortName = user.shortName
- isLicensed = user.isLicensed
- },
- )
+ override fun setOwner(u: MeshUser) = toRemoteExceptions {
+ router.actionHandler.handleSetOwner(u, myNodeNum)
}
override fun setRemoteOwner(id: Int, payload: ByteArray) = toRemoteExceptions {
- val parsed = MeshProtos.User.parseFrom(payload)
- setOwner(id, parsed)
+ router.actionHandler.handleSetRemoteOwner(id, payload, myNodeNum)
}
override fun getRemoteOwner(id: Int, destNum: Int) = toRemoteExceptions {
- packetHandler.sendToRadio(
- newMeshPacketTo(destNum).buildAdminPacket(id = id, wantResponse = true) { getOwnerRequest = true },
- )
+ router.actionHandler.handleGetRemoteOwner(id, destNum)
}
- override fun send(p: DataPacket) {
- toRemoteExceptions {
- if (p.id == 0) p.id = generatePacketId()
- val bytes = p.bytes!!
- Logger.i {
- "sendData dest=${p.to}, id=${p.id} <- ${bytes.size} bytes (connectionState=${connectionStateHolder.connectionState.value})"
- }
- if (p.dataType == 0) throw Exception("Port numbers must be non-zero!")
- if (bytes.size >= MeshProtos.Constants.DATA_PAYLOAD_LEN.number) {
- p.status = MessageStatus.ERROR
- throw RemoteException("Message too long")
- } else {
- p.status = MessageStatus.QUEUED
- }
- if (connectionStateHolder.connectionState.value == ConnectionState.Connected) {
- try {
- sendNow(p)
- } catch (ex: Exception) {
- Logger.e(ex) { "Error sending message, so enqueueing" }
- enqueueForSending(p)
- }
- } else {
- enqueueForSending(p)
- }
- serviceBroadcasts.broadcastMessageStatus(p)
- rememberDataPacket(p, false)
- analytics.track("data_send", DataPair("num_bytes", bytes.size), DataPair("type", p.dataType))
+ override fun send(p: DataPacket) = toRemoteExceptions { router.actionHandler.handleSend(p, myNodeNum) }
+
+ override fun getConfig(): ByteArray = toRemoteExceptions {
+ runBlocking {
+ radioConfigRepository.localConfigFlow.first().toByteArray() ?: throw NoDeviceConfigException()
}
}
- override fun getConfig(): ByteArray = toRemoteExceptions {
- this@MeshService.localConfig.toByteArray() ?: throw NoDeviceConfigException()
- }
-
override fun setConfig(payload: ByteArray) = toRemoteExceptions {
- setRemoteConfig(generatePacketId(), myNodeNum, payload)
+ router.actionHandler.handleSetConfig(payload, myNodeNum)
}
override fun setRemoteConfig(id: Int, num: Int, payload: ByteArray) = toRemoteExceptions {
- Logger.d { "Setting new radio config!" }
- val config = ConfigProtos.Config.parseFrom(payload)
- packetHandler.sendToRadio(newMeshPacketTo(num).buildAdminPacket(id = id) { setConfig = config })
- if (num == myNodeNum) setLocalConfig(config)
+ router.actionHandler.handleSetRemoteConfig(id, num, payload)
}
override fun getRemoteConfig(id: Int, destNum: Int, config: Int) = toRemoteExceptions {
- packetHandler.sendToRadio(
- newMeshPacketTo(destNum).buildAdminPacket(id = id, wantResponse = true) {
- if (config == AdminProtos.AdminMessage.ConfigType.SESSIONKEY_CONFIG_VALUE) {
- getDeviceMetadataRequest = true
- } else {
- getConfigRequestValue = config
- }
- },
- )
+ router.actionHandler.handleGetRemoteConfig(id, destNum, config)
}
override fun setModuleConfig(id: Int, num: Int, payload: ByteArray) = toRemoteExceptions {
- Logger.d { "Setting new module config!" }
- val config = ModuleConfigProtos.ModuleConfig.parseFrom(payload)
- packetHandler.sendToRadio(newMeshPacketTo(num).buildAdminPacket(id = id) { setModuleConfig = config })
- if (num == myNodeNum) setLocalModuleConfig(config)
+ router.actionHandler.handleSetModuleConfig(id, num, payload)
}
override fun getModuleConfig(id: Int, destNum: Int, config: Int) = toRemoteExceptions {
- packetHandler.sendToRadio(
- newMeshPacketTo(destNum).buildAdminPacket(id = id, wantResponse = true) {
- getModuleConfigRequestValue = config
- },
- )
+ router.actionHandler.handleGetModuleConfig(id, destNum, config)
}
override fun setRingtone(destNum: Int, ringtone: String) = toRemoteExceptions {
- packetHandler.sendToRadio(newMeshPacketTo(destNum).buildAdminPacket { setRingtoneMessage = ringtone })
+ router.actionHandler.handleSetRingtone(destNum, ringtone)
}
override fun getRingtone(id: Int, destNum: Int) = toRemoteExceptions {
- packetHandler.sendToRadio(
- newMeshPacketTo(destNum).buildAdminPacket(id = id, wantResponse = true) {
- getRingtoneRequest = true
- },
- )
+ router.actionHandler.handleGetRingtone(id, destNum)
}
override fun setCannedMessages(destNum: Int, messages: String) = toRemoteExceptions {
- packetHandler.sendToRadio(
- newMeshPacketTo(destNum).buildAdminPacket { setCannedMessageModuleMessages = messages },
- )
+ router.actionHandler.handleSetCannedMessages(destNum, messages)
}
override fun getCannedMessages(id: Int, destNum: Int) = toRemoteExceptions {
- packetHandler.sendToRadio(
- newMeshPacketTo(destNum).buildAdminPacket(id = id, wantResponse = true) {
- getCannedMessageModuleMessagesRequest = true
- },
- )
+ router.actionHandler.handleGetCannedMessages(id, destNum)
}
override fun setChannel(payload: ByteArray?) = toRemoteExceptions {
- setRemoteChannel(generatePacketId(), myNodeNum, payload)
+ router.actionHandler.handleSetChannel(payload, myNodeNum)
}
override fun setRemoteChannel(id: Int, num: Int, payload: ByteArray?) = toRemoteExceptions {
- val channel = ChannelProtos.Channel.parseFrom(payload)
- packetHandler.sendToRadio(newMeshPacketTo(num).buildAdminPacket(id = id) { setChannel = channel })
+ router.actionHandler.handleSetRemoteChannel(id, num, payload)
}
override fun getRemoteChannel(id: Int, destNum: Int, index: Int) = toRemoteExceptions {
- packetHandler.sendToRadio(
- newMeshPacketTo(destNum).buildAdminPacket(id = id, wantResponse = true) {
- getChannelRequest = index + 1
- },
- )
+ router.actionHandler.handleGetRemoteChannel(id, destNum, index)
}
override fun beginEditSettings() = toRemoteExceptions {
- packetHandler.sendToRadio(newMeshPacketTo(myNodeNum).buildAdminPacket { beginEditSettings = true })
+ router.actionHandler.handleBeginEditSettings(myNodeNum)
}
override fun commitEditSettings() = toRemoteExceptions {
- packetHandler.sendToRadio(newMeshPacketTo(myNodeNum).buildAdminPacket { commitEditSettings = true })
+ router.actionHandler.handleCommitEditSettings(myNodeNum)
}
- override fun getChannelSet(): ByteArray = toRemoteExceptions { this@MeshService.channelSet.toByteArray() }
-
- override fun getNodes(): MutableList = toRemoteExceptions {
- val r = nodeDBbyNodeNum.values.map { it.toNodeInfo() }.toMutableList()
- Logger.i { "in getOnline, count=${r.size}" }
- r
+ override fun getChannelSet(): ByteArray = toRemoteExceptions {
+ runBlocking { radioConfigRepository.channelSetFlow.first().toByteArray() }
}
- override fun connectionState(): String = toRemoteExceptions {
- val r = connectionStateHolder.connectionState.value
- Logger.i { "in connectionState=$r" }
- r.toString()
+ override fun getNodes(): List = nodeManager.getNodes()
+
+ override fun connectionState(): String = connectionStateHolder.connectionState.value.toString()
+
+ override fun startProvideLocation() {
+ locationManager.start(serviceScope) { commandSender.sendPosition(it) }
}
- override fun startProvideLocation() = toRemoteExceptions { startLocationRequests() }
-
- override fun stopProvideLocation() = toRemoteExceptions { stopLocationRequests() }
+ override fun stopProvideLocation() {
+ locationManager.stop()
+ }
override fun removeByNodenum(requestId: Int, nodeNum: Int) = toRemoteExceptions {
- nodeDBbyNodeNum.remove(nodeNum)
- packetHandler.sendToRadio(newMeshPacketTo(myNodeNum).buildAdminPacket { removeByNodenum = nodeNum })
+ router.actionHandler.handleRemoveByNodenum(nodeNum, requestId, myNodeNum)
}
override fun requestUserInfo(destNum: Int) = toRemoteExceptions {
if (destNum != myNodeNum) {
- packetHandler.sendToRadio(
- newMeshPacketTo(destNum).buildMeshPacket(channel = nodeDBbyNodeNum[destNum]?.channel ?: 0) {
- portnumValue = Portnums.PortNum.NODEINFO_APP_VALUE
- wantResponse = true
- payload = nodeDBbyNodeNum[myNodeNum]!!.user.toByteString()
- },
- )
- }
- }
-
- override fun requestNeighborInfo(requestId: Int, destNum: Int) = toRemoteExceptions {
- if (destNum != myNodeNum) {
- neighborInfoStartTimes[requestId] = System.currentTimeMillis()
- // Always send the neighbor info from our connected radio (myNodeNum), not request from destNum
- val neighborInfoToSend =
- lastNeighborInfo
- ?: run {
- // If we don't have it, send dummy/interceptable data
- Logger.d { "No stored neighbor info from connected radio, sending dummy data" }
- MeshProtos.NeighborInfo.newBuilder()
- .setNodeId(myNodeNum)
- .setLastSentById(myNodeNum)
- .setNodeBroadcastIntervalSecs(oneHour)
- .addNeighbors(
- MeshProtos.Neighbor.newBuilder()
- .setNodeId(0) // Dummy node ID that can be intercepted
- .setSnr(0f)
- .setLastRxTime(currentSecond())
- .setNodeBroadcastIntervalSecs(oneHour)
- .build(),
- )
- .build()
- }
-
- // Send the neighbor info from our connected radio to the destination
- packetHandler.sendToRadio(
- newMeshPacketTo(destNum).buildMeshPacket(
- wantAck = true,
- id = requestId,
- channel = nodeDBbyNodeNum[destNum]?.channel ?: 0,
- ) {
- portnumValue = Portnums.PortNum.NEIGHBORINFO_APP_VALUE
- payload = neighborInfoToSend.toByteString()
- wantResponse = true
- },
- )
+ commandSender.requestUserInfo(destNum)
}
}
override fun requestPosition(destNum: Int, position: Position) = toRemoteExceptions {
- if (destNum != myNodeNum) {
- val provideLocation = meshPrefs.shouldProvideNodeLocation(myNodeNum)
- val currentPosition =
- when {
- provideLocation && position.isValid() -> position
- else -> nodeDBbyNodeNum[myNodeNum]?.position?.let { Position(it) }?.takeIf { it.isValid() }
- }
- if (currentPosition == null) {
- Logger.d { "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()
- }
- packetHandler.sendToRadio(
- newMeshPacketTo(destNum).buildMeshPacket(
- channel = nodeDBbyNodeNum[destNum]?.channel ?: 0,
- priority = MeshPacket.Priority.BACKGROUND,
- ) {
- portnumValue = Portnums.PortNum.POSITION_APP_VALUE
- payload = meshPosition.toByteString()
- wantResponse = true
- },
- )
- }
+ router.actionHandler.handleRequestPosition(destNum, position, myNodeNum)
}
override fun setFixedPosition(destNum: Int, position: Position) = toRemoteExceptions {
- val pos = position {
- latitudeI = Position.degI(position.latitude)
- longitudeI = Position.degI(position.longitude)
- altitude = position.altitude
- }
- packetHandler.sendToRadio(
- newMeshPacketTo(destNum).buildAdminPacket {
- if (position != Position(0.0, 0.0, 0)) {
- setFixedPosition = pos
- } else {
- removeFixedPosition = true
- }
- },
- )
- updateNodeInfo(destNum) { it.setPosition(pos, currentSecond()) }
+ commandSender.setFixedPosition(destNum, position)
}
override fun requestTraceroute(requestId: Int, destNum: Int) = toRemoteExceptions {
- tracerouteStartTimes[requestId] = System.currentTimeMillis()
- packetHandler.sendToRadio(
- newMeshPacketTo(destNum).buildMeshPacket(
- wantAck = true,
- id = requestId,
- channel = nodeDBbyNodeNum[destNum]?.channel ?: 0,
- ) {
- portnumValue = Portnums.PortNum.TRACEROUTE_APP_VALUE
- wantResponse = true
- },
- )
+ commandSender.requestTraceroute(requestId, destNum)
+ }
+
+ override fun requestNeighborInfo(requestId: Int, destNum: Int) = toRemoteExceptions {
+ router.actionHandler.handleRequestNeighborInfo(requestId, destNum)
}
override fun requestShutdown(requestId: Int, destNum: Int) = toRemoteExceptions {
- packetHandler.sendToRadio(
- newMeshPacketTo(destNum).buildAdminPacket(id = requestId) { shutdownSeconds = 5 },
- )
+ router.actionHandler.handleRequestShutdown(requestId, destNum)
}
override fun requestReboot(requestId: Int, destNum: Int) = toRemoteExceptions {
- packetHandler.sendToRadio(
- newMeshPacketTo(destNum).buildAdminPacket(id = requestId) { rebootSeconds = 5 },
- )
+ router.actionHandler.handleRequestReboot(requestId, destNum)
}
- override fun rebootToDfu() {
- packetHandler.sendToRadio(newMeshPacketTo(myNodeNum).buildAdminPacket { enterDfuModeRequest = true })
- }
+ override fun rebootToDfu() = toRemoteExceptions { router.actionHandler.handleRebootToDfu(myNodeNum) }
override fun requestFactoryReset(requestId: Int, destNum: Int) = toRemoteExceptions {
- packetHandler.sendToRadio(
- newMeshPacketTo(destNum).buildAdminPacket(id = requestId) { factoryResetDevice = 1 },
- )
+ router.actionHandler.handleRequestFactoryReset(requestId, destNum)
}
override fun requestNodedbReset(requestId: Int, destNum: Int, preserveFavorites: Boolean) =
toRemoteExceptions {
- packetHandler.sendToRadio(
- newMeshPacketTo(destNum).buildAdminPacket(id = requestId) { nodedbReset = preserveFavorites },
- )
+ router.actionHandler.handleRequestNodedbReset(requestId, destNum, preserveFavorites)
}
override fun getDeviceConnectionStatus(requestId: Int, destNum: Int) = toRemoteExceptions {
- packetHandler.sendToRadio(
- newMeshPacketTo(destNum).buildAdminPacket(id = requestId, wantResponse = true) {
- getDeviceConnectionStatusRequest = true
- },
- )
+ router.actionHandler.handleGetDeviceConnectionStatus(requestId, destNum)
}
override fun requestTelemetry(requestId: Int, destNum: Int, type: Int) = toRemoteExceptions {
- if (destNum != myNodeNum) {
- val telemetryRequest = telemetry {
- when (type) {
- TelemetryType.ENVIRONMENT.ordinal ->
- environmentMetrics = TelemetryProtos.EnvironmentMetrics.getDefaultInstance()
- TelemetryType.AIR_QUALITY.ordinal ->
- airQualityMetrics = TelemetryProtos.AirQualityMetrics.getDefaultInstance()
- TelemetryType.POWER.ordinal ->
- powerMetrics = TelemetryProtos.PowerMetrics.getDefaultInstance()
- TelemetryType.LOCAL_STATS.ordinal ->
- localStats = TelemetryProtos.LocalStats.getDefaultInstance()
- TelemetryType.DEVICE.ordinal ->
- deviceMetrics = TelemetryProtos.DeviceMetrics.getDefaultInstance()
- else -> deviceMetrics = TelemetryProtos.DeviceMetrics.getDefaultInstance()
- }
- }
- packetHandler.sendToRadio(
- newMeshPacketTo(destNum).buildMeshPacket(
- id = requestId,
- channel = nodeDBbyNodeNum[destNum]?.channel ?: 0,
- ) {
- portnumValue = Portnums.PortNum.TELEMETRY_APP_VALUE
- payload = telemetryRequest.toByteString()
- wantResponse = true
- },
- )
- }
+ router.actionHandler.handleRequestTelemetry(requestId, destNum, type)
}
}
}
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 1588394d3..8ef84f41e 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshServiceBroadcasts.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshServiceBroadcasts.kt
@@ -35,7 +35,7 @@ class MeshServiceBroadcasts
@Inject
constructor(
@ApplicationContext private val context: Context,
- private val connectionStateHolder: MeshServiceConnectionStateHolder,
+ private val connectionStateHolder: ConnectionStateHandler,
private val serviceRepository: ServiceRepository,
) {
// A mapping of receiver class name to package name - used for explicit broadcasts
@@ -52,7 +52,7 @@ constructor(
fun broadcastNodeChange(info: NodeInfo) {
Logger.d { "Broadcasting node change ${info.user?.toPIIString()}" }
- val intent = Intent(MeshService.ACTION_NODE_CHANGE).putExtra(EXTRA_NODEINFO, info)
+ val intent = Intent(ACTION_NODE_CHANGE).putExtra(EXTRA_NODEINFO, info)
explicitBroadcast(intent)
}
@@ -65,7 +65,7 @@ constructor(
// Do not log, contains PII possibly
// MeshService.Logger.d { "Broadcasting message status $p" }
val intent =
- Intent(MeshService.ACTION_MESSAGE_STATUS).apply {
+ Intent(ACTION_MESSAGE_STATUS).apply {
putExtra(EXTRA_PACKET_ID, id)
putExtra(EXTRA_STATUS, status as Parcelable)
}
@@ -76,7 +76,7 @@ constructor(
/** Broadcast our current connection status */
fun broadcastConnection() {
val connectionState = connectionStateHolder.connectionState.value
- val intent = Intent(MeshService.ACTION_MESH_CONNECTED).putExtra(EXTRA_CONNECTED, connectionState.toString())
+ val intent = Intent(ACTION_MESH_CONNECTED).putExtra(EXTRA_CONNECTED, connectionState.toString())
serviceRepository.setConnectionState(connectionState)
explicitBroadcast(intent)
}
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshTracerouteHandler.kt b/app/src/main/java/com/geeksville/mesh/service/MeshTracerouteHandler.kt
new file mode 100644
index 000000000..3e6cc4065
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshTracerouteHandler.kt
@@ -0,0 +1,106 @@
+/*
+ * 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 co.touchlab.kermit.Logger
+import com.geeksville.mesh.concurrent.handledLaunch
+import com.meshtastic.core.strings.getString
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import org.meshtastic.core.data.repository.NodeRepository
+import org.meshtastic.core.data.repository.TracerouteSnapshotRepository
+import org.meshtastic.core.model.fullRouteDiscovery
+import org.meshtastic.core.model.getFullTracerouteResponse
+import org.meshtastic.core.service.ServiceRepository
+import org.meshtastic.core.service.TracerouteResponse
+import org.meshtastic.core.strings.Res
+import org.meshtastic.core.strings.unknown_username
+import org.meshtastic.proto.MeshProtos.MeshPacket
+import java.util.Locale
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class MeshTracerouteHandler
+@Inject
+constructor(
+ private val nodeManager: MeshNodeManager,
+ private val serviceRepository: ServiceRepository,
+ private val tracerouteSnapshotRepository: TracerouteSnapshotRepository,
+ private val nodeRepository: NodeRepository,
+ private val commandSender: MeshCommandSender,
+) {
+ private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
+
+ fun start(scope: CoroutineScope) {
+ this.scope = scope
+ }
+
+ fun handleTraceroute(packet: MeshPacket, logUuid: String?, logInsertJob: kotlinx.coroutines.Job?) {
+ val full =
+ packet.getFullTracerouteResponse { num ->
+ nodeManager.nodeDBbyNodeNum[num]?.let { "${it.longName} (${it.shortName})" }
+ ?: getString(Res.string.unknown_username)
+ } ?: return
+
+ val requestId = packet.decoded.requestId
+ if (logUuid != null) {
+ scope.handledLaunch {
+ logInsertJob?.join()
+ val routeDiscovery = packet.fullRouteDiscovery
+ val forwardRoute = routeDiscovery?.routeList.orEmpty()
+ val returnRoute = routeDiscovery?.routeBackList.orEmpty()
+ val routeNodeNums = (forwardRoute + returnRoute).distinct()
+ val nodeDbByNum = nodeRepository.nodeDBbyNum.value
+ val snapshotPositions =
+ routeNodeNums.mapNotNull { num -> nodeDbByNum[num]?.validPosition?.let { num to it } }.toMap()
+ tracerouteSnapshotRepository.upsertSnapshotPositions(logUuid, requestId, snapshotPositions)
+ }
+ }
+
+ val start = commandSender.tracerouteStartTimes.remove(requestId)
+ val responseText =
+ if (start != null) {
+ val elapsedMs = System.currentTimeMillis() - start
+ val seconds = elapsedMs / MILLISECONDS_IN_SECOND
+ Logger.i { "Traceroute $requestId complete in $seconds s" }
+ String.format(Locale.US, "%s\n\nDuration: %.1f s", full, seconds)
+ } else {
+ full
+ }
+
+ val routeDiscovery = packet.fullRouteDiscovery
+ val destination = routeDiscovery?.routeList?.firstOrNull() ?: routeDiscovery?.routeBackList?.lastOrNull() ?: 0
+
+ serviceRepository.setTracerouteResponse(
+ TracerouteResponse(
+ message = responseText,
+ destinationNodeNum = destination,
+ requestId = requestId,
+ forwardRoute = routeDiscovery?.routeList.orEmpty(),
+ returnRoute = routeDiscovery?.routeBackList.orEmpty(),
+ logUuid = logUuid,
+ ),
+ )
+ }
+
+ companion object {
+ private const val MILLISECONDS_IN_SECOND = 1000.0
+ }
+}
diff --git a/app/src/main/java/com/geeksville/mesh/service/PacketHandler.kt b/app/src/main/java/com/geeksville/mesh/service/PacketHandler.kt
index a46b835ba..d2fe37f02 100644
--- a/app/src/main/java/com/geeksville/mesh/service/PacketHandler.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/PacketHandler.kt
@@ -21,11 +21,13 @@ import co.touchlab.kermit.Logger
import com.geeksville.mesh.concurrent.handledLaunch
import com.geeksville.mesh.repository.radio.RadioInterfaceService
import dagger.Lazy
-import java8.util.concurrent.CompletableFuture
+import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
+import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.delay
+import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
import org.meshtastic.core.data.repository.MeshLogRepository
import org.meshtastic.core.data.repository.PacketRepository
@@ -40,12 +42,12 @@ import org.meshtastic.proto.MeshProtos.MeshPacket
import org.meshtastic.proto.MeshProtos.ToRadio
import org.meshtastic.proto.fromRadio
import java.util.UUID
+import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ConcurrentLinkedQueue
-import java.util.concurrent.TimeUnit
-import java.util.concurrent.TimeoutException
import javax.inject.Inject
import javax.inject.Singleton
+@Suppress("TooManyFunctions")
@Singleton
class PacketHandler
@Inject
@@ -54,18 +56,22 @@ constructor(
private val serviceBroadcasts: MeshServiceBroadcasts,
private val radioInterfaceService: RadioInterfaceService,
private val meshLogRepository: Lazy,
- private val connectionStateHolder: MeshServiceConnectionStateHolder,
+ private val connectionStateHolder: ConnectionStateHandler,
) {
companion object {
- private const val TIMEOUT_MS = 250L
+ private const val TIMEOUT_MS = 5000L // Increased from 250ms to be more tolerant
}
private var queueJob: Job? = null
- private val scope = CoroutineScope(Dispatchers.IO)
+ private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO)
private val queuedPackets = ConcurrentLinkedQueue()
- private val queueResponse = mutableMapOf>()
+ private val queueResponse = ConcurrentHashMap>()
+
+ fun start(scope: CoroutineScope) {
+ this.scope = scope
+ }
/**
* Send a command/packet to our radio. But cope with the possibility that we might start up before we are fully
@@ -109,7 +115,7 @@ constructor(
queueJob?.cancel()
queueJob = null
queuedPackets.clear()
- queueResponse.entries.lastOrNull { !it.value.isDone }?.value?.complete(false)
+ queueResponse.entries.lastOrNull { !it.value.isCompleted }?.value?.complete(false)
queueResponse.clear()
}
}
@@ -121,7 +127,8 @@ constructor(
if (requestId != 0) {
queueResponse.remove(requestId)?.complete(success)
} else {
- queueResponse.entries.lastOrNull { !it.value.isDone }?.value?.complete(success)
+ // This is slightly suboptimal but matches legacy behavior for packets without IDs
+ queueResponse.values.firstOrNull { !it.isCompleted }?.complete(success)
}
}
@@ -142,12 +149,14 @@ constructor(
// send packet to the radio and wait for response
val response = sendPacket(packet)
Logger.d { "queueJob packet id=${packet.id.toUInt()} waiting" }
- val success = response.get(TIMEOUT_MS, TimeUnit.MILLISECONDS)
+ val success = withTimeout(TIMEOUT_MS) { response.await() }
Logger.d { "queueJob packet id=${packet.id.toUInt()} success $success" }
- } catch (e: TimeoutException) {
+ } catch (e: TimeoutCancellationException) {
Logger.d { "queueJob packet id=${packet.id.toUInt()} timeout" }
} catch (e: Exception) {
Logger.d { "queueJob packet id=${packet.id.toUInt()} failed" }
+ } finally {
+ queueResponse.remove(packet.id)
}
}
}
@@ -175,11 +184,11 @@ constructor(
}
@Suppress("TooGenericExceptionCaught")
- private fun sendPacket(packet: MeshPacket): CompletableFuture {
- // send the packet to the radio and return a CompletableFuture that will be completed with
+ private fun sendPacket(packet: MeshPacket): CompletableDeferred {
+ // send the packet to the radio and return a CompletableDeferred that will be completed with
// the result
- val future = CompletableFuture()
- queueResponse[packet.id] = future
+ val deferred = CompletableDeferred()
+ queueResponse[packet.id] = deferred
try {
if (connectionStateHolder.connectionState.value != ConnectionState.Connected) {
throw RadioNotConnectedException()
@@ -187,9 +196,9 @@ constructor(
sendToRadio(ToRadio.newBuilder().apply { this.packet = packet })
} catch (ex: Exception) {
Logger.e(ex) { "sendToRadio error: ${ex.message}" }
- future.complete(false)
+ deferred.complete(false)
}
- return future
+ return deferred
}
private fun insertMeshLog(packetToSave: MeshLog) {
diff --git a/app/src/main/java/com/geeksville/mesh/service/RadioNotConnectedException.kt b/app/src/main/java/com/geeksville/mesh/service/RadioNotConnectedException.kt
index 3390557c6..74d4c8d54 100644
--- a/app/src/main/java/com/geeksville/mesh/service/RadioNotConnectedException.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/RadioNotConnectedException.kt
@@ -19,5 +19,13 @@ package com.geeksville.mesh.service
import android.os.RemoteException
-open class RadioNotConnectedException(message: String = "Not connected to radio") :
- RemoteException(message)
\ No newline at end of file
+open class RadioNotConnectedException(message: String = "Not connected to radio") : RemoteException(message)
+
+class NoDeviceConfigException(message: String = "No radio settings received (is our app too old?)") :
+ RadioNotConnectedException(message)
+
+class BLEException(message: String) : RadioNotConnectedException(message)
+
+class BLECharacteristicNotFoundException(message: String) : RadioNotConnectedException(message)
+
+class BLEConnectionClosing(message: String = "BLE connection is closing") : RadioNotConnectedException(message)
diff --git a/app/src/test/java/com/geeksville/mesh/service/Fakes.kt b/app/src/test/java/com/geeksville/mesh/service/Fakes.kt
new file mode 100644
index 000000000..90a36a490
--- /dev/null
+++ b/app/src/test/java/com/geeksville/mesh/service/Fakes.kt
@@ -0,0 +1,106 @@
+/*
+ * 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 android.app.Notification
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.flowOf
+import org.meshtastic.core.data.datasource.NodeInfoReadDataSource
+import org.meshtastic.core.data.datasource.NodeInfoWriteDataSource
+import org.meshtastic.core.database.entity.MetadataEntity
+import org.meshtastic.core.database.entity.MyNodeEntity
+import org.meshtastic.core.database.entity.NodeEntity
+import org.meshtastic.core.database.entity.NodeWithRelations
+import org.meshtastic.core.service.MeshServiceNotifications
+import org.meshtastic.proto.MeshProtos
+import org.meshtastic.proto.TelemetryProtos
+
+class FakeNodeInfoReadDataSource : NodeInfoReadDataSource {
+ val myNodeInfo = MutableStateFlow(null)
+ val nodes = MutableStateFlow