diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt index bf37a8bbe5a..73b81f367b0 100644 --- a/.skills/compose-ui/strings-index.txt +++ b/.skills/compose-ui/strings-index.txt @@ -1031,12 +1031,37 @@ message message_delivery_status message_device_managed message_input_label +message_routing_error_admin_bad_session_key +message_routing_error_admin_bad_session_key_detail +message_routing_error_admin_public_key_unauthorized +message_routing_error_admin_public_key_unauthorized_detail +message_routing_error_bad_request +message_routing_error_bad_request_detail +message_routing_error_duty_cycle_limit +message_routing_error_duty_cycle_limit_detail +message_routing_error_got_nak_detail message_routing_error_max_retransmit +message_routing_error_max_retransmit_detail message_routing_error_no_channel +message_routing_error_no_channel_detail +message_routing_error_no_interface +message_routing_error_no_interface_detail +message_routing_error_no_response +message_routing_error_no_response_detail +message_routing_error_no_route_detail +message_routing_error_not_authorized +message_routing_error_not_authorized_detail message_routing_error_pki_failed +message_routing_error_pki_failed_detail message_routing_error_pki_send_fail_public_key +message_routing_error_pki_send_fail_public_key_detail message_routing_error_pki_unknown_pubkey +message_routing_error_pki_unknown_pubkey_detail +message_routing_error_rate_limit_exceeded +message_routing_error_rate_limit_exceeded_detail +message_routing_error_timeout_detail message_routing_error_too_large +message_routing_error_too_large_detail message_status_delivered message_status_enroute message_status_queued diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Message.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Message.kt index 2e5fafb5e42..e28e22a6129 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Message.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Message.kt @@ -20,12 +20,37 @@ import org.jetbrains.compose.resources.StringResource import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.error import org.meshtastic.core.resources.message_delivery_status +import org.meshtastic.core.resources.message_routing_error_admin_bad_session_key +import org.meshtastic.core.resources.message_routing_error_admin_bad_session_key_detail +import org.meshtastic.core.resources.message_routing_error_admin_public_key_unauthorized +import org.meshtastic.core.resources.message_routing_error_admin_public_key_unauthorized_detail +import org.meshtastic.core.resources.message_routing_error_bad_request +import org.meshtastic.core.resources.message_routing_error_bad_request_detail +import org.meshtastic.core.resources.message_routing_error_duty_cycle_limit +import org.meshtastic.core.resources.message_routing_error_duty_cycle_limit_detail +import org.meshtastic.core.resources.message_routing_error_got_nak_detail import org.meshtastic.core.resources.message_routing_error_max_retransmit +import org.meshtastic.core.resources.message_routing_error_max_retransmit_detail import org.meshtastic.core.resources.message_routing_error_no_channel +import org.meshtastic.core.resources.message_routing_error_no_channel_detail +import org.meshtastic.core.resources.message_routing_error_no_interface +import org.meshtastic.core.resources.message_routing_error_no_interface_detail +import org.meshtastic.core.resources.message_routing_error_no_response +import org.meshtastic.core.resources.message_routing_error_no_response_detail +import org.meshtastic.core.resources.message_routing_error_no_route_detail +import org.meshtastic.core.resources.message_routing_error_not_authorized +import org.meshtastic.core.resources.message_routing_error_not_authorized_detail import org.meshtastic.core.resources.message_routing_error_pki_failed +import org.meshtastic.core.resources.message_routing_error_pki_failed_detail import org.meshtastic.core.resources.message_routing_error_pki_send_fail_public_key +import org.meshtastic.core.resources.message_routing_error_pki_send_fail_public_key_detail import org.meshtastic.core.resources.message_routing_error_pki_unknown_pubkey +import org.meshtastic.core.resources.message_routing_error_pki_unknown_pubkey_detail +import org.meshtastic.core.resources.message_routing_error_rate_limit_exceeded +import org.meshtastic.core.resources.message_routing_error_rate_limit_exceeded_detail +import org.meshtastic.core.resources.message_routing_error_timeout_detail import org.meshtastic.core.resources.message_routing_error_too_large +import org.meshtastic.core.resources.message_routing_error_too_large_detail import org.meshtastic.core.resources.message_status_delivered import org.meshtastic.core.resources.message_status_enroute import org.meshtastic.core.resources.message_status_recipient_delivered @@ -78,26 +103,86 @@ fun getStringResFrom(routingError: Int): StringResource = when (routingError) { else -> Res.string.unrecognized } +@Suppress("CyclomaticComplexMethod") fun getMessageRoutingErrorStringResFrom(routingError: Int): StringResource = when (routingError) { Routing.Error.GOT_NAK.value, Routing.Error.TIMEOUT.value, Routing.Error.MAX_RETRANSMIT.value, - Routing.Error.NO_RESPONSE.value, -> Res.string.message_routing_error_max_retransmit Routing.Error.NO_CHANNEL.value -> Res.string.message_routing_error_no_channel + Routing.Error.NO_INTERFACE.value -> Res.string.message_routing_error_no_interface + + Routing.Error.DUTY_CYCLE_LIMIT.value -> Res.string.message_routing_error_duty_cycle_limit + + Routing.Error.RATE_LIMIT_EXCEEDED.value -> Res.string.message_routing_error_rate_limit_exceeded + Routing.Error.TOO_LARGE.value -> Res.string.message_routing_error_too_large + Routing.Error.NO_RESPONSE.value -> Res.string.message_routing_error_no_response + + Routing.Error.BAD_REQUEST.value -> Res.string.message_routing_error_bad_request + + Routing.Error.NOT_AUTHORIZED.value -> Res.string.message_routing_error_not_authorized + Routing.Error.PKI_FAILED.value -> Res.string.message_routing_error_pki_failed Routing.Error.PKI_UNKNOWN_PUBKEY.value -> Res.string.message_routing_error_pki_unknown_pubkey Routing.Error.PKI_SEND_FAIL_PUBLIC_KEY.value -> Res.string.message_routing_error_pki_send_fail_public_key + Routing.Error.ADMIN_BAD_SESSION_KEY.value -> Res.string.message_routing_error_admin_bad_session_key + + Routing.Error.ADMIN_PUBLIC_KEY_UNAUTHORIZED.value -> + Res.string.message_routing_error_admin_public_key_unauthorized + else -> getStringResFrom(routingError) } +@Suppress("CyclomaticComplexMethod") +fun getMessageRoutingErrorDetailResFrom(routingError: Int): StringResource? = when (routingError) { + Routing.Error.NO_ROUTE.value -> Res.string.message_routing_error_no_route_detail + + Routing.Error.GOT_NAK.value -> Res.string.message_routing_error_got_nak_detail + + Routing.Error.TIMEOUT.value -> Res.string.message_routing_error_timeout_detail + + Routing.Error.MAX_RETRANSMIT.value -> Res.string.message_routing_error_max_retransmit_detail + + Routing.Error.NO_CHANNEL.value -> Res.string.message_routing_error_no_channel_detail + + Routing.Error.NO_INTERFACE.value -> Res.string.message_routing_error_no_interface_detail + + Routing.Error.DUTY_CYCLE_LIMIT.value -> Res.string.message_routing_error_duty_cycle_limit_detail + + Routing.Error.RATE_LIMIT_EXCEEDED.value -> Res.string.message_routing_error_rate_limit_exceeded_detail + + Routing.Error.TOO_LARGE.value -> Res.string.message_routing_error_too_large_detail + + Routing.Error.NO_RESPONSE.value -> Res.string.message_routing_error_no_response_detail + + Routing.Error.BAD_REQUEST.value -> Res.string.message_routing_error_bad_request_detail + + Routing.Error.NOT_AUTHORIZED.value -> Res.string.message_routing_error_not_authorized_detail + + Routing.Error.PKI_FAILED.value -> Res.string.message_routing_error_pki_failed_detail + + Routing.Error.PKI_UNKNOWN_PUBKEY.value -> Res.string.message_routing_error_pki_unknown_pubkey_detail + + Routing.Error.PKI_SEND_FAIL_PUBLIC_KEY.value -> Res.string.message_routing_error_pki_send_fail_public_key_detail + + Routing.Error.ADMIN_BAD_SESSION_KEY.value -> Res.string.message_routing_error_admin_bad_session_key_detail + + Routing.Error.ADMIN_PUBLIC_KEY_UNAUTHORIZED.value -> + Res.string.message_routing_error_admin_public_key_unauthorized_detail + + else -> null +} + +fun getMessageStatusDetailRes(status: MessageStatus?, routingError: Int): StringResource? = + if (status == MessageStatus.ERROR) getMessageRoutingErrorDetailResFrom(routingError) else null + fun getMessageStatusStringRes( status: MessageStatus?, routingError: Int, @@ -198,6 +283,8 @@ data class Message( fun getStatusStringRes(isDirectMessage: Boolean = false): Pair = getMessageStatusStringRes(status, routingError, isDirectMessage) + fun getStatusDetailRes(): StringResource? = getMessageStatusDetailRes(status, routingError) + fun isStatusRetryable(isDirectMessage: Boolean = false): Boolean = isMessageStatusRetryable(status, routingError, isDirectMessage) } diff --git a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/DataPacketTest.kt b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/DataPacketTest.kt index ab5d93c9432..4ae13ee57f7 100644 --- a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/DataPacketTest.kt +++ b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/DataPacketTest.kt @@ -20,24 +20,57 @@ import okio.ByteString.Companion.encodeUtf8 import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.error import org.meshtastic.core.resources.message_delivery_status +import org.meshtastic.core.resources.message_routing_error_admin_bad_session_key +import org.meshtastic.core.resources.message_routing_error_admin_bad_session_key_detail +import org.meshtastic.core.resources.message_routing_error_admin_public_key_unauthorized +import org.meshtastic.core.resources.message_routing_error_admin_public_key_unauthorized_detail +import org.meshtastic.core.resources.message_routing_error_bad_request +import org.meshtastic.core.resources.message_routing_error_bad_request_detail +import org.meshtastic.core.resources.message_routing_error_duty_cycle_limit +import org.meshtastic.core.resources.message_routing_error_duty_cycle_limit_detail +import org.meshtastic.core.resources.message_routing_error_got_nak_detail import org.meshtastic.core.resources.message_routing_error_max_retransmit +import org.meshtastic.core.resources.message_routing_error_max_retransmit_detail import org.meshtastic.core.resources.message_routing_error_no_channel +import org.meshtastic.core.resources.message_routing_error_no_channel_detail +import org.meshtastic.core.resources.message_routing_error_no_interface +import org.meshtastic.core.resources.message_routing_error_no_interface_detail +import org.meshtastic.core.resources.message_routing_error_no_response +import org.meshtastic.core.resources.message_routing_error_no_response_detail +import org.meshtastic.core.resources.message_routing_error_no_route_detail +import org.meshtastic.core.resources.message_routing_error_not_authorized +import org.meshtastic.core.resources.message_routing_error_not_authorized_detail import org.meshtastic.core.resources.message_routing_error_pki_failed +import org.meshtastic.core.resources.message_routing_error_pki_failed_detail import org.meshtastic.core.resources.message_routing_error_pki_send_fail_public_key +import org.meshtastic.core.resources.message_routing_error_pki_send_fail_public_key_detail import org.meshtastic.core.resources.message_routing_error_pki_unknown_pubkey +import org.meshtastic.core.resources.message_routing_error_pki_unknown_pubkey_detail +import org.meshtastic.core.resources.message_routing_error_rate_limit_exceeded +import org.meshtastic.core.resources.message_routing_error_rate_limit_exceeded_detail +import org.meshtastic.core.resources.message_routing_error_timeout_detail import org.meshtastic.core.resources.message_routing_error_too_large +import org.meshtastic.core.resources.message_routing_error_too_large_detail import org.meshtastic.core.resources.message_status_delivered import org.meshtastic.core.resources.message_status_enroute import org.meshtastic.core.resources.message_status_recipient_delivered import org.meshtastic.core.resources.message_status_relayed_not_confirmed import org.meshtastic.core.resources.message_status_unknown +import org.meshtastic.core.resources.routing_error_admin_bad_session_key +import org.meshtastic.core.resources.routing_error_admin_public_key_unauthorized +import org.meshtastic.core.resources.routing_error_bad_request +import org.meshtastic.core.resources.routing_error_duty_cycle_limit import org.meshtastic.core.resources.routing_error_max_retransmit import org.meshtastic.core.resources.routing_error_no_channel +import org.meshtastic.core.resources.routing_error_no_interface +import org.meshtastic.core.resources.routing_error_no_response import org.meshtastic.core.resources.routing_error_no_route import org.meshtastic.core.resources.routing_error_none +import org.meshtastic.core.resources.routing_error_not_authorized import org.meshtastic.core.resources.routing_error_pki_failed import org.meshtastic.core.resources.routing_error_pki_send_fail_public_key import org.meshtastic.core.resources.routing_error_pki_unknown_pubkey +import org.meshtastic.core.resources.routing_error_rate_limit_exceeded import org.meshtastic.core.resources.routing_error_too_large import org.meshtastic.core.resources.unrecognized import org.meshtastic.proto.PortNum @@ -309,13 +342,21 @@ class MessageTest { Routing.Error.MAX_RETRANSMIT.value to Res.string.message_routing_error_max_retransmit, Routing.Error.GOT_NAK.value to Res.string.message_routing_error_max_retransmit, Routing.Error.TIMEOUT.value to Res.string.message_routing_error_max_retransmit, - Routing.Error.NO_RESPONSE.value to Res.string.message_routing_error_max_retransmit, Routing.Error.NO_CHANNEL.value to Res.string.message_routing_error_no_channel, + Routing.Error.NO_INTERFACE.value to Res.string.message_routing_error_no_interface, + Routing.Error.DUTY_CYCLE_LIMIT.value to Res.string.message_routing_error_duty_cycle_limit, + Routing.Error.RATE_LIMIT_EXCEEDED.value to Res.string.message_routing_error_rate_limit_exceeded, + Routing.Error.NO_RESPONSE.value to Res.string.message_routing_error_no_response, + Routing.Error.BAD_REQUEST.value to Res.string.message_routing_error_bad_request, + Routing.Error.NOT_AUTHORIZED.value to Res.string.message_routing_error_not_authorized, Routing.Error.PKI_FAILED.value to Res.string.message_routing_error_pki_failed, Routing.Error.PKI_SEND_FAIL_PUBLIC_KEY.value to Res.string.message_routing_error_pki_send_fail_public_key, Routing.Error.PKI_UNKNOWN_PUBKEY.value to Res.string.message_routing_error_pki_unknown_pubkey, Routing.Error.TOO_LARGE.value to Res.string.message_routing_error_too_large, + Routing.Error.ADMIN_BAD_SESSION_KEY.value to Res.string.message_routing_error_admin_bad_session_key, + Routing.Error.ADMIN_PUBLIC_KEY_UNAUTHORIZED.value to + Res.string.message_routing_error_admin_public_key_unauthorized, ) for ((routingError, expectedText) in mappings) { @@ -327,6 +368,40 @@ class MessageTest { } } + @Test + fun getMessageStatusDetailRes_returnsActionableRoutingFailureDetails() { + val mappings = + listOf( + Routing.Error.NO_ROUTE.value to Res.string.message_routing_error_no_route_detail, + Routing.Error.GOT_NAK.value to Res.string.message_routing_error_got_nak_detail, + Routing.Error.TIMEOUT.value to Res.string.message_routing_error_timeout_detail, + Routing.Error.MAX_RETRANSMIT.value to Res.string.message_routing_error_max_retransmit_detail, + Routing.Error.NO_CHANNEL.value to Res.string.message_routing_error_no_channel_detail, + Routing.Error.NO_INTERFACE.value to Res.string.message_routing_error_no_interface_detail, + Routing.Error.DUTY_CYCLE_LIMIT.value to Res.string.message_routing_error_duty_cycle_limit_detail, + Routing.Error.RATE_LIMIT_EXCEEDED.value to Res.string.message_routing_error_rate_limit_exceeded_detail, + Routing.Error.TOO_LARGE.value to Res.string.message_routing_error_too_large_detail, + Routing.Error.NO_RESPONSE.value to Res.string.message_routing_error_no_response_detail, + Routing.Error.BAD_REQUEST.value to Res.string.message_routing_error_bad_request_detail, + Routing.Error.NOT_AUTHORIZED.value to Res.string.message_routing_error_not_authorized_detail, + Routing.Error.PKI_FAILED.value to Res.string.message_routing_error_pki_failed_detail, + Routing.Error.PKI_UNKNOWN_PUBKEY.value to Res.string.message_routing_error_pki_unknown_pubkey_detail, + Routing.Error.PKI_SEND_FAIL_PUBLIC_KEY.value to + Res.string.message_routing_error_pki_send_fail_public_key_detail, + Routing.Error.ADMIN_BAD_SESSION_KEY.value to + Res.string.message_routing_error_admin_bad_session_key_detail, + Routing.Error.ADMIN_PUBLIC_KEY_UNAUTHORIZED.value to + Res.string.message_routing_error_admin_public_key_unauthorized_detail, + ) + + for ((routingError, expectedDetail) in mappings) { + assertEquals(expectedDetail, getMessageStatusDetailRes(MessageStatus.ERROR, routingError)) + } + + assertNull(getMessageStatusDetailRes(MessageStatus.DELIVERED, Routing.Error.MAX_RETRANSMIT.value)) + assertNull(getMessageStatusDetailRes(MessageStatus.ERROR, Int.MAX_VALUE)) + } + @Test fun getStatusStringRes_returnsUnknownForMissingStatus() { val message = @@ -366,10 +441,19 @@ class MessageTest { listOf( Routing.Error.MAX_RETRANSMIT.value to Res.string.routing_error_max_retransmit, Routing.Error.NO_CHANNEL.value to Res.string.routing_error_no_channel, + Routing.Error.NO_INTERFACE.value to Res.string.routing_error_no_interface, + Routing.Error.DUTY_CYCLE_LIMIT.value to Res.string.routing_error_duty_cycle_limit, + Routing.Error.RATE_LIMIT_EXCEEDED.value to Res.string.routing_error_rate_limit_exceeded, + Routing.Error.NO_RESPONSE.value to Res.string.routing_error_no_response, + Routing.Error.BAD_REQUEST.value to Res.string.routing_error_bad_request, + Routing.Error.NOT_AUTHORIZED.value to Res.string.routing_error_not_authorized, Routing.Error.PKI_FAILED.value to Res.string.routing_error_pki_failed, Routing.Error.PKI_SEND_FAIL_PUBLIC_KEY.value to Res.string.routing_error_pki_send_fail_public_key, Routing.Error.PKI_UNKNOWN_PUBKEY.value to Res.string.routing_error_pki_unknown_pubkey, Routing.Error.TOO_LARGE.value to Res.string.routing_error_too_large, + Routing.Error.ADMIN_BAD_SESSION_KEY.value to Res.string.routing_error_admin_bad_session_key, + Routing.Error.ADMIN_PUBLIC_KEY_UNAUTHORIZED.value to + Res.string.routing_error_admin_public_key_unauthorized, ) for ((routingError, expectedText) in mappings) { @@ -385,9 +469,17 @@ class MessageTest { ) val retryableRoutingErrors = listOf( + Routing.Error.NO_INTERFACE.value, + Routing.Error.NO_RESPONSE.value, + Routing.Error.BAD_REQUEST.value, + Routing.Error.NOT_AUTHORIZED.value, + Routing.Error.DUTY_CYCLE_LIMIT.value, Routing.Error.PKI_FAILED.value, Routing.Error.PKI_SEND_FAIL_PUBLIC_KEY.value, Routing.Error.PKI_UNKNOWN_PUBKEY.value, + Routing.Error.ADMIN_BAD_SESSION_KEY.value, + Routing.Error.ADMIN_PUBLIC_KEY_UNAUTHORIZED.value, + Routing.Error.RATE_LIMIT_EXCEEDED.value, ) for (routingError in retryableRoutingErrors) { diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 8fd76e4ca72..09ef2c001d3 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -1064,12 +1064,37 @@ Message delivery status This radio is managed and can only be changed by a remote admin. Message + Admin session expired + The admin session key is missing, expired, or invalid. Request a new session before trying again. + Admin key not authorized + The remote node does not authorize your admin key. + Invalid request + The destination rejected this request as invalid. + Duty cycle limit + Local airtime limits are temporarily blocking sends. Wait before trying again. + A node rejected this message. Try again when the route changes. Failed to deliver to mesh - No channel selected + No node confirmed this message. Try again when you have better signal or more mesh coverage. + Channel/key mismatch + The sender or recipient could not use a matching channel/key for this message. + No radio interface + The sender has no usable radio interface for this message. + No app response + The destination received the request, but no app or module responded. Try again when the recipient is reachable. + No route to the destination node was found in the mesh. Try again when more nodes are reachable. + Not authorized + The destination refused this request because it is not authorized. Could not send encrypted message + The encrypted send path could not be used. Wait for node info or keys to sync, then try again. Recipient key unavailable + Your node does not have the recipient's public key yet. Wait for node info to sync, then try again. Recipient needs your key + The recipient does not know your public key yet. Your node may share its info automatically; try again after it syncs. + Rate limited + Messages are being sent too quickly. Wait before trying again. + No acknowledgment was received in time. Try again when you have better signal or more mesh coverage. Message is too large to send + Shorten the message and send it again. Delivered to mesh Sending... Queued for sending @@ -1468,24 +1493,24 @@ Rotary encoder #1 enabled I have read the <a href="https://meshtastic.org/docs/configuration/radio/device/#roles">Device Role Documentation</a> and the blog post about <a href="http://meshtastic.org/blog/choosing-the-right-device-role">Choosing The Right Device Role</a>. - Bad session key - Public Key unauthorized - Bad Request - Regional Duty Cycle Limit Reached + Admin session expired + Admin key not authorized + Invalid request + Duty cycle limit Received a negative acknowledgment - Max Retransmission Reached - No Channel - No Interface - No response + Failed to deliver to mesh + Channel/key mismatch + No radio interface + No app response No route Acknowledged - Not Authorized - Encrypted Send Failed - PKI send failed, no public key - Unknown Public Key - Rate Limit Exceeded. Please try again later. + Not authorized + Could not send encrypted message + Recipient key unavailable + Recipient needs your key + Rate limited Timeout - Packet too large + Message is too large to send RSSI Received Signal Strength Indicator, a measurement used to determine the power level being received by the antenna. A higher RSSI value generally indicates a stronger and more stable connection. rsyslog server diff --git a/docs/en/user/messages-and-channels.md b/docs/en/user/messages-and-channels.md index 2968c054b29..48c6a54c0db 100644 --- a/docs/en/user/messages-and-channels.md +++ b/docs/en/user/messages-and-channels.md @@ -78,13 +78,13 @@ When a message fails to deliver, the error indicator shows what went wrong: | No Route | No path exists to the destination node | The recipient may be offline or out of mesh range. Try later or move closer. | | Got NAK | The next-hop node refused to relay | The relay node may be congested. Wait and retry. | | Timeout | No acknowledgment within retry window | The recipient may be just out of range. Try increasing hop limit or moving to a better position. | -| No Interface | No radio interface available to send | Check that your radio is connected and the channel is configured. | -| Max Retransmit | All retry attempts exhausted | The mesh path is unreliable. Try a different channel or wait for conditions to improve. | -| No Channel | The destination channel doesn't exist | Verify both nodes share the same channel configuration. | -| Too Large | Message exceeds maximum payload size | Shorten your message (max ~200 characters). | -| No Response | Node received message but didn't respond | The recipient's radio may be busy or in low-power sleep mode. | -| Duty Cycle Limit | Regional airtime limit reached | Your radio has used its allowed transmit time. Wait for the duty cycle window to reset (typically 1 hour in EU regions). | -| Bad Request | Malformed or invalid message | This usually indicates a software bug. Try restarting the app. | +| No radio interface | No radio interface available to send | Check that your radio is connected and available. | +| Failed to deliver to mesh | All retry attempts exhausted | Move closer, improve signal, or wait for mesh conditions to improve. | +| Channel/key mismatch | Destination channel/key does not match | Verify both nodes share the same channel and PSK. | +| Message is too large to send | Message exceeds maximum payload size | Shorten the message and try again. | +| No app response | App or plugin did not respond to the request | Retry or check the destination app or module state. | +| Duty cycle limit | Regional airtime limit reached | Wait for the duty cycle window to reset. | +| Invalid request | Malformed or invalid request | Retry after updating or restarting the app if this persists. | > 💡 **Tip:** Most delivery errors resolve themselves. If a node is intermittently reachable, the mesh will retry. For persistent "No Route" errors, check that intermediate Router nodes are online. diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/DeliveryInfoDialog.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/DeliveryInfoDialog.kt index f161030f001..5213d579d9d 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/DeliveryInfoDialog.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/DeliveryInfoDialog.kt @@ -40,6 +40,7 @@ fun DeliveryInfo( title: StringResource, resendOption: Boolean, text: StringResource? = null, + detail: StringResource? = null, relays: Int = 0, onConfirm: (() -> Unit) = {}, onDismiss: () -> Unit = {}, @@ -58,6 +59,14 @@ fun DeliveryInfo( style = MaterialTheme.typography.bodyMedium, ) } + detail?.let { + Text( + text = stringResource(it), + modifier = Modifier.padding(top = 8.dp), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium, + ) + } if (relays != 0) { Text( text = pluralStringResource(Res.plurals.relays, relays, relays), diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt index c7c5db0ca3d..fc384b87704 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt @@ -50,6 +50,7 @@ import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.SemanticsPropertyKey import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.semantics @@ -145,6 +146,7 @@ fun MessageItem( val isLocal = node.num == ourNode.num val statusString = message.getStatusStringRes(isDirectMessage) val isDirectImplicitAck = message.status == MessageStatus.DELIVERED && isDirectMessage + val isRetryableFailure = message.status == MessageStatus.ERROR && message.isStatusRetryable(isDirectMessage) // While searching, always show the original text — FTS matches and highlights apply to it, not the translation. val showsTranslation = message.showTranslated && message.translatedText != null && searchQuery.isEmpty() val bodyText = message.displayedText(searching = searchQuery.isNotEmpty()) @@ -403,7 +405,7 @@ fun MessageItem( status = message.status ?: MessageStatus.UNKNOWN, text = stringResource(statusString.second), metadataStyle = metadataStyle, - isWarning = isDirectImplicitAck, + isWarning = isDirectImplicitAck || isRetryableFailure, onStatusClick = onStatusClick, ) } @@ -432,6 +434,8 @@ private enum class ActiveSheet { Emoji, } +internal val MessageStatusColorKey = SemanticsPropertyKey("MessageStatusColor") + /** Row grouping a received message's mesh diagnostics (signature, signal or hops, transport). */ @Composable private fun DiagnosticsRow(modifier: Modifier = Modifier, content: @Composable RowScope.() -> Unit) { @@ -458,6 +462,7 @@ private fun MessageStatusLabel( modifier .fillMaxWidth() .testTag(MESSAGE_STATUS_LABEL_TEST_TAG) + .semantics { this[MessageStatusColorKey] = statusColor } .clickable( onClickLabel = stringResource(Res.string.action_show_message_status), role = Role.Button, diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItemPreviews.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItemPreviews.kt index 9df6ed23fb0..535fdbe097f 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItemPreviews.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItemPreviews.kt @@ -177,7 +177,7 @@ fun MessageItemStatusStatesPreview() { ), StatusPreviewMessage( outgoingStatusPreviewMessage( - text = "No ACK received", + text = "Failed to deliver to mesh", time = "10:04", status = MessageStatus.ERROR, routingError = Routing.Error.MAX_RETRANSMIT.value, @@ -186,7 +186,7 @@ fun MessageItemStatusStatesPreview() { ), StatusPreviewMessage( outgoingStatusPreviewMessage( - text = "No channel selected", + text = "Channel/key mismatch", time = "10:05", status = MessageStatus.ERROR, routingError = Routing.Error.NO_CHANNEL.value, @@ -195,7 +195,7 @@ fun MessageItemStatusStatesPreview() { ), StatusPreviewMessage( outgoingStatusPreviewMessage( - text = "Encrypted send failed", + text = "Could not send encrypted message", time = "10:06", status = MessageStatus.ERROR, routingError = Routing.Error.PKI_FAILED.value, @@ -222,13 +222,85 @@ fun MessageItemStatusStatesPreview() { ), StatusPreviewMessage( outgoingStatusPreviewMessage( - text = "Too large to send", + text = "Message is too large to send", time = "10:09", status = MessageStatus.ERROR, routingError = Routing.Error.TOO_LARGE.value, node = ourNode, ), ), + StatusPreviewMessage( + outgoingStatusPreviewMessage( + text = "No radio interface", + time = "10:10", + status = MessageStatus.ERROR, + routingError = Routing.Error.NO_INTERFACE.value, + node = ourNode, + ), + ), + StatusPreviewMessage( + outgoingStatusPreviewMessage( + text = "Duty cycle limit", + time = "10:11", + status = MessageStatus.ERROR, + routingError = Routing.Error.DUTY_CYCLE_LIMIT.value, + node = ourNode, + ), + ), + StatusPreviewMessage( + outgoingStatusPreviewMessage( + text = "Rate limited", + time = "10:12", + status = MessageStatus.ERROR, + routingError = Routing.Error.RATE_LIMIT_EXCEEDED.value, + node = ourNode, + ), + ), + StatusPreviewMessage( + outgoingStatusPreviewMessage( + text = "No app response", + time = "10:13", + status = MessageStatus.ERROR, + routingError = Routing.Error.NO_RESPONSE.value, + node = ourNode, + ), + ), + StatusPreviewMessage( + outgoingStatusPreviewMessage( + text = "Invalid request", + time = "10:14", + status = MessageStatus.ERROR, + routingError = Routing.Error.BAD_REQUEST.value, + node = ourNode, + ), + ), + StatusPreviewMessage( + outgoingStatusPreviewMessage( + text = "Not authorized", + time = "10:15", + status = MessageStatus.ERROR, + routingError = Routing.Error.NOT_AUTHORIZED.value, + node = ourNode, + ), + ), + StatusPreviewMessage( + outgoingStatusPreviewMessage( + text = "Admin session expired", + time = "10:16", + status = MessageStatus.ERROR, + routingError = Routing.Error.ADMIN_BAD_SESSION_KEY.value, + node = ourNode, + ), + ), + StatusPreviewMessage( + outgoingStatusPreviewMessage( + text = "Admin key not authorized", + time = "10:17", + status = MessageStatus.ERROR, + routingError = Routing.Error.ADMIN_PUBLIC_KEY_UNAUTHORIZED.value, + node = ourNode, + ), + ), ) AppTheme { diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageScreenComponents.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageScreenComponents.kt index 8a202f279c4..a3a78ae59e6 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageScreenComponents.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageScreenComponents.kt @@ -675,6 +675,7 @@ fun MessageStatusDialog( title = title, resendOption = resendOption, text = text, + detail = message.getStatusDetailRes(), relays = message.relays, onConfirm = onResend, onDismiss = onDismiss, diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt index 681dcfd7380..0a092945163 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt @@ -58,6 +58,7 @@ import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.model.MessageStatus import org.meshtastic.core.model.NodeAddress import org.meshtastic.core.model.Reaction +import org.meshtastic.core.model.getMessageStatusDetailRes import org.meshtastic.core.model.getMessageStatusStringRes import org.meshtastic.core.model.isMessageStatusRetryable import org.meshtastic.core.model.util.getShortDateTime @@ -206,6 +207,7 @@ internal fun ReactionDialog( DeliveryInfo( title = title, text = text, + detail = getMessageStatusDetailRes(reaction.status, reaction.routingError), resendOption = isMessageStatusRetryable(reaction.status, reaction.routingError, isDirectMessage), onConfirm = { onResend(reaction) diff --git a/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt index 7578f2d3c09..11c9bce964c 100644 --- a/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt +++ b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt @@ -16,7 +16,11 @@ */ package org.meshtastic.feature.messaging.component +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.graphics.Color import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assert import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithTag @@ -28,6 +32,8 @@ import org.meshtastic.core.model.Message import org.meshtastic.core.model.MessageStatus import org.meshtastic.core.model.Node import org.meshtastic.core.ui.component.preview.NodePreviewParameterProvider +import org.meshtastic.core.ui.theme.AppTheme +import org.meshtastic.core.ui.theme.StatusColors.StatusYellow import org.meshtastic.proto.Routing import kotlin.test.Test import kotlin.test.assertEquals @@ -262,6 +268,65 @@ class MessageItemTest { onNodeWithText("Failed to deliver to mesh", useUnmergedTree = true).assertIsDisplayed() } + @Test + fun channelKeyMismatch_displaysTerminalStatusText() = runComposeUiTest { + val testNode = NodePreviewParameterProvider().mickeyMouse + val message = + localMessage(node = testNode, status = MessageStatus.ERROR, routingError = Routing.Error.NO_CHANNEL.value) + + setContent { + MessageItem(message = message, node = testNode, selected = false, onStatusClick = {}, ourNode = testNode) + } + + onNodeWithText("Channel/key mismatch", useUnmergedTree = true).assertIsDisplayed() + } + + @Test + fun retryableRoutingError_usesWarningStatusColor() = runComposeUiTest { + val testNode = NodePreviewParameterProvider().mickeyMouse + val message = + localMessage( + node = testNode, + status = MessageStatus.ERROR, + routingError = Routing.Error.MAX_RETRANSMIT.value, + ) + var warningColor = Color.Unspecified + + setContent { + AppTheme { + warningColor = MaterialTheme.colorScheme.StatusYellow + MessageItem( + message = message, + node = testNode, + selected = false, + onStatusClick = {}, + ourNode = testNode, + ) + } + } + + onNodeWithTag(MESSAGE_STATUS_LABEL_TEST_TAG, useUnmergedTree = true) + .assert(SemanticsMatcher.expectValue(MessageStatusColorKey, warningColor)) + } + + @Test + fun messageStatusDialog_displaysRoutingFailureExplanation() = runComposeUiTest { + val testNode = NodePreviewParameterProvider().mickeyMouse + val message = + localMessage( + node = testNode, + status = MessageStatus.ERROR, + routingError = Routing.Error.MAX_RETRANSMIT.value, + ) + + setContent { MessageStatusDialog(message = message, resendOption = true, onResend = {}, onDismiss = {}) } + + onNodeWithText("Failed to deliver to mesh").assertIsDisplayed() + onNodeWithText("No node confirmed this message. Try again when you have better signal or more mesh coverage.") + .assertIsDisplayed() + onNodeWithText("Resend").assertIsDisplayed() + } + @Test fun localMessageStatus_invokesStatusClick() = runComposeUiTest { val testNode = NodePreviewParameterProvider().mickeyMouse diff --git a/screenshot-tests/src/screenshotTest/kotlin/org/meshtastic/screenshots/feature/MessagingScreenshotTests.kt b/screenshot-tests/src/screenshotTest/kotlin/org/meshtastic/screenshots/feature/MessagingScreenshotTests.kt index f80889d2134..0881a438ef9 100644 --- a/screenshot-tests/src/screenshotTest/kotlin/org/meshtastic/screenshots/feature/MessagingScreenshotTests.kt +++ b/screenshot-tests/src/screenshotTest/kotlin/org/meshtastic/screenshots/feature/MessagingScreenshotTests.kt @@ -19,6 +19,12 @@ package org.meshtastic.screenshots.feature import androidx.compose.runtime.Composable import androidx.compose.ui.tooling.preview.PreviewLightDark import com.android.tools.screenshot.PreviewTest +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.error +import org.meshtastic.core.resources.message_routing_error_max_retransmit +import org.meshtastic.core.resources.message_routing_error_max_retransmit_detail +import org.meshtastic.core.ui.theme.AppTheme +import org.meshtastic.feature.messaging.DeliveryInfo import org.meshtastic.feature.messaging.EditQuickChatDialogPreview import org.meshtastic.feature.messaging.MessageInputPreview import org.meshtastic.feature.messaging.QuickChatItemPreview @@ -78,6 +84,20 @@ fun ScreenshotMessageItemStatusStates() { MessageItemStatusStatesPreview() } +@PreviewTest +@PreviewLightDark +@Composable +fun ScreenshotMessageStatusFailureDetails() { + AppTheme { + DeliveryInfo( + title = Res.string.error, + text = Res.string.message_routing_error_max_retransmit, + detail = Res.string.message_routing_error_max_retransmit_detail, + resendOption = true, + ) + } +} + @PreviewTest @PreviewLightDark @Composable diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/MessagingScreenshotTestsKt/ScreenshotMessageItemStatusStates_Dark_d19fbf1f_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/MessagingScreenshotTestsKt/ScreenshotMessageItemStatusStates_Dark_d19fbf1f_0.png index b35bd89e7e7..4f13156da75 100644 Binary files a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/MessagingScreenshotTestsKt/ScreenshotMessageItemStatusStates_Dark_d19fbf1f_0.png and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/MessagingScreenshotTestsKt/ScreenshotMessageItemStatusStates_Dark_d19fbf1f_0.png differ diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/MessagingScreenshotTestsKt/ScreenshotMessageItemStatusStates_Light_b29dc7a7_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/MessagingScreenshotTestsKt/ScreenshotMessageItemStatusStates_Light_b29dc7a7_0.png index 2b651d9009c..defcb9d8885 100644 Binary files a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/MessagingScreenshotTestsKt/ScreenshotMessageItemStatusStates_Light_b29dc7a7_0.png and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/MessagingScreenshotTestsKt/ScreenshotMessageItemStatusStates_Light_b29dc7a7_0.png differ diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/MessagingScreenshotTestsKt/ScreenshotMessageStatusFailureDetails_Dark_d19fbf1f_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/MessagingScreenshotTestsKt/ScreenshotMessageStatusFailureDetails_Dark_d19fbf1f_0.png new file mode 100644 index 00000000000..fd40aebd300 Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/MessagingScreenshotTestsKt/ScreenshotMessageStatusFailureDetails_Dark_d19fbf1f_0.png differ diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/MessagingScreenshotTestsKt/ScreenshotMessageStatusFailureDetails_Light_b29dc7a7_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/MessagingScreenshotTestsKt/ScreenshotMessageStatusFailureDetails_Light_b29dc7a7_0.png new file mode 100644 index 00000000000..27e93d80bb1 Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/MessagingScreenshotTestsKt/ScreenshotMessageStatusFailureDetails_Light_b29dc7a7_0.png differ