mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
feat: Remove auto-retry confirmation for messages (#4513)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
parent
8167fdaa89
commit
bd8ff75787
20 changed files with 1020 additions and 636 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -232,8 +232,6 @@ internal fun ReactionDialog(
|
|||
onDismiss = { showStatusDialog = null },
|
||||
relayNodeName = relayNodeName,
|
||||
relays = reaction.relays,
|
||||
retryCount = reaction.retryCount,
|
||||
maxRetries = 2,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue