mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
feat: retry message/reaction dialog (#4195)
This commit is contained in:
parent
4b7f20000e
commit
afeff9a460
11 changed files with 516 additions and 39 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -236,7 +236,7 @@ internal fun ReactionDialog(
|
|||
relayNodeName = relayNodeName,
|
||||
relays = reaction.relays,
|
||||
retryCount = reaction.retryCount,
|
||||
maxRetries = 5,
|
||||
maxRetries = 2,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) },
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue