mirror of
https://github.com/meshtastic/Meshtastic-Apple.git
synced 2026-04-20 22:13:56 +00:00
working
This commit is contained in:
parent
3eef38926f
commit
a612cf6518
17 changed files with 2208 additions and 217 deletions
|
|
@ -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" : {
|
||||
|
|
|
|||
|
|
@ -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 */,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
873
Meshtastic/Helpers/MessageRetryQueueManager.swift
Normal file
873
Meshtastic/Helpers/MessageRetryQueueManager.swift
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
12
Meshtastic/Views/Helpers/AnimatedEllipsis.swift
Normal file
12
Meshtastic/Views/Helpers/AnimatedEllipsis.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
614
Meshtastic/Views/Settings/RetryQueueView.swift
Normal file
614
Meshtastic/Views/Settings/RetryQueueView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue