diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 3dc62e68..57ce2fed 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -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" : { diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 817570e2..8d5be962 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -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 = ""; }; BC10380E2DD4333C00B00BFA /* AddContactIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddContactIntent.swift; sourceTree = ""; }; BC6B45FE2CB2F98900723CEB /* SaveChannelSettingsIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaveChannelSettingsIntent.swift; sourceTree = ""; }; + BC7A24072F20AF9100C08B77 /* MessageRetryQueueManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRetryQueueManager.swift; sourceTree = ""; }; + BC7A24092F20BD3A00C08B77 /* RetryQueueView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RetryQueueView.swift; sourceTree = ""; }; BCA9A82B2EC802CF00166292 /* CompassView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompassView.swift; sourceTree = ""; }; BCB35B4E2E5FC41E00B04F60 /* MessageNodeIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageNodeIntent.swift; sourceTree = ""; }; BCB613802C67290800485544 /* SendWaypointIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendWaypointIntent.swift; sourceTree = ""; }; BCB613822C672A2600485544 /* MessageChannelIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageChannelIntent.swift; sourceTree = ""; }; BCB613842C68703800485544 /* NodePositionIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodePositionIntent.swift; sourceTree = ""; }; BCB613862C69A0FB00485544 /* AppIntentErrors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIntentErrors.swift; sourceTree = ""; }; + BCCFE27D2F25ECE000984673 /* AnimatedEllipsis.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimatedEllipsis.swift; sourceTree = ""; }; BCD7448C2E0F2FA300F265A2 /* ContactURLHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactURLHandler.swift; sourceTree = ""; }; BCD93CB92D9E11A2006C9214 /* DisconnectNodeIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisconnectNodeIntent.swift; sourceTree = ""; }; BCDDFA992DBB180D0065189C /* ScrollToBottomButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollToBottomButton.swift; sourceTree = ""; }; @@ -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 */, diff --git a/Meshtastic.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Meshtastic.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 3b339cec..1ce62265 100644 --- a/Meshtastic.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Meshtastic.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -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" } } ], diff --git a/Meshtastic.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Meshtastic.xcworkspace/xcshareddata/swiftpm/Package.resolved index ba8776f7..fdab7f93 100644 --- a/Meshtastic.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Meshtastic.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -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", diff --git a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Position.swift b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Position.swift index e7c8cd4f..6fb640e6 100644 --- a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Position.swift +++ b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Position.swift @@ -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).. 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 { diff --git a/Meshtastic/Helpers/MessageRetryQueueManager.swift b/Meshtastic/Helpers/MessageRetryQueueManager.swift new file mode 100644 index 00000000..9e57d938 --- /dev/null +++ b/Meshtastic/Helpers/MessageRetryQueueManager.swift @@ -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 = [] // Track messages that have exhausted retries + private var processingTask: Task? + private var cancellables = Set() + + 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).. 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).. 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 + } +} diff --git a/Meshtastic/Router/NavigationState.swift b/Meshtastic/Router/NavigationState.swift index 48a97b93..1318abcc 100644 --- a/Meshtastic/Router/NavigationState.swift +++ b/Meshtastic/Router/NavigationState.swift @@ -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 { diff --git a/Meshtastic/Views/Helpers/AnimatedEllipsis.swift b/Meshtastic/Views/Helpers/AnimatedEllipsis.swift new file mode 100644 index 00000000..083a7b48 --- /dev/null +++ b/Meshtastic/Views/Helpers/AnimatedEllipsis.swift @@ -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() + } + } +} diff --git a/Meshtastic/Views/Messages/ChannelMessageRow.swift b/Meshtastic/Views/Messages/ChannelMessageRow.swift index eb0f2a9f..51736f85 100644 --- a/Meshtastic/Views/Messages/ChannelMessageRow.swift +++ b/Meshtastic/Views/Messages/ChannelMessageRow.swift @@ -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 + } } } diff --git a/Meshtastic/Views/Messages/RetryButton.swift b/Meshtastic/Views/Messages/RetryButton.swift index ab48072b..ea78672e 100644 --- a/Meshtastic/Views/Messages/RetryButton.swift +++ b/Meshtastic/Views/Messages/RetryButton.swift @@ -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)") + } } } } diff --git a/Meshtastic/Views/Messages/UserMessageRow.swift b/Meshtastic/Views/Messages/UserMessageRow.swift index d469462b..2fad5c90 100644 --- a/Meshtastic/Views/Messages/UserMessageRow.swift +++ b/Meshtastic/Views/Messages/UserMessageRow.swift @@ -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 + } } } diff --git a/Meshtastic/Views/Settings/RetryQueueView.swift b/Meshtastic/Views/Settings/RetryQueueView.swift new file mode 100644 index 00000000..746f7a0e --- /dev/null +++ b/Meshtastic/Views/Settings/RetryQueueView.swift @@ -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 + } + } +} diff --git a/Meshtastic/Views/Settings/Settings.swift b/Meshtastic/Views/Settings/Settings.swift index d3d15a66..8d797461 100644 --- a/Meshtastic/Views/Settings/Settings.swift +++ b/Meshtastic/Views/Settings/Settings.swift @@ -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 diff --git a/protobufs b/protobufs index c8d5047b..1b1dc090 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit c8d5047b6351b732c0bccfcea6960a532f7ae49a +Subproject commit 1b1dc090ef38f708a276dfb51b17de5ca06b3ade