feat: retry message/reaction dialog (#4195)

This commit is contained in:
Mac DeCourcy 2026-01-15 15:23:33 -08:00 committed by GitHub
parent 4b7f20000e
commit afeff9a460
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 516 additions and 39 deletions

View file

@ -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

View file

@ -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()
}

View file

@ -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)
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<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

@ -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 <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

@ -56,6 +56,11 @@
<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>

View file

@ -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<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() }
@ -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 = {

View file

@ -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)

View file

@ -87,6 +87,8 @@ 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>> =
contactKeyForPagedMessages
@ -231,4 +233,8 @@ constructor(
Logger.e { "Send DataPacket error: ${ex.message}" }
}
}
fun respondToRetry(packetId: Int, shouldRetry: Boolean) {
serviceRepository.respondToRetry(packetId, shouldRetry)
}
}

View file

@ -236,7 +236,7 @@ internal fun ReactionDialog(
relayNodeName = relayNodeName,
relays = reaction.relays,
retryCount = reaction.retryCount,
maxRetries = 5,
maxRetries = 2,
)
}

View file

@ -0,0 +1,141 @@
/*
* 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.AlertDialog
import androidx.compose.material3.FilledTonalButton
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
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()
}
AlertDialog(
onDismissRequest = { /* Prevent dismissal by clicking outside */ },
dismissButton = {
FilledTonalButton(onClick = onCancel) { Text(text = stringResource(Res.string.retry_dialog_cancel)) }
},
confirmButton = {
FilledTonalButton(onClick = onConfirm) { Text(text = stringResource(Res.string.retry_dialog_confirm)) }
},
title = {
Text(
text = stringResource(Res.string.retry_dialog_title),
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.headlineSmall,
)
},
text = { RetryDialogContent(retryEvent, timeRemaining) },
)
}