feat: Remove auto-retry confirmation for messages (#4513)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-02-10 08:00:41 -06:00 committed by GitHub
parent 8167fdaa89
commit bd8ff75787
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 1020 additions and 636 deletions

View file

@ -42,7 +42,6 @@ import org.meshtastic.core.model.util.decodeOrNull
import org.meshtastic.core.model.util.toOneLiner
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.service.filter.MessageFilterService
import org.meshtastic.core.strings.Res
@ -467,123 +466,13 @@ constructor(
val isAck = routingError == Routing.Error.NONE.value
val p = packetRepository.get().getPacketById(requestId)
val reaction = packetRepository.get().getReactionByPacketId(requestId)
val isMaxRetransmit = routingError == Routing.Error.MAX_RETRANSMIT.value
val shouldRetry =
isMaxRetransmit &&
p != null &&
p.port_num == PortNum.TEXT_MESSAGE_APP.value &&
(p.data.from == DataPacket.ID_LOCAL || p.data.from == nodeManager.getMyId()) &&
p.data.retryCount < MAX_RETRY_ATTEMPTS
val shouldRetryReaction =
isMaxRetransmit &&
reaction != null &&
(reaction.userId == DataPacket.ID_LOCAL || reaction.userId == nodeManager.getMyId()) &&
reaction.retryCount < MAX_RETRY_ATTEMPTS &&
reaction.to != null
@Suppress("MaxLineLength")
Logger.d {
val retryInfo =
"packetId=${p?.packetId ?: reaction?.packetId} dataId=${p?.data?.id} retry=${p?.data?.retryCount ?: reaction?.retryCount}"
val statusInfo = "status=${p?.data?.status ?: reaction?.status}"
"[ackNak] req=$requestId routeErr=$routingError isAck=$isAck " +
"maxRetransmit=$isMaxRetransmit shouldRetry=$shouldRetry reaction=$shouldRetryReaction $retryInfo $statusInfo"
}
if (shouldRetry) {
val newRetryCount = p.data.retryCount + 1
// 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
)
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 = 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) {
val newRetryCount = reaction.retryCount + 1
// 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
)
Logger.w { "[ackNak] requesting retry for reaction req=$requestId retry=$newRetryCount" }
val shouldProceed = serviceRepository.requestRetry(retryEvent, RETRY_DELAY_MS)
if (shouldProceed) {
val newId = commandSender.generatePacketId()
val reactionPacket =
DataPacket(
to = reaction.to,
channel = reaction.channel,
bytes = reaction.emoji.encodeToByteArray().toByteString(),
dataType = 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 = 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
"maxRetransmit=$isMaxRetransmit packetId=${p?.packetId ?: reaction?.packetId} dataId=${p?.data?.id} $statusInfo"
}
val m =
@ -893,8 +782,6 @@ constructor(
}
companion object {
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

View file

@ -195,7 +195,6 @@ class MeshService : Service() {
override fun onDestroy() {
Logger.i { "Destroying mesh service" }
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
serviceRepository.cancelPendingRetries()
serviceJob.cancel()
super.onDestroy()
}

View file

@ -17,12 +17,10 @@
package com.geeksville.mesh.service
import dagger.Lazy
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.slot
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
@ -37,13 +35,11 @@ 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.RetryEvent
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.service.filter.MessageFilterService
import org.meshtastic.proto.Data
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.Routing
import org.meshtastic.proto.StoreForwardPlusPlus
class MeshDataHandlerTest {
@ -157,58 +153,4 @@ class MeshDataHandlerTest {
)
}
}
@Test
fun `handleAckNak triggers RetryEvent when MAX_RETRANSMIT and conditions met`() = runTest {
val requestId = 555
val originalPacket =
org.meshtastic.core.database.entity.Packet(
uuid = 1L,
myNodeNum = 123,
packetId = requestId,
port_num = PortNum.TEXT_MESSAGE_APP.value,
contact_key = "contact",
received_time = System.currentTimeMillis(),
read = true,
data =
DataPacket(
to = "recipient",
bytes = "Important Message".encodeToByteArray().toByteString(),
dataType = PortNum.TEXT_MESSAGE_APP.value,
channel = 0,
id = requestId,
from = "!0000007b", // ID_LOCAL or my ID
retryCount = 0,
),
)
coEvery { packetRepository.getPacketById(requestId) } returns originalPacket
coEvery { packetRepository.getReactionByPacketId(requestId) } returns null
coEvery { serviceRepository.requestRetry(any(), any()) } returns true
every { commandSender.generatePacketId() } returns 888
val routingPayload = Routing(error_reason = Routing.Error.MAX_RETRANSMIT)
val payload = Routing.ADAPTER.encode(routingPayload).toByteString()
val meshPacket =
MeshPacket(
from = 123,
decoded = Data(portnum = PortNum.ROUTING_APP, payload = payload, request_id = requestId),
id = 2002,
)
every { dataMapper.toNodeID(any()) } returns "!0000007b"
meshDataHandler.handleReceivedData(meshPacket, 123)
val retryEventSlot = slot<RetryEvent.MessageRetry>()
coVerify { serviceRepository.requestRetry(capture(retryEventSlot), any()) }
assert(retryEventSlot.captured.packetId == requestId)
assert(retryEventSlot.captured.attemptNumber == 1)
// Verify update and resend
coVerify { packetRepository.update(any()) }
coVerify { commandSender.sendData(any()) }
}
}

View file

@ -2,7 +2,7 @@
"formatVersion": 1,
"database": {
"version": 34,
"identityHash": "34352663e54f76b7b9c13de31d9ac8e7",
"identityHash": "25bf8e7feb6d0e7f9eab4dfccf546e45",
"entities": [
{
"tableName": "my_node",
@ -611,7 +611,7 @@
},
{
"tableName": "reactions",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`myNodeNum` INTEGER NOT NULL DEFAULT 0, `reply_id` INTEGER NOT NULL, `user_id` TEXT NOT NULL, `emoji` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `snr` REAL NOT NULL DEFAULT 0, `rssi` INTEGER NOT NULL DEFAULT 0, `hopsAway` INTEGER NOT NULL DEFAULT -1, `packet_id` INTEGER NOT NULL DEFAULT 0, `status` INTEGER NOT NULL DEFAULT 0, `routing_error` INTEGER NOT NULL DEFAULT 0, `retry_count` INTEGER NOT NULL DEFAULT 0, `relays` INTEGER NOT NULL DEFAULT 0, `relay_node` INTEGER, `to` TEXT, `channel` INTEGER NOT NULL DEFAULT 0, `sfpp_hash` BLOB, PRIMARY KEY(`myNodeNum`, `reply_id`, `user_id`, `emoji`))",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`myNodeNum` INTEGER NOT NULL DEFAULT 0, `reply_id` INTEGER NOT NULL, `user_id` TEXT NOT NULL, `emoji` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `snr` REAL NOT NULL DEFAULT 0, `rssi` INTEGER NOT NULL DEFAULT 0, `hopsAway` INTEGER NOT NULL DEFAULT -1, `packet_id` INTEGER NOT NULL DEFAULT 0, `status` INTEGER NOT NULL DEFAULT 0, `routing_error` INTEGER NOT NULL DEFAULT 0, `relays` INTEGER NOT NULL DEFAULT 0, `relay_node` INTEGER, `to` TEXT, `channel` INTEGER NOT NULL DEFAULT 0, `sfpp_hash` BLOB, PRIMARY KEY(`myNodeNum`, `reply_id`, `user_id`, `emoji`))",
"fields": [
{
"fieldPath": "myNodeNum",
@ -686,13 +686,6 @@
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "retryCount",
"columnName": "retry_count",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "relays",
"columnName": "relays",
@ -1010,7 +1003,7 @@
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '34352663e54f76b7b9c13de31d9ac8e7')"
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '25bf8e7feb6d0e7f9eab4dfccf546e45')"
]
}
}

File diff suppressed because it is too large Load diff

View file

@ -91,7 +91,7 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity
AutoMigration(from = 30, to = 31),
AutoMigration(from = 31, to = 32),
AutoMigration(from = 32, to = 33),
AutoMigration(from = 33, to = 34),
AutoMigration(from = 33, to = 34, spec = AutoMigration33to34::class),
],
version = 34,
exportSchema = true,
@ -126,3 +126,7 @@ class AutoMigration12to13 : AutoMigrationSpec
@DeleteColumn.Entries(DeleteColumn(tableName = "packet", columnName = "reply_id"))
class AutoMigration29to30 : AutoMigrationSpec
@DeleteColumn(tableName = "packet", columnName = "retry_count")
@DeleteColumn(tableName = "reactions", columnName = "retry_count")
class AutoMigration33to34 : AutoMigrationSpec

View file

@ -57,7 +57,6 @@ data class PacketEntity(
viaMqtt = data.viaMqtt,
relayNode = data.relayNode,
relays = data.relays,
retryCount = data.retryCount,
filtered = filtered,
)
}
@ -140,7 +139,6 @@ data class Reaction(
val packetId: Int = 0,
val status: MessageStatus = MessageStatus.UNKNOWN,
val routingError: Int = 0,
val retryCount: Int = 0,
val relays: Int = 0,
val relayNode: Int? = null,
val to: String? = null,
@ -166,7 +164,6 @@ data class ReactionEntity(
@ColumnInfo(name = "packet_id", defaultValue = "0") val packetId: Int = 0,
@ColumnInfo(name = "status", defaultValue = "0") val status: MessageStatus = MessageStatus.UNKNOWN,
@ColumnInfo(name = "routing_error", defaultValue = "0") val routingError: Int = 0,
@ColumnInfo(name = "retry_count", defaultValue = "0") val retryCount: Int = 0,
@ColumnInfo(name = "relays", defaultValue = "0") val relays: Int = 0,
@ColumnInfo(name = "relay_node") val relayNode: Int? = null,
@ColumnInfo(name = "to") val to: String? = null,
@ -187,7 +184,6 @@ private suspend fun ReactionEntity.toReaction(getNode: suspend (userId: String?)
packetId = packetId,
status = status,
routingError = routingError,
retryCount = retryCount,
relays = relays,
relayNode = relayNode,
to = to,

View file

@ -91,7 +91,6 @@ data class Message(
val viaMqtt: Boolean = false,
val relayNode: Int? = null,
val relays: Int = 0,
val retryCount: Int = 0,
val filtered: Boolean = false,
) {
fun getStatusStringRes(): Pair<StringResource, StringResource> {

View file

@ -66,7 +66,6 @@ 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,
@Serializable(with = ByteStringSerializer::class)
@TypeParceler<ByteString?, ByteStringParceler>
@ -107,7 +106,6 @@ data class DataPacket(
relayNode = if (parcel.readInt() == 0) null else parcel.readInt()
relays = parcel.readInt()
viaMqtt = parcel.readInt() != 0
retryCount = parcel.readInt()
emoji = parcel.readInt()
sfppHash = ByteStringParceler.create(parcel)
}

View file

@ -114,7 +114,6 @@ class DataPacketParcelTest {
relayNode = 202,
relays = 1,
viaMqtt = true,
retryCount = 2,
emoji = 0x1F600,
sfppHash = "sfpp".toByteArray().toByteString(),
)
@ -137,7 +136,6 @@ class DataPacketParcelTest {
assertEquals("relayNode", expected.relayNode, actual.relayNode)
assertEquals("relays", expected.relays, actual.relays)
assertEquals("viaMqtt", expected.viaMqtt, actual.viaMqtt)
assertEquals("retryCount", expected.retryCount, actual.retryCount)
assertEquals("emoji", expected.emoji, actual.emoji)
assertEquals("sfppHash", expected.sfppHash, actual.sfppHash)
}

View file

@ -102,7 +102,6 @@ class DataPacketTest {
relayNode = 123,
relays = 2,
viaMqtt = true,
retryCount = 1,
emoji = 10,
sfppHash = sfppHash,
)
@ -132,7 +131,6 @@ class DataPacketTest {
assertEquals(123, packetToUpdate.relayNode)
assertEquals(2, packetToUpdate.relays)
assertEquals(true, packetToUpdate.viaMqtt)
assertEquals(1, packetToUpdate.retryCount)
assertEquals(10, packetToUpdate.emoji)
assertEquals(sfppHash, packetToUpdate.sfppHash)

View file

@ -17,40 +17,17 @@
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.ClientNotification
import org.meshtastic.proto.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,
@ -158,51 +135,4 @@ class ServiceRepository @Inject constructor() {
suspend fun onServiceAction(action: ServiceAction) {
_serviceAction.send(action)
}
// Retry management
private val _retryEvents = MutableStateFlow<RetryEvent?>(null)
val retryEvents: StateFlow<RetryEvent?>
get() = _retryEvents
private val pendingRetries = ConcurrentHashMap<Int, CompletableDeferred<Boolean>>()
/**
* 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<Boolean>()
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()
}
}

View file

@ -1,164 +0,0 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
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
}
}

View file

@ -55,12 +55,6 @@
<string name="message_status_queued">Queued for sending</string>
<string name="message_status_sfpp_routing">Routing via SF++ chain…</string>
<string name="message_status_sfpp_confirmed">Confirmed on SF++ chain</string>
<string name="message_retry_count">Retries: %1$d / %2$d</string>
<string name="retry_dialog_title">Message Failed to Send</string>
<string name="retry_dialog_message">Retrying in %1$d seconds… (Attempt %2$d of %3$d)</string>
<string name="retry_dialog_reaction_message">Retrying reaction in %1$d seconds… (Attempt %2$d of %3$d)</string>
<string name="retry_dialog_confirm">Retry Now</string>
<string name="retry_dialog_cancel">Cancel Retry</string>
<string name="routing_error_none">Acknowledged</string>
<string name="routing_error_no_route">No route</string>
<string name="routing_error_got_nak">Received a negative acknowledgment</string>
@ -657,7 +651,7 @@
<string name="ipv4_mode">IPv4 mode</string>
<string name="ip">IP</string>
<string name="gateway">Gateway</string>
<string name="subnet">Subnet</string>
<string name="subnet">Subred</string>
<string name="paxcounter_config">Paxcounter Config</string>
<string name="paxcounter_enabled">Paxcounter enabled</string>
<string name="status_message">Status Message</string>
@ -940,7 +934,7 @@
<string name="notifications_for_newly_discovered_nodes">Notifications for newly discovered nodes.</string>
<string name="low_battery">Low Battery</string>
<string name="notifications_for_low_battery_alerts">Notifications for low battery alerts for the connected device.</string>
<string name="critical_alerts_description">Select packets sent as critical will ignore the mute switch and Do Not Disturb settings in the OS notification center.</string>
<string name="critical_alerts_description">Select packets sent as critical will ignore the msg switch and Do Not Disturb settings in the OS notification center.</string>
<string name="configure_notification_permissions">Configure notification permissions</string>
<string name="phone_location">Phone Location</string>
<string name="phone_location_description">Meshtastic uses your phone's location to enable a number of features. You can update your location permissions at any time from settings.</string>

View file

@ -31,7 +31,6 @@ 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
import org.meshtastic.core.ui.component.MeshtasticDialog
@ -44,8 +43,6 @@ fun DeliveryInfo(
text: StringResource? = null,
relayNodeName: String? = null,
relays: Int = 0,
retryCount: Int = 0,
maxRetries: Int = 0,
onConfirm: (() -> Unit) = {},
onDismiss: () -> Unit = {},
) = MeshtasticDialog(
@ -63,14 +60,6 @@ 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),

View file

@ -105,7 +105,6 @@ 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_reply
@ -138,7 +137,6 @@ 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.ChannelSet
import java.nio.charset.StandardCharsets
@ -189,24 +187,6 @@ fun MessageScreen(
val showFiltered by viewModel.showFiltered.collectAsStateWithLifecycle()
val filteringDisabled = contactSettings[contactKey]?.filteringDisabled ?: false
// Retry dialog state
var currentRetryEvent by remember { mutableStateOf<RetryEvent?>(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() }
@ -323,29 +303,6 @@ 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 = {

View file

@ -122,8 +122,6 @@ internal fun MessageListPaged(
nodes = state.nodes,
ourNode = state.ourNode,
resendOption = message.status?.equals(MessageStatus.ERROR) ?: false,
retryCount = message.retryCount,
maxRetries = 2,
onResend = {
handlers.onDeleteMessages(listOf(message.uuid))
handlers.onSendMessage(message.text, state.contactKey)
@ -510,8 +508,6 @@ internal fun MessageStatusDialog(
nodes: List<Node>,
ourNode: Node?,
resendOption: Boolean,
retryCount: Int,
maxRetries: Int,
onResend: () -> Unit,
onDismiss: () -> Unit,
) {
@ -530,8 +526,6 @@ internal fun MessageStatusDialog(
text = text,
relayNodeName = relayNodeName,
relays = message.relays,
retryCount = retryCount,
maxRetries = maxRetries,
onConfirm = onResend,
onDismiss = onDismiss,
)

View file

@ -93,8 +93,6 @@ constructor(
val contactSettings: StateFlow<Map<String, ContactSettings>> =
packetRepository.getContactSettings().stateInWhileSubscribed(initialValue = emptyMap())
val retryEvents = serviceRepository.retryEvents
private val contactKeyForPagedMessages: MutableStateFlow<String?> = MutableStateFlow(null)
private val pagedMessagesForContactKey: Flow<PagingData<Message>> =
combine(contactKeyForPagedMessages.filterNotNull(), _showFiltered, contactSettings) {
@ -270,8 +268,4 @@ constructor(
Logger.e { "Send DataPacket error: ${ex.message}" }
}
}
fun respondToRetry(packetId: Int, shouldRetry: Boolean) {
serviceRepository.respondToRetry(packetId, shouldRetry)
}
}

View file

@ -232,8 +232,6 @@ internal fun ReactionDialog(
onDismiss = { showStatusDialog = null },
relayNodeName = relayNodeName,
relays = reaction.relays,
retryCount = reaction.retryCount,
maxRetries = 2,
)
}

View file

@ -1,131 +0,0 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.messaging.component
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.service.RetryEvent
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.retry_dialog_cancel
import org.meshtastic.core.strings.retry_dialog_confirm
import org.meshtastic.core.strings.retry_dialog_message
import org.meshtastic.core.strings.retry_dialog_reaction_message
import org.meshtastic.core.strings.retry_dialog_title
import org.meshtastic.core.ui.component.MeshtasticDialog
private const val COUNTDOWN_DELAY_MS = 1000L
private const val MESSAGE_PREVIEW_LENGTH = 50
@Composable
private fun RetryDialogContent(retryEvent: RetryEvent, timeRemaining: Int) {
Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
when (retryEvent) {
is RetryEvent.MessageRetry -> {
// Show message preview
if (retryEvent.text.isNotEmpty()) {
Text(
text =
"\"${retryEvent.text.take(MESSAGE_PREVIEW_LENGTH)}${
if (retryEvent.text.length > MESSAGE_PREVIEW_LENGTH) "" else ""
}\"",
modifier = Modifier.padding(bottom = 8.dp),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
text =
stringResource(
Res.string.retry_dialog_message,
timeRemaining,
retryEvent.attemptNumber,
retryEvent.maxAttempts,
),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium,
)
}
is RetryEvent.ReactionRetry -> {
// Show emoji preview
Text(
text = retryEvent.emoji,
modifier = Modifier.padding(bottom = 8.dp),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.displayMedium,
)
Text(
text =
stringResource(
Res.string.retry_dialog_reaction_message,
timeRemaining,
retryEvent.attemptNumber,
retryEvent.maxAttempts,
),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
}
@Composable
fun RetryConfirmationDialog(
retryEvent: RetryEvent,
countdownSeconds: Int = 5,
onConfirm: () -> Unit,
onCancel: () -> Unit,
onTimeout: () -> Unit,
) {
var timeRemaining by remember { mutableIntStateOf(countdownSeconds) }
LaunchedEffect(retryEvent.packetId) {
timeRemaining = countdownSeconds // Reset countdown for new event
while (timeRemaining > 0) {
delay(COUNTDOWN_DELAY_MS)
timeRemaining--
}
// Countdown reached 0, auto-retry
onTimeout()
}
MeshtasticDialog(
onDismiss = onCancel,
dismissText = stringResource(Res.string.retry_dialog_cancel),
confirmText = stringResource(Res.string.retry_dialog_confirm),
onConfirm = onConfirm,
title = stringResource(Res.string.retry_dialog_title),
text = { RetryDialogContent(retryEvent, timeRemaining) },
dismissable = false,
)
}