From a4422b32cb4a212dc8a8f2f18a1b7728f63fd14b Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sat, 4 Apr 2026 18:51:00 -0700 Subject: [PATCH] perf: Quick-win performance optimizations for node list and Core Data lookups (#1650) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: improve routing performance with split state, fetch batching, node cache, and debounce - Split Router's single @Published navigationState into per-tab properties to reduce spurious re-renders across unrelated views - Add fetchBatchSize=50 and relationshipKeyPathsForPrefetching to node list - Optimize in-body array re-sort from 2 filter passes to single pass - Add in-memory node object ID cache on Router for O(1) lookups - Add fetchLimit=1 to getNodeInfo for early termination - Debounce rapid node selection changes with 100ms Task delay Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/9bfe91f2-8ed7-4d2c-bb2e-4ed3dfd3a16c Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * Address code review: add debounce constant and thread-safety comment Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/9bfe91f2-8ed7-4d2c-bb2e-4ed3dfd3a16c Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * Migrate test project to Swift Testing and add connect view and router tests (#1643) * Migrate to Swift Testing and add connect view tests - Convert RouterTests.swift from XCTest to Swift Testing (@Suite, @Test, #expect, #require) - Create ConnectViewTests.swift with tests for connect view child types: - Device struct (creation, signal strength, RSSI, description, codable) - TransportType enum (cases, raw values, codable) - ConnectionState enum (equality, codable) - BLESignalStrength enum (raw values, init) - TransportStatus enum (equality) - NavigationState (defaults, tabs, sub-states) - InvalidVersion view (creation with versions) - ConnectedDevice view (connected/disconnected/MQTT states) - CircleText view (default/custom sizes, emoji) - BatteryCompact view (levels, nil, charging, plugged in) - SignalStrengthIndicator view (dimensions, strength levels) - Update Xcode project to include new test file Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/d7bb7a89-2105-4fcb-96bc-7ec794467c74 Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * Fix signal strength test boundary conditions The getSignalStrength() method uses NSNumber.compare(.orderedDescending), which is a strict greater-than check. Fix the boundary test cases: - RSSI -65 is .normal (not .strong), since -65 is not > -65 - RSSI -85 is .weak (not .normal), since -85 is not > -85 - Add -64 → .strong and -84 → .normal as adjacent boundary tests Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/4fcbc01e-cbea-4d11-b2c0-e923c6730d69 Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * Improve and complete router tests with comprehensive coverage Added tests for: - Custom initial state - Invalid scheme / unknown path handling (state unchanged) - navigateToNodeDetail public method - Messages edge cases: channelId only, userNum only, messageId only, non-numeric params - Nodes with non-numeric nodenum - Map with both nodenum+waypointId (nodeId priority), non-numeric params - Parameterized settings test covering all 31 SettingsNavigationState cases - State transitions: consecutive routes, invalid scheme preserves existing state Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/f69b7352-21aa-494c-8864-31fc0f4b21b8 Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * Localizable update * Merge translations file --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> Co-authored-by: Garth Vander Houwen * Fix merge conflicts in PR #1614 (Spanish translations) (#1644) * 20% of strings translated to spanish * add more translations * add rest of translations * small fixes --------- Co-authored-by: Joel Pérez Izquierdo Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * fix typo in hop limit option description (#1631) O hop -> 0 hop Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * Tak server improvements (#1603) * added read only mode cot to meshtastic parsing and warning to not enable on pub channel * better icons * fully fixed markers * telemetry support * Update Meshtastic/Helpers/TAK/TAKServerManager.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fixes * fixes * Resolve merge conflicts for PR #1603 (TAK server improvements) (#1645) * Delete Messages fix * Bump version to 2.7.9 * Bump widgets version * TAK Server channel index picker Create a settings picker for the TAK Server's channel index. This allows users to specify TAK traffic to use the non-primary channel to help reduce channel congestion. * Changed capitalization from 'environment' to 'Environment' for section header. (#1591) * Add Danish (da) translations — resolves merge conflicts from PR #1609 (#1612) * Initial plan * Add Danish (da) translations from PR #1609 Resolves merge conflicts from PR #1609 by adding Danish translations to the Localizable.xcstrings file. The PR adds Danish translation strings throughout the app while preserving all existing translations for other languages. Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * Migrate test project to Swift Testing and add connect view and router tests (#1643) * Migrate to Swift Testing and add connect view tests - Convert RouterTests.swift from XCTest to Swift Testing (@Suite, @Test, #expect, #require) - Create ConnectViewTests.swift with tests for connect view child types: - Device struct (creation, signal strength, RSSI, description, codable) - TransportType enum (cases, raw values, codable) - ConnectionState enum (equality, codable) - BLESignalStrength enum (raw values, init) - TransportStatus enum (equality) - NavigationState (defaults, tabs, sub-states) - InvalidVersion view (creation with versions) - ConnectedDevice view (connected/disconnected/MQTT states) - CircleText view (default/custom sizes, emoji) - BatteryCompact view (levels, nil, charging, plugged in) - SignalStrengthIndicator view (dimensions, strength levels) - Update Xcode project to include new test file Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/d7bb7a89-2105-4fcb-96bc-7ec794467c74 Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * Fix signal strength test boundary conditions The getSignalStrength() method uses NSNumber.compare(.orderedDescending), which is a strict greater-than check. Fix the boundary test cases: - RSSI -65 is .normal (not .strong), since -65 is not > -65 - RSSI -85 is .weak (not .normal), since -85 is not > -85 - Add -64 → .strong and -84 → .normal as adjacent boundary tests Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/4fcbc01e-cbea-4d11-b2c0-e923c6730d69 Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * Improve and complete router tests with comprehensive coverage Added tests for: - Custom initial state - Invalid scheme / unknown path handling (state unchanged) - navigateToNodeDetail public method - Messages edge cases: channelId only, userNum only, messageId only, non-numeric params - Nodes with non-numeric nodenum - Map with both nodenum+waypointId (nodeId priority), non-numeric params - Parameterized settings test covering all 31 SettingsNavigationState cases - State transitions: consecutive routes, invalid scheme preserves existing state Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/f69b7352-21aa-494c-8864-31fc0f4b21b8 Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * Localizable update * Merge translations file --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> Co-authored-by: Garth Vander Houwen * Fix merge conflicts in PR #1614 (Spanish translations) (#1644) * 20% of strings translated to spanish * add more translations * add rest of translations * small fixes --------- Co-authored-by: Joel Pérez Izquierdo Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * fix typo in hop limit option description (#1631) O hop -> 0 hop --------- Co-authored-by: Jake-B Co-authored-by: Garth Vander Houwen Co-authored-by: niccellular <79813408+niccellular@users.noreply.github.com> Co-authored-by: Austin Hargis <25471876+austinhargis@users.noreply.github.com> Co-authored-by: Ben Meadors Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> Co-authored-by: Joel Pérez Izquierdo Co-authored-by: axunes * Fix merge conflicts * Merge main into tak-server-improvements to resolve PR #1603 conflicts (#1646) * Delete Messages fix * Bump version to 2.7.9 * Bump widgets version * TAK Server channel index picker Create a settings picker for the TAK Server's channel index. This allows users to specify TAK traffic to use the non-primary channel to help reduce channel congestion. * Changed capitalization from 'environment' to 'Environment' for section header. (#1591) * Add Danish (da) translations — resolves merge conflicts from PR #1609 (#1612) * Initial plan * Add Danish (da) translations from PR #1609 Resolves merge conflicts from PR #1609 by adding Danish translations to the Localizable.xcstrings file. The PR adds Danish translation strings throughout the app while preserving all existing translations for other languages. Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * Migrate test project to Swift Testing and add connect view and router tests (#1643) * Migrate to Swift Testing and add connect view tests - Convert RouterTests.swift from XCTest to Swift Testing (@Suite, @Test, #expect, #require) - Create ConnectViewTests.swift with tests for connect view child types: - Device struct (creation, signal strength, RSSI, description, codable) - TransportType enum (cases, raw values, codable) - ConnectionState enum (equality, codable) - BLESignalStrength enum (raw values, init) - TransportStatus enum (equality) - NavigationState (defaults, tabs, sub-states) - InvalidVersion view (creation with versions) - ConnectedDevice view (connected/disconnected/MQTT states) - CircleText view (default/custom sizes, emoji) - BatteryCompact view (levels, nil, charging, plugged in) - SignalStrengthIndicator view (dimensions, strength levels) - Update Xcode project to include new test file Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/d7bb7a89-2105-4fcb-96bc-7ec794467c74 Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * Fix signal strength test boundary conditions The getSignalStrength() method uses NSNumber.compare(.orderedDescending), which is a strict greater-than check. Fix the boundary test cases: - RSSI -65 is .normal (not .strong), since -65 is not > -65 - RSSI -85 is .weak (not .normal), since -85 is not > -85 - Add -64 → .strong and -84 → .normal as adjacent boundary tests Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/4fcbc01e-cbea-4d11-b2c0-e923c6730d69 Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * Improve and complete router tests with comprehensive coverage Added tests for: - Custom initial state - Invalid scheme / unknown path handling (state unchanged) - navigateToNodeDetail public method - Messages edge cases: channelId only, userNum only, messageId only, non-numeric params - Nodes with non-numeric nodenum - Map with both nodenum+waypointId (nodeId priority), non-numeric params - Parameterized settings test covering all 31 SettingsNavigationState cases - State transitions: consecutive routes, invalid scheme preserves existing state Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/f69b7352-21aa-494c-8864-31fc0f4b21b8 Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * Localizable update * Merge translations file --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> Co-authored-by: Garth Vander Houwen * Fix merge conflicts in PR #1614 (Spanish translations) (#1644) * 20% of strings translated to spanish * add more translations * add rest of translations * small fixes --------- Co-authored-by: Joel Pérez Izquierdo Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * fix typo in hop limit option description (#1631) O hop -> 0 hop --------- Co-authored-by: Jake-B Co-authored-by: Garth Vander Houwen Co-authored-by: niccellular <79813408+niccellular@users.noreply.github.com> Co-authored-by: Austin Hargis <25471876+austinhargis@users.noreply.github.com> Co-authored-by: Ben Meadors Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> Co-authored-by: Joel Pérez Izquierdo Co-authored-by: axunes * Merge main into tak-server-improvements to resolve PR #1603 conflicts (#1647) * Delete Messages fix * Bump version to 2.7.9 * Bump widgets version * TAK Server channel index picker Create a settings picker for the TAK Server's channel index. This allows users to specify TAK traffic to use the non-primary channel to help reduce channel congestion. * Changed capitalization from 'environment' to 'Environment' for section header. (#1591) * Add Danish (da) translations — resolves merge conflicts from PR #1609 (#1612) * Initial plan * Add Danish (da) translations from PR #1609 Resolves merge conflicts from PR #1609 by adding Danish translations to the Localizable.xcstrings file. The PR adds Danish translation strings throughout the app while preserving all existing translations for other languages. Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * Migrate test project to Swift Testing and add connect view and router tests (#1643) * Migrate to Swift Testing and add connect view tests - Convert RouterTests.swift from XCTest to Swift Testing (@Suite, @Test, #expect, #require) - Create ConnectViewTests.swift with tests for connect view child types: - Device struct (creation, signal strength, RSSI, description, codable) - TransportType enum (cases, raw values, codable) - ConnectionState enum (equality, codable) - BLESignalStrength enum (raw values, init) - TransportStatus enum (equality) - NavigationState (defaults, tabs, sub-states) - InvalidVersion view (creation with versions) - ConnectedDevice view (connected/disconnected/MQTT states) - CircleText view (default/custom sizes, emoji) - BatteryCompact view (levels, nil, charging, plugged in) - SignalStrengthIndicator view (dimensions, strength levels) - Update Xcode project to include new test file Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/d7bb7a89-2105-4fcb-96bc-7ec794467c74 Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * Fix signal strength test boundary conditions The getSignalStrength() method uses NSNumber.compare(.orderedDescending), which is a strict greater-than check. Fix the boundary test cases: - RSSI -65 is .normal (not .strong), since -65 is not > -65 - RSSI -85 is .weak (not .normal), since -85 is not > -85 - Add -64 → .strong and -84 → .normal as adjacent boundary tests Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/4fcbc01e-cbea-4d11-b2c0-e923c6730d69 Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * Improve and complete router tests with comprehensive coverage Added tests for: - Custom initial state - Invalid scheme / unknown path handling (state unchanged) - navigateToNodeDetail public method - Messages edge cases: channelId only, userNum only, messageId only, non-numeric params - Nodes with non-numeric nodenum - Map with both nodenum+waypointId (nodeId priority), non-numeric params - Parameterized settings test covering all 31 SettingsNavigationState cases - State transitions: consecutive routes, invalid scheme preserves existing state Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/f69b7352-21aa-494c-8864-31fc0f4b21b8 Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * Localizable update * Merge translations file --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> Co-authored-by: Garth Vander Houwen * Fix merge conflicts in PR #1614 (Spanish translations) (#1644) * 20% of strings translated to spanish * add more translations * add rest of translations * small fixes --------- Co-authored-by: Joel Pérez Izquierdo Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * fix typo in hop limit option description (#1631) O hop -> 0 hop --------- Co-authored-by: Jake-B Co-authored-by: Garth Vander Houwen Co-authored-by: niccellular <79813408+niccellular@users.noreply.github.com> Co-authored-by: Austin Hargis <25471876+austinhargis@users.noreply.github.com> Co-authored-by: Ben Meadors Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> Co-authored-by: Joel Pérez Izquierdo Co-authored-by: axunes --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: Jake-B Co-authored-by: Garth Vander Houwen Co-authored-by: niccellular <79813408+niccellular@users.noreply.github.com> Co-authored-by: Austin Hargis <25471876+austinhargis@users.noreply.github.com> Co-authored-by: Ben Meadors Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> Co-authored-by: Joel Pérez Izquierdo Co-authored-by: axunes --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> Co-authored-by: Garth Vander Houwen Co-authored-by: Joel Pérez Izquierdo Co-authored-by: axunes Co-authored-by: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Jake-B Co-authored-by: niccellular <79813408+niccellular@users.noreply.github.com> Co-authored-by: Austin Hargis <25471876+austinhargis@users.noreply.github.com> Co-authored-by: Ben Meadors --- Localizable.xcstrings | 95 +++++++++++++++++++ Meshtastic.xcodeproj/project.pbxproj | 2 + Meshtastic/Persistence/QueryCoreData.swift | 1 + Meshtastic/Router/Router.swift | 94 +++++++++++++++--- Meshtastic/Views/ContentView.swift | 2 +- .../Views/Messages/ChannelMessageList.swift | 2 +- Meshtastic/Views/Messages/Messages.swift | 12 +-- .../Views/Messages/UserMessageList.swift | 2 +- Meshtastic/Views/Nodes/MeshMap.swift | 4 +- Meshtastic/Views/Nodes/NodeList.swift | 59 ++++++++++-- Meshtastic/Views/Settings/Settings.swift | 4 +- 11 files changed, 240 insertions(+), 37 deletions(-) diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 7caaa7cd..3a8d12cf 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -233,6 +233,12 @@ "value" : ": %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : " : %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -274,6 +280,12 @@ "value" : ": %d" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : " : %d" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -52706,6 +52718,7 @@ } } } + }, "TAK Cannot Be Used on Public Channel" : { "comment" : "A warning displayed when the user's primary channel is public.", @@ -62950,6 +62963,88 @@ } } } + }, + ": %@" : { + "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : ": %@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : ": %@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : ": %@" + } + }, + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : ": %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : ": %@" + } + }, + "zh-Hant-TW" : { + "stringUnit" : { + "state" : "translated", + "value" : ": %@" + } + } + }, + "shouldTranslate" : false + }, + ": %d" : { + "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : ": %d" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : ": %d" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : ": %d" + } + }, + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : ": %d" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : ": %d" + } + }, + "zh-Hant-TW" : { + "stringUnit" : { + "state" : "translated", + "value" : ": %d" + } + } + }, + "shouldTranslate" : false } }, "version" : "1.1" diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index ac3d99cf..5038f261 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -82,6 +82,7 @@ 25F5D5C02C3F6DA6008036E3 /* Router.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25F5D5BF2C3F6DA6008036E3 /* Router.swift */; }; 25F5D5C22C3F6E4B008036E3 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25F5D5C12C3F6E4B008036E3 /* AppState.swift */; }; 25F5D5D12C4375DF008036E3 /* RouterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25F5D5D02C4375DF008036E3 /* RouterTests.swift */; }; + AA0001012E2730EC00600001 /* ConnectViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA00010022E2730EC0060000 /* ConnectViewTests.swift */; }; 2849A5E4CE9FDC1DB33DFA34 /* TAKConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01028778B8BFD81F7A039593 /* TAKConnection.swift */; }; 300424F80C4A445A0FBAE82D /* TAKMeshtasticBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87D006C85B250291D5925F30 /* TAKMeshtasticBridge.swift */; }; 3D3417B42E2730EC006A988B /* GeoJSONOverlayManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3417B32E2730EC006A988B /* GeoJSONOverlayManager.swift */; }; @@ -412,6 +413,7 @@ 25F5D5C12C3F6E4B008036E3 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; 25F5D5C72C4375A8008036E3 /* MeshtasticTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MeshtasticTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 25F5D5D02C4375DF008036E3 /* RouterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouterTests.swift; sourceTree = ""; }; + AA00010022E2730EC0060000 /* ConnectViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectViewTests.swift; sourceTree = ""; }; 2B37CCEE8B44A4BA123ED118 /* TAKServerManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TAKServerManager.swift; sourceTree = ""; }; 3D0A8ABAEF1E587683970927 /* EXICodec.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = EXICodec.swift; sourceTree = ""; }; 3D3417B32E2730EC006A988B /* GeoJSONOverlayManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeoJSONOverlayManager.swift; sourceTree = ""; }; diff --git a/Meshtastic/Persistence/QueryCoreData.swift b/Meshtastic/Persistence/QueryCoreData.swift index 55889764..dffe425b 100644 --- a/Meshtastic/Persistence/QueryCoreData.swift +++ b/Meshtastic/Persistence/QueryCoreData.swift @@ -11,6 +11,7 @@ public func getNodeInfo(id: Int64, context: NSManagedObjectContext) -> NodeInfoE let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(id)) + fetchNodeInfoRequest.fetchLimit = 1 do { let fetchedNode = try context.fetch(fetchNodeInfoRequest) diff --git a/Meshtastic/Router/Router.swift b/Meshtastic/Router/Router.swift index 4ea89d94..61a599c2 100644 --- a/Meshtastic/Router/Router.swift +++ b/Meshtastic/Router/Router.swift @@ -7,7 +7,67 @@ import SwiftUI class Router: ObservableObject { @Published - var navigationState: NavigationState + var selectedTab: NavigationState.Tab + + @Published + var messagesState: MessagesNavigationState? + + @Published + var nodeListSelectedNodeNum: Int64? + + @Published + var mapState: MapNavigationState? + + @Published + var settingsState: SettingsNavigationState? + + /// Computed property that assembles the individual per-tab properties into a `NavigationState`. + /// Provided for backward compatibility (e.g. tests) and convenience. + var navigationState: NavigationState { + get { + NavigationState( + selectedTab: selectedTab, + messages: messagesState, + nodeListSelectedNodeNum: nodeListSelectedNodeNum, + map: mapState, + settings: settingsState + ) + } + set { + selectedTab = newValue.selectedTab + messagesState = newValue.messages + nodeListSelectedNodeNum = newValue.nodeListSelectedNodeNum + mapState = newValue.map + settingsState = newValue.settings + } + } + + // MARK: Node Object ID Cache + + /// In-memory cache mapping node numbers to their Core Data `NSManagedObjectID` for O(1) lookups. + /// Thread-safe by virtue of Router's @MainActor isolation — all access is on the main thread. + private var nodeObjectIDCache: [Int64: NSManagedObjectID] = [:] + + /// Updates the node cache from a set of fetched nodes. Call this when the node list changes. + func updateNodeIndex(from nodes: C) where C.Element: NodeInfoEntity { + nodeObjectIDCache = Dictionary( + nodes.map { ($0.num, $0.objectID) }, + uniquingKeysWith: { _, new in new } + ) + } + + /// Looks up a node using the in-memory cache for O(1) performance, falling back to a Core Data fetch. + func cachedNodeInfo(id: Int64, context: NSManagedObjectContext) -> NodeInfoEntity? { + if let objectID = nodeObjectIDCache[id] { + return try? context.existingObject(with: objectID) as? NodeInfoEntity + } + // Cache miss — fall back to standard fetch + let node = getNodeInfo(id: id, context: context) + if let node { + nodeObjectIDCache[id] = node.objectID + } + return node + } private var cancellables: Set = [] @@ -16,10 +76,14 @@ class Router: ObservableObject { selectedTab: .connect ) ) { - self.navigationState = navigationState + self.selectedTab = navigationState.selectedTab + self.messagesState = navigationState.messages + self.nodeListSelectedNodeNum = navigationState.nodeListSelectedNodeNum + self.mapState = navigationState.map + self.settingsState = navigationState.settings - $navigationState.sink { destination in - Logger.services.info("🛣 [App] Routed to \(destination.selectedTab.rawValue, privacy: .public)") + $selectedTab.sink { tab in + Logger.services.info("🛣 [App] Routed to \(tab.rawValue, privacy: .public)") }.store(in: &cancellables) } @@ -36,7 +100,7 @@ class Router: ObservableObject { if components.path == "/messages" { routeMessages(components) } else if components.path == "/connect" { - navigationState.selectedTab = .connect + selectedTab = .connect } else if components.path == "/nodes" { routeNodes(components) } else if components.path == "/map" { @@ -73,8 +137,8 @@ class Router: ObservableObject { } else { nil } - navigationState.selectedTab = .messages - navigationState.messages = state + selectedTab = .messages + messagesState = state } private func routeNodes(_ components: URLComponents) { @@ -83,13 +147,13 @@ class Router: ObservableObject { .value .flatMap(Int64.init) - navigationState.selectedTab = .nodes - navigationState.nodeListSelectedNodeNum = nodeId + selectedTab = .nodes + nodeListSelectedNodeNum = nodeId } func navigateToNodeDetail(nodeNum: Int64) { Logger.services.info("🛣 [App] Direct route to node detail \(nodeNum, privacy: .public)") - navigationState.selectedTab = .nodes - navigationState.nodeListSelectedNodeNum = nodeNum + selectedTab = .nodes + nodeListSelectedNodeNum = nodeNum } private func routeMap(_ components: URLComponents) { @@ -102,8 +166,8 @@ class Router: ObservableObject { .value .flatMap(Int64.init) - navigationState.selectedTab = .map - navigationState.map = if let nodeId { + selectedTab = .map + mapState = if let nodeId { .selectedNode(nodeId) } else if let waypointId { .waypoint(waypointId) @@ -120,7 +184,7 @@ class Router: ObservableObject { .flatMap(String.init) .flatMap(SettingsNavigationState.init(rawValue:)) - navigationState.selectedTab = .settings - navigationState.settings = settingFromPath + selectedTab = .settings + settingsState = settingFromPath } } diff --git a/Meshtastic/Views/ContentView.swift b/Meshtastic/Views/ContentView.swift index ac18b9a4..5e10edc3 100644 --- a/Meshtastic/Views/ContentView.swift +++ b/Meshtastic/Views/ContentView.swift @@ -17,7 +17,7 @@ struct ContentView: View { } var body: some View { - TabView(selection: $appState.router.navigationState.selectedTab) { + TabView(selection: $appState.router.selectedTab) { Messages( router: appState.router, unreadChannelMessages: $appState.unreadChannelMessages, diff --git a/Meshtastic/Views/Messages/ChannelMessageList.swift b/Meshtastic/Views/Messages/ChannelMessageList.swift index be3959d2..a1c70b89 100644 --- a/Meshtastic/Views/Messages/ChannelMessageList.swift +++ b/Meshtastic/Views/Messages/ChannelMessageList.swift @@ -60,7 +60,7 @@ struct ChannelMessageList: View { } private func routerIsShowingThisChannel() -> Bool { - guard appState.router.navigationState.selectedTab == .messages else { return false } + guard appState.router.selectedTab == .messages else { return false } return scenePhase == .active } diff --git a/Meshtastic/Views/Messages/Messages.swift b/Meshtastic/Views/Messages/Messages.swift index 82df1ad9..2f4e2950 100644 --- a/Meshtastic/Views/Messages/Messages.swift +++ b/Meshtastic/Views/Messages/Messages.swift @@ -25,7 +25,7 @@ struct Messages: View { var body: some View { NavigationSplitView(columnVisibility: $columnVisibility) { - List(selection: $router.navigationState.messages) { + List(selection: $router.messagesState) { NavigationLink(value: MessagesNavigationState.channels()) { Spacer() Label { @@ -74,7 +74,7 @@ struct Messages: View { .navigationBarTitleDisplayMode(.large) .navigationBarItems(leading: MeshtasticLogo()) } content: { - switch router.navigationState.messages { + switch router.messagesState { case .channels(let channelId, let messageId): ChannelList(node: $node, channelSelection: $channelSelection) // Removed navigationTitle and navigationBarTitleDisplayMode here. @@ -91,12 +91,12 @@ struct Messages: View { // The toolbar is now defined inside ChannelMessageList.swift } else if let userSelection { UserMessageList(user: userSelection) - } else if case .channels = router.navigationState.messages { + } else if case .channels = router.messagesState { Text("Select a channel") - } else if case .directMessages = router.navigationState.messages { + } else if case .directMessages = router.messagesState { Text("Select a conversation") } - }.onChange(of: router.navigationState) { + }.onChange(of: router.messagesState) { setupNavigationState() } } @@ -107,7 +107,7 @@ struct Messages: View { node = getNodeInfo(id: nodeId, context: context) } - guard let state = router.navigationState.messages else { + guard let state = router.messagesState else { channelSelection = nil userSelection = nil return diff --git a/Meshtastic/Views/Messages/UserMessageList.swift b/Meshtastic/Views/Messages/UserMessageList.swift index 9a3425bc..dc417565 100644 --- a/Meshtastic/Views/Messages/UserMessageList.swift +++ b/Meshtastic/Views/Messages/UserMessageList.swift @@ -57,7 +57,7 @@ struct UserMessageList: View { } private func routerIsShowingThisUser() -> Bool { - guard appState.router.navigationState.selectedTab == .messages else { return false } + guard appState.router.selectedTab == .messages else { return false } return scenePhase == .active } diff --git a/Meshtastic/Views/Nodes/MeshMap.swift b/Meshtastic/Views/Nodes/MeshMap.swift index b1bf58ba..6414eb3f 100644 --- a/Meshtastic/Views/Nodes/MeshMap.swift +++ b/Meshtastic/Views/Nodes/MeshMap.swift @@ -134,8 +134,8 @@ struct MeshMap: View { .sheet(isPresented: $editingSettings) { MapSettingsForm(traffic: $showTraffic, pointsOfInterest: $showPointsOfInterest, mapLayer: $selectedMapLayer, meshMap: $isMeshMap, enabledOverlayConfigs: $enabledOverlayConfigs) } - .onChange(of: router.navigationState) { - guard case .map = router.navigationState.selectedTab else { return } + .onChange(of: router.mapState) { + guard case .map = router.selectedTab else { return } // TODO: handle deep link for waypoints } .onChange(of: selectedMapLayer) { _, newMapLayer in diff --git a/Meshtastic/Views/Nodes/NodeList.swift b/Meshtastic/Views/Nodes/NodeList.swift index c751f84a..798e4d6e 100644 --- a/Meshtastic/Views/Nodes/NodeList.swift +++ b/Meshtastic/Views/Nodes/NodeList.swift @@ -11,10 +11,14 @@ import CoreData import Foundation struct NodeList: View { + /// Debounce delay for node selection changes (100ms) + private static let nodeSelectionDebounceNs: UInt64 = 100_000_000 + @Environment(\.managedObjectContext) var context @EnvironmentObject var accessoryManager: AccessoryManager @StateObject var router: Router @State private var selectedNode: NodeInfoEntity? + @State private var nodeSelectionTask: Task? @State private var isPresentingTraceRouteSentAlert = false @State private var isPresentingPositionSentAlert = false @State private var isPresentingPositionFailedAlert = false @@ -35,6 +39,7 @@ struct NodeList: View { var body: some View { NavigationSplitView { FilteredNodeList( + router: router, withFilters: filters, selectedNode: $selectedNode, connectedNode: connectedNode, @@ -122,18 +127,27 @@ struct NodeList: View { ContentUnavailableView("Select a Node", systemImage: "flipphone") } } - .onChange(of: router.navigationState.nodeListSelectedNodeNum) { _, newNum in - if let num = newNum { - self.selectedNode = getNodeInfo(id: num, context: context) - } else { - self.selectedNode = nil + .onChange(of: router.nodeListSelectedNodeNum) { _, newNum in + // Debounce rapid route changes — only process the last selection after a short delay + nodeSelectionTask?.cancel() + nodeSelectionTask = Task { + do { + try await Task.sleep(nanoseconds: Self.nodeSelectionDebounceNs) + } catch { + return // Cancelled by a newer selection + } + if let num = newNum { + self.selectedNode = router.cachedNodeInfo(id: num, context: context) + } else { + self.selectedNode = nil + } } } .onChange(of: selectedNode) { _, node in if let num = node?.num { - router.navigationState.nodeListSelectedNodeNum = num + router.nodeListSelectedNodeNum = num } else { - router.navigationState.nodeListSelectedNodeNum = nil + router.nodeListSelectedNodeNum = nil } } } @@ -154,6 +168,7 @@ fileprivate struct FilteredNodeList: View { @EnvironmentObject var accessoryManager: AccessoryManager @FetchRequest private var nodes: FetchedResults @Environment(\.managedObjectContext) var context + var router: Router @Binding var selectedNode: NodeInfoEntity? var connectedNode: NodeInfoEntity? @@ -163,6 +178,7 @@ fileprivate struct FilteredNodeList: View { // The initializer for the FetchRequest init( + router: Router, withFilters: NodeFilterParameters, selectedNode: Binding, connectedNode: NodeInfoEntity?, @@ -170,6 +186,7 @@ fileprivate struct FilteredNodeList: View { deleteNodeId: Binding, shareContactNode: Binding ) { + self.router = router let request: NSFetchRequest = NodeInfoEntity.fetchRequest() request.sortDescriptors = [ NSSortDescriptor(key: "ignored", ascending: true), @@ -178,6 +195,8 @@ fileprivate struct FilteredNodeList: View { NSSortDescriptor(key: "user.longName", ascending: true) ] request.predicate = withFilters.buildPredicate() + request.fetchBatchSize = 50 + request.relationshipKeyPathsForPrefetching = ["user"] self._nodes = FetchRequest(fetchRequest: request) self._selectedNode = selectedNode @@ -189,8 +208,24 @@ fileprivate struct FilteredNodeList: View { // The body of the view var body: some View { - // If the connected node passes filters, always show it first - let nodesWithConnectedFirst = nodes.filter { $0.num == accessoryManager.activeDeviceNum } + nodes.filter { $0.num != accessoryManager.activeDeviceNum } + // If the connected node passes filters, always show it first (single-pass) + let nodesWithConnectedFirst: [NodeInfoEntity] = { + let activeNum = accessoryManager.activeDeviceNum + var result: [NodeInfoEntity] = [] + result.reserveCapacity(nodes.count) + var connectedNode: NodeInfoEntity? + for node in nodes { + if node.num == activeNum { + connectedNode = node + } else { + result.append(node) + } + } + if let connectedNode { + result.insert(connectedNode, at: 0) + } + return result + }() List(nodesWithConnectedFirst, id: \.self, selection: $selectedNode) { node in NavigationLink(value: node) { NodeListItem( @@ -206,6 +241,12 @@ fileprivate struct FilteredNodeList: View { ) } } + .onAppear { + router.updateNodeIndex(from: nodes) + } + .onChange(of: nodes.count) { _, _ in + router.updateNodeIndex(from: nodes) + } } @ViewBuilder diff --git a/Meshtastic/Views/Settings/Settings.swift b/Meshtastic/Views/Settings/Settings.swift index 449efc6c..1c953c73 100644 --- a/Meshtastic/Views/Settings/Settings.swift +++ b/Meshtastic/Views/Settings/Settings.swift @@ -343,10 +343,10 @@ struct Settings: View { NavigationStack( path: Binding<[SettingsNavigationState]>( get: { - [router.navigationState.settings].compactMap { $0 } + [router.settingsState].compactMap { $0 } }, set: { newPath in - router.navigationState.settings = newPath.first + router.settingsState = newPath.first } ) ) {