This commit is contained in:
Benjamin Faershtein 2026-02-08 18:43:26 -08:00
parent 3eef38926f
commit a612cf6518
17 changed files with 2208 additions and 217 deletions

View file

@ -96,6 +96,9 @@
}
},
"shouldTranslate" : false
},
"-" : {
},
": %@" : {
"localizations" : {
@ -200,6 +203,9 @@
}
}
}
},
"#%d" : {
},
"%@" : {
"localizations" : {
@ -1448,6 +1454,12 @@
}
}
}
},
"%llds" : {
},
"•" : {
},
"• %@" : {
"shouldTranslate" : false
@ -3412,6 +3424,9 @@
}
}
}
},
"All Types" : {
},
"Allow Position Requests" : {
"localizations" : {
@ -4349,6 +4364,29 @@
}
}
},
"Attempt %lld" : {
},
"Attempting to send (%lld/%lld)" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Attempting to send (%1$lld/%2$lld)"
}
}
}
},
"Attempts %lld-%lld" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Attempts %1$lld-%2$lld"
}
}
}
},
"Australia / New Zealand" : {
"localizations" : {
"it" : {
@ -6082,6 +6120,13 @@
}
}
},
"Cancel All Retries" : {
},
"Cancel Retry" : {
"comment" : "The text in the confirmation dialog that lets the user cancel a message retry.",
"isCommentAutoGenerated" : true
},
"Canned Message module config received: %@" : {
"localizations" : {
"fr" : {
@ -7519,6 +7564,9 @@
}
}
}
},
"Clear All" : {
},
"Clear App Data" : {
"localizations" : {
@ -9192,6 +9240,9 @@
}
}
}
},
"Created" : {
},
"Created: %@" : {
"localizations" : {
@ -9710,6 +9761,9 @@
}
}
}
},
"Delete %lld group(s) from the queue?" : {
},
"Delete all config, keys and BLE bonds? " : {
"localizations" : {
@ -9908,6 +9962,9 @@
}
}
}
},
"Delete Item?" : {
},
"Delete Message" : {
"localizations" : {
@ -10060,6 +10117,9 @@
}
}
}
},
"Delete retry for message %@?" : {
},
"Description" : {
"localizations" : {
@ -17919,6 +17979,9 @@
}
}
}
},
"ID: %@" : {
},
"If DOP is set, use HDOP / VDOP values instead of PDOP" : {
"localizations" : {
@ -18276,6 +18339,10 @@
}
}
},
"In %llds" : {
"comment" : "A label showing how many seconds remain until a message in the retry queue is retried.",
"isCommentAutoGenerated" : true
},
"In addition to Config, Keys and BLE bonds will be wiped" : {
"localizations" : {
"sr" : {
@ -20660,70 +20727,6 @@
}
}
},
"Max Retransmission Reached" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Maximale Wiederholungen erreicht"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Nombre maximum de retransmissions atteint"
}
},
"he" : {
"stringUnit" : {
"state" : "translated",
"value" : "הגיע למקסימום השליחות מדש"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "Raggiunta la massima ritrasmissione"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "最大再送信回数に到達"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "Osiągnięto limit retransmisji"
}
},
"se" : {
"stringUnit" : {
"state" : "translated",
"value" : "Max antal omsändningar nått"
}
},
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Достигнут максималан број поновних слања"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "已达到最大重试次数"
}
},
"zh-Hant-TW" : {
"stringUnit" : {
"state" : "translated",
"value" : "已達到最大重試次數"
}
}
}
},
"Medium Range - Fast" : {
"localizations" : {
"it" : {
@ -22809,6 +22812,9 @@
}
}
}
},
"Next" : {
},
"Nighttime" : {
"localizations" : {
@ -22908,6 +22914,76 @@
}
}
},
"No acknowledgment heard" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Maximale Wiederholungen erreicht"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "No acknowledgment heard"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Nombre maximum de retransmissions atteint"
}
},
"he" : {
"stringUnit" : {
"state" : "translated",
"value" : "הגיע למקסימום השליחות מדש"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "Raggiunta la massima ritrasmissione"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "最大再送信回数に到達"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "Osiągnięto limit retransmisji"
}
},
"se" : {
"stringUnit" : {
"state" : "translated",
"value" : "Max antal omsändningar nått"
}
},
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Достигнут максималан број поновних слања"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "已达到最大重试次数"
}
},
"zh-Hant-TW" : {
"stringUnit" : {
"state" : "translated",
"value" : "已達到最大重試次數"
}
}
}
},
"No Channel" : {
"localizations" : {
"de" : {
@ -23264,6 +23340,10 @@
}
}
},
"No Matching Items" : {
"comment" : "A label and icon that describe the state when there are no items in the queue.",
"isCommentAutoGenerated" : true
},
"No PAX Counter Logs" : {
"localizations" : {
"it" : {
@ -24119,6 +24199,9 @@
}
}
}
},
"Not sent yet" : {
},
"Notes" : {
"localizations" : {
@ -24201,6 +24284,9 @@
}
}
}
},
"Now" : {
},
"Number of hops" : {
"localizations" : {
@ -27883,6 +27969,10 @@
}
}
},
"Queue Empty" : {
"comment" : "A message indicating that the queue is empty or there are no matching items.",
"isCommentAutoGenerated" : true
},
"Radiation" : {
"localizations" : {
"it" : {
@ -29564,6 +29654,9 @@
}
}
}
},
"Retries" : {
},
"Retrieving nodes" : {
"localizations" : {
@ -29584,6 +29677,20 @@
}
}
}
},
"Retry Attempts" : {
},
"Retry Details" : {
"comment" : "The title of the view that shows detailed information about a retry attempt.",
"isCommentAutoGenerated" : true
},
"Retry Queue" : {
"comment" : "A label displayed in the Developers section of the settings view, describing the retry queue.",
"isCommentAutoGenerated" : true
},
"Retry Queue%@" : {
},
"Retrying (attempt %lld)" : {
"localizations" : {
@ -31168,6 +31275,9 @@
}
}
}
},
"Search queue" : {
},
"Second" : {
"localizations" : {
@ -32220,6 +32330,9 @@
}
}
}
},
"Sending" : {
},
"Sensor" : {
"localizations" : {
@ -35059,6 +35172,10 @@
}
}
},
"Status" : {
"comment" : "A section header for the status information of a retry queue item.",
"isCommentAutoGenerated" : true
},
"Stay Connected Anywhere" : {
"localizations" : {
"de" : {
@ -35075,6 +35192,10 @@
}
}
},
"Stop" : {
"comment" : "A button label that stops a retry attempt.",
"isCommentAutoGenerated" : true
},
"Store & Forward" : {
"localizations" : {
"it" : {
@ -37120,7 +37241,12 @@
}
}
},
"This message is currently being retried (%@). Would you like to cancel the retry?" : {
"comment" : "A message inside the confirmation dialog that informs the user that a message is currently being retried and asks if they want to cancel it.",
"isCommentAutoGenerated" : true
},
"This message was likely not delivered." : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@ -37154,6 +37280,10 @@
}
}
},
"This message was likely not delivered. Would you like to try again?" : {
"comment" : "A message displayed in a confirmation dialog within the RetryButton, explaining that the message was not delivered and the user can try again.",
"isCommentAutoGenerated" : true
},
"This node does not support any configurable modules." : {
"localizations" : {
"it" : {
@ -37635,6 +37765,9 @@
}
}
}
},
"Time:" : {
},
"Timeout" : {
"localizations" : {
@ -38774,6 +38907,10 @@
}
}
},
"Type" : {
"comment" : "The column header for the \"Type\" column in the Retry Queue view.",
"isCommentAutoGenerated" : true
},
"UDP Broadcast" : {
"localizations" : {
"it" : {
@ -40863,8 +41000,12 @@
}
}
}
},
"Waiting for acknowledgment" : {
},
"Waiting to be acknowledged. . ." : {
"extractionState" : "stale",
"localizations" : {
"it" : {
"stringUnit" : {

View file

@ -98,12 +98,15 @@
B3E905B12B71F7F300654D07 /* TextMessageField.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3E905B02B71F7F300654D07 /* TextMessageField.swift */; };
BC10380F2DD4334400B00BFA /* AddContactIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC10380E2DD4333C00B00BFA /* AddContactIntent.swift */; };
BC6B45FF2CB2F98900723CEB /* SaveChannelSettingsIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC6B45FE2CB2F98900723CEB /* SaveChannelSettingsIntent.swift */; };
BC7A24082F20AF9100C08B77 /* MessageRetryQueueManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC7A24072F20AF9100C08B77 /* MessageRetryQueueManager.swift */; };
BC7A240A2F20BD3A00C08B77 /* RetryQueueView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC7A24092F20BD3A00C08B77 /* RetryQueueView.swift */; };
BCA9A82C2EC802CF00166292 /* CompassView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCA9A82B2EC802CF00166292 /* CompassView.swift */; };
BCB35B4F2E5FC42500B04F60 /* MessageNodeIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCB35B4E2E5FC41E00B04F60 /* MessageNodeIntent.swift */; };
BCB613812C67290800485544 /* SendWaypointIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCB613802C67290800485544 /* SendWaypointIntent.swift */; };
BCB613832C672A2600485544 /* MessageChannelIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCB613822C672A2600485544 /* MessageChannelIntent.swift */; };
BCB613852C68703800485544 /* NodePositionIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCB613842C68703800485544 /* NodePositionIntent.swift */; };
BCB613872C69A0FB00485544 /* AppIntentErrors.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCB613862C69A0FB00485544 /* AppIntentErrors.swift */; };
BCCFE27E2F25ECE000984673 /* AnimatedEllipsis.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCCFE27D2F25ECE000984673 /* AnimatedEllipsis.swift */; };
BCD7448D2E0F2FAA00F265A2 /* ContactURLHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCD7448C2E0F2FA300F265A2 /* ContactURLHandler.swift */; };
BCD93CBA2D9E11A2006C9214 /* DisconnectNodeIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCD93CB92D9E11A2006C9214 /* DisconnectNodeIntent.swift */; };
BCDDFA9A2DBB180D0065189C /* ScrollToBottomButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCDDFA992DBB180D0065189C /* ScrollToBottomButton.swift */; };
@ -412,12 +415,15 @@
B3E905B02B71F7F300654D07 /* TextMessageField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextMessageField.swift; sourceTree = "<group>"; };
BC10380E2DD4333C00B00BFA /* AddContactIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddContactIntent.swift; sourceTree = "<group>"; };
BC6B45FE2CB2F98900723CEB /* SaveChannelSettingsIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaveChannelSettingsIntent.swift; sourceTree = "<group>"; };
BC7A24072F20AF9100C08B77 /* MessageRetryQueueManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRetryQueueManager.swift; sourceTree = "<group>"; };
BC7A24092F20BD3A00C08B77 /* RetryQueueView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RetryQueueView.swift; sourceTree = "<group>"; };
BCA9A82B2EC802CF00166292 /* CompassView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompassView.swift; sourceTree = "<group>"; };
BCB35B4E2E5FC41E00B04F60 /* MessageNodeIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageNodeIntent.swift; sourceTree = "<group>"; };
BCB613802C67290800485544 /* SendWaypointIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendWaypointIntent.swift; sourceTree = "<group>"; };
BCB613822C672A2600485544 /* MessageChannelIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageChannelIntent.swift; sourceTree = "<group>"; };
BCB613842C68703800485544 /* NodePositionIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodePositionIntent.swift; sourceTree = "<group>"; };
BCB613862C69A0FB00485544 /* AppIntentErrors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIntentErrors.swift; sourceTree = "<group>"; };
BCCFE27D2F25ECE000984673 /* AnimatedEllipsis.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimatedEllipsis.swift; sourceTree = "<group>"; };
BCD7448C2E0F2FA300F265A2 /* ContactURLHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactURLHandler.swift; sourceTree = "<group>"; };
BCD93CB92D9E11A2006C9214 /* DisconnectNodeIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisconnectNodeIntent.swift; sourceTree = "<group>"; };
BCDDFA992DBB180D0065189C /* ScrollToBottomButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollToBottomButton.swift; sourceTree = "<group>"; };
@ -963,6 +969,7 @@
DD4A911C2708C57100501B7E /* Settings */ = {
isa = PBXGroup;
children = (
BC7A24092F20BD3A00C08B77 /* RetryQueueView.swift */,
DD9C70102E916EA200106227 /* UpdateIntervalPicker.swift */,
DDD5BB0E2C285F92007E03CA /* Logs */,
DD93800C2BA74CE3008BEC06 /* Channels */,
@ -1263,6 +1270,7 @@
DDC2E18D26CE25CB0042C5E4 /* Helpers */ = {
isa = PBXGroup;
children = (
BCCFE27D2F25ECE000984673 /* AnimatedEllipsis.swift */,
233E99B42D849C2D00CC3A77 /* Compact Widgets */,
DD6F65772C6EAB860053C113 /* Help */,
DD5E523D298F5A7D00D21B61 /* Weather */,
@ -1290,6 +1298,7 @@
DDC2E1A526CEB32B0042C5E4 /* Helpers */ = {
isa = PBXGroup;
children = (
BC7A24072F20AF9100C08B77 /* MessageRetryQueueManager.swift */,
3D3417D12E2DC260006A988B /* MapDataManager.swift */,
3D3417C72E29D38A006A988B /* GeoJSONOverlayConfig.swift */,
BCD7448C2E0F2FA300F265A2 /* ContactURLHandler.swift */,
@ -1662,6 +1671,7 @@
237AEB952E1FE516003B7CE3 /* Device.swift in Sources */,
2373AE172D0A26620086C749 /* EnvironmentDefaultSeries.swift in Sources */,
233E99B82D849C6500CC3A77 /* HumidityCompactWidget.swift in Sources */,
BC7A24082F20AF9100C08B77 /* MessageRetryQueueManager.swift in Sources */,
DD4640202AFF10F4002A5ECB /* WaypointForm.swift in Sources */,
233E99C12D849D6000CC3A77 /* DistanceCompactWidget.swift in Sources */,
DD769E0328D18BF1001A3F05 /* DeviceMetricsLog.swift in Sources */,
@ -1799,6 +1809,7 @@
D9C983A02B79D0E800BDBE6A /* AlertButton.swift in Sources */,
8D3F8A412D44C2A6009EAAA4 /* PowerMetricsLog.swift in Sources */,
DD86D4112881D16900BAEB7A /* WriteCsvFile.swift in Sources */,
BC7A240A2F20BD3A00C08B77 /* RetryQueueView.swift in Sources */,
DD6F65762C6EA5490053C113 /* AckErrors.swift in Sources */,
DDDB445029F8AC9C00EE2349 /* UIImage.swift in Sources */,
DD86D40F2881BE4C00BAEB7A /* CsvDocument.swift in Sources */,
@ -1815,6 +1826,7 @@
DDB6ABE428B13FFF00384BA1 /* DisplayEnums.swift in Sources */,
DD4975A52B147BA90026544E /* AmbientLightingConfig.swift in Sources */,
3D3417D22E2DC260006A988B /* MapDataManager.swift in Sources */,
BCCFE27E2F25ECE000984673 /* AnimatedEllipsis.swift in Sources */,
D93068D92B81509C0066FBC8 /* TapbackResponses.swift in Sources */,
D93068DA2B81509D0066FBC8 /* TapbackInputView.swift in Sources */,
DDA9F5E82E77FAC100E70DEB /* AnimatedNodePin.swift in Sources */,

View file

@ -1,5 +1,5 @@
{
"originHash" : "2569905853aec088d5bac6b540eac77f78963f88b406e8dd95a88c40623cc8b4",
"originHash" : "295f50f25c1dce2f9776968206cbdeca800c36d0f49d4c77f36ddc3954798d2b",
"pins" : [
{
"identity" : "cocoamqtt",
@ -15,8 +15,17 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/DataDog/dd-sdk-ios.git",
"state" : {
"revision" : "d0a42d8067665cb6ee86af51251ccc071f62bd54",
"version" : "2.29.0"
"revision" : "c4dc12da013508db4d3dc2993faa4b1b3eb56fc9",
"version" : "3.5.0"
}
},
{
"identity" : "kscrash",
"kind" : "remoteSourceControl",
"location" : "https://github.com/kstenerud/KSCrash.git",
"state" : {
"revision" : "72e742c81d4ba03fab137e2651a1de342cdd8b3a",
"version" : "2.5.0"
}
},
{
@ -29,21 +38,12 @@
}
},
{
"identity" : "opentelemetry-swift-packages",
"identity" : "opentelemetry-swift-core",
"kind" : "remoteSourceControl",
"location" : "https://github.com/DataDog/opentelemetry-swift-packages.git",
"location" : "https://github.com/open-telemetry/opentelemetry-swift-core",
"state" : {
"revision" : "4a7295600d4ebb9525a23c11586c5fdb74ae8b7e",
"version" : "1.13.1"
}
},
{
"identity" : "plcrashreporter",
"kind" : "remoteSourceControl",
"location" : "https://github.com/microsoft/plcrashreporter.git",
"state" : {
"revision" : "8c61e5e38e9f737dd68512ed1ea5ab081244ad65",
"version" : "1.12.0"
"revision" : "240c8d5e36c3c7b774ed961325369f0b1f2c965f",
"version" : "2.3.0"
}
},
{
@ -55,13 +55,22 @@
"version" : "4.0.8"
}
},
{
"identity" : "swift-atomics",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-atomics.git",
"state" : {
"revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7",
"version" : "1.3.0"
}
},
{
"identity" : "swift-protobuf",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-protobuf.git",
"state" : {
"revision" : "102a647b573f60f73afdce5613a51d71349fe507",
"version" : "1.30.0"
"revision" : "c169a5744230951031770e27e475ff6eefe51f9d",
"version" : "1.33.3"
}
}
],

View file

@ -1,13 +1,13 @@
{
"originHash" : "25240dd07109fa832be10093f5d97529f872f18e8d9df6468e5e4212bc0b487e",
"originHash" : "dfbb49c0054837d8ee431d028632d3dcd136e6d827e039d8867b1343ec8ca69b",
"pins" : [
{
"identity" : "cocoamqtt",
"kind" : "remoteSourceControl",
"location" : "https://github.com/emqx/CocoaMQTT",
"state" : {
"revision" : "aff43422925cc30b9af319f4c4dce4f52859baf4",
"version" : "2.1.8"
"revision" : "22b98acc75bdca77917a1093bd3e1b45ef6e9718",
"version" : "2.1.9"
}
},
{
@ -15,8 +15,17 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/DataDog/dd-sdk-ios.git",
"state" : {
"revision" : "8d67e973ff4a958cb536263cb816646ee904c508",
"version" : "3.3.0"
"revision" : "c4dc12da013508db4d3dc2993faa4b1b3eb56fc9",
"version" : "3.5.0"
}
},
{
"identity" : "kscrash",
"kind" : "remoteSourceControl",
"location" : "https://github.com/kstenerud/KSCrash.git",
"state" : {
"revision" : "72e742c81d4ba03fab137e2651a1de342cdd8b3a",
"version" : "2.5.0"
}
},
{
@ -29,21 +38,12 @@
}
},
{
"identity" : "opentelemetry-swift-packages",
"identity" : "opentelemetry-swift-core",
"kind" : "remoteSourceControl",
"location" : "https://github.com/DataDog/opentelemetry-swift-packages.git",
"location" : "https://github.com/open-telemetry/opentelemetry-swift-core",
"state" : {
"revision" : "4a7295600d4ebb9525a23c11586c5fdb74ae8b7e",
"version" : "1.13.1"
}
},
{
"identity" : "plcrashreporter",
"kind" : "remoteSourceControl",
"location" : "https://github.com/microsoft/plcrashreporter.git",
"state" : {
"revision" : "8c61e5e38e9f737dd68512ed1ea5ab081244ad65",
"version" : "1.12.0"
"revision" : "240c8d5e36c3c7b774ed961325369f0b1f2c965f",
"version" : "2.3.0"
}
},
{
@ -55,6 +55,15 @@
"version" : "4.0.8"
}
},
{
"identity" : "swift-atomics",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-atomics.git",
"state" : {
"revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7",
"version" : "1.3.0"
}
},
{
"identity" : "swift-protobuf",
"kind" : "remoteSourceControl",

View file

@ -54,6 +54,9 @@ extension AccessoryManager {
Logger.services.error("Failed to serialize position packet data")
throw AccessoryError.ioFailed("sendPosition: Unable to serialize position packet data")
}
meshPacket.id = UInt32.random(in: UInt32(UInt8.max)..<UInt32.max)
meshPacket.wantAck = true
var toRadio: ToRadio!
toRadio = ToRadio()

View file

@ -156,6 +156,9 @@ class AccessoryManager: ObservableObject, MqttClientProxyManagerDelegate {
// Flash subjects
@Published var packetsSent: Int = 0
// Sent packets for retry on NACK
var sentPackets: [UInt32: MeshPacket] = [:]
@Published var packetsReceived: Int = 0
// Continuations
@ -358,7 +361,12 @@ class AccessoryManager: ObservableObject, MqttClientProxyManagerDelegate {
func send(_ data: ToRadio, debugDescription: String? = nil) async throws {
packetsSent += 1
// Store packet for potential retry on NACK
if data.packet.wantAck {
sentPackets[data.packet.id] = data.packet
}
guard let active = activeConnection,
await active.connection.isConnected else {
throw AccessoryError.connectionFailed("Not connected to any device")

View file

@ -39,11 +39,11 @@ enum RoutingError: Int, CaseIterable, Identifiable {
case .gotNak:
return "Received a negative acknowledgment".localized
case .timeout:
return "Timeout".localized
return "No acknowledgment heard".localized
case .noInterface:
return "No Interface".localized
case .maxRetransmit:
return "Max Retransmission Reached".localized
return "No acknowledgment heard".localized
case .noChannel:
return "No Channel".localized
case .tooLarge:

View file

@ -618,26 +618,38 @@ func adminResponseAck (packet: MeshPacket, context: NSManagedObjectContext) {
let fetchedAdminMessageRequest = MessageEntity.fetchRequest()
fetchedAdminMessageRequest.predicate = NSPredicate(format: "messageId == %lld", packet.decoded.requestID)
do {
let fetchedMessage = try context.fetch(fetchedAdminMessageRequest)
if fetchedMessage.count > 0 {
fetchedMessage[0].ackTimestamp = Int32(Date().timeIntervalSince1970)
fetchedMessage[0].ackError = Int32(RoutingError.none.rawValue)
fetchedMessage[0].receivedACK = true
fetchedMessage[0].realACK = true
fetchedMessage[0].relayNode = Int64(packet.relayNode)
fetchedMessage[0].ackSNR = packet.rxSnr
if fetchedMessage[0].fromUser != nil {
fetchedMessage[0].fromUser?.objectWillChange.send()
let fetchedMessage = try context.fetch(fetchedAdminMessageRequest)
if fetchedMessage.count > 0 {
fetchedMessage[0].ackTimestamp = Int32(Date().timeIntervalSince1970)
fetchedMessage[0].ackError = Int32(RoutingError.none.rawValue)
fetchedMessage[0].receivedACK = true
fetchedMessage[0].realACK = true
fetchedMessage[0].relayNode = Int64(packet.relayNode)
fetchedMessage[0].ackSNR = packet.rxSnr
if fetchedMessage[0].fromUser != nil {
fetchedMessage[0].fromUser?.objectWillChange.send()
}
do {
try context.save()
} catch {
Logger.data.error("Failed to save admin message response as an ack: \(error.localizedDescription, privacy: .public)")
}
}
do {
try context.save()
} catch {
Logger.data.error("Failed to save admin message response as an ack: \(error.localizedDescription, privacy: .public)")
// If this ACK is for a retried packet (new packet ID), also mark the original message ID as completed.
Task {
if let originalId = await MessageRetryQueueManager.shared.originalMessageId(forPacketId: UInt32(packet.decoded.requestID)) {
await MessageRetryQueueManager.shared.markCompleted(for: originalId)
}
}
// Always try to remove from retry queue - works for both original and retry packet IDs
Task {
await MessageRetryQueueManager.shared.markCompleted(for: Int64(packet.decoded.requestID))
await MessageRetryQueueManager.shared.markCompletedByPacketId(UInt32(packet.decoded.requestID))
}
} catch {
Logger.data.error("Failed to fetch admin message by requestID: \(error.localizedDescription, privacy: .public)")
}
} catch {
Logger.data.error("Failed to fetch admin message by requestID: \(error.localizedDescription, privacy: .public)")
}
}
func paxCounterPacket (packet: MeshPacket, context: NSManagedObjectContext) {
@ -694,28 +706,39 @@ func routingPacket (packet: MeshPacket, connectedNodeNum: Int64, context: NSMana
do {
let fetchedMessage = try context.fetch(fetchMessageRequest)
if fetchedMessage.count > 0 {
if fetchedMessage[0].toUser != nil {
// Real ACK from DM Recipient
let message = fetchedMessage[0]
if message.toUser != nil {
if packet.to != packet.from {
fetchedMessage[0].realACK = true
message.realACK = true
}
}
fetchedMessage[0].relayNode = Int64(packet.relayNode)
fetchedMessage[0].ackError = Int32(routingMessage.errorReason.rawValue)
message.relayNode = Int64(packet.relayNode)
message.ackError = Int32(routingMessage.errorReason.rawValue)
if routingMessage.errorReason == Routing.Error.none {
fetchedMessage[0].receivedACK = true
fetchedMessage[0].relays += 1
// Successful ACK
message.receivedACK = true
message.relays += 1
} else if RoutingError(rawValue: routingMessage.errorReason.rawValue)?.canRetry ?? false {
// Failed but retryable - add to retry queue
Task {
let retryQueue = MessageRetryQueueManager.shared
// Handle the NACK - this will find existing items and increment retry count
await retryQueue.handleNack(for: Int64(packet.decoded.requestID))
}
}
fetchedMessage[0].ackSNR = packet.rxSnr
message.ackSNR = packet.rxSnr
if packet.rxTime > 0 {
fetchedMessage[0].ackTimestamp = Int32(truncatingIfNeeded: packet.rxTime)
message.ackTimestamp = Int32(truncatingIfNeeded: packet.rxTime)
} else {
fetchedMessage[0].ackTimestamp = Int32(Date().timeIntervalSince1970)
message.ackTimestamp = Int32(Date().timeIntervalSince1970)
}
if fetchedMessage[0].toUser != nil {
fetchedMessage[0].toUser!.objectWillChange.send()
if message.toUser != nil {
message.toUser!.objectWillChange.send()
} else {
let fetchMyInfoRequest = MyInfoEntity.fetchRequest()
fetchMyInfoRequest.predicate = NSPredicate(format: "myNodeNum == %lld", connectedNodeNum)
@ -730,9 +753,100 @@ func routingPacket (packet: MeshPacket, connectedNodeNum: Int64, context: NSMana
} catch { }
}
} else {
return
}
// If this ACK/NACK is for a retried packet (new packet ID), also update the original message entity.
Task { @MainActor in
if let originalId = await MessageRetryQueueManager.shared.originalMessageId(forPacketId: UInt32(packet.decoded.requestID)) {
let originalFetch = MessageEntity.fetchRequest()
originalFetch.predicate = NSPredicate(format: "messageId == %lld", originalId)
if let originalMessage = try? context.fetch(originalFetch).first {
originalMessage.ackError = Int32(routingMessage.errorReason.rawValue)
if routingMessage.errorReason == Routing.Error.none {
originalMessage.receivedACK = true
originalMessage.relays += 1
}
if packet.rxTime > 0 {
originalMessage.ackTimestamp = Int32(truncatingIfNeeded: packet.rxTime)
} else {
originalMessage.ackTimestamp = Int32(Date().timeIntervalSince1970)
}
originalMessage.ackSNR = packet.rxSnr
try? context.save()
}
}
}
// Also check for traceroute failures (uses TraceRouteEntity instead of MessageEntity)
if routingMessage.errorReason != Routing.Error.none {
let fetchTraceRouteRequest = TraceRouteEntity.fetchRequest()
fetchTraceRouteRequest.predicate = NSPredicate(format: "id == %lld", Int64(packet.decoded.requestID))
do {
let fetchedTraceRoutes = try context.fetch(fetchTraceRouteRequest)
if fetchedTraceRoutes.count > 0 {
let traceRoute = fetchedTraceRoutes[0]
if RoutingError(rawValue: routingMessage.errorReason.rawValue)?.canRetry ?? false {
// Failed but retryable - add traceroute to retry queue
Task {
let retryQueue = MessageRetryQueueManager.shared
// Handle the NACK - this will find existing items and increment retry count
await retryQueue.handleNack(for: Int64(packet.decoded.requestID))
}
} else {
// Mark as not sent for non-retryable errors
traceRoute.sent = false
}
}
} catch {
Logger.mesh.error("Failed to fetch TraceRouteEntity for retry: \(error.localizedDescription, privacy: .public)")
}
} else {
// Check for other packet types that can retry
if RoutingError(rawValue: routingMessage.errorReason.rawValue)?.canRetry ?? false {
Task { @MainActor in
if let originalPacket = AccessoryManager.shared.sentPackets[UInt32(packet.decoded.requestID)] {
let messageType: MessageType
switch originalPacket.decoded.portnum {
case .positionApp: messageType = .position
case .waypointApp: messageType = .waypoint
case .adminApp: messageType = .admin
case .nodeinfoApp: messageType = .nodeInfo
default: messageType = .unknown
}
if let serializedData = try? originalPacket.serializedData() {
let item = RetryQueueItem(
originalMessageId: Int64(packet.decoded.requestID),
messageType: messageType,
serializedPacket: serializedData
)
Task {
await MessageRetryQueueManager.shared.addToRetryQueue(item)
}
}
}
}
}
}
// Always try to remove from retry queue - works for both original and retry packet IDs
if routingMessage.errorReason == Routing.Error.none {
Task {
if let originalId = await MessageRetryQueueManager.shared.originalMessageId(forPacketId: UInt32(packet.decoded.requestID)) {
await MessageRetryQueueManager.shared.markCompleted(for: originalId)
}
await MessageRetryQueueManager.shared.markCompleted(for: Int64(packet.decoded.requestID))
// Also check by packetId for retried messages (ACK comes with new packet ID)
await MessageRetryQueueManager.shared.markCompletedByPacketId(UInt32(packet.decoded.requestID))
}
}
// Remove the sent packet from cache
Task { @MainActor in
AccessoryManager.shared.sentPackets.removeValue(forKey: UInt32(packet.decoded.requestID))
}
try context.save()
Logger.data.info("💾 ACK Saved for Message: \(packet.decoded.requestID, privacy: .public)")
} catch {

View file

@ -0,0 +1,873 @@
//
// MessageRetryQueueManager.swift
// Meshtastic
//
// Retry queue manager using Swift actors and Task scheduling
//
import Foundation
import CoreData
import MeshtasticProtobufs
import OSLog
import Combine
enum MessageType: String, Codable, CaseIterable {
case text = "text"
case position = "position"
case waypoint = "waypoint"
case admin = "admin"
case traceroute = "traceroute"
case nodeInfo = "nodeInfo"
case unknown = "unknown"
}
enum RetryState: String, Codable {
case pending = "pending"
case sending = "sending"
case waitingForAck = "waitingForAck"
case completed = "completed"
case failed = "failed"
case cancelled = "cancelled"
}
struct RetryQueueItem: Identifiable, Hashable {
let id: UUID
let originalMessageId: Int64
let messageType: MessageType
let serializedPacket: Data? // Full MeshPacket serialized for retry
let createdAt: Date
var retryCount: Int
var state: RetryState
var nextRetryDate: Date
var lastError: String?
var currentPacketId: UInt32? // Track the current packet ID being sent for ACK lookup
var packetIdHistory: [UInt32] // Track all packet IDs associated with this retry chain
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
init(
id: UUID = UUID(),
originalMessageId: Int64,
messageType: MessageType,
serializedPacket: Data? = nil
) {
self.id = id
self.originalMessageId = originalMessageId
self.messageType = messageType
self.serializedPacket = serializedPacket
self.createdAt = Date()
// If an item is in the retry queue, we're scheduling at least the first retry.
// retryCount is 1-based: 1 = first retry (attempt 2/3), 2 = second retry (attempt 3/3)
self.retryCount = 1
self.state = .pending
self.nextRetryDate = Date().addingTimeInterval(messageType == .traceroute ? 30 : 10)
self.currentPacketId = nil
self.packetIdHistory = [UInt32(truncatingIfNeeded: originalMessageId)]
}
// Convenience initializers for backwards compatibility
init(
id: UUID = UUID(),
originalMessageId: Int64,
messageType: MessageType,
payload: Data,
portNum: PortNum,
toUserNum: Int64,
channel: Int32,
isEmoji: Bool = false,
replyID: Int64 = 0,
pkiEncrypted: Bool = false,
publicKey: Data? = nil,
originalPayload: String? = nil,
hopLimit: UInt32? = nil
) {
self.id = id
self.originalMessageId = originalMessageId
self.messageType = messageType
self.serializedPacket = nil
self.createdAt = Date()
self.retryCount = 1
self.state = .pending
self.nextRetryDate = Date().addingTimeInterval(messageType == .traceroute ? 30 : 10)
self.currentPacketId = nil
self.packetIdHistory = [UInt32(truncatingIfNeeded: originalMessageId)]
}
var normalizedRetryCount: Int { max(1, retryCount) }
// Display attempt number: original send = 1, first retry = 2, second retry = 3
var displayAttemptNumber: Int {
normalizedRetryCount + 1
}
func matchesPacketId(_ packetId: UInt32) -> Bool {
currentPacketId == packetId || packetIdHistory.contains(packetId)
}
static func == (lhs: RetryQueueItem, rhs: RetryQueueItem) -> Bool {
lhs.id == rhs.id
}
}
actor MessageRetryQueueManager {
static let shared = MessageRetryQueueManager()
// Posted whenever queue state changes (for UI refresh)
nonisolated static let didUpdateNotification = Foundation.Notification.Name("MessageRetryQueueManager.didUpdate")
private var queue: [RetryQueueItem] = []
private var failedMessageIds: Set<Int64> = [] // Track messages that have exhausted retries
private var processingTask: Task<Void, Never>?
private var cancellables = Set<AnyCancellable>()
private(set) var pendingCount: Int = 0
let maxRetries = 2
private let retryDelays: [TimeInterval] = [10, 20] // First retry at 10s, second at 20s
private let tracerouteRetryDelays: [TimeInterval] = [30, 60] // Traceroute retries with 30s and 60s delays
private let minimumRetrySpacing: TimeInterval = 10
private let queueProcessingInterval: TimeInterval = 1.0
private let tracerouteCooldown: TimeInterval = 30.0 // Traceroute has 30s rate limit
private var lastTracerouteRetryTime: Date?
private func setCurrentPacketId(for itemId: UUID, packetId: UInt32) {
if let index = queue.firstIndex(where: { $0.id == itemId }) {
queue[index].currentPacketId = packetId
if !queue[index].packetIdHistory.contains(packetId) {
queue[index].packetIdHistory.append(packetId)
}
notifyUpdate()
}
}
private func notifyUpdate() {
Task { @MainActor in
NotificationCenter.default.post(name: MessageRetryQueueManager.didUpdateNotification, object: nil)
}
}
private init() {
Task { [weak self] in
guard let self = self else { return }
await self.startQueueProcessor()
}
}
func startQueueProcessor() {
guard processingTask == nil else { return }
processingTask = Task { [weak self] in
guard let self = self else { return }
while !Task.isCancelled {
await self.processQueue()
try? await Task.sleep(nanoseconds: UInt64(self.queueProcessingInterval * 1_000_000_000))
}
}
Logger.mesh.info("📬 Message retry queue processor started")
}
func stopQueueProcessor() {
processingTask?.cancel()
processingTask = nil
Logger.mesh.info("📬 Message retry queue processor stopped")
notifyUpdate()
}
func processQueue() async {
guard await AccessoryManager.shared.isConnected else {
return
}
let now = Date()
var itemsToProcess: [RetryQueueItem] = []
for item in queue where item.state == .pending && item.nextRetryDate <= now {
// Check traceroute cooldown
if item.messageType == .traceroute, let lastTime = lastTracerouteRetryTime {
let timeSinceLastTraceroute = now.timeIntervalSince(lastTime)
if timeSinceLastTraceroute < tracerouteCooldown {
// Skip this traceroute retry, will be picked up in next processing cycle
continue
}
}
itemsToProcess.append(item)
}
for item in itemsToProcess {
guard !Task.isCancelled else { break }
// Update traceroute cooldown tracker
if item.messageType == .traceroute {
lastTracerouteRetryTime = Date()
}
await processItem(item)
}
self.pendingCount = queue.filter { $0.state == .pending || $0.state == .waitingForAck || $0.state == .sending }.count
}
private func processItem(_ item: RetryQueueItem) async {
guard item.state == .pending else { return }
updateItemState(item.id, state: .sending)
do {
switch item.messageType {
case .text:
if item.serializedPacket != nil {
try await resendFromSerializedPacket(item)
} else {
try await resendTextMessage(item)
}
case .position:
try await resendPositionMessage(item)
case .waypoint:
if item.serializedPacket != nil {
try await resendFromSerializedPacket(item)
} else {
try await resendWaypointMessage(item)
}
case .admin:
if item.serializedPacket != nil {
try await resendFromSerializedPacket(item)
} else {
Logger.mesh.warning("Admin message retry with no payload")
updateItemState(item.id, state: .failed)
return
}
case .traceroute:
try await resendTracerouteMessage(item)
case .nodeInfo:
if item.serializedPacket != nil {
try await resendFromSerializedPacket(item)
} else {
Logger.mesh.warning("Node info retry with no payload")
updateItemState(item.id, state: .failed)
return
}
case .unknown:
if item.serializedPacket != nil {
try await resendFromSerializedPacket(item)
} else {
Logger.mesh.warning("Unknown message retry with no payload")
updateItemState(item.id, state: .failed)
return
}
}
updateItemState(item.id, state: .waitingForAck)
Logger.mesh.info("📬 Message \(item.originalMessageId) attempt \(item.displayAttemptNumber)/\(self.maxRetries + 1) sent successfully")
} catch {
Logger.mesh.error("📬 Failed to retry message \(item.originalMessageId): \(error.localizedDescription, privacy: .public)")
let newRetryCount = item.normalizedRetryCount + 1
if newRetryCount > maxRetries {
updateItemState(item.id, state: .failed)
updateItemError(item.id, error: error.localizedDescription)
// Track that this message has exhausted its retries
failedMessageIds.insert(item.originalMessageId)
} else {
let delay = retryDelay(for: item.messageType, retryCount: newRetryCount)
updateItemRetry(item.id, retryCount: newRetryCount, nextRetryDate: Date().addingTimeInterval(delay))
updateItemState(item.id, state: .pending)
}
}
}
private func retryDelay(for messageType: MessageType, retryCount: Int) -> TimeInterval {
switch messageType {
case .traceroute:
return tracerouteRetryDelays[safe: retryCount - 1] ?? tracerouteCooldown
default:
return retryDelays[safe: retryCount - 1] ?? 20
}
}
private func resendFromSerializedPacket(_ item: RetryQueueItem) async throws {
guard let serializedData = item.serializedPacket else {
throw AccessoryError.appError("No serialized packet for retry")
}
var meshPacket = try MeshPacket(serializedData: serializedData)
let newMessageId = UInt32.random(in: UInt32(UInt8.max)..<UInt32.max)
meshPacket.id = newMessageId
setCurrentPacketId(for: item.id, packetId: newMessageId)
var toRadio = ToRadio()
toRadio.packet = meshPacket
try await AccessoryManager.shared.send(toRadio, debugDescription: "Retry \(item.messageType) for message \(item.originalMessageId)")
}
private func resendTextMessage(_ item: RetryQueueItem) async throws {
// Try to get the original message from the database
let context = PersistenceController.shared.container.viewContext
let fetchRequest = MessageEntity.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "messageId == %lld", item.originalMessageId)
var originalPayload: String?
var channel: Int32 = 0
var isEmoji: Bool = false
var replyID: Int64 = 0
var toUserNum: Int64 = 0
do {
let fetchedMessages = try context.fetch(fetchRequest)
if let message = fetchedMessages.first {
originalPayload = message.messagePayload
channel = message.channel
isEmoji = message.isEmoji
replyID = message.replyID
toUserNum = message.toUser?.num ?? 0
}
} catch {
Logger.mesh.error("📬 Failed to fetch message for retry: \(error.localizedDescription, privacy: .public)")
}
guard originalPayload != nil else {
throw AccessoryError.appError("Missing message payload")
}
let newMessageId = UInt32.random(in: UInt32(UInt8.max)..<UInt32.max)
setCurrentPacketId(for: item.id, packetId: newMessageId)
var meshPacket = MeshPacket()
meshPacket.id = newMessageId
if toUserNum > 0 {
meshPacket.to = UInt32(toUserNum)
} else {
meshPacket.to = Constants.maximumNodeNum
}
meshPacket.channel = UInt32(channel)
meshPacket.from = UInt32(await AccessoryManager.shared.activeDeviceNum ?? 0)
meshPacket.wantAck = true
var dataMessage = DataMessage()
if let payloadData = originalPayload?.data(using: .utf8) {
dataMessage.payload = payloadData
}
dataMessage.portnum = .textMessageApp
dataMessage.emoji = isEmoji ? 1 : 0
if replyID > 0 {
dataMessage.replyID = UInt32(replyID)
}
meshPacket.decoded = dataMessage
var toRadio = ToRadio()
toRadio.packet = meshPacket
try await AccessoryManager.shared.send(toRadio, debugDescription: "Retry message \(item.originalMessageId) -> \(newMessageId)")
}
private func resendPositionMessage(_ item: RetryQueueItem) async throws {
guard let fromNodeNum = await AccessoryManager.shared.activeConnection?.device.num else {
throw AccessoryError.ioFailed("Not connected to any device")
}
guard let positionPacket = try await AccessoryManager.shared.getPositionFromPhoneGPS(destNum: fromNodeNum, fixedPosition: false) else {
throw AccessoryError.appError("Unable to get position data")
}
var meshPacket = MeshPacket()
let newMessageId = UInt32.random(in: UInt32(UInt8.max)..<UInt32.max)
meshPacket.id = newMessageId
meshPacket.to = Constants.maximumNodeNum
meshPacket.channel = 0
meshPacket.from = UInt32(fromNodeNum)
meshPacket.wantAck = true
setCurrentPacketId(for: item.id, packetId: newMessageId)
var dataMessage = DataMessage()
if let serializedData = try? positionPacket.serializedData() {
dataMessage.payload = serializedData
dataMessage.portnum = PortNum.positionApp
meshPacket.decoded = dataMessage
} else {
throw AccessoryError.ioFailed("Failed to serialize position packet")
}
var toRadio = ToRadio()
toRadio.packet = meshPacket
try await AccessoryManager.shared.send(toRadio, debugDescription: "Retry position for message \(item.originalMessageId)")
}
private func resendWaypointMessage(_ item: RetryQueueItem) async throws {
Logger.mesh.warning("📬 Waypoint retry - requires serialized packet for full implementation")
guard let serializedData = item.serializedPacket else {
throw AccessoryError.appError("No serialized packet for waypoint retry")
}
var meshPacket = try MeshPacket(serializedData: serializedData)
let newMessageId = UInt32.random(in: UInt32(UInt8.max)..<UInt32.max)
meshPacket.id = newMessageId
setCurrentPacketId(for: item.id, packetId: newMessageId)
var toRadio = ToRadio()
toRadio.packet = meshPacket
try await AccessoryManager.shared.send(toRadio, debugDescription: "Retry waypoint for message \(item.originalMessageId)")
}
private func resendTracerouteMessage(_ item: RetryQueueItem) async throws {
guard let fromNodeNum = await AccessoryManager.shared.activeConnection?.device.num else {
throw AccessoryError.ioFailed("Not connected to any device")
}
let routePacket = RouteDiscovery()
var meshPacket = MeshPacket()
let newMessageId = UInt32.random(in: UInt32(UInt8.max)..<UInt32.max)
meshPacket.id = newMessageId
meshPacket.to = Constants.maximumNodeNum
meshPacket.from = UInt32(fromNodeNum)
meshPacket.channel = 0
meshPacket.wantAck = true
var dataMessage = DataMessage()
if let serializedData = try? routePacket.serializedData() {
dataMessage.payload = serializedData
dataMessage.portnum = PortNum.tracerouteApp
dataMessage.wantResponse = true
meshPacket.decoded = dataMessage
} else {
throw AccessoryError.ioFailed("Failed to serialize traceroute packet")
}
setCurrentPacketId(for: item.id, packetId: newMessageId)
var toRadio = ToRadio()
toRadio.packet = meshPacket
try await AccessoryManager.shared.send(toRadio, debugDescription: "Retry traceroute for message \(item.originalMessageId)")
// Update TraceRouteEntity with the new packet ID
let context = PersistenceController.shared.container.viewContext
let traceRequest = TraceRouteEntity.fetchRequest()
traceRequest.predicate = NSPredicate(format: "id == %lld", item.originalMessageId)
do {
let fetchedRoutes = try context.fetch(traceRequest)
var targetNodeNum: Int64 = 0
for route in fetchedRoutes {
targetNodeNum = route.node?.num ?? 0
context.delete(route)
}
let newTraceRoute = TraceRouteEntity(context: context)
newTraceRoute.id = Int64(newMessageId)
newTraceRoute.time = Date()
newTraceRoute.sent = true
if targetNodeNum > 0 {
let nodesRequest = NodeInfoEntity.fetchRequest()
nodesRequest.predicate = NSPredicate(format: "num == %lld", targetNodeNum)
if let nodes = try? context.fetch(nodesRequest), let node = nodes.first {
newTraceRoute.node = node
}
}
try context.save()
Logger.mesh.info("📬 Created replacement TraceRouteEntity with new ID \(newMessageId) for retry of \(item.originalMessageId)")
} catch {
Logger.mesh.error("📬 Failed to update TraceRouteEntity for retry: \(error.localizedDescription, privacy: .public)")
}
}
// MARK: - Queue Management
func addToQueue(
originalMessageId: Int64,
messageType: MessageType,
payload: Data,
portNum: PortNum,
toUserNum: Int64,
channel: Int32,
isEmoji: Bool = false,
replyID: Int64 = 0,
pkiEncrypted: Bool = false,
publicKey: Data? = nil,
originalPayload: String? = nil,
hopLimit: UInt32? = nil
) {
// Don't add if this message has already exhausted its retries
if failedMessageIds.contains(originalMessageId) {
Logger.mesh.info("📬 Message \(originalMessageId) has exhausted retries, not re-adding to queue")
return
}
let originalPacketId = UInt32(truncatingIfNeeded: originalMessageId)
// Check if already in queue by original message ID / any known packet ID
if queue.contains(where: { $0.originalMessageId == originalMessageId || $0.matchesPacketId(originalPacketId) }) {
return
}
// Check if we have an existing item with this packet ID (late routing for a prior retry)
if let existingItem = queue.first(where: { $0.matchesPacketId(originalPacketId) }) {
Logger.mesh.info("📬 Message with packet ID \(originalMessageId) is already being tracked (original: \(existingItem.originalMessageId))")
return
}
let item = RetryQueueItem(
originalMessageId: originalMessageId,
messageType: messageType,
payload: payload,
portNum: portNum,
toUserNum: toUserNum,
channel: channel,
isEmoji: isEmoji,
replyID: replyID,
pkiEncrypted: pkiEncrypted,
publicKey: publicKey,
originalPayload: originalPayload,
hopLimit: hopLimit
)
queue.append(item)
self.pendingCount = queue.filter { $0.state == .pending || $0.state == .waitingForAck || $0.state == .sending }.count
Logger.mesh.info("📬 Added message \(originalMessageId) to retry queue (retry 1/\(self.maxRetries + 1) in 10s)")
notifyUpdate()
}
func cancelRetry(for messageId: Int64) {
if let index = queue.firstIndex(where: { $0.originalMessageId == messageId }) {
queue[index].state = .cancelled
self.pendingCount = queue.filter { $0.state == .pending || $0.state == .waitingForAck || $0.state == .sending }.count
Logger.mesh.info("📬 Cancelled retry for message \(messageId)")
notifyUpdate()
}
}
func cancelRetry(forItemId itemId: UUID) {
if let index = queue.firstIndex(where: { $0.id == itemId }) {
queue[index].state = .cancelled
self.pendingCount = queue.filter { $0.state == .pending || $0.state == .waitingForAck || $0.state == .sending }.count
Logger.mesh.info("📬 Cancelled retry for item \(itemId)")
notifyUpdate()
}
}
func clearAllRetries() {
for index in queue.indices {
queue[index].state = .cancelled
}
self.pendingCount = 0
Logger.mesh.info("📬 Cleared all pending retries")
notifyUpdate()
}
func markCompleted(for messageId: Int64) {
if let index = queue.firstIndex(where: { $0.originalMessageId == messageId }) {
queue[index].state = .completed
self.pendingCount = queue.filter { $0.state == .pending || $0.state == .waitingForAck || $0.state == .sending }.count
Logger.mesh.info("📬 Marked message \(messageId) as completed")
notifyUpdate()
}
}
func markFailed(for messageId: Int64, error: String? = nil) {
if let index = queue.firstIndex(where: { $0.originalMessageId == messageId }) {
queue[index].state = .failed
if let error = error {
queue[index].lastError = error
}
// Track that this message has exhausted its retries
failedMessageIds.insert(messageId)
self.pendingCount = queue.filter { $0.state == .pending || $0.state == .waitingForAck || $0.state == .sending }.count
Logger.mesh.info("📬 Marked message \(messageId) as failed")
notifyUpdate()
}
}
func removeCompleted() {
queue.removeAll { $0.state == .completed || $0.state == .failed || $0.state == .cancelled }
self.pendingCount = queue.filter { $0.state == .pending || $0.state == .waitingForAck || $0.state == .sending }.count
notifyUpdate()
}
func clearAll() {
queue.removeAll()
self.pendingCount = 0
Logger.mesh.info("📬 Cleared all pending retries")
notifyUpdate()
}
func getQueue() -> [RetryQueueItem] {
return queue
}
func getPendingItems() -> [RetryQueueItem] {
return queue.filter { $0.state == .pending || $0.state == .waitingForAck }
}
func getStatus(for messageId: Int64) -> RetryState? {
if let item = queue.first(where: { $0.originalMessageId == messageId }) {
return item.state
}
let packetId = UInt32(truncatingIfNeeded: messageId)
if let item = queue.first(where: { $0.matchesPacketId(packetId) }) {
return item.state
}
return nil
}
func addToRetryQueue(_ item: RetryQueueItem) {
queue.append(item)
pendingCount = queue.filter { $0.state == .pending || $0.state == .waitingForAck || $0.state == .sending }.count
Logger.mesh.info("📬 Added message \(item.originalMessageId) to retry queue (retry 1/\(self.maxRetries + 1) in 10s)")
notifyUpdate()
}
func getRetryStatus(for messageId: Int64) -> (current: Int, max: Int, state: RetryState)? {
if let item = queue.first(where: { $0.originalMessageId == messageId }) {
return (item.displayAttemptNumber, maxRetries + 1, item.state)
}
let packetId = UInt32(truncatingIfNeeded: messageId)
if let item = queue.first(where: { $0.matchesPacketId(packetId) }) {
return (item.displayAttemptNumber, maxRetries + 1, item.state)
}
return nil
}
func originalMessageId(forPacketId packetId: UInt32) -> Int64? {
queue.first(where: { $0.matchesPacketId(packetId) })?.originalMessageId
}
func markCompletedByPacketId(_ packetId: UInt32) {
// Check both original message ID and current packet ID
if let index = queue.firstIndex(where: { $0.matchesPacketId(packetId) }) {
queue[index].state = .completed
self.pendingCount = queue.filter { $0.state == .pending || $0.state == .waitingForAck || $0.state == .sending }.count
Logger.mesh.info("📬 Marked message with packet ID \(packetId) as completed (retry)")
notifyUpdate()
}
}
func canRetry(_ messageId: Int64) -> Bool {
// Check if message has already exhausted its retries
if failedMessageIds.contains(messageId) {
return false
}
// Check by original message ID
if let item = queue.first(where: { $0.originalMessageId == messageId }) {
return item.state == .pending || item.state == .waitingForAck || item.state == .sending
}
// Also check by any known packet ID (for retries that created new packet IDs)
let packetId = UInt32(truncatingIfNeeded: messageId)
if let item = queue.first(where: { $0.matchesPacketId(packetId) }) {
return item.state == .pending || item.state == .waitingForAck || item.state == .sending
}
return false
}
/// Handle a NACK for a packet - finds existing item and increments retry count
func handleNack(for packetId: Int64) {
// Check if message has already exhausted its retries
if failedMessageIds.contains(packetId) {
Logger.mesh.info("📬 Message \(packetId) has exhausted retries, ignoring NACK")
return
}
let pid = UInt32(truncatingIfNeeded: packetId)
// Prefer matching by current packet ID / history (covers late routing for older retry packets)
if let index = queue.firstIndex(where: { $0.matchesPacketId(pid) }) {
let item = queue[index]
// If this NACK is for an older packet ID, but we're currently waiting on a newer packet,
// ignore it so we don't flip the UI to failed while the latest attempt is in-flight.
if item.currentPacketId != nil, item.currentPacketId != pid,
(item.state == .sending || item.state == .waitingForAck) {
Logger.mesh.info("📬 Ignoring stale NACK for packet \(packetId) (current: \(String(describing: item.currentPacketId)))")
return
}
handleNackForItem(at: index)
return
}
// Message not in queue - this is the first NACK, add it to queue
Logger.mesh.info("📬 First NACK for packet \(packetId), adding to retry queue")
addNewRetryForPacket(packetId)
}
private func addNewRetryForPacket(_ packetId: Int64) {
// Try to find message data to create a proper retry item
let context = PersistenceController.shared.container.viewContext
// Try to find a MessageEntity with this ID (text messages)
let fetchRequest = MessageEntity.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "messageId == %lld", packetId)
do {
let fetchedMessages = try context.fetch(fetchRequest)
if let message = fetchedMessages.first {
// Clear error status so UI shows retry state
message.ackError = 0
message.receivedACK = false
message.ackTimestamp = 0
try context.save()
// Found message entity, create retry with full data
let payloadData = message.messagePayload?.data(using: .utf8) ?? Data()
let item = RetryQueueItem(
originalMessageId: packetId,
messageType: .text,
payload: payloadData,
portNum: .textMessageApp,
toUserNum: message.toUser?.num ?? 0,
channel: message.channel,
isEmoji: message.isEmoji,
replyID: message.replyID,
pkiEncrypted: message.pkiEncrypted,
publicKey: message.publicKey,
originalPayload: message.messagePayload
)
queue.append(item)
Logger.mesh.info("📬 Added text message \(packetId) to retry queue (retry 1/\(self.maxRetries + 1) in 10s)")
} else {
// Check if it's a traceroute message
let traceRequest = TraceRouteEntity.fetchRequest()
traceRequest.predicate = NSPredicate(format: "id == %lld", packetId)
do {
let fetchedRoutes = try context.fetch(traceRequest)
if let traceRoute = fetchedRoutes.first {
// Clear error status
traceRoute.sent = true
try context.save()
let item = RetryQueueItem(
originalMessageId: packetId,
messageType: .traceroute,
payload: Data(),
portNum: .tracerouteApp,
toUserNum: traceRoute.node?.num ?? 0,
channel: 0
)
queue.append(item)
Logger.mesh.info("📬 Added traceroute \(packetId) to retry queue (retry 1/\(self.maxRetries + 1) in 30s)")
} else {
// No entity found - create basic unknown retry
// The actual resend will fail gracefully if no serialized packet
let item = RetryQueueItem(
originalMessageId: packetId,
messageType: .unknown
)
queue.append(item)
Logger.mesh.info("📬 Added unknown packet \(packetId) to retry queue (basic, retry 1/\(self.maxRetries + 1) in 10s)")
}
} catch {
// No traceroute found, create basic unknown retry
let item = RetryQueueItem(
originalMessageId: packetId,
messageType: .unknown
)
queue.append(item)
Logger.mesh.info("📬 Added packet \(packetId) to retry queue (fallback, retry 1/\(self.maxRetries + 1) in 10s)")
}
}
self.pendingCount = queue.filter { $0.state == .pending || $0.state == .waitingForAck || $0.state == .sending }.count
} catch {
// Even on error, add basic unknown item
let item = RetryQueueItem(
originalMessageId: packetId,
messageType: .unknown
)
queue.append(item)
self.pendingCount = queue.filter { $0.state == .pending || $0.state == .waitingForAck || $0.state == .sending }.count
Logger.mesh.error("📬 Failed to fetch message for retry, added basic item: \(error.localizedDescription, privacy: .public)")
}
}
private func handleNackForItem(at index: Int) {
let item = queue[index]
let newRetryCount = item.normalizedRetryCount + 1
if newRetryCount > self.maxRetries {
// Exhausted retries
queue[index].state = .failed
failedMessageIds.insert(item.originalMessageId)
Logger.mesh.info("📬 Message \(item.originalMessageId) exhausted \(self.maxRetries) retries, marking as failed")
} else {
// Increment retry count and reschedule
let delay = retryDelay(for: item.messageType, retryCount: newRetryCount)
queue[index].retryCount = newRetryCount
queue[index].nextRetryDate = Date().addingTimeInterval(delay)
queue[index].state = .pending
queue[index].currentPacketId = nil // Will be set when retry is sent
// Clear the error in the database so UI shows retry state instead of error
clearMessageError(for: item.originalMessageId)
Logger.mesh.info("📬 Message \(item.originalMessageId) NACK received, retry \(newRetryCount)/\(self.maxRetries + 1) scheduled in \(Int(delay))s")
}
self.pendingCount = queue.filter { $0.state == .pending || $0.state == .waitingForAck || $0.state == .sending }.count
notifyUpdate()
}
private func clearMessageError(for messageId: Int64) {
let context = PersistenceController.shared.container.viewContext
let fetchRequest = MessageEntity.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "messageId == %lld", messageId)
do {
let messages = try context.fetch(fetchRequest)
for message in messages {
message.ackError = 0
message.receivedACK = false
message.ackTimestamp = 0
}
try context.save()
Logger.mesh.info("📬 Cleared error status for message \(messageId) during retry")
} catch {
Logger.mesh.error("📬 Failed to clear message error: \(error.localizedDescription, privacy: .public)")
}
}
// MARK: - Private Helpers
private func updateItemState(_ itemId: UUID, state: RetryState) {
if let index = queue.firstIndex(where: { $0.id == itemId }) {
queue[index].state = state
notifyUpdate()
}
}
private func updateItemRetry(_ itemId: UUID, retryCount: Int, nextRetryDate: Date) {
if let index = queue.firstIndex(where: { $0.id == itemId }) {
queue[index].retryCount = retryCount
queue[index].nextRetryDate = nextRetryDate
notifyUpdate()
}
}
private func updateItemError(_ itemId: UUID, error: String) {
if let index = queue.firstIndex(where: { $0.id == itemId }) {
queue[index].lastError = error
notifyUpdate()
}
}
}
extension Array {
subscript(safe index: Index) -> Element? {
indices.contains(index) ? self[index] : nil
}
}

View file

@ -22,37 +22,38 @@ enum MapNavigationState: Hashable {
// MARK: Settings
enum SettingsNavigationState: String {
case about
case appSettings
case routes
case routeRecorder
case lora
case channels
case shareQRCode
case user
case bluetooth
case device
case display
case network
case position
case power
case ambientLighting
case cannedMessages
case detectionSensor
case externalNotification
case mqtt
case rangeTest
case paxCounter
case ringtone
case serial
case security
case storeAndForward
case telemetry
case debugLogs
case appFiles
case firmwareUpdates
}
enum SettingsNavigationState: String {
case about
case appSettings
case routes
case routeRecorder
case lora
case channels
case shareQRCode
case user
case bluetooth
case device
case display
case network
case position
case power
case ambientLighting
case cannedMessages
case detectionSensor
case externalNotification
case mqtt
case rangeTest
case paxCounter
case ringtone
case serial
case security
case storeAndForward
case telemetry
case debugLogs
case appFiles
case firmwareUpdates
case retryQueue
}
struct NavigationState: Hashable {
enum Tab: String, Hashable {

View file

@ -0,0 +1,12 @@
import SwiftUI
struct AnimatedEllipsis: View {
var body: some View {
TimelineView(.periodic(from: .now, by: 0.45)) { context in
let ticks = Int(context.date.timeIntervalSinceReferenceDate / 0.45)
let dotCount = (ticks % 3) + 1
Text(String(repeating: ".", count: dotCount))
.monospacedDigit()
}
}
}

View file

@ -18,6 +18,9 @@ struct ChannelMessageRow: View {
@Binding var messageToHighlight: Int64
let scrollView: ScrollViewProxy
let onInteractionComplete: () -> Void
@State private var retryStatus: (current: Int, max: Int, state: RetryState)?
@State private var isRetrying: Bool = false
private var isCurrentUser: Bool {
Int64(preferredPeripheralNum) == message.fromUser?.num
@ -135,14 +138,52 @@ struct ChannelMessageRow: View {
// ACK Status / Error
HStack {
let ackErrorVal = RoutingError(rawValue: Int(message.ackError))
// Status line (retry queue takes precedence over ack error)
if isCurrentUser {
if let (current, max, state) = retryStatus, state != .completed && state != .cancelled {
if state == .waitingForAck {
Text("Waiting for acknowledgment")
.font(.caption2)
.foregroundColor(.orange)
AnimatedEllipsis()
.font(.caption2)
.foregroundColor(.orange)
} else if state == .sending {
Text("Sending")
.font(.caption2)
.foregroundColor(.orange)
AnimatedEllipsis()
.font(.caption2)
.foregroundColor(.orange)
} else if state == .pending {
Text("Attempting to send (\(current)/\(max))")
.font(.caption2)
.foregroundColor(.orange)
AnimatedEllipsis()
.font(.caption2)
.foregroundColor(.orange)
}
}
}
if isCurrentUser && message.receivedACK {
Text("\(ackErrorVal?.display ?? "Empty Ack Error")").fixedSize(horizontal: false, vertical: true)
.foregroundStyle(ackErrorVal?.color ?? Color.red).font(.caption2)
} else if isCurrentUser && message.ackError == 0 {
Text("Waiting to be acknowledged. . .").font(.caption2).foregroundColor(.orange)
if !isRetrying {
Text("Waiting for acknowledgment")
.font(.caption2)
.foregroundColor(.orange)
AnimatedEllipsis()
.font(.caption2)
.foregroundColor(.orange)
}
} else if isCurrentUser && !isDetectionSensorMessage {
Text("\(ackErrorVal?.display ?? "Empty Ack Error")").fixedSize(horizontal: false, vertical: true)
.foregroundStyle(ackErrorVal?.color ?? Color.red).font(.caption2)
if !isRetrying {
Text("\(ackErrorVal?.display ?? "Empty Ack Error")").fixedSize(horizontal: false, vertical: true)
.foregroundStyle(ackErrorVal?.color ?? Color.red).font(.caption2)
}
}
}
}
@ -155,5 +196,19 @@ struct ChannelMessageRow: View {
}
.id(message.messageId) // ID for scrolling/highlighting
.task { await refreshRetryState() }
.onReceive(NotificationCenter.default.publisher(for: MessageRetryQueueManager.didUpdateNotification)) { _ in
Task { await refreshRetryState() }
}
}
private func refreshRetryState() async {
let messageId = message.messageId
let status = await MessageRetryQueueManager.shared.getRetryStatus(for: messageId)
let retrying = await MessageRetryQueueManager.shared.canRetry(messageId)
await MainActor.run {
retryStatus = status
isRetrying = retrying
}
}
}

View file

@ -4,61 +4,121 @@ import OSLog
struct RetryButton: View {
@Environment(\.managedObjectContext) var context
@EnvironmentObject var accessoryManager: AccessoryManager
let message: MessageEntity
let destination: MessageDestination
@State var isShowingConfirmation = false
@State private var isRetrying: Bool = false
@State private var retryCount: Int = 0
var body: some View {
Button {
isShowingConfirmation = true
} label: {
Image(systemName: "exclamationmark.circle")
.foregroundColor(.gray)
.frame(height: 30)
.padding(.top, 5)
Group {
if isRetrying {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .orange))
.scaleEffect(0.8)
} else {
Image(systemName: "exclamationmark.circle")
.foregroundColor(.orange)
}
}
.frame(height: 30)
.padding(.top, 5)
}
.onAppear {
updateRetryStatus()
}
.confirmationDialog(
"This message was likely not delivered.",
getDialogTitle(),
isPresented: $isShowingConfirmation,
titleVisibility: .visible
) {
Button("Try Again") {
guard accessoryManager.isConnected else {
return
if isRetrying {
Button("Cancel Retry", role: .destructive) {
cancelRetry()
}
let messageID = message.messageId
let payload = message.messagePayload ?? ""
let userNum = message.toUser?.num ?? 0
let channel = message.channel
let isEmoji = message.isEmoji
let replyID = message.replyID
context.delete(message)
do {
try context.save()
} catch {
Logger.data.error("Failed to delete message \(messageID, privacy: .public): \(error.localizedDescription, privacy: .public)")
}
Task {
do {
try await accessoryManager.sendMessage(message: payload, toUserNum: userNum, channel: channel,
isEmoji: isEmoji, replyID: replyID)
if case let .channel(channel) = destination {
// We must refresh the channel to trigger a view update since its relationship
// to messages is via a weak fetched property which is not updated by
// `bleManager.sendMessage` unlike the user entity.
Task { @MainActor in
context.refresh(channel, mergeChanges: true)
}
}
} catch {
// Best effort
Logger.services.warning("Failed to resend message \(messageID, privacy: .public)")
}
} else {
Button("Try Again") {
resendMessage()
}
}
Button("Cancel", role: .cancel) {}
} message: {
if isRetrying {
Text("This message is currently being retried (\(retryCount > 0 ? "retry \(retryCount)" : "waiting to retry")). Would you like to cancel the retry?")
} else {
Text("This message was likely not delivered. Would you like to try again?")
}
}
.onReceive(NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange, object: context)) { _ in
updateRetryStatus()
}
.onReceive(NotificationCenter.default.publisher(for: MessageRetryQueueManager.didUpdateNotification)) { _ in
updateRetryStatus()
}
}
private func getDialogTitle() -> String {
if isRetrying {
return "Cancel Retry?"
} else {
return "Retry Message?"
}
}
private func updateRetryStatus() {
let messageId = message.messageId
Task {
let status = await MessageRetryQueueManager.shared.getRetryStatus(for: messageId)
await MainActor.run {
if let (current, _, state) = status {
isRetrying = state == .pending || state == .waitingForAck || state == .sending
retryCount = current
} else {
isRetrying = false
retryCount = 0
}
}
}
}
private func cancelRetry() {
Task {
await MessageRetryQueueManager.shared.cancelRetry(for: message.messageId)
}
}
private func resendMessage() {
guard accessoryManager.isConnected else {
return
}
Task {
await MessageRetryQueueManager.shared.cancelRetry(for: message.messageId)
}
let messageID = message.messageId
let payload = message.messagePayload ?? ""
let userNum = message.toUser?.num ?? 0
let channel = message.channel
let isEmoji = message.isEmoji
let replyID = message.replyID
Task {
do {
try await accessoryManager.sendMessage(message: payload, toUserNum: userNum, channel: channel,
isEmoji: isEmoji, replyID: replyID)
if case let .channel(channel) = destination {
Task { @MainActor in
context.refresh(channel, mergeChanges: true)
}
}
} catch {
Logger.services.warning("Failed to resend message \(messageID)")
}
}
}
}

View file

@ -23,10 +23,28 @@ struct UserMessageRow: View {
let scrollView: ScrollViewProxy
let onInteractionComplete: () -> Void
@State private var retryStatus: (current: Int, max: Int, state: RetryState)?
@State private var isRetrying: Bool = false
private var isCurrentUser: Bool {
Int64(preferredPeripheralNum) == message.fromUser?.num
}
private var canShowRetryButton: Bool {
guard isCurrentUser else { return false }
if message.receivedACK && !message.realACK {
return true
}
if let (_, _, state) = retryStatus {
return state == .pending || state == .waitingForAck || state == .sending
}
let re = RoutingError(rawValue: Int(message.ackError))
return re?.canRetry ?? false
}
init(message: MessageEntity,
allMessages: [MessageEntity],
previousMessage: MessageEntity?,
@ -130,7 +148,7 @@ struct UserMessageRow: View {
self.messageFieldFocused = true
}
if isCurrentUser && message.canRetry || (isCurrentUser && message.receivedACK && !message.realACK) {
if isCurrentUser && canShowRetryButton {
RetryButton(message: message, destination: .user(user))
}
}
@ -138,9 +156,38 @@ struct UserMessageRow: View {
// Tapback Responses - Pass the closure to trigger the parent redraw
TapbackResponses(message: message, onRead: onInteractionComplete)
// ACK Error
// ACK Error & Retry Status
HStack {
let ackErrorVal = RoutingError(rawValue: Int(message.ackError))
// Status line (retry queue takes precedence over ack error)
if isCurrentUser {
if let (current, max, state) = retryStatus, state != .completed && state != .cancelled {
if state == .waitingForAck {
Text("Waiting for acknowledgment")
.font(.caption2)
.foregroundColor(.orange)
AnimatedEllipsis()
.font(.caption2)
.foregroundColor(.orange)
} else if state == .sending {
Text("Sending")
.font(.caption2)
.foregroundColor(.orange)
AnimatedEllipsis()
.font(.caption2)
.foregroundColor(.orange)
} else if state == .pending {
Text("Attempting to send (\(current)/\(max))")
.font(.caption2)
.foregroundColor(.orange)
AnimatedEllipsis()
.font(.caption2)
.foregroundColor(.orange)
}
}
}
if isCurrentUser && message.receivedACK {
// Ack Received
if message.realACK {
@ -150,13 +197,22 @@ struct UserMessageRow: View {
} else {
Text("Acknowledged by another node").font(.caption2).foregroundColor(.orange)
}
} else if isCurrentUser && message.ackError == 0 {
// Empty Error
Text("Waiting to be acknowledged. . .").font(.caption2).foregroundColor(.yellow)
} else if isCurrentUser && message.ackError > 0 {
Text("\(ackErrorVal?.display ?? "Empty Ack Error")").fixedSize(horizontal: false, vertical: true)
.foregroundStyle(ackErrorVal?.color ?? Color.red)
.font(.caption2)
} else if isCurrentUser && message.ackError == 0 && !message.receivedACK {
// Empty Error and not received ACK
if !isRetrying {
Text("Waiting for acknowledgment")
.font(.caption2)
.foregroundColor(.yellow)
AnimatedEllipsis()
.font(.caption2)
.foregroundColor(.yellow)
}
} else if isCurrentUser && message.ackError > 0 && !message.receivedACK {
if !isRetrying {
Text("\(ackErrorVal?.display ?? "Empty Ack Error")").fixedSize(horizontal: false, vertical: true)
.foregroundStyle(ackErrorVal?.color ?? Color.red)
.font(.caption2)
}
}
}
}
@ -169,5 +225,19 @@ struct UserMessageRow: View {
}
.id(message.messageId)
.task { await refreshRetryState() }
.onReceive(NotificationCenter.default.publisher(for: MessageRetryQueueManager.didUpdateNotification)) { _ in
Task { await refreshRetryState() }
}
}
private func refreshRetryState() async {
let messageId = message.messageId
let status = await MessageRetryQueueManager.shared.getRetryStatus(for: messageId)
let retrying = await MessageRetryQueueManager.shared.canRetry(messageId)
await MainActor.run {
retryStatus = status
isRetrying = retrying
}
}
}

View file

@ -0,0 +1,614 @@
//
// RetryQueueView.swift
// Meshtastic
//
// Retry queue management view for debugging and queue management
//
import SwiftUI
import MeshtasticProtobufs
struct RetryQueueView: View {
@State private var queueItems: [RetryQueueItem] = []
@State private var selectedGroup: RetryGroup?
@State private var searchText = ""
@State private var filterType: MessageType?
@State private var showingDeleteAlert = false
@State private var itemToDelete: RetryQueueItem?
@State private var isRefreshing = false
@State private var refreshTimer: Timer?
@State private var currentDate = Date()
private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom }
private var groupedItems: [RetryGroup] {
let items = filteredItems
let grouped = Dictionary(grouping: items) { item in
RetryGroupKey(originalMessageId: item.originalMessageId, messageType: item.messageType)
}
return grouped.values.compactMap { items -> RetryGroup? in
guard let first = items.first else { return nil }
let sortedItems = items.sorted { $0.createdAt < $1.createdAt }
return RetryGroup(
originalMessageId: first.originalMessageId,
messageType: first.messageType,
items: sortedItems,
createdAt: first.createdAt
)
}.sorted { $0.createdAt < $1.createdAt }
}
private var filteredItems: [RetryQueueItem] {
var items = queueItems
if let filter = filterType {
items = items.filter { $0.messageType == filter }
}
if !searchText.isEmpty {
items = items.filter {
$0.originalMessageId.description.localizedCaseInsensitiveContains(searchText) ||
$0.messageType.rawValue.localizedCaseInsensitiveContains(searchText) ||
($0.currentPacketId?.description.localizedCaseInsensitiveContains(searchText) ?? false)
}
}
return items
}
var body: some View {
VStack(spacing: 0) {
if idiom == .phone {
phoneContent
} else {
iPadContent
}
}
.searchable(text: $searchText, placement: .navigationBarDrawer, prompt: "Search queue")
.navigationTitle("Retry Queue\(groupedItems.isEmpty ? "" : " (\(groupedItems.count))")")
.sheet(item: $selectedGroup) { group in
RetryQueueDetailSheet(group: group, allItems: groupedItems)
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Menu {
Button {
filterType = nil
} label: {
Label("All Types", systemImage: "tray.full")
}
Divider()
ForEach(MessageType.allCases, id: \.self) { type in
Button {
filterType = type
} label: {
Label(type.rawValue.capitalized, systemImage: type.icon)
}
}
} label: {
Image(systemName: filterType == nil ? "line.3.horizontal.decrease.circle" : "line.3.horizontal.decrease.circle.fill")
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button {
Task {
await refreshQueue()
}
} label: {
Image(systemName: "arrow.clockwise")
}
.disabled(isRefreshing)
}
if !groupedItems.isEmpty {
let hasAnyPending = groupedItems.contains { group in
group.items.contains { $0.state == .pending || $0.state == .sending || $0.state == .waitingForAck }
}
ToolbarItem(placement: .navigationBarTrailing) {
Menu {
Button(role: .destructive) {
itemToDelete = nil
showingDeleteAlert = true
} label: {
Label("Clear All", systemImage: "trash")
}
if hasAnyPending {
Button {
Task {
await MessageRetryQueueManager.shared.clearAll()
await refreshQueue()
}
} label: {
Label("Cancel All Retries", systemImage: "stop.circle")
}
}
} label: {
Image(systemName: "ellipsis.circle")
}
}
}
}
.alert("Delete Item?", isPresented: $showingDeleteAlert) {
Button("Cancel", role: .cancel) {
itemToDelete = nil
}
Button("Delete", role: .destructive) {
if let item = itemToDelete {
Task {
await MessageRetryQueueManager.shared.cancelRetry(forItemId: item.id)
await refreshQueue()
}
} else {
Task {
for group in groupedItems {
for item in group.items {
await MessageRetryQueueManager.shared.cancelRetry(forItemId: item.id)
}
}
await refreshQueue()
}
}
}
} message: {
if let item = itemToDelete {
Text("Delete retry for message \(item.originalMessageId.toHex())?")
} else {
Text("Delete \(groupedItems.count) group(s) from the queue?")
}
}
.task {
await refreshQueue()
startRefreshTimer()
}
.onDisappear {
stopRefreshTimer()
}
}
private func startRefreshTimer() {
stopRefreshTimer()
refreshTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
currentDate = Date()
}
}
private func stopRefreshTimer() {
refreshTimer?.invalidate()
refreshTimer = nil
}
private func refreshQueue() async {
isRefreshing = true
queueItems = await MessageRetryQueueManager.shared.getQueue()
isRefreshing = false
}
@ViewBuilder
private var phoneContent: some View {
if groupedItems.isEmpty {
ContentUnavailableView(
queueItems.isEmpty ? "Queue Empty" : "No Matching Items",
systemImage: queueItems.isEmpty ? "checkmark.circle" : "magnifyingglass"
)
} else {
List {
ForEach(groupedItems) { group in
RetryGroupRow(group: group, currentDate: currentDate)
.contentShape(Rectangle())
.onTapGesture {
selectedGroup = group
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
itemToDelete = group.items.first
showingDeleteAlert = true
} label: {
Label("Delete", systemImage: "trash")
}
}
.swipeActions(edge: .leading) {
if group.items.contains(where: { $0.state == .pending || $0.state == .sending || $0.state == .waitingForAck }) {
Button {
Task {
for item in group.items {
await MessageRetryQueueManager.shared.cancelRetry(forItemId: item.id)
}
await refreshQueue()
}
} label: {
Label("Stop", systemImage: "stop.circle")
}
.tint(.orange)
}
}
}
}
.listStyle(.plain)
}
}
@ViewBuilder
private var iPadContent: some View {
if groupedItems.isEmpty {
ContentUnavailableView(
queueItems.isEmpty ? "Queue Empty" : "No Matching Items",
systemImage: queueItems.isEmpty ? "checkmark.circle" : "magnifyingglass"
)
} else {
Table(groupedItems, selection: Binding(
get: { selectedGroup?.originalMessageId },
set: { newId in
selectedGroup = groupedItems.first { $0.originalMessageId == newId }
}
)) {
TableColumn("Type") { group in
HStack(spacing: 4) {
Image(systemName: group.messageType.icon)
.symbolRenderingMode(.hierarchical)
.foregroundColor(group.messageType.color)
Text(group.messageType.rawValue.capitalized)
}
}
.width(min: 80, max: 120)
TableColumn("Retries") { group in
let pendingItems = group.items.filter { $0.state == .pending }
if pendingItems.count > 1 {
Text("Attempts \(pendingItems.first?.displayAttemptNumber ?? 0)-\(pendingItems.last?.displayAttemptNumber ?? 0)")
} else if let first = group.items.first {
Text("Attempt \(first.displayAttemptNumber)")
}
}
.width(min: 80, max: 100)
TableColumn("Next") { group in
if let nextRetry = group.nextRetryDate {
let seconds = Int(nextRetry.timeIntervalSince(currentDate))
if seconds > 0 {
Text("\(seconds)s")
.foregroundColor(.orange)
} else {
Text("Now")
.foregroundColor(.red)
}
} else {
Text("-")
}
}
.width(min: 60, max: 80)
TableColumn("Status") { group in
let state = group.items.first?.state ?? .pending
Text(state.rawValue.capitalized)
.font(.caption)
.foregroundColor(state.color)
}
.width(min: 100, max: 120)
}
.onChange(of: selectedGroup) { _, newGroup in
if newGroup != nil {
selectedGroup = newGroup
}
}
}
}
}
struct RetryGroup: Identifiable, Hashable {
let id: Int64
let originalMessageId: Int64
let messageType: MessageType
let items: [RetryQueueItem]
let createdAt: Date
var nextRetryDate: Date? {
items.filter { $0.state == .pending }.map { $0.nextRetryDate }.min()
}
init(originalMessageId: Int64, messageType: MessageType, items: [RetryQueueItem], createdAt: Date) {
self.id = originalMessageId
self.originalMessageId = originalMessageId
self.messageType = messageType
self.items = items
self.createdAt = createdAt
}
func hash(into hasher: inout Hasher) {
hasher.combine(originalMessageId)
}
static func == (lhs: RetryGroup, rhs: RetryGroup) -> Bool {
lhs.originalMessageId == rhs.originalMessageId
}
}
struct RetryGroupKey: Hashable {
let originalMessageId: Int64
let messageType: MessageType
}
struct RetryGroupRow: View {
let group: RetryGroup
let currentDate: Date
var body: some View {
HStack(spacing: 12) {
Image(systemName: group.messageType.icon)
.symbolRenderingMode(.hierarchical)
.font(.title2)
.foregroundColor(group.messageType.color)
.frame(width: 32)
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(group.messageType.rawValue.capitalized)
.font(.headline)
let state = group.items.first?.state ?? .pending
Text(state.rawValue.capitalized)
.font(.caption)
.padding(.horizontal, 8)
.padding(.vertical, 2)
.background(state.color.opacity(0.2))
.foregroundColor(state.color)
.clipShape(Capsule())
}
HStack(spacing: 8) {
Text(group.originalMessageId.toHex())
.font(.caption.monospaced())
Text(group.messageType.rawValue.capitalized)
.font(.caption)
.foregroundColor(.secondary)
}
HStack(spacing: 8) {
let pendingItems = group.items.filter { $0.state == .pending }
if pendingItems.count > 1 {
Label {
Text("Attempts \(pendingItems.first?.displayAttemptNumber ?? 0)-\(pendingItems.last?.displayAttemptNumber ?? 0)")
.font(.caption)
.foregroundColor(.secondary)
} icon: {
Image(systemName: "arrow.clockwise")
}
} else if let first = group.items.first {
Label {
Text("Attempt \(first.displayAttemptNumber)")
.font(.caption)
.foregroundColor(.secondary)
} icon: {
Image(systemName: "arrow.clockwise")
}
}
Spacer()
if let nextRetry = group.nextRetryDate {
let seconds = Int(nextRetry.timeIntervalSince(currentDate))
if seconds > 0 {
Text("In \(seconds)s")
.font(.caption)
.foregroundColor(.orange)
.monospacedDigit()
} else {
Text("Now")
.font(.caption)
.foregroundColor(.red)
}
}
}
}
Spacer()
}
.padding(.vertical, 4)
}
}
struct RetryQueueDetailSheet: View {
let group: RetryGroup
let allItems: [RetryGroup]
@Environment(\.dismiss) private var dismiss
@State private var isCancelling = false
private var currentState: RetryState {
group.items.first?.state ?? .pending
}
var body: some View {
NavigationStack {
ScrollView {
LazyVStack(alignment: .leading, spacing: 16) {
Section {
HStack {
Image(systemName: group.messageType.icon)
.symbolRenderingMode(.hierarchical)
.font(.largeTitle)
.foregroundColor(group.messageType.color)
VStack(alignment: .leading) {
Text(group.messageType.rawValue.capitalized)
.font(.headline)
Text("ID: \(group.originalMessageId.toHex())")
.font(.caption.monospaced())
.foregroundColor(.secondary)
}
Spacer()
Text(currentState.rawValue.capitalized)
.font(.caption)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(currentState.color.opacity(0.2))
.foregroundColor(currentState.color)
.clipShape(Capsule())
}
}
Divider()
Section("Retry Attempts") {
ForEach(group.items.indices, id: \.self) { index in
let item = group.items[index]
HStack {
Image(systemName: item.state.icon)
.symbolRenderingMode(.hierarchical)
.foregroundColor(item.state.color)
VStack(alignment: .leading) {
Text("Attempt \(item.displayAttemptNumber)")
.font(.subheadline)
HStack {
if let currentId = item.currentPacketId {
Text("ID: \(currentId.toHex())")
.font(.caption.monospaced())
} else {
Text("Not sent yet")
.font(.caption)
}
if item.state == .pending {
Text("")
.foregroundColor(.secondary)
let seconds = Int(item.nextRetryDate.timeIntervalSince(Date()))
if seconds > 0 {
Text("In \(seconds)s")
.font(.caption)
.foregroundColor(.orange)
.monospacedDigit()
}
}
}
}
Spacer()
Text(item.state.rawValue.capitalized)
.font(.caption)
.foregroundColor(item.state.color)
}
.padding(.vertical, 4)
}
}
Divider()
Section("Created") {
HStack {
Text("Time:")
Spacer()
Text(group.createdAt.formatted(date: .abbreviated, time: .shortened))
}
}
Section {
let hasPendingRetries = group.items.contains(where: { $0.state == .pending || $0.state == .sending || $0.state == .waitingForAck })
Button(role: .destructive) {
isCancelling = true
} label: {
HStack {
Spacer()
if isCancelling {
ProgressView()
} else {
Label("Cancel All Retries", systemImage: "stop.circle.fill")
}
Spacer()
}
}
.disabled(isCancelling || !hasPendingRetries)
}
}
.padding()
}
.navigationTitle("Retry Details")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") {
dismiss()
}
}
}
.task {
if isCancelling {
for item in group.items {
await MessageRetryQueueManager.shared.cancelRetry(forItemId: item.id)
}
isCancelling = false
dismiss()
}
}
}
}
}
extension RetryState {
var icon: String {
switch self {
case .pending: return "clock"
case .sending: return "arrow.up.circle"
case .waitingForAck: return "hourglass"
case .completed: return "checkmark.circle"
case .failed: return "xmark.circle"
case .cancelled: return "minus.circle"
}
}
}
extension RetryQueueDetailSheet: Identifiable {
var id: Int64 { group.originalMessageId }
}
extension MessageType {
var icon: String {
switch self {
case .text: return "message"
case .position: return "location"
case .waypoint: return "mappin.circle"
case .admin: return "lock.shield"
case .traceroute: return "point.topleft.down.curvedto.point.bottomright.up"
case .nodeInfo: return "person.circle"
case .unknown: return "questionmark.circle"
}
}
var color: Color {
switch self {
case .text: return .blue
case .position: return .green
case .waypoint: return .orange
case .admin: return .purple
case .traceroute: return .cyan
case .nodeInfo: return .indigo
case .unknown: return .gray
}
}
}
extension RetryState {
var color: Color {
switch self {
case .pending: return .orange
case .sending: return .blue
case .waitingForAck: return .purple
case .completed: return .green
case .failed: return .red
case .cancelled: return .gray
}
}
}

View file

@ -311,6 +311,14 @@ struct Settings: View {
Image(systemName: "folder")
}
}
NavigationLink(value: SettingsNavigationState.retryQueue) {
Label {
Text("Retry Queue")
} icon: {
Image(systemName: "arrow.clockwise.circle")
}
}
}
}
@ -521,6 +529,8 @@ struct Settings: View {
AppData()
case .firmwareUpdates:
Firmware(node: node)
case .retryQueue:
RetryQueueView()
}
}
.onChange(of: UserDefaults.preferredPeripheralNum ) { _, newConnectedNode in

@ -1 +1 @@
Subproject commit c8d5047b6351b732c0bccfcea6960a532f7ae49a
Subproject commit 1b1dc090ef38f708a276dfb51b17de5ca06b3ade