From 6bb40e4d20d734331a60bc3c4a5605c8e001d332 Mon Sep 17 00:00:00 2001
From: Mac DeCourcy <49794076+mdecourcy@users.noreply.github.com>
Date: Sat, 3 Jan 2026 04:21:43 -0800
Subject: [PATCH] feat: auto retry text message send on max retransmit (#4124)
---
.../mesh/service/MeshDataHandler.kt | 34 +++++++++++++++++++
.../meshtastic/core/database/entity/Packet.kt | 4 +--
.../meshtastic/core/database/model/Message.kt | 4 +--
.../org/meshtastic/core/model/DataPacket.kt | 6 ++++
.../composeResources/values/strings.xml | 1 +
.../feature/messaging/DeliveryInfoDialog.kt | 14 ++++++--
.../feature/messaging/MessageListPaged.kt | 9 +++--
7 files changed, 64 insertions(+), 8 deletions(-)
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt b/app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt
index a973888b3..a53500e64 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt
@@ -27,6 +27,7 @@ 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 org.meshtastic.core.analytics.DataPair
import org.meshtastic.core.analytics.platform.PlatformAnalytics
@@ -326,6 +327,37 @@ constructor(
scope.handledLaunch {
val isAck = routingError == MeshProtos.Routing.Error.NONE_VALUE
val p = packetRepository.get().getPacketById(requestId)
+ val isMaxRetransmit = routingError == MeshProtos.Routing.Error.MAX_RETRANSMIT_VALUE
+ val shouldRetry =
+ isMaxRetransmit &&
+ p != null &&
+ p.port_num == Portnums.PortNum.TEXT_MESSAGE_APP_VALUE &&
+ p.data.from == DataPacket.ID_LOCAL &&
+ p.data.retryCount < MAX_RETRY_ATTEMPTS
+
+ Logger.d {
+ val retryInfo = "packetId=${p?.packetId} dataId=${p?.data?.id} retry=${p?.data?.retryCount}"
+ val statusInfo = "status=${p?.data?.status}"
+ "[ackNak] req=$requestId routeErr=$routingError isAck=$isAck " +
+ "maxRetransmit=$isMaxRetransmit shouldRetry=$shouldRetry $retryInfo $statusInfo"
+ }
+
+ if (shouldRetry && p != null) {
+ val newRetryCount = p.data.retryCount + 1
+ val newId = commandSender.generatePacketId()
+ val updatedData =
+ p.data.copy(id = newId, status = MessageStatus.QUEUED, retryCount = newRetryCount, relayNode = null)
+ val updatedPacket =
+ p.copy(packetId = newId, data = updatedData, routingError = MeshProtos.Routing.Error.NONE_VALUE)
+ packetRepository.get().update(updatedPacket)
+
+ Logger.w { "[ackNak] retrying req=$requestId newId=$newId retry=$newRetryCount" }
+
+ delay(RETRY_DELAY_MS)
+ commandSender.sendData(updatedData)
+ return@handledLaunch
+ }
+
val m =
when {
isAck && fromId == p?.data?.to -> MessageStatus.RECEIVED
@@ -528,6 +560,8 @@ constructor(
}
companion object {
+ private const val MAX_RETRY_ATTEMPTS = 5
+ private const val RETRY_DELAY_MS = 5_000L
private const val MILLISECONDS_IN_SECOND = 1000L
private const val HOPS_AWAY_UNAVAILABLE = -1
diff --git a/core/database/src/main/kotlin/org/meshtastic/core/database/entity/Packet.kt b/core/database/src/main/kotlin/org/meshtastic/core/database/entity/Packet.kt
index 986359359..3c4bdfa51 100644
--- a/core/database/src/main/kotlin/org/meshtastic/core/database/entity/Packet.kt
+++ b/core/database/src/main/kotlin/org/meshtastic/core/database/entity/Packet.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2025 Meshtastic LLC
+ * Copyright (c) 2025-2026 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
@@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
package org.meshtastic.core.database.entity
import androidx.room.ColumnInfo
@@ -55,6 +54,7 @@ data class PacketEntity(
viaMqtt = data.viaMqtt,
relayNode = data.relayNode,
relays = data.relays,
+ retryCount = data.retryCount,
)
}
}
diff --git a/core/database/src/main/kotlin/org/meshtastic/core/database/model/Message.kt b/core/database/src/main/kotlin/org/meshtastic/core/database/model/Message.kt
index 0d5610fd7..06faca24a 100644
--- a/core/database/src/main/kotlin/org/meshtastic/core/database/model/Message.kt
+++ b/core/database/src/main/kotlin/org/meshtastic/core/database/model/Message.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2025 Meshtastic LLC
+ * Copyright (c) 2025-2026 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
@@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
package org.meshtastic.core.database.model
import org.jetbrains.compose.resources.StringResource
@@ -88,6 +87,7 @@ data class Message(
val viaMqtt: Boolean = false,
val relayNode: Int? = null,
val relays: Int = 0,
+ val retryCount: Int = 0,
) {
fun getStatusStringRes(): Pair {
val title = if (routingError > 0) Res.string.error else Res.string.message_delivery_status
diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/DataPacket.kt b/core/model/src/main/kotlin/org/meshtastic/core/model/DataPacket.kt
index 80e7e97f9..f795eef01 100644
--- a/core/model/src/main/kotlin/org/meshtastic/core/model/DataPacket.kt
+++ b/core/model/src/main/kotlin/org/meshtastic/core/model/DataPacket.kt
@@ -63,6 +63,7 @@ data class DataPacket(
var relayNode: Int? = null,
var relays: Int = 0,
var viaMqtt: Boolean = false, // True if this packet passed via MQTT somewhere along its path
+ var retryCount: Int = 0, // Number of automatic retry attempts
var emoji: Int = 0,
) : Parcelable {
@@ -139,6 +140,7 @@ data class DataPacket(
parcel.readInt().let { if (it == -1) null else it },
parcel.readInt(), // relays
parcel.readInt() == 1, // viaMqtt
+ parcel.readInt(), // retryCount
parcel.readInt(), // emoji
)
@@ -164,6 +166,7 @@ data class DataPacket(
if (rssi != other.rssi) return false
if (replyId != other.replyId) return false
if (relayNode != other.relayNode) return false
+ if (retryCount != other.retryCount) return false
if (emoji != other.emoji) return false
return true
@@ -185,6 +188,7 @@ data class DataPacket(
result = 31 * result + rssi
result = 31 * result + replyId.hashCode()
result = 31 * result + relayNode.hashCode()
+ result = 31 * result + retryCount
result = 31 * result + emoji
return result
}
@@ -207,6 +211,7 @@ data class DataPacket(
parcel.writeInt(relayNode ?: -1)
parcel.writeInt(relays)
parcel.writeInt(if (viaMqtt) 1 else 0)
+ parcel.writeInt(retryCount)
parcel.writeInt(emoji)
}
@@ -231,6 +236,7 @@ data class DataPacket(
relayNode = parcel.readInt().let { if (it == -1) null else it }
relays = parcel.readInt()
viaMqtt = parcel.readInt() == 1
+ retryCount = parcel.readInt()
emoji = parcel.readInt()
}
diff --git a/core/strings/src/commonMain/composeResources/values/strings.xml b/core/strings/src/commonMain/composeResources/values/strings.xml
index d89f5b4e0..2d3f112d8 100644
--- a/core/strings/src/commonMain/composeResources/values/strings.xml
+++ b/core/strings/src/commonMain/composeResources/values/strings.xml
@@ -51,6 +51,7 @@
Unrecognized
Waiting to be acknowledged
Queued for sending
+ Retries: %1$d / %2$d
Acknowledged
No route
Received a negative acknowledgment
diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/DeliveryInfoDialog.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/DeliveryInfoDialog.kt
index 83d1da2a0..f27a798be 100644
--- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/DeliveryInfoDialog.kt
+++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/DeliveryInfoDialog.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2025 Meshtastic LLC
+ * Copyright (c) 2025-2026 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
@@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
package org.meshtastic.feature.messaging
import androidx.compose.foundation.layout.Column
@@ -34,6 +33,7 @@ import org.jetbrains.compose.resources.pluralStringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.close
+import org.meshtastic.core.strings.message_retry_count
import org.meshtastic.core.strings.relays
import org.meshtastic.core.strings.resend
@@ -45,6 +45,8 @@ fun DeliveryInfo(
text: StringResource? = null,
relayNodeName: String? = null,
relays: Int = 0,
+ retryCount: Int = 0,
+ maxRetries: Int = 0,
onConfirm: (() -> Unit) = {},
onDismiss: () -> Unit = {},
) = AlertDialog(
@@ -78,6 +80,14 @@ fun DeliveryInfo(
style = MaterialTheme.typography.bodyMedium,
)
}
+ if (maxRetries > 0) {
+ Text(
+ text = stringResource(Res.string.message_retry_count, retryCount, maxRetries),
+ modifier = Modifier.padding(top = 8.dp),
+ textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ }
if (relays != 0) {
Text(
text = pluralStringResource(Res.plurals.relays, relays, relays),
diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt
index 7a342be9f..ed17ec107 100644
--- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt
+++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2025 Meshtastic LLC
+ * Copyright (c) 2025-2026 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
@@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
package org.meshtastic.feature.messaging
import androidx.compose.foundation.layout.Arrangement
@@ -118,6 +117,8 @@ internal fun MessageListPaged(
nodes = state.nodes,
ourNode = state.ourNode,
resendOption = message.status?.equals(MessageStatus.ERROR) ?: false,
+ retryCount = message.retryCount,
+ maxRetries = 5,
onResend = {
handlers.onDeleteMessages(listOf(message.uuid))
handlers.onSendMessage(message.text, state.contactKey)
@@ -436,6 +437,8 @@ internal fun MessageStatusDialog(
nodes: List,
ourNode: Node?,
resendOption: Boolean,
+ retryCount: Int,
+ maxRetries: Int,
onResend: () -> Unit,
onDismiss: () -> Unit,
) {
@@ -454,6 +457,8 @@ internal fun MessageStatusDialog(
text = text,
relayNodeName = relayNodeName,
relays = message.relays,
+ retryCount = retryCount,
+ maxRetries = maxRetries,
onConfirm = onResend,
onDismiss = onDismiss,
)