From afeff9a46060fb692439237945b9311dd93f369d Mon Sep 17 00:00:00 2001
From: Mac DeCourcy <49794076+mdecourcy@users.noreply.github.com>
Date: Thu, 15 Jan 2026 15:23:33 -0800
Subject: [PATCH] feat: retry message/reaction dialog (#4195)
---
.../mesh/service/MeshDataHandler.kt | 115 ++++++++----
.../geeksville/mesh/service/MeshService.kt | 1 +
core/service/build.gradle.kts | 3 +
.../core/service/ServiceRepository.kt | 73 +++++++-
.../service/ServiceRepositoryRetryTest.kt | 164 ++++++++++++++++++
.../composeResources/values/strings.xml | 5 +
.../meshtastic/feature/messaging/Message.kt | 43 +++++
.../feature/messaging/MessageListPaged.kt | 2 +-
.../feature/messaging/MessageViewModel.kt | 6 +
.../feature/messaging/component/Reaction.kt | 2 +-
.../component/RetryConfirmationDialog.kt | 141 +++++++++++++++
11 files changed, 516 insertions(+), 39 deletions(-)
create mode 100644 core/service/src/test/kotlin/org/meshtastic/core/service/ServiceRepositoryRetryTest.kt
create mode 100644 feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/RetryConfirmationDialog.kt
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 a22e7de3d..369ade227 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt
@@ -27,7 +27,6 @@ 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
@@ -40,6 +39,7 @@ import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.util.SfppHasher
import org.meshtastic.core.prefs.mesh.MeshPrefs
import org.meshtastic.core.service.MeshServiceNotifications
+import org.meshtastic.core.service.RetryEvent
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.critical_alert
@@ -439,51 +439,96 @@ constructor(
if (shouldRetry) {
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" }
+ // Emit retry event to UI and wait for user response
+ val retryEvent =
+ RetryEvent.MessageRetry(
+ packetId = requestId,
+ text = p.data.text ?: "",
+ attemptNumber = newRetryCount,
+ maxAttempts = MAX_RETRY_ATTEMPTS + 1, // +1 for initial attempt
+ )
- delay(RETRY_DELAY_MS)
- commandSender.sendData(updatedData)
+ Logger.w { "[ackNak] requesting retry for req=$requestId retry=$newRetryCount" }
+ Log.d("MeshDataHandler", "[ackNak] Emitting retry event for req=$requestId retry=$newRetryCount")
+
+ val shouldProceed = serviceRepository.requestRetry(retryEvent, RETRY_DELAY_MS)
+ Log.d("MeshDataHandler", "[ackNak] Retry response for req=$requestId: shouldProceed=$shouldProceed")
+
+ if (shouldProceed) {
+ 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" }
+ commandSender.sendData(updatedData)
+ } else {
+ // User cancelled retry - mark as ERROR
+ Logger.w { "[ackNak] retry cancelled by user for req=$requestId" }
+ p.data.status = MessageStatus.ERROR
+ packetRepository.get().update(p)
+ }
return@handledLaunch
}
if (shouldRetryReaction && reaction != null) {
val newRetryCount = reaction.retryCount + 1
- val newId = commandSender.generatePacketId()
- val reactionPacket =
- DataPacket(
- to = reaction.to,
- channel = reaction.channel,
- bytes = reaction.emoji.toByteArray(Charsets.UTF_8),
- dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE,
- replyId = reaction.replyId,
- wantAck = true,
- emoji = reaction.emoji.codePointAt(0),
- id = newId,
- retryCount = newRetryCount,
+ // Emit retry event to UI and wait for user response
+ val retryEvent =
+ RetryEvent.ReactionRetry(
+ packetId = requestId,
+ emoji = reaction.emoji,
+ attemptNumber = newRetryCount,
+ maxAttempts = MAX_RETRY_ATTEMPTS + 1, // +1 for initial attempt
)
- val updatedReaction =
- reaction.copy(
- packetId = newId,
- status = MessageStatus.QUEUED,
- retryCount = newRetryCount,
- relayNode = null,
- routingError = MeshProtos.Routing.Error.NONE_VALUE,
- )
- packetRepository.get().updateReaction(updatedReaction)
+ Logger.w { "[ackNak] requesting retry for reaction req=$requestId retry=$newRetryCount" }
- Logger.w { "[ackNak] retrying reaction req=$requestId newId=$newId retry=$newRetryCount" }
+ val shouldProceed = serviceRepository.requestRetry(retryEvent, RETRY_DELAY_MS)
- delay(RETRY_DELAY_MS)
- commandSender.sendData(reactionPacket)
+ if (shouldProceed) {
+ val newId = commandSender.generatePacketId()
+
+ val reactionPacket =
+ DataPacket(
+ to = reaction.to,
+ channel = reaction.channel,
+ bytes = reaction.emoji.toByteArray(Charsets.UTF_8),
+ dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE,
+ replyId = reaction.replyId,
+ wantAck = true,
+ emoji = reaction.emoji.codePointAt(0),
+ id = newId,
+ retryCount = newRetryCount,
+ )
+
+ val updatedReaction =
+ reaction.copy(
+ packetId = newId,
+ status = MessageStatus.QUEUED,
+ retryCount = newRetryCount,
+ relayNode = null,
+ routingError = MeshProtos.Routing.Error.NONE_VALUE,
+ )
+ packetRepository.get().updateReaction(updatedReaction)
+
+ Logger.w { "[ackNak] retrying reaction req=$requestId newId=$newId retry=$newRetryCount" }
+ commandSender.sendData(reactionPacket)
+ } else {
+ // User cancelled retry - mark as ERROR
+ Logger.w { "[ackNak] retry cancelled by user for reaction req=$requestId" }
+ val errorReaction = reaction.copy(status = MessageStatus.ERROR, routingError = routingError)
+ packetRepository.get().updateReaction(errorReaction)
+ }
return@handledLaunch
}
@@ -766,7 +811,7 @@ constructor(
}
companion object {
- private const val MAX_RETRY_ATTEMPTS = 5
+ private const val MAX_RETRY_ATTEMPTS = 2
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/app/src/main/java/com/geeksville/mesh/service/MeshService.kt b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt
index f9a714b3a..2ec8040fd 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt
@@ -172,6 +172,7 @@ class MeshService : Service() {
override fun onDestroy() {
Logger.i { "Destroying mesh service" }
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
+ serviceRepository.cancelPendingRetries()
serviceJob.cancel()
super.onDestroy()
}
diff --git a/core/service/build.gradle.kts b/core/service/build.gradle.kts
index 2fe88c680..28c8af0af 100644
--- a/core/service/build.gradle.kts
+++ b/core/service/build.gradle.kts
@@ -47,4 +47,7 @@ dependencies {
implementation(libs.javax.inject)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kermit)
+
+ testImplementation(libs.junit)
+ testImplementation(libs.kotlinx.coroutines.test)
}
diff --git a/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceRepository.kt b/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceRepository.kt
index a74df12e2..70c1b71df 100644
--- a/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceRepository.kt
+++ b/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceRepository.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,21 +14,43 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
package org.meshtastic.core.service
import co.touchlab.kermit.Logger
+import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.withTimeoutOrNull
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.MeshProtos.MeshPacket
+import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
+sealed class RetryEvent {
+ abstract val packetId: Int
+ abstract val attemptNumber: Int
+ abstract val maxAttempts: Int
+
+ data class MessageRetry(
+ override val packetId: Int,
+ val text: String,
+ override val attemptNumber: Int,
+ override val maxAttempts: Int,
+ ) : RetryEvent()
+
+ data class ReactionRetry(
+ override val packetId: Int,
+ val emoji: String,
+ override val attemptNumber: Int,
+ override val maxAttempts: Int,
+ ) : RetryEvent()
+}
+
data class TracerouteResponse(
val message: String,
val destinationNodeNum: Int,
@@ -136,4 +158,51 @@ class ServiceRepository @Inject constructor() {
suspend fun onServiceAction(action: ServiceAction) {
_serviceAction.send(action)
}
+
+ // Retry management
+ private val _retryEvents = MutableStateFlow(null)
+ val retryEvents: StateFlow
+ get() = _retryEvents
+
+ private val pendingRetries = ConcurrentHashMap>()
+
+ /**
+ * Request a retry for a message or reaction. Emits a retry event to the UI and waits for user response.
+ *
+ * @param event The retry event containing packet information
+ * @param timeoutMs Maximum time to wait for user response (defaults to auto-retry)
+ * @return true if should proceed with retry, false if user cancelled
+ */
+ suspend fun requestRetry(event: RetryEvent, timeoutMs: Long): Boolean {
+ val packetId = event.packetId
+ val deferred = CompletableDeferred()
+ pendingRetries[packetId] = deferred
+
+ Logger.i { "ServiceRepository: Setting retry event for packet $packetId" }
+ _retryEvents.value = event
+ Logger.i { "ServiceRepository: Retry event set, waiting for response..." }
+
+ // Wait for user response with timeout
+ // If timeout occurs (user doesn't respond), default to retry
+ val result = withTimeoutOrNull(timeoutMs) { deferred.await() } ?: true
+ Logger.i { "ServiceRepository: Retry result for packet $packetId: $result" }
+ return result
+ }
+
+ /**
+ * Respond to a retry request. Called by the UI when user interacts with retry dialog.
+ *
+ * @param packetId The packet ID of the message/reaction
+ * @param shouldRetry true to proceed with retry, false to cancel
+ */
+ fun respondToRetry(packetId: Int, shouldRetry: Boolean) {
+ pendingRetries.remove(packetId)?.complete(shouldRetry)
+ _retryEvents.value = null // Clear the event to prevent replay
+ }
+
+ /** Cancel all pending retry requests. Should be called when service is stopped or restarted. */
+ fun cancelPendingRetries() {
+ pendingRetries.forEach { (_, deferred) -> deferred.complete(false) }
+ pendingRetries.clear()
+ }
}
diff --git a/core/service/src/test/kotlin/org/meshtastic/core/service/ServiceRepositoryRetryTest.kt b/core/service/src/test/kotlin/org/meshtastic/core/service/ServiceRepositoryRetryTest.kt
new file mode 100644
index 000000000..7976e9273
--- /dev/null
+++ b/core/service/src/test/kotlin/org/meshtastic/core/service/ServiceRepositoryRetryTest.kt
@@ -0,0 +1,164 @@
+/*
+ * 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
+ * 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 org.meshtastic.core.service
+
+import kotlinx.coroutines.async
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+
+/** Unit tests for ServiceRepository retry management functionality. */
+class ServiceRepositoryRetryTest {
+
+ private lateinit var serviceRepository: ServiceRepository
+
+ @Before
+ fun setUp() {
+ serviceRepository = ServiceRepository()
+ }
+
+ @Test
+ fun `requestRetry returns true when user confirms`() = runTest {
+ val testEvent =
+ RetryEvent.MessageRetry(packetId = 123, text = "Test message", attemptNumber = 1, maxAttempts = 3)
+
+ // Start retry request in background
+ val retryDeferred = async { serviceRepository.requestRetry(testEvent, timeoutMs = 5000) }
+
+ // Wait for non-null event to be set
+ val emittedEvent = serviceRepository.retryEvents.first { it != null }
+ assertEquals(testEvent, emittedEvent)
+
+ // Simulate user clicking "Retry Now"
+ serviceRepository.respondToRetry(testEvent.packetId, shouldRetry = true)
+
+ // Verify result
+ val result = retryDeferred.await()
+ assertTrue("Expected retry to proceed", result)
+ }
+
+ @Test
+ fun `requestRetry returns false when user cancels`() = runTest {
+ val testEvent = RetryEvent.ReactionRetry(packetId = 456, emoji = "👍", attemptNumber = 2, maxAttempts = 3)
+
+ // Start retry request in background
+ val retryDeferred = async { serviceRepository.requestRetry(testEvent, timeoutMs = 5000) }
+
+ // Wait for non-null event to be set
+ val emittedEvent = serviceRepository.retryEvents.first { it != null }
+ assertEquals(testEvent, emittedEvent)
+
+ // Simulate user clicking "Cancel Retry"
+ serviceRepository.respondToRetry(testEvent.packetId, shouldRetry = false)
+
+ // Verify result
+ val result = retryDeferred.await()
+ assertFalse("Expected retry to be cancelled", result)
+ }
+
+ @Test
+ fun `requestRetry returns true on timeout when user does not respond`() = runTest {
+ val testEvent =
+ RetryEvent.MessageRetry(packetId = 789, text = "Timeout test", attemptNumber = 1, maxAttempts = 3)
+
+ // Start retry request with short timeout
+ val result = serviceRepository.requestRetry(testEvent, timeoutMs = 100)
+
+ // Should auto-retry on timeout
+ assertTrue("Expected auto-retry on timeout", result)
+ }
+
+ @Test
+ fun `multiple simultaneous retry requests handled independently`() = runTest {
+ val event1 = RetryEvent.MessageRetry(packetId = 100, text = "Message 1", attemptNumber = 1, maxAttempts = 3)
+ val event2 = RetryEvent.MessageRetry(packetId = 200, text = "Message 2", attemptNumber = 1, maxAttempts = 3)
+
+ // Start two retry requests simultaneously
+ val retry1 = async { serviceRepository.requestRetry(event1, timeoutMs = 5000) }
+ val retry2 = async { serviceRepository.requestRetry(event2, timeoutMs = 5000) }
+
+ // Give time for events to be emitted
+ delay(50)
+
+ // Respond differently to each
+ serviceRepository.respondToRetry(event1.packetId, shouldRetry = true)
+ serviceRepository.respondToRetry(event2.packetId, shouldRetry = false)
+
+ // Verify results
+ val result1 = retry1.await()
+ val result2 = retry2.await()
+
+ assertTrue("First retry should proceed", result1)
+ assertFalse("Second retry should be cancelled", result2)
+ }
+
+ @Test
+ fun `cancelPendingRetries completes all pending requests with false`() = runTest {
+ val event1 = RetryEvent.MessageRetry(packetId = 111, text = "Message 1", attemptNumber = 1, maxAttempts = 3)
+ val event2 = RetryEvent.MessageRetry(packetId = 222, text = "Message 2", attemptNumber = 1, maxAttempts = 3)
+
+ // Start two retry requests
+ val retry1 = async { serviceRepository.requestRetry(event1, timeoutMs = 10000) }
+ val retry2 = async { serviceRepository.requestRetry(event2, timeoutMs = 10000) }
+
+ // Give time for requests to register
+ delay(50)
+
+ // Cancel all pending retries
+ serviceRepository.cancelPendingRetries()
+
+ // Verify both completed with false
+ val result1 = retry1.await()
+ val result2 = retry2.await()
+
+ assertFalse("First retry should be cancelled", result1)
+ assertFalse("Second retry should be cancelled", result2)
+ }
+
+ @Test
+ fun `retryEvents are cleared after user responds`() = runTest {
+ val testEvent = RetryEvent.MessageRetry(packetId = 333, text = "Clear test", attemptNumber = 1, maxAttempts = 3)
+
+ // Start retry request
+ val retryDeferred = async { serviceRepository.requestRetry(testEvent, timeoutMs = 5000) }
+
+ // Wait for event to be set
+ val emittedEvent = serviceRepository.retryEvents.first { it != null }
+ assertEquals("Should receive event", testEvent, emittedEvent)
+
+ // Respond to the retry
+ serviceRepository.respondToRetry(testEvent.packetId, shouldRetry = true)
+
+ // Wait for response to complete
+ retryDeferred.await()
+
+ // Verify event is cleared
+ assertEquals("Event should be cleared after responding", null, serviceRepository.retryEvents.value)
+ }
+
+ @Test
+ fun `respondToRetry does nothing for unknown packetId`() = runTest {
+ // This should not throw or cause issues
+ serviceRepository.respondToRetry(999, shouldRetry = true)
+ // Test passes if no exception thrown
+ }
+}
diff --git a/core/strings/src/commonMain/composeResources/values/strings.xml b/core/strings/src/commonMain/composeResources/values/strings.xml
index f329c0762..3db9a108e 100644
--- a/core/strings/src/commonMain/composeResources/values/strings.xml
+++ b/core/strings/src/commonMain/composeResources/values/strings.xml
@@ -56,6 +56,11 @@
Routing via SF++ chain…
Confirmed on SF++ chain
Retries: %1$d / %2$d
+ Message Failed to Send
+ Retrying in %1$d seconds… (Attempt %2$d of %3$d)
+ Retrying reaction in %1$d seconds… (Attempt %2$d of %3$d)
+ Retry Now
+ Cancel Retry
Acknowledged
No route
Received a negative acknowledgment
diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/Message.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/Message.kt
index da3d85bf9..ab1cac512 100644
--- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/Message.kt
+++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/Message.kt
@@ -103,6 +103,7 @@ import org.meshtastic.core.database.model.Message
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.util.getChannel
+import org.meshtastic.core.service.RetryEvent
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.alert_bell_text
import org.meshtastic.core.strings.cancel
@@ -131,6 +132,7 @@ import org.meshtastic.core.ui.component.SecurityIcon
import org.meshtastic.core.ui.component.SharedContactDialog
import org.meshtastic.core.ui.component.smartScrollToIndex
import org.meshtastic.core.ui.theme.AppTheme
+import org.meshtastic.feature.messaging.component.RetryConfirmationDialog
import org.meshtastic.proto.AppOnlyProtos
import java.nio.charset.StandardCharsets
@@ -177,6 +179,24 @@ fun MessageScreen(
val messageInputState = rememberTextFieldState(message)
val showQuickChat by viewModel.showQuickChat.collectAsStateWithLifecycle()
+ // Retry dialog state
+ var currentRetryEvent by remember { mutableStateOf(null) }
+
+ // Observe retry events from the service
+ // Key on contactKey to restart collection when navigating between conversations
+ LaunchedEffect(contactKey) {
+ android.util.Log.d("MessageScreen", "Starting retry event collection for contact: $contactKey")
+ viewModel.retryEvents.collect { event ->
+ if (event != null) {
+ android.util.Log.d("MessageScreen", "Received retry event: ${event.packetId}")
+ currentRetryEvent = event
+ } else {
+ android.util.Log.d("MessageScreen", "Retry event cleared")
+ currentRetryEvent = null
+ }
+ }
+ }
+
// Prevent the message TextField from stealing focus when the screen opens
LaunchedEffect(contactKey) { focusManager.clearFocus() }
@@ -294,6 +314,29 @@ fun MessageScreen(
sharedContact?.let { contact -> SharedContactDialog(contact = contact, onDismiss = { sharedContact = null }) }
+ // Show retry confirmation dialog
+ currentRetryEvent?.let { event ->
+ RetryConfirmationDialog(
+ retryEvent = event,
+ countdownSeconds = 5,
+ onConfirm = {
+ // User clicked "Retry Now" - proceed immediately
+ viewModel.respondToRetry(event.packetId, shouldRetry = true)
+ currentRetryEvent = null
+ },
+ onCancel = {
+ // User clicked "Cancel Retry" - stop retrying
+ viewModel.respondToRetry(event.packetId, shouldRetry = false)
+ currentRetryEvent = null
+ },
+ onTimeout = {
+ // Countdown reached 0 - auto-retry
+ viewModel.respondToRetry(event.packetId, shouldRetry = true)
+ currentRetryEvent = null
+ },
+ )
+ }
+
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
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 8345181c0..f6ea4ec2d 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
@@ -120,7 +120,7 @@ internal fun MessageListPaged(
ourNode = state.ourNode,
resendOption = message.status?.equals(MessageStatus.ERROR) ?: false,
retryCount = message.retryCount,
- maxRetries = 5,
+ maxRetries = 2,
onResend = {
handlers.onDeleteMessages(listOf(message.uuid))
handlers.onSendMessage(message.text, state.contactKey)
diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt
index 4c962ab61..e7e6ea3c1 100644
--- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt
+++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt
@@ -87,6 +87,8 @@ constructor(
val contactSettings: StateFlow