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