perf: Quick-win performance optimizations for node list and Core Data lookups (#1650)

* 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 <garthvh@yahoo.com>

* 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 <joelperez91@gmail.com>
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 <garthvh@yahoo.com>

* 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 <joelperez91@gmail.com>
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 <jake-b@users.noreply.github.com>
Co-authored-by: Garth Vander Houwen <garthvh@yahoo.com>
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 <benmmeadors@gmail.com>
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 <joelperez91@gmail.com>
Co-authored-by: axunes <axunes@axunes.net>

* 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 <garthvh@yahoo.com>

* 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 <joelperez91@gmail.com>
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 <jake-b@users.noreply.github.com>
Co-authored-by: Garth Vander Houwen <garthvh@yahoo.com>
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 <benmmeadors@gmail.com>
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 <joelperez91@gmail.com>
Co-authored-by: axunes <axunes@axunes.net>

* 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 <garthvh@yahoo.com>

* 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 <joelperez91@gmail.com>
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 <jake-b@users.noreply.github.com>
Co-authored-by: Garth Vander Houwen <garthvh@yahoo.com>
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 <benmmeadors@gmail.com>
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 <joelperez91@gmail.com>
Co-authored-by: axunes <axunes@axunes.net>

---------

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 <jake-b@users.noreply.github.com>
Co-authored-by: Garth Vander Houwen <garthvh@yahoo.com>
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 <benmmeadors@gmail.com>
Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com>
Co-authored-by: Joel Pérez Izquierdo <joelperez91@gmail.com>
Co-authored-by: axunes <axunes@axunes.net>

---------

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 <garthvh@yahoo.com>
Co-authored-by: Joel Pérez Izquierdo <joelperez91@gmail.com>
Co-authored-by: axunes <axunes@axunes.net>
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 <jake-b@users.noreply.github.com>
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 <benmmeadors@gmail.com>
This commit is contained in:
Copilot 2026-04-04 18:51:00 -07:00 committed by GitHub
parent 04ef427ec8
commit a4422b32cb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 240 additions and 37 deletions

View file

@ -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"

View file

@ -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 = "<group>"; };
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 = "<group>"; };
AA00010022E2730EC0060000 /* ConnectViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectViewTests.swift; sourceTree = "<group>"; };
2B37CCEE8B44A4BA123ED118 /* TAKServerManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TAKServerManager.swift; sourceTree = "<group>"; };
3D0A8ABAEF1E587683970927 /* EXICodec.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = EXICodec.swift; sourceTree = "<group>"; };
3D3417B32E2730EC006A988B /* GeoJSONOverlayManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeoJSONOverlayManager.swift; sourceTree = "<group>"; };

View file

@ -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)

View file

@ -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<C: Collection>(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<AnyCancellable> = []
@ -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
}
}

View file

@ -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,

View file

@ -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
}

View file

@ -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

View file

@ -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
}

View file

@ -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

View file

@ -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<Void, Never>?
@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<NodeInfoEntity>
@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<NodeInfoEntity?>,
connectedNode: NodeInfoEntity?,
@ -170,6 +186,7 @@ fileprivate struct FilteredNodeList: View {
deleteNodeId: Binding<Int64>,
shareContactNode: Binding<NodeInfoEntity?>
) {
self.router = router
let request: NSFetchRequest<NodeInfoEntity> = 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

View file

@ -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
}
)
) {