From a04a261b80b7d617447651af1fd67ec623775efa Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Thu, 14 May 2026 07:50:01 -0500 Subject: [PATCH] feat: TAK v2 protocol integration with zstd compression and full CoT type support (#5434) Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: jamesarich <2199651+jamesarich@users.noreply.github.com> Co-authored-by: James Rich Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitmodules | 1 + .skills/compose-ui/strings-index.txt | 11 + .skills/testing-ci/SKILL.md | 22 + app/src/fdroid/AndroidManifest.xml | 31 ++ app/src/main/AndroidManifest.xml | 4 - .../core/data/manager/MeshDataHandlerImpl.kt | 2 +- .../org/meshtastic/core/model/Capabilities.kt | 7 + .../org/meshtastic/core/navigation/Routes.kt | 2 + .../composeResources/values/strings.xml | 13 +- .../meshtastic/core/service/MeshService.kt | 48 ++ .../service/MeshServiceOrchestratorTest.kt | 10 +- core/takserver/build.gradle.kts | 69 ++- .../core/takserver/AtakFileWriter.kt | 53 ++ .../core/takserver/AtakFileWriter.kt | 32 ++ .../core/takserver/CoTDetailStripper.kt | 168 ++++++ .../org/meshtastic/core/takserver/CoTXml.kt | 62 ++- .../core/takserver/CoTXmlFrameBuffer.kt | 6 +- .../meshtastic/core/takserver/CoTXmlParser.kt | 34 +- .../takserver/RouteDataPackageGenerator.kt | 119 ++++ .../core/takserver/TAKClientConnection.kt | 253 --------- .../core/takserver/TAKDataPackageGenerator.kt | 166 +++++- .../meshtastic/core/takserver/TAKDefaults.kt | 45 +- .../core/takserver/TAKMeshIntegration.kt | 519 +++++++++++++++--- .../meshtastic/core/takserver/TAKModels.kt | 20 +- .../core/takserver/TAKPacketConversion.kt | 54 +- .../core/takserver/TAKPacketV2Conversion.kt | 270 +++++++++ .../meshtastic/core/takserver/TAKServer.kt | 211 ++----- .../core/takserver/TAKServerManager.kt | 178 +++--- .../core/takserver/TakConversionHelpers.kt | 58 ++ .../CoTHandler.kt => TakFixtureLoader.kt} | 16 +- .../core/takserver/TakMeshTestRunner.kt | 193 +++++++ .../core/takserver/TakSdkCompressor.kt | 34 ++ .../core/takserver/TakV2Compressor.kt | 62 +++ .../core/takserver/TakV2TypeMapper.kt | 75 +++ .../core/takserver/di/CoreTakServerModule.kt | 23 +- .../core/takserver/fountain/FountainCodec.kt | 468 ---------------- .../takserver/fountain/GenericCoTHandler.kt | 231 -------- .../core/takserver/CoTDetailStripperTest.kt | 238 ++++++++ .../core/takserver/CoTXmlParserTest.kt | 73 +++ .../meshtastic/core/takserver/CoTXmlTest.kt | 9 +- .../core/takserver/TAKDefaultsTest.kt | 15 +- .../core/takserver/TAKMeshIntegrationTest.kt | 486 ++++++++++++++++ .../takserver/TAKPacketV2RawDetailTest.kt | 137 +++++ .../core/takserver/TAKServerManagerTest.kt | 251 +++++++++ .../takserver/TakV2CompressorBoundaryTest.kt | 63 +++ .../takserver/fountain/FountainCodecTest.kt | 115 ---- .../core/takserver/AtakFileWriter.kt | 22 + .../meshtastic/core/takserver/TAKServerIos.kt | 50 ++ .../core/takserver/TakFixtureLoader.kt | 21 + .../core/takserver/TakSdkCompressor.kt} | 16 +- .../core/takserver/TakV2Compressor.kt | 71 +++ .../core/takserver/fountain/ZlibCodec.kt | 105 ---- .../core/takserver/TAKClientConnection.kt | 336 ++++++++++++ .../meshtastic/core/takserver/TAKServerJvm.kt | 299 ++++++++++ .../core/takserver/TakCertLoader.kt | 160 ++++++ .../core/takserver/TakFixtureLoader.kt | 25 + .../core/takserver/TakSdkCompressor.kt | 30 + .../core/takserver/TakV2Compressor.kt | 481 ++++++++++++++++ .../core/takserver/fountain/ZlibCodec.kt | 67 --- .../jvmAndroidMain/resources/tak_certs/ca.pem | 23 + .../resources/tak_certs/client.p12 | Bin 0 -> 3827 bytes .../resources/tak_certs/server.p12 | Bin 0 -> 3859 bytes .../tak_test_fixtures/aircraft_adsb.xml | 5 + .../tak_test_fixtures/aircraft_hostile.xml | 5 + .../resources/tak_test_fixtures/alert_tic.xml | 8 + .../resources/tak_test_fixtures/casevac.xml | 10 + .../tak_test_fixtures/casevac_medline.xml | 10 + .../chat_receipt_delivered.xml | 9 + .../tak_test_fixtures/chat_receipt_read.xml | 9 + .../tak_test_fixtures/delete_event.xml | 5 + .../tak_test_fixtures/drawing_circle.xml | 25 + .../drawing_circle_large.xml | 15 + .../tak_test_fixtures/drawing_ellipse.xml | 17 + .../tak_test_fixtures/drawing_freeform.xml | 19 + .../tak_test_fixtures/drawing_polygon.xml | 19 + .../tak_test_fixtures/drawing_rectangle.xml | 19 + .../drawing_rectangle_itak.xml | 16 + .../drawing_telestration.xml | 53 ++ .../tak_test_fixtures/emergency_911.xml | 10 + .../tak_test_fixtures/emergency_cancel.xml | 11 + .../tak_test_fixtures/geochat_broadcast.xml | 12 + .../tak_test_fixtures/geochat_dm.xml | 12 + .../tak_test_fixtures/geochat_simple.xml | 12 + .../tak_test_fixtures/marker_2525.xml | 14 + .../tak_test_fixtures/marker_goto.xml | 12 + .../tak_test_fixtures/marker_goto_itak.xml | 10 + .../tak_test_fixtures/marker_icon_set.xml | 14 + .../tak_test_fixtures/marker_spot.xml | 14 + .../tak_test_fixtures/marker_tank.xml | 14 + .../resources/tak_test_fixtures/pli_basic.xml | 5 + .../resources/tak_test_fixtures/pli_full.xml | 5 + .../resources/tak_test_fixtures/pli_itak.xml | 11 + .../tak_test_fixtures/pli_stationary.xml | 12 + .../tak_test_fixtures/pli_takaware.xml | 11 + .../tak_test_fixtures/pli_webtak.xml | 5 + .../tak_test_fixtures/ranging_bullseye.xml | 17 + .../tak_test_fixtures/ranging_circle.xml | 17 + .../tak_test_fixtures/ranging_line.xml | 14 + .../resources/tak_test_fixtures/route_3wp.xml | 16 + .../tak_test_fixtures/route_itak_3wp.xml | 11 + .../tak_test_fixtures/task_engage.xml | 10 + .../resources/tak_test_fixtures/waypoint.xml | 12 + .../core/takserver/AtakFileWriter.kt | 25 + .../meshtastic/core/ui/util/PlatformUtils.kt | 16 +- .../feature/settings/tak/TakPermissionUtil.kt | 14 +- .../settings/ModuleConfigurationScreen.kt | 5 +- .../settings/navigation/SettingsNavigation.kt | 3 + .../feature/settings/radio/RadioConfig.kt | 8 + .../radio/component/TAKConfigItemList.kt | 336 ++++++++++-- .../radio/component/TAKConfigPreviews.kt | 110 ++++ .../TAKConfigPermissionDeniedTest.kt | 94 ++++ .../feature/SettingsScreenshotTests.kt | 48 ++ ...creenshotTakConfigCard_Dark_d19fbf1f_0.png | Bin 0 -> 23863 bytes ...reenshotTakConfigCard_Light_b29dc7a7_0.png | Bin 0 -> 23710 bytes ...kServerSectionDisabled_Dark_d19fbf1f_0.png | Bin 0 -> 27766 bytes ...ServerSectionDisabled_Light_b29dc7a7_0.png | Bin 0 -> 27506 bytes ...akServerSectionEnabled_Dark_d19fbf1f_0.png | Bin 0 -> 42840 bytes ...kServerSectionEnabled_Light_b29dc7a7_0.png | Bin 0 -> 42564 bytes ...eenshotTakTestCardIdle_Dark_d19fbf1f_0.png | Bin 0 -> 22354 bytes ...enshotTakTestCardIdle_Light_b29dc7a7_0.png | Bin 0 -> 22112 bytes ...shotTakTestCardResults_Dark_d19fbf1f_0.png | Bin 0 -> 42948 bytes ...hotTakTestCardResults_Light_b29dc7a7_0.png | Bin 0 -> 42754 bytes ...shotTakTestCardRunning_Dark_d19fbf1f_0.png | Bin 0 -> 29695 bytes ...hotTakTestCardRunning_Light_b29dc7a7_0.png | Bin 0 -> 29437 bytes .../checklists/protocol.md | 131 +++++ .../checklists/requirements.md | 37 ++ .../contracts/wire-protocol.md | 184 +++++++ specs/005-tak-v2-protocol/data-model.md | 249 +++++++++ specs/005-tak-v2-protocol/plan.md | 155 ++++++ specs/005-tak-v2-protocol/quickstart.md | 121 ++++ specs/005-tak-v2-protocol/research.md | 164 ++++++ specs/005-tak-v2-protocol/spec.md | 220 ++++++++ specs/005-tak-v2-protocol/tasks.md | 294 ++++++++++ 133 files changed, 7620 insertions(+), 1782 deletions(-) create mode 100644 app/src/fdroid/AndroidManifest.xml create mode 100644 core/takserver/src/androidMain/kotlin/org/meshtastic/core/takserver/AtakFileWriter.kt create mode 100644 core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/AtakFileWriter.kt create mode 100644 core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/CoTDetailStripper.kt create mode 100644 core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/RouteDataPackageGenerator.kt delete mode 100644 core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKClientConnection.kt create mode 100644 core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKPacketV2Conversion.kt create mode 100644 core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TakConversionHelpers.kt rename core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/{fountain/CoTHandler.kt => TakFixtureLoader.kt} (57%) create mode 100644 core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TakMeshTestRunner.kt create mode 100644 core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TakSdkCompressor.kt create mode 100644 core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TakV2Compressor.kt create mode 100644 core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TakV2TypeMapper.kt delete mode 100644 core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/fountain/FountainCodec.kt delete mode 100644 core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/fountain/GenericCoTHandler.kt create mode 100644 core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/CoTDetailStripperTest.kt create mode 100644 core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKMeshIntegrationTest.kt create mode 100644 core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKPacketV2RawDetailTest.kt create mode 100644 core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKServerManagerTest.kt create mode 100644 core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TakV2CompressorBoundaryTest.kt delete mode 100644 core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/fountain/FountainCodecTest.kt create mode 100644 core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/AtakFileWriter.kt create mode 100644 core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/TAKServerIos.kt create mode 100644 core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/TakFixtureLoader.kt rename core/takserver/src/{commonMain/kotlin/org/meshtastic/core/takserver/fountain/CodecExpect.kt => iosMain/kotlin/org/meshtastic/core/takserver/TakSdkCompressor.kt} (62%) create mode 100644 core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/TakV2Compressor.kt delete mode 100644 core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/fountain/ZlibCodec.kt create mode 100644 core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/TAKClientConnection.kt create mode 100644 core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/TAKServerJvm.kt create mode 100644 core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/TakCertLoader.kt create mode 100644 core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/TakFixtureLoader.kt create mode 100644 core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/TakSdkCompressor.kt create mode 100644 core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/TakV2Compressor.kt delete mode 100644 core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/fountain/ZlibCodec.kt create mode 100644 core/takserver/src/jvmAndroidMain/resources/tak_certs/ca.pem create mode 100644 core/takserver/src/jvmAndroidMain/resources/tak_certs/client.p12 create mode 100644 core/takserver/src/jvmAndroidMain/resources/tak_certs/server.p12 create mode 100644 core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/aircraft_adsb.xml create mode 100644 core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/aircraft_hostile.xml create mode 100644 core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/alert_tic.xml create mode 100644 core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/casevac.xml create mode 100644 core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/casevac_medline.xml create mode 100644 core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/chat_receipt_delivered.xml create mode 100644 core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/chat_receipt_read.xml create mode 100644 core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/delete_event.xml create mode 100644 core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/drawing_circle.xml create mode 100644 core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/drawing_circle_large.xml create mode 100644 core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/drawing_ellipse.xml create mode 100644 core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/drawing_freeform.xml create mode 100644 core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/drawing_polygon.xml create mode 100644 core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/drawing_rectangle.xml create mode 100644 core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/drawing_rectangle_itak.xml create mode 100644 core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/drawing_telestration.xml create mode 100644 core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/emergency_911.xml create mode 100644 core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/emergency_cancel.xml create mode 100644 core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/geochat_broadcast.xml create mode 100644 core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/geochat_dm.xml create mode 100644 core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/geochat_simple.xml create mode 100644 core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/marker_2525.xml create mode 100644 core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/marker_goto.xml create mode 100644 core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/marker_goto_itak.xml create mode 100644 core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/marker_icon_set.xml create mode 100644 core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/marker_spot.xml create mode 100644 core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/marker_tank.xml create mode 100644 core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/pli_basic.xml create mode 100644 core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/pli_full.xml create mode 100644 core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/pli_itak.xml create mode 100644 core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/pli_stationary.xml create mode 100644 core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/pli_takaware.xml create mode 100644 core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/pli_webtak.xml create mode 100644 core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/ranging_bullseye.xml create mode 100644 core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/ranging_circle.xml create mode 100644 core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/ranging_line.xml create mode 100644 core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/route_3wp.xml create mode 100644 core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/route_itak_3wp.xml create mode 100644 core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/task_engage.xml create mode 100644 core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/waypoint.xml create mode 100644 core/takserver/src/jvmMain/kotlin/org/meshtastic/core/takserver/AtakFileWriter.kt create mode 100644 feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigPreviews.kt create mode 100644 feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigPermissionDeniedTest.kt create mode 100644 screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakConfigCard_Dark_d19fbf1f_0.png create mode 100644 screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakConfigCard_Light_b29dc7a7_0.png create mode 100644 screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionDisabled_Dark_d19fbf1f_0.png create mode 100644 screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionDisabled_Light_b29dc7a7_0.png create mode 100644 screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionEnabled_Dark_d19fbf1f_0.png create mode 100644 screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionEnabled_Light_b29dc7a7_0.png create mode 100644 screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakTestCardIdle_Dark_d19fbf1f_0.png create mode 100644 screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakTestCardIdle_Light_b29dc7a7_0.png create mode 100644 screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakTestCardResults_Dark_d19fbf1f_0.png create mode 100644 screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakTestCardResults_Light_b29dc7a7_0.png create mode 100644 screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakTestCardRunning_Dark_d19fbf1f_0.png create mode 100644 screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakTestCardRunning_Light_b29dc7a7_0.png create mode 100644 specs/005-tak-v2-protocol/checklists/protocol.md create mode 100644 specs/005-tak-v2-protocol/checklists/requirements.md create mode 100644 specs/005-tak-v2-protocol/contracts/wire-protocol.md create mode 100644 specs/005-tak-v2-protocol/data-model.md create mode 100644 specs/005-tak-v2-protocol/plan.md create mode 100644 specs/005-tak-v2-protocol/quickstart.md create mode 100644 specs/005-tak-v2-protocol/research.md create mode 100644 specs/005-tak-v2-protocol/spec.md create mode 100644 specs/005-tak-v2-protocol/tasks.md diff --git a/.gitmodules b/.gitmodules index e115fe990..c7adfe346 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,4 @@ [submodule "app proto submodule"] path = core/proto/src/main/proto url = https://github.com/meshtastic/protobufs.git + branch = master diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt index 7a4fdab44..71c89ac96 100644 --- a/.skills/compose-ui/strings-index.txt +++ b/.skills/compose-ui/strings-index.txt @@ -1123,8 +1123,19 @@ tak_role_sniper tak_role_teamlead tak_role_teammember tak_role_unspecified +tak_server tak_server_enabled tak_server_enabled_desc +tak_server_export_data_package_desc +tak_server_loading +tak_server_section +tak_server_test_card_title +tak_server_test_idle +tak_server_test_result_bytes +tak_server_test_result_unknown_error +tak_server_test_results +tak_server_test_run +tak_server_test_running tak_team tak_team_blue tak_team_brown diff --git a/.skills/testing-ci/SKILL.md b/.skills/testing-ci/SKILL.md index 1c8b7b901..bb9857966 100644 --- a/.skills/testing-ci/SKILL.md +++ b/.skills/testing-ci/SKILL.md @@ -21,6 +21,28 @@ Run in a single invocation for routine changes to ensure code formatting, analys *Note: If testing Compose UI on the JVM (Robolectric) with Java 21, pin tests to `@Config(sdk = [34])` to avoid SDK 35 compatibility crashes.* +### SharedFlow + backgroundScope in `runTest` + +When testing long-lived coroutines (e.g., `Flow.collect` loops launched in `backgroundScope`), **use `runTest(UnconfinedTestDispatcher())`** instead of plain `runTest`: + +```kotlin +// ❌ BAD — SharedFlow emissions silently never reach collectors +@Test fun `inbound packet is forwarded`() = runTest { + backgroundScope.launch { sut.start(backgroundScope) } + sharedFlow.emit(packet) + // assertion fails — collector never receives the emission +} + +// ✅ GOOD — UnconfinedTestDispatcher eagerly dispatches subscriber resumptions +@Test fun `inbound packet is forwarded`() = runTest(UnconfinedTestDispatcher()) { + backgroundScope.launch { sut.start(backgroundScope) } + sharedFlow.emit(packet) + // assertion passes — collector receives emission immediately +} +``` + +**Why:** `backgroundScope` uses `StandardTestDispatcher` by default, which does **not** eagerly dispatch `SharedFlow` subscriber resumptions. Even `advanceUntilIdle()` won't trigger delivery. `UnconfinedTestDispatcher()` fixes this by dispatching eagerly. This affects any test where a coroutine in `backgroundScope` collects from a `SharedFlow` or `MutableSharedFlow`. + ## 2) Change-type verification matrix - `docs-only` changes: Usually no Gradle run required, but run `spotlessCheck` if practical. diff --git a/app/src/fdroid/AndroidManifest.xml b/app/src/fdroid/AndroidManifest.xml new file mode 100644 index 000000000..efd581020 --- /dev/null +++ b/app/src/fdroid/AndroidManifest.xml @@ -0,0 +1,31 @@ + + + + + + + + + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index bc5dab9d3..d6d296ea1 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -67,10 +67,6 @@ --> - - diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt index fa935473a..96edbe41f 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt @@ -195,7 +195,7 @@ class MeshDataHandlerImpl( } PortNum.ATAK_PLUGIN, - PortNum.ATAK_FORWARDER, + PortNum.ATAK_PLUGIN_V2, PortNum.PRIVATE_APP, -> { shouldBroadcast = true diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt index 8dbccf69a..724bdfdd2 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt @@ -58,6 +58,13 @@ data class Capabilities(val firmwareVersion: String?, internal val forceEnableAl /** Support for TAK (ATAK) module configuration. Supported since firmware v2.7.19. */ val supportsTakConfig = atLeast(V2_7_19) + /** + * Support for the v2 TAK port (ATAK_PLUGIN_V2 = 78) with TAKPacketV2 + zstd dictionary compression. Supported since + * firmware v2.8.0. Firmware v2.7.x and earlier only support the legacy ATAK_PLUGIN port (72) with the original + * TAKPacket schema (PLI + GeoChat only, no compression), so the bridge falls back to that path for older nodes. + */ + val supportsTakV2 = atLeast(V2_8_0) + /** Support for location sharing on secondary channels. Supported since firmware v2.6.10. */ val supportsSecondaryChannelLocation = atLeast(V2_6_10) diff --git a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt index 418ddd58a..d8682c40c 100644 --- a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt +++ b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt @@ -172,6 +172,8 @@ sealed interface SettingsRoute : Route { @Serializable data object CleanNodeDb : SettingsRoute + @Serializable data object TakServer : SettingsRoute + @Serializable data object DebugPanel : SettingsRoute @Serializable data object About : SettingsRoute diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 7d8347df8..ff6c6333b 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -1165,8 +1165,19 @@ Team Lead Team Member Unspecified + TAK Server Enable Local TAK Server - Starts a TCP server on port 8089 for ATAK connections + Starts a local TLS server on port 8089 for ATAK/iTAK connections + Generate .zip for ATAK/iTAK to connect to this server + + Server + TAK Mesh Test (Debug) + Send all %1$d test fixtures to mesh + %1$dB ✓ + + %1$d passed, %2$d failed of %3$d/%4$d + Run + Running: %1$s Team Color Blue Brown diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt index 0f4bc60b7..cf636923a 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt @@ -14,6 +14,8 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ +@file:Suppress("TooGenericExceptionCaught") + package org.meshtastic.core.service import android.app.Service @@ -22,6 +24,7 @@ import android.content.Intent import android.content.pm.ServiceInfo import android.os.Build import android.os.IBinder +import android.os.PowerManager import androidx.core.app.ServiceCompat import co.touchlab.kermit.Logger import kotlinx.coroutines.CoroutineScope @@ -91,6 +94,14 @@ class MeshService : Service() { private var isServiceInitialized = false + /** + * Partial wake lock held while the foreground service is running. Prevents the CPU from being throttled while the + * TAK server's keepalive coroutines, socket writes, and mesh packet handlers need to run on a regular cadence. + * Without this, OEM battery optimizations can pause coroutines for long enough that connected TAK clients + * (ATAK/iTAK) time out waiting for data, even though the foreground service itself keeps the process alive. + */ + private var wakeLock: PowerManager.WakeLock? = null + private val myNodeNum: Int get() = nodeManager.myNodeNum.value ?: throw RadioNotConnectedException() @@ -110,6 +121,8 @@ class MeshService : Service() { val minDeviceVersion = DeviceVersion(DeviceVersion.MIN_FW_VERSION) val absoluteMinDeviceVersion = DeviceVersion(DeviceVersion.ABS_MIN_FW_VERSION) + + private const val WAKE_LOCK_TIMEOUT_MS = 30L * 60L * 1_000L // 30 minutes } override fun onCreate() { @@ -163,10 +176,12 @@ class MeshService : Service() { return if (!wantForeground) { Logger.i { "Stopping mesh service because no device is selected" } + releaseWakeLock() ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) stopSelf() START_NOT_STICKY } else { + acquireWakeLock() START_STICKY } } @@ -205,6 +220,38 @@ class MeshService : Service() { } } + private fun acquireWakeLock() { + if (wakeLock?.isHeld == true) return + try { + val powerManager = getSystemService(POWER_SERVICE) as PowerManager + val lock = + powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "Meshtastic::MeshServiceWakeLock").apply { + setReferenceCounted(false) + } + lock.acquire(WAKE_LOCK_TIMEOUT_MS) + wakeLock = lock + Logger.i { "Acquired partial wake lock for mesh service" } + } catch (e: SecurityException) { + Logger.w(e) { "Failed to acquire wake lock — WAKE_LOCK permission missing?" } + } catch (e: Exception) { + Logger.w(e) { "Failed to acquire wake lock" } + } + } + + private fun releaseWakeLock() { + val lock = wakeLock ?: return + try { + if (lock.isHeld) { + lock.release() + Logger.i { "Released partial wake lock for mesh service" } + } + } catch (e: Exception) { + Logger.w(e) { "Failed to release wake lock" } + } finally { + wakeLock = null + } + } + override fun onTaskRemoved(rootIntent: Intent?) { super.onTaskRemoved(rootIntent) Logger.i { "Mesh service: onTaskRemoved" } @@ -214,6 +261,7 @@ class MeshService : Service() { override fun onDestroy() { Logger.i { "Destroying mesh service" } + releaseWakeLock() ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) if (isServiceInitialized) { orchestrator.stop() diff --git a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt index 87109be1e..31178449c 100644 --- a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt +++ b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt @@ -48,7 +48,6 @@ import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.repository.TakPrefs import org.meshtastic.core.takserver.TAKMeshIntegration import org.meshtastic.core.takserver.TAKServerManager -import org.meshtastic.core.takserver.fountain.CoTHandler import org.meshtastic.proto.LocalModuleConfig import kotlin.test.Test import kotlin.test.assertFalse @@ -59,7 +58,7 @@ class MeshServiceOrchestratorTest { private val radioInterfaceService: RadioInterfaceService = mock(MockMode.autofill) private val serviceRepository: ServiceRepository = mock(MockMode.autofill) private val nodeManager: NodeManager = mock(MockMode.autofill) - private val nodeRepository: NodeRepository = mock(MockMode.autofill) + private val messageProcessor: MeshMessageProcessor = mock(MockMode.autofill) private val commandSender: CommandSender = mock(MockMode.autofill) private val router: MeshRouter = mock(MockMode.autofill) @@ -68,7 +67,7 @@ class MeshServiceOrchestratorTest { private val serviceNotifications: MeshServiceNotifications = mock(MockMode.autofill) private val takServerManager: TAKServerManager = mock(MockMode.autofill) private val takPrefs: TakPrefs = mock(MockMode.autofill) - private val cotHandler: CoTHandler = mock(MockMode.autofill) + private val nodeRepository: NodeRepository = mock(MockMode.autofill) private val databaseManager: DatabaseManager = mock(MockMode.autofill) private val connectionManager: MeshConnectionManager = mock(MockMode.autofill) @@ -94,17 +93,16 @@ class MeshServiceOrchestratorTest { every { takPrefs.isTakServerEnabled } returns takEnabledFlow every { takServerManager.isRunning } returns takRunningFlow every { takServerManager.inboundMessages } returns MutableSharedFlow() - every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(emptyMap()) every { router.actionHandler } returns actionHandler + every { nodeRepository.myNodeInfo } returns MutableStateFlow(null) val takMeshIntegration = TAKMeshIntegration( takServerManager = takServerManager, commandSender = commandSender, - nodeRepository = nodeRepository, serviceRepository = serviceRepository, meshConfigHandler = meshConfigHandler, - cotHandler = cotHandler, + nodeRepository = nodeRepository, ) return MeshServiceOrchestrator( diff --git a/core/takserver/build.gradle.kts b/core/takserver/build.gradle.kts index f3ef5bbad..ffd87c693 100644 --- a/core/takserver/build.gradle.kts +++ b/core/takserver/build.gradle.kts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2026 Meshtastic LLC + * Copyright (c) 2025-2026 Meshtastic LLC * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -23,6 +23,7 @@ plugins { } kotlin { + @Suppress("UnstableApiUsage") android { namespace = "org.meshtastic.core.takserver" androidResources.enable = false @@ -50,9 +51,75 @@ kotlin { implementation(libs.kermit) } + jvmAndroidMain.dependencies { + // TAKPacket-SDK for v2 compression/decompression (via JitPack). + // + // We depend on the `-jvm` variant directly rather than the parent + // `com.github.meshtastic:TAKPacket-SDK` coordinate. JitPack does + // not publish a root-level Gradle module metadata (.module) file + // for the KMP parent, only per-target ones. With just the parent + // POM, Gradle reads the four KMP variants (jvm, iosarm64, + // iossimulatorarm64, metadata) as unconditional Maven deps and + // tries to resolve them ALL against this Android consumer — the + // iOS klibs declare `platform.type=native` with no androidJvm + // variant, so variant selection fails with "No matching variant". + // + // Depending directly on `takpacket-sdk-jvm` skips the parent POM + // entirely and goes straight to the JVM artifact's own module + // metadata, which is compatible with both `jvm()` and Android + // targets in this `jvmAndroidMain` source set. It still pulls + // zstd-jni + xpp3 + wire-runtime-jvm + kotlin-stdlib as + // transitive deps from the JVM variant's POM. + // + // zstd-jni's @aar variant is still declared explicitly in the + // androidMain source set below so Android gets the .so files. + implementation("com.github.meshtastic.TAKPacket-SDK:takpacket-sdk-jvm:v0.2.1") { + // Issue #5: pre-0.2.1 the SDK JAR bundled `org.meshtastic.proto.*` + // (Wire-generated TAKPacketV2 + friends) inside the same JAR as + // `org.meshtastic.tak.*`. Our own `:core:proto` module runs its + // own Wire codegen against the same protobufs submodule and emits + // the identical classes, so R8 hit "Type is defined multiple + // times" errors during release builds. v0.2.1 strips the proto + // classes from the JAR entirely — the SDK's bytecode still + // REFERENCES them, but they come from `:core:proto` on our + // classpath. No exclude needed; the SDK simply doesn't ship them. + // The SDK's jvmMain declares zstd-jni as a runtime dep (standard + // JAR with desktop native libs). Android needs the @aar variant + // instead (ships arm/arm64/x86/x86_64 .so files). Both packaging + // formats contain the same Java classes, so Android's dex merger + // hits "Duplicate class" errors if both land on the classpath. + // Exclude here; androidMain re-adds it as @aar below, and jvmMain + // re-adds the JAR for desktop. + exclude(group = "com.github.luben", module = "zstd-jni") + // xpp3 bundles org.xmlpull.v1.XmlPullParser which Android provides + // as a platform class (android.content.res.XmlResourceParser + // implements it). R8 fails when both the library and program + // classpaths define the same type. + exclude(group = "org.ogce", module = "xpp3") + } + } + + jvmMain.dependencies { + // Desktop JVM: standard JAR bundles native libs for desktop archs. + implementation("com.github.luben:zstd-jni:1.5.7-7") + // xpp3 is excluded from jvmAndroidMain (Android ships it as a + // platform class), but Desktop JVM still needs it for XmlPullParser. + implementation("org.ogce:xpp3:1.1.6") + } + + androidMain.dependencies { + // Android: @aar variant ships .so files for arm/arm64/x86/x86_64. + // Without this, zstd-jni's ZstdDictCompress. throws + // UnsatisfiedLinkError and poisons TakV2Compressor permanently. + implementation("com.github.luben:zstd-jni:1.5.7-7@aar") + } + commonTest.dependencies { implementation(projects.core.testing) implementation(libs.kotlinx.coroutines.test) + implementation(libs.turbine) + implementation(libs.kotest.assertions) + implementation(libs.kotest.property) } } } diff --git a/core/takserver/src/androidMain/kotlin/org/meshtastic/core/takserver/AtakFileWriter.kt b/core/takserver/src/androidMain/kotlin/org/meshtastic/core/takserver/AtakFileWriter.kt new file mode 100644 index 000000000..9abd1017e --- /dev/null +++ b/core/takserver/src/androidMain/kotlin/org/meshtastic/core/takserver/AtakFileWriter.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.takserver + +import co.touchlab.kermit.Logger +import java.io.File + +/** + * Android implementation — writes route data packages to ATAK's monitored auto-import directory. Tries multiple + * locations in order of preference: + * 1. `/sdcard/atak/tools/datapackage/` (ATAK monitors this) + * 2. `/sdcard/Download/` (user can manually import from here) + */ +@Suppress("TooGenericExceptionCaught") +internal actual object AtakFileWriter { + + actual fun writeToImportDir(fileName: String, zipBytes: ByteArray): Boolean { + // Sanitize: fileName originates from untrusted mesh CoT uid attributes. + val safeName = fileName.replace(Regex("[^a-zA-Z0-9._-]"), "_") + // Use hardcoded paths — on Android /sdcard/ maps to external storage. + // On JVM desktop these paths don't exist and the fallback returns false. + val targets = listOf(File("/sdcard/atak/tools/datapackage"), File("/sdcard/Download")) + + for (dir in targets) { + try { + if (!dir.exists()) dir.mkdirs() + val target = File(dir, safeName) + target.writeBytes(zipBytes) + Logger.i { "Route data package written: $fileName (${zipBytes.size} bytes) → ${target.absolutePath}" } + return true + } catch (e: Exception) { + Logger.d { "Cannot write to ${dir.absolutePath}: ${e.message}" } + } + } + + Logger.w { "Failed to write route data package to any ATAK import directory" } + return false + } +} diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/AtakFileWriter.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/AtakFileWriter.kt new file mode 100644 index 000000000..4d863e14f --- /dev/null +++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/AtakFileWriter.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.takserver + +/** + * Writes data package files to ATAK's auto-import directory. + * + * On Android, the actual implementation writes to `/sdcard/atak/tools/datapackage/` which ATAK monitors for new zip + * files. On other platforms this is a no-op. + */ +internal expect object AtakFileWriter { + /** + * Write a data package zip to ATAK's monitored import directory. + * + * @return true if the file was written successfully, false otherwise. + */ + fun writeToImportDir(fileName: String, zipBytes: ByteArray): Boolean +} diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/CoTDetailStripper.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/CoTDetailStripper.kt new file mode 100644 index 000000000..fa6b25e04 --- /dev/null +++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/CoTDetailStripper.kt @@ -0,0 +1,168 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.takserver + +/** + * Removes bloat elements from the `` content of a CoT event before it is stuffed into a + * [org.meshtastic.proto.TAKPacketV2] `raw_detail` field for mesh transmission. + * + * # Why this exists + * + * A LoRa mesh packet has a hard payload limit of [org.meshtastic.proto.Constants.DATA_PAYLOAD_LEN] = 233 bytes for the + * entire encoded `Data` proto (portnum + payload + reply_id + emoji). Subtracting the wrapper overhead leaves roughly + * **~225 bytes** for the TAK wire payload, and the wire payload itself is `[1 byte dict-id flag][zstd-compressed + * TAKPacketV2 protobuf]`. + * + * ATAK emits CoT events with rich visual metadata that is **never useful over a mesh**: icon set paths, ARGB colors, + * shape geometry, archive flags, file references, etc. A typical `u-d-c-c` (user-drawn circle) event from ATAK is + * **800+ bytes of XML**, of which maybe 80 bytes are actually meaningful to a receiving node. Even with dictionary + * compression, the full payload overflows the MTU. + * + * This stripper deletes elements the receiving node can synthesize or ignore, leaving only the minimum needed to + * rebuild a usable `` on the other side: who sent it, where they are, what team/role they're on, battery status, + * chat content, and the high-level CoT type (which rides separately on [TAKPacketV2.cot_type_id] / + * [TAKPacketV2.cot_type_str]). + * + * # What gets dropped + * + * **Cosmetic / rendering-only** (pure visual, no situational awareness value): + * - `` — ARGB stroke/fill colors + * - ``, ``, `` — shape styling + * - `` — label visibility toggle + * - `` — icon set path (`COT_MAPPING_2525B/...`) + * - `` — 3D model reference + * + * **Geometric detail** (we keep lat/lon on the event; shape primitives are too big): + * - `...` — ellipse/polyline/polygon geometry + * - ``, `` — rendering hints + * + * **Resource references** (useless without the resource being reachable): + * - `` — file transfer references + * - `<__video .../>` — video stream URL + * + * **Flags and redundant metadata**: + * - `` — "save to archive" flag + * - `` — redundant with the event's `` attributes + * - `` — rectangle "toggle" UI state flag + * - `<_flow-tags_ .../>` — TAK Server routing metadata (server-to-server, not needed on mesh) + * + * # What gets preserved + * + * Anything the stripper doesn't explicitly match is passed through untouched. That includes all of the structured + * elements that the regular [CoTXmlParser] understands (contact, __group, status, track, remarks, __chat, chatgrp, + * link, uid, __serverdestination) plus any unknown extensions — better to over-preserve than silently drop something + * the receiving ATAK actually needs. + * + * # Whitespace + * + * All inter-element whitespace and indentation is collapsed. Whitespace inside text nodes (e.g. `hello + * world`) is preserved. + * + * # Not a real XML parser + * + * This is intentionally string/regex based, not DOM. The input is a small, well-formed fragment produced by ATAK's + * serializer, so a full parser is overkill — and we want this to be dependency-free so it can run on every KMP target + * without pulling in xmlutil for a one-off job. If ATAK starts emitting namespaced elements or embedded CDATA that + * tangles with these patterns, the stripper will leave them alone rather than corrupt the output, which is the safer + * failure mode. + */ +internal object CoTDetailStripper { + + /** + * Element names whose entire subtree (or self-closing tag) is removed. + * + * Order matters only for documentation. Each entry is tried against both the self-closing form `` and + * the paired form `...`. + */ + private val STRIPPED_ELEMENTS = + listOf( + // Cosmetic / rendering + "color", + "strokeColor", + "strokeWeight", + "fillColor", + "labels_on", + "usericon", + "model", + // Geometric + "shape", + "height", + "height_unit", + // Resource refs + "fileshare", + "__video", + // Flags / redundant + "archive", + "precisionlocation", + // Rectangle/polyline "toggle" UI flag, and TAK Server routing metadata. + // The underscore-prefixed element names are legal XML identifiers ATAK uses + // for internal state that receiving meshtastic nodes have no use for. + "tog", + "_flow-tags_", + ) + + /** + * Pre-compiled regex list: for each stripped element, one pattern that matches either a self-closing tag or a + * paired open/close tag (non-greedy content). + * + * `[^>]*?` inside the open tag tolerates attribute quoting with both single and double quotes but bails if it + * encounters a `>` (so it won't accidentally swallow unrelated content). + * + * The leading `(?s)` inline flag is the KMP-portable equivalent of `RegexOption.DOT_MATCHES_ALL` — it lets `.` + * match newlines so a multi-line `...` subtree is captured in one pass. + * `RegexOption.DOT_MATCHES_ALL` itself is JVM-only and breaks the Kotlin/Native build. + */ + private val STRIPPED_ELEMENT_PATTERNS: List = + STRIPPED_ELEMENTS.map { name -> + // Escape the name in case it contains regex metacharacters (e.g. __video). + val escaped = Regex.escape(name) + // Matches: + // + // + // ...content... + Regex("""(?s)<$escaped(?:\s[^>]*?)?/>|<$escaped(?:\s[^>]*?)?>.*?""") + } + + /** Matches whitespace between tags: `> \n <` → `><`. */ + private val INTER_TAG_WHITESPACE = Regex(""">\s+<""") + + /** Collapse leading / trailing whitespace across the whole fragment. */ + private val EDGE_WHITESPACE = Regex("""^\s+|\s+$""") + + /** + * Strip bloat elements and normalize whitespace on an inner `` fragment. + * + * The input is assumed to be the concatenated children of `` — i.e., what + * [CoTXmlParser.extractDetailInnerXml] returns. It is NOT the full `` or the `` wrapper itself. + * + * Returns an empty string if every element was stripped (so callers can treat "empty" and "nothing worth sending" + * uniformly). + */ + fun strip(detailInnerXml: String): String { + if (detailInnerXml.isEmpty()) return "" + var result = detailInnerXml + for (pattern in STRIPPED_ELEMENT_PATTERNS) { + result = pattern.replace(result, "") + } + // Collapse whitespace between remaining tags. Preserves whitespace inside + // text nodes (e.g. hello world) because that whitespace + // isn't bracketed by '>' and '<'. + result = INTER_TAG_WHITESPACE.replace(result, "><") + result = EDGE_WHITESPACE.replace(result, "") + return result + } +} diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/CoTXml.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/CoTXml.kt index 732d03064..841e8e699 100644 --- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/CoTXml.kt +++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/CoTXml.kt @@ -20,41 +20,69 @@ package org.meshtastic.core.takserver import kotlin.time.Instant -fun CoTMessage.toXml(): String = buildString { - append( - "", +/** + * Serialize this [CoTMessage] to a single `` XML element suitable for the CoT streaming TCP protocol used by + * ATAK / iTAK / WinTAK clients. + * + * **Important:** the output must NOT include an `` declaration. The CoT stream protocol is a continuous + * sequence of `` elements concatenated together; an XML declaration is only legal at the very start of a + * document and ATAK will drop the connection as malformed the moment it sees a second declaration mid-stream. + */ +fun CoTMessage.toXml(): String { + val sb = StringBuilder() + sb.append( + "", ) contact?.let { - append( - "", + sb.append( + "", ) } - group?.let { append("<__group role='${it.role.xmlEscaped()}' name='${it.name.xmlEscaped()}'/>") } + group?.let { sb.append("<__group role='${it.role.xmlEscaped()}' name='${it.name.xmlEscaped()}'/>") } - status?.let { append("") } + status?.let { sb.append("") } - track?.let { append("") } + track?.let { sb.append("") } if (chat != null) { val senderUid = uid.geoChatSenderUid() val messageId = uid.geoChatMessageId() - append( - "<__chat parent='RootContactGroup' groupOwner='false' messageId='$messageId' chatroom='${chat.chatroom.xmlEscaped()}' id='${chat.chatroom.xmlEscaped()}' senderCallsign='${chat.senderCallsign?.xmlEscaped() ?: ""}'>", + sb.append( + "<__chat parent='RootContactGroup' groupOwner='false' messageId='${messageId.xmlEscaped()}' chatroom='${chat.chatroom.xmlEscaped()}' id='${chat.chatroom.xmlEscaped()}' senderCallsign='${chat.senderCallsign?.xmlEscaped() ?: ""}'>", ) - append("") - append("<__serverdestination destinations='0.0.0.0:4242:tcp:${senderUid.xmlEscaped()}'/>") - append( + sb.append("") + sb.append("<__serverdestination destinations='0.0.0.0:4242:tcp:${senderUid.xmlEscaped()}'/>") + sb.append( "${chat.message.xmlEscaped()}", ) } else if (!remarks.isNullOrEmpty()) { - append("${remarks.xmlEscaped()}") + sb.append("${remarks.xmlEscaped()}") } - rawDetailXml?.takeIf { it.isNotEmpty() }?.let { append(it) } + rawDetailXml?.let { + if (it.isNotEmpty()) { + sb.append(it) + } + } - append("") + sb.append("") + return sb.toString() } -private fun Instant.toXmlString(): String = this.toString() +/** + * Format this [Instant] for CoT XML `time` / `start` / `stale` attributes. + * + * Always emits millisecond precision (`YYYY-MM-DDThh:mm:ss.SSSZ`). kotlinx-datetime's default [Instant.toString] can + * emit up to nanosecond precision; some TAK implementations choke on anything beyond milliseconds, so we truncate to ms + * and always include the millisecond field even when it would otherwise be zero. + */ +private fun Instant.toXmlString(): String { + val millis = this.toEpochMilliseconds() + val truncated = Instant.fromEpochMilliseconds(millis) + val base = truncated.toString() + // kotlinx-datetime omits the fractional part when it's zero; pad it ourselves so the + // CoT timestamp format is stable at ms precision. + return if (base.contains('.')) base else base.removeSuffix("Z") + ".000Z" +} diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/CoTXmlFrameBuffer.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/CoTXmlFrameBuffer.kt index 7cf937d35..c55759347 100644 --- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/CoTXmlFrameBuffer.kt +++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/CoTXmlFrameBuffer.kt @@ -85,6 +85,10 @@ internal class CoTXmlFrameBuffer(private val maxMessageSize: Long = DEFAULT_MAX_ companion object { private val EVENT_START_BYTES = ". */ +@file:Suppress("ReturnCount") + package org.meshtastic.core.takserver +import co.touchlab.kermit.Logger import nl.adaptivity.xmlutil.serialization.XML import kotlin.time.Clock import kotlin.time.Instant @@ -59,9 +62,37 @@ class CoTXmlParser(private val xml: String) { track = detail?.track?.let { CoTTrack(speed = it.speed, course = it.course) }, chat = buildChat(detail), remarks = buildRemarks(detail), + // Stripped version used as the raw_detail protobuf payload: drops bloat + // elements (colors, icons, archives, shapes, etc.) so unmapped CoT types + // have any chance of fitting in a LoRa mesh packet. See [CoTDetailStripper]. + parsedDetailXml = extractDetailInnerXml(xml)?.let(CoTDetailStripper::strip), + // Verbatim original event XML kept for diagnostic logging only — never + // goes on the wire. + sourceEventXml = xml, ) } + /** + * Extract the exact content between `` and `` from the original XML string. Used as the + * `raw_detail` fallback payload when we can't map the CoT type to a structured [org.meshtastic.proto.TAKPacketV2] + * payload. Preserves any extension elements the xmlutil parser discarded as "unknown children". + * + * Returns null for self-closed `` or when no detail element is present. + */ + private fun extractDetailInnerXml(xml: String): String? { + // Match `` (not ``) through its matching close tag. + val openIdx = xml.indexOf("', openIdx) + if (openEnd < 0) return null + // Self-closed tag like `` has no content. + if (xml[openEnd - 1] == '/') return null + val closeIdx = xml.indexOf("", openEnd) + if (closeIdx < 0) return null + val inner = xml.substring(openEnd + 1, closeIdx).trim() + return inner.ifEmpty { null } + } + private fun buildContact(detail: CoTDetailXml?): CoTContact? = detail?.contact?.let { if (it.callsign.isNotEmpty() || it.endpoint != null || it.phone != null) { CoTContact(callsign = it.callsign, endpoint = it.endpoint, phone = it.phone) @@ -107,7 +138,8 @@ class CoTXmlParser(private val xml: String) { val cleaned = dateString.replace(Regex("""\.\d+"""), "").replace("Z", "+00:00") Instant.parse(cleaned) } catch (ignoredInner: IllegalArgumentException) { - Clock.System.now() // Return now as fallback + Logger.w { "Unparseable CoT date '$dateString', falling back to now()" } + Clock.System.now() } } } diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/RouteDataPackageGenerator.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/RouteDataPackageGenerator.kt new file mode 100644 index 000000000..9744b9597 --- /dev/null +++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/RouteDataPackageGenerator.kt @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +@file:Suppress("ReturnCount") + +package org.meshtastic.core.takserver + +/** + * Converts route CoT XML (b-m-r) into ATAK-importable KML data packages. + * + * ATAK silently ignores route CoT events received over TCP streaming connections — it only accepts routes from KML/GPX + * file import, TAK Server mission sync, or data packages auto-imported from the monitored directory + * `/sdcard/atak/tools/datapackage/`. This generator bridges the gap by extracting waypoints from the SDK-reconstructed + * route XML and packaging them as a KML LineString inside a MissionPackageManifest v2 zip. + */ +object RouteDataPackageGenerator { + + private val EVENT_UID_RE = Regex("""]*\buid="([^"]*)"""") + private val CONTACT_CALLSIGN_RE = Regex("""]*\bcallsign="([^"]*)"""") + private val LINK_POINT_RE = Regex("""]*\bpoint="([^"]*)"[^>]*/>""") + + data class RouteKmlResult(val kml: String, val routeUid: String, val routeName: String) + + /** + * Extract waypoints from route CoT XML and generate a KML LineString. Returns null if fewer than 2 waypoints are + * found. + */ + fun generateKml(routeXml: String): RouteKmlResult? { + val uid = EVENT_UID_RE.find(routeXml)?.groupValues?.getOrNull(1) ?: return null + val name = CONTACT_CALLSIGN_RE.find(routeXml)?.groupValues?.getOrNull(1) ?: "Mesh Route" + + // Extract all waypoint coordinates from elements + val waypoints = + LINK_POINT_RE.findAll(routeXml) + .mapNotNull { match -> + val point = match.groupValues[1] // "lat,lon,hae" or "lat,lon" + val parts = point.split(",").map { it.trim() } + if (parts.size >= 2) { + val lat = parts[0] + val lon = parts[1] + val hae = parts.getOrElse(2) { "0" } + // KML coordinate order is lon,lat,hae (opposite of CoT's lat,lon,hae) + "$lon,$lat,$hae" + } else { + null + } + } + .toList() + + if (waypoints.size < 2) return null + + val kml = buildString { + appendLine("""""") + appendLine("""""") + appendLine(" ") + appendLine(" ${name.xmlEscaped()}") + appendLine(" ") + appendLine(" ${name.xmlEscaped()}") + appendLine(" ") + appendLine(" ") + appendLine(" ") + for (coord in waypoints) { + appendLine(" $coord") + } + appendLine(" ") + appendLine(" ") + appendLine(" ") + appendLine(" ") + append("") + } + + return RouteKmlResult(kml = kml, routeUid = uid, routeName = name) + } + + /** + * Generate a complete ATAK data package (zip) containing the route as KML. Returns (fileName, zipBytes) or null if + * the route XML can't be parsed. + */ + fun generateDataPackage(routeXml: String): Pair? { + val result = generateKml(routeXml) ?: return null + val kmlFileName = "${result.routeUid}.kml" + val zipFileName = "${result.routeUid}.zip" + + val manifest = buildString { + appendLine("""""") + appendLine(" ") + appendLine(""" """) + appendLine(""" """) + appendLine(""" """) + appendLine(" ") + appendLine(" ") + appendLine(""" """) + appendLine(" ") + append("") + } + + val zipBytes = + ZipArchiver.createZip( + mapOf(kmlFileName to result.kml.encodeToByteArray(), "manifest.xml" to manifest.encodeToByteArray()), + ) + + return zipFileName to zipBytes + } +} diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKClientConnection.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKClientConnection.kt deleted file mode 100644 index eaa31a013..000000000 --- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKClientConnection.kt +++ /dev/null @@ -1,253 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -@file:Suppress("TooManyFunctions", "TooGenericExceptionCaught") - -package org.meshtastic.core.takserver - -import co.touchlab.kermit.Logger -import io.ktor.network.sockets.Socket -import io.ktor.network.sockets.isClosed -import io.ktor.network.sockets.openReadChannel -import io.ktor.network.sockets.openWriteChannel -import io.ktor.utils.io.ByteReadChannel -import io.ktor.utils.io.ByteWriteChannel -import io.ktor.utils.io.readAvailable -import io.ktor.utils.io.writeStringUtf8 -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlin.concurrent.Volatile -import kotlin.random.Random -import kotlin.time.Clock -import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.Instant -import kotlinx.coroutines.isActive as coroutineIsActive - -class TAKClientConnection( - private val socket: Socket, - val clientInfo: TAKClientInfo, - private val onEvent: (TAKConnectionEvent) -> Unit, - private val scope: CoroutineScope, -) { - private var currentClientInfo = clientInfo - private val frameBuffer = CoTXmlFrameBuffer() - - private val readChannel: ByteReadChannel = socket.openReadChannel() - private val writeChannel: ByteWriteChannel = socket.openWriteChannel(autoFlush = true) - private val writeMutex = Mutex() - - /** Tracks the last time data was received from the client, used for idle timeout detection. */ - @Volatile private var lastDataReceived: Instant = Clock.System.now() - - /** Guards against emitting [TAKConnectionEvent.Disconnected] more than once. */ - @Volatile private var disconnectedEmitted = false - - fun start() { - onEvent(TAKConnectionEvent.Connected(currentClientInfo)) - sendProtocolSupport() - - scope.launch { readLoop() } - - scope.launch { keepaliveLoop() } - } - - private fun sendProtocolSupport() { - val serverUid = "Meshtastic-TAK-Server-${Random.nextInt().toString(TAK_HEX_RADIX)}" - val now = Clock.System.now() - val stale = now + TAK_KEEPALIVE_INTERVAL_MS.milliseconds - val detail = - """ - - - - """ - .trimIndent() - sendXml(buildEventXml(uid = serverUid, type = "t-x-takp-v", now = now, stale = stale, detail = detail)) - } - - private suspend fun readLoop() { - try { - val buffer = ByteArray(TAK_XML_READ_BUFFER_SIZE) - while (scope.coroutineIsActive && !socket.isClosed) { - // Suspend until data is available — no polling delay needed - readChannel.awaitContent() - val bytesRead = readChannel.readAvailable(buffer) - if (bytesRead > 0) { - lastDataReceived = Clock.System.now() - processReceivedData(buffer.copyOfRange(0, bytesRead)) - } else if (bytesRead == -1) { - break // EOF - } - } - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Logger.w(e) { "TAK client read error: ${currentClientInfo.id}" } - emitDisconnected(TAKConnectionEvent.Error(e)) - return - } - emitDisconnected(TAKConnectionEvent.Disconnected) - } - - private suspend fun keepaliveLoop() { - val idleTimeoutMs = TAK_KEEPALIVE_INTERVAL_MS * TAK_READ_IDLE_TIMEOUT_MULTIPLIER - while (scope.coroutineIsActive && !socket.isClosed) { - kotlinx.coroutines.delay(TAK_KEEPALIVE_INTERVAL_MS) - - val idleMs = (Clock.System.now() - lastDataReceived).inWholeMilliseconds - if (idleMs > idleTimeoutMs) { - Logger.w { - "TAK client ${currentClientInfo.id} idle for ${idleMs}ms " + - "(threshold ${idleTimeoutMs}ms), closing connection" - } - close() - return - } - - sendKeepalive() - } - } - - private fun sendKeepalive() { - val now = Clock.System.now() - val stale = now + (TAK_KEEPALIVE_INTERVAL_MS * TAK_KEEPALIVE_STALE_MULTIPLIER).milliseconds - sendXml(buildEventXml(uid = "takPong", type = "t-x-c-t", now = now, stale = stale, detail = "")) - } - - private fun sendPong() { - val now = Clock.System.now() - val stale = now + (TAK_KEEPALIVE_INTERVAL_MS * TAK_KEEPALIVE_STALE_MULTIPLIER).milliseconds - sendXml(buildEventXml(uid = "takPong", type = "t-x-c-t-r", now = now, stale = stale, detail = "")) - } - - private fun processReceivedData(newData: ByteArray) { - // frameBuffer.append returns List — pass directly without re-encoding - frameBuffer.append(newData).forEach { xmlString -> parseAndHandleMessage(xmlString) } - } - - private fun parseAndHandleMessage(xmlString: String) { - // Parse first, then filter on the structured type field to avoid false positives - val parser = CoTXmlParser(xmlString) - val result = parser.parse() - - result.onSuccess { cotMessage -> - when { - cotMessage.type.startsWith("t-x-takp") -> { - handleProtocolControl(cotMessage.type, xmlString) - return - } - - cotMessage.type == "t-x-c-t" || cotMessage.uid == "ping" -> { - sendPong() - return - } - - else -> { - cotMessage.contact?.let { contact -> - val updatedClientInfo = - currentClientInfo.copy( - callsign = currentClientInfo.callsign ?: contact.callsign, - uid = currentClientInfo.uid ?: cotMessage.uid, - ) - if (updatedClientInfo != currentClientInfo) { - currentClientInfo = updatedClientInfo - onEvent(TAKConnectionEvent.ClientInfoUpdated(updatedClientInfo)) - } - } - - onEvent(TAKConnectionEvent.Message(cotMessage)) - } - } - } - } - - private fun handleProtocolControl(type: String, xmlString: String) { - if (type == "t-x-takp-q") { - sendProtocolResponse() - } else { - Logger.d { "Unhandled protocol control type: $type (raw=$xmlString)" } - } - } - - private fun sendProtocolResponse() { - val serverUid = "Meshtastic-TAK-Server-${Random.nextInt().toString(TAK_HEX_RADIX)}" - val now = Clock.System.now() - val stale = now + TAK_KEEPALIVE_INTERVAL_MS.milliseconds - val detail = - """ - - - - """ - .trimIndent() - sendXml(buildEventXml(uid = serverUid, type = "t-x-takp-r", now = now, stale = stale, detail = detail)) - } - - fun send(cotMessage: CoTMessage) { - val xml = cotMessage.toXml() - sendXml(xml) - } - - private fun buildEventXml(uid: String, type: String, now: Instant, stale: Instant, detail: String): String { - val detailContent = if (detail.isBlank()) "" else "$detail" - val point = """""" - return """""" + - point + - detailContent + - "" - } - - private fun sendXml(xml: String) { - scope.launch { - try { - writeMutex.withLock { - if (!socket.isClosed) { - writeChannel.writeStringUtf8(xml) - } - } - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Logger.w(e) { "TAK client send error: ${currentClientInfo.id}" } - close() - } - } - } - - fun close() { - frameBuffer.clear() - try { - socket.close() - } catch (e: Exception) { - Logger.w(e) { "Error closing TAK client socket: ${currentClientInfo.id}" } - } - emitDisconnected(TAKConnectionEvent.Disconnected) - } - - /** - * Emits [event] (expected to be [TAKConnectionEvent.Disconnected] or [TAKConnectionEvent.Error]) at most once - * across all code paths. - */ - private fun emitDisconnected(event: TAKConnectionEvent) { - if (!disconnectedEmitted) { - disconnectedEmitted = true - onEvent(event) - } - } -} diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKDataPackageGenerator.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKDataPackageGenerator.kt index e9a7ae668..1b5a86d39 100644 --- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKDataPackageGenerator.kt +++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKDataPackageGenerator.kt @@ -14,6 +14,8 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ +@file:Suppress("LongMethod") + package org.meshtastic.core.takserver import nl.adaptivity.xmlutil.XmlDeclMode @@ -21,17 +23,27 @@ import nl.adaptivity.xmlutil.serialization.XML import kotlin.uuid.Uuid /** - * Generates TAK data packages (.zip) compatible with ATAK/iTAK import. + * Generates TAK data packages (.zip) compatible with ATAK/iTAK/WinTAK import. * * The data package follows the MissionPackageManifest v2 format: * ``` * Meshtastic_TAK_Server.zip * ├── meshtastic-server.pref (ATAK connection preferences) + * ├── truststore.p12 (server cert — matches iOS "truststore.p12") + * ├── client.p12 (client identity for mTLS) * └── manifest.xml (MissionPackageManifest v2) * ``` + * + * The bundled certificates / password match Meshtastic-Apple so a single exported package works on both ATAK (Android) + * and iTAK (iOS) without reconfiguration. + * + * Override [bundledCertBytesProvider] in tests to avoid touching the real classpath resources. In production the + * default reads from [TakCertLoader]. */ object TAKDataPackageGenerator { private const val PREF_FILE_NAME = "meshtastic-server.pref" + private const val TRUSTSTORE_FILE_NAME = "truststore.p12" + private const val CLIENT_P12_FILE_NAME = "client.p12" private const val PACKAGE_NAME = "Meshtastic_TAK_Server" private val xmlSerializer = XML { @@ -39,24 +51,37 @@ object TAKDataPackageGenerator { indentString = " " } + /** + * Platform-specific hook for reading the bundled TLS certificate bytes. Default implementation lives in + * `jvmAndroidMain` and reads them from classpath resources via [TakCertLoader]. + */ + var bundledCertBytesProvider: BundledCertBytesProvider = DefaultBundledCertBytesProvider + /** * Generate a complete TAK data package zip. * + * @param useTls when true, package includes `truststore.p12` + `client.p12` and the pref file uses `ssl`; when + * false, package is TCP-only (legacy). * @return zip file contents as a [ByteArray] */ fun generateDataPackage( serverHost: String = "127.0.0.1", port: Int = DEFAULT_TAK_PORT, + useTls: Boolean = true, description: String = "Meshtastic TAK Server", ): ByteArray { - val prefContent = generateConfigPref(serverHost, port, description) - val manifestContent = generateManifest(uid = Uuid.random().toString(), description = description) + val prefContent = generateConfigPref(serverHost, port, useTls, description) + val manifestContent = + generateManifest(uid = Uuid.random().toString(), description = description, useTls = useTls) - val entries = - mapOf( - PREF_FILE_NAME to prefContent.encodeToByteArray(), - "manifest.xml" to manifestContent.encodeToByteArray(), - ) + val entries = mutableMapOf() + entries[PREF_FILE_NAME] = prefContent.encodeToByteArray() + entries["manifest.xml"] = manifestContent.encodeToByteArray() + + if (useTls) { + bundledCertBytesProvider.serverP12Bytes()?.let { entries[TRUSTSTORE_FILE_NAME] = it } + bundledCertBytesProvider.clientP12Bytes()?.let { entries[CLIENT_P12_FILE_NAME] = it } + } return ZipArchiver.createZip(entries) } @@ -64,31 +89,88 @@ object TAKDataPackageGenerator { internal fun generateConfigPref( serverHost: String = "127.0.0.1", port: Int = DEFAULT_TAK_PORT, + useTls: Boolean = true, description: String = "Meshtastic TAK Server", ): String { + val protocolType = if (useTls) "ssl" else "tcp" val prefs = - TAKPreferencesXml( - preferences = - listOf( - TAKPreferenceXml( - version = "1", - name = "cot_streams", - entries = - listOf( - TAKEntryXml("count", "class java.lang.Integer", "1"), - TAKEntryXml("description0", "class java.lang.String", description), - TAKEntryXml("enabled0", "class java.lang.Boolean", "true"), - TAKEntryXml("connectString0", "class java.lang.String", "$serverHost:$port:tcp"), + if (useTls) { + // TLS / mTLS mode — matches the iOS data package format exactly. + TAKPreferencesXml( + preferences = + listOf( + TAKPreferenceXml( + version = "1", + name = "cot_streams", + entries = + listOf( + TAKEntryXml("count", "class java.lang.Integer", "1"), + TAKEntryXml("description0", "class java.lang.String", description), + TAKEntryXml("enabled0", "class java.lang.Boolean", "true"), + TAKEntryXml( + "connectString0", + "class java.lang.String", + "$serverHost:$port:$protocolType", + ), + ), + ), + TAKPreferenceXml( + version = "1", + name = "com.atakmap.app_preferences", + entries = + listOf( + TAKEntryXml("displayServerConnectionWidget", "class java.lang.Boolean", "true"), + TAKEntryXml( + "caLocation", + "class java.lang.String", + "cert/$TRUSTSTORE_FILE_NAME", + ), + TAKEntryXml("caPassword", "class java.lang.String", TAK_BUNDLED_CERT_PASSWORD), + TAKEntryXml( + "certificateLocation", + "class java.lang.String", + "cert/$CLIENT_P12_FILE_NAME", + ), + TAKEntryXml( + "clientPassword", + "class java.lang.String", + TAK_BUNDLED_CERT_PASSWORD, + ), + ), ), ), - TAKPreferenceXml( - version = "1", - name = "com.atakmap.app_preferences", - entries = - listOf(TAKEntryXml("displayServerConnectionWidget", "class java.lang.Boolean", "true")), + ) + } else { + // Legacy plain-TCP mode (not used in production, kept for tests / fallback) + TAKPreferencesXml( + preferences = + listOf( + TAKPreferenceXml( + version = "1", + name = "cot_streams", + entries = + listOf( + TAKEntryXml("count", "class java.lang.Integer", "1"), + TAKEntryXml("description0", "class java.lang.String", description), + TAKEntryXml("enabled0", "class java.lang.Boolean", "true"), + TAKEntryXml( + "connectString0", + "class java.lang.String", + "$serverHost:$port:$protocolType", + ), + ), + ), + TAKPreferenceXml( + version = "1", + name = "com.atakmap.app_preferences", + entries = + listOf( + TAKEntryXml("displayServerConnectionWidget", "class java.lang.Boolean", "true"), + ), + ), ), - ), - ) + ) + } return xmlSerializer .encodeToString(TAKPreferencesXml.serializer(), prefs) @@ -98,7 +180,11 @@ object TAKDataPackageGenerator { ) } - internal fun generateManifest(uid: String, description: String = "Meshtastic TAK Server"): String = buildString { + internal fun generateManifest( + uid: String, + description: String = "Meshtastic TAK Server", + useTls: Boolean = true, + ): String = buildString { appendLine("""""") appendLine(" ") appendLine(""" """) @@ -107,7 +193,31 @@ object TAKDataPackageGenerator { appendLine(" ") appendLine(" ") appendLine(""" """) + if (useTls) { + appendLine(""" """) + appendLine(""" """) + } appendLine(" ") append("") } } + +/** + * Supplies the bundled server / client PKCS#12 bytes for [TAKDataPackageGenerator]. Platform implementations live in + * `jvmAndroidMain`. + */ +interface BundledCertBytesProvider { + fun serverP12Bytes(): ByteArray? + + fun clientP12Bytes(): ByteArray? +} + +/** + * Default provider that returns `null` on platforms without a real implementation. Overridden at startup on JVM / + * Android by pointing [TAKDataPackageGenerator.bundledCertBytesProvider] at [TakCertLoader]. + */ +private object DefaultBundledCertBytesProvider : BundledCertBytesProvider { + override fun serverP12Bytes(): ByteArray? = null + + override fun clientP12Bytes(): ByteArray? = null +} diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKDefaults.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKDefaults.kt index 32dc35829..e228bd267 100644 --- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKDefaults.kt +++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKDefaults.kt @@ -20,22 +20,59 @@ import org.meshtastic.proto.MemberRole import org.meshtastic.proto.Team import org.meshtastic.proto.User -internal const val DEFAULT_TAK_PORT = 8087 +// Port 8089 is the standard TAK TLS port. Matches the iOS implementation so that +// a single exported data package (containing truststore.p12 + client.p12) works for +// both Meshtastic-iOS and Meshtastic-Android without reconfiguration in ATAK/iTAK. +internal const val DEFAULT_TAK_PORT = 8089 internal const val DEFAULT_TAK_ENDPOINT = "0.0.0.0:4242:tcp" + +// Bundled certificate password — matches iOS (`"meshtastic"`). Used for the +// server.p12 / client.p12 PKCS#12 files shipped under `tak_certs/` on the classpath. +internal const val TAK_BUNDLED_CERT_PASSWORD = "meshtastic" internal const val DEFAULT_TAK_TEAM_NAME = "Cyan" internal const val DEFAULT_TAK_ROLE_NAME = "Team Member" internal const val DEFAULT_TAK_BATTERY = 100 internal const val DEFAULT_TAK_STALE_MINUTES = 10 internal const val TAK_HEX_RADIX = 16 internal const val TAK_XML_READ_BUFFER_SIZE = 4_096 -internal const val TAK_KEEPALIVE_INTERVAL_MS = 30_000L -internal const val TAK_KEEPALIVE_STALE_MULTIPLIER = 3 -internal const val TAK_READ_IDLE_TIMEOUT_MULTIPLIER = 5 + +// ATAK's native commo library declares the connection dead after 25 seconds of +// silence (RX_TIMEOUT_SECONDS in streamingsocketmanagement.cpp) and starts +// sending t-x-c-t pings at 15 seconds (RX_STALE_SECONDS). Send keepalives +// well under the 15-second threshold so ATAK never enters its stale phase. +internal const val TAK_KEEPALIVE_INTERVAL_MS = 10_000L internal const val TAK_ACCEPT_LOOP_DELAY_MS = 100L internal const val TAK_COORDINATE_SCALE = 1e7 internal const val TAK_UNKNOWN_POINT_VALUE = 9_999_999.0 internal const val TAK_DIRECT_MESSAGE_PARTS_MIN = 3 +/** + * Hard cap on the size of a TAK v2 wire payload we will hand to the mesh layer. + * + * `CommandSenderImpl.sendData` checks `Data.ADAPTER.isWithinSizeLimit(data, Constants.DATA_PAYLOAD_LEN.value)` where + * `DATA_PAYLOAD_LEN = 233`. That 233 applies to the ENTIRE encoded `Data` proto (portnum tag + payload length-delim + + * reply_id + emoji), not just the `payload` bytes. The wrapper for a port-78 (`ATAK_PLUGIN_V2`) message costs roughly: + * * portnum varint + tag: 2 bytes + * * payload length prefix + tag: 2–3 bytes (depending on size) + * * reply_id / emoji: 0 bytes when unset + * + * That leaves ~228 bytes for the `payload` field alone. We use 225 to keep a small margin for future proto evolution. + * Anything larger than this is dropped in [TAKMeshIntegration.sendCoTToMesh] rather than being handed to the mesh + * layer, because the mesh layer would throw and the outer `SharedFlow` collector would eat the crash on every + * subsequent emission. + */ +internal const val MAX_TAK_WIRE_PAYLOAD_BYTES = 225 + +/** Default CoT type for PLI (Position Location Information) — friendly ground unit. */ +internal const val DEFAULT_PLI_COT_TYPE = "a-f-G-U-C" + +/** + * Max characters of raw CoT XML we'll write to logcat when dropping an oversized packet. ATAK can emit events several + * KB long; logging the whole thing floods logcat and buries the signal. 1024 chars is enough to see the event type, + * point, and the first few detail elements. + */ +internal const val TAK_LOG_XML_MAX_CHARS = 1_024 + internal fun Team?.toTakTeamName(): String = when (this) { null, Team.Unspecifed_Color, diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKMeshIntegration.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKMeshIntegration.kt index 4f3001427..4db83f5c3 100644 --- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKMeshIntegration.kt +++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKMeshIntegration.kt @@ -14,7 +14,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -@file:Suppress("ReturnCount", "TooGenericExceptionCaught") +@file:Suppress("ReturnCount", "TooGenericExceptionCaught", "LongMethod") package org.meshtastic.core.takserver @@ -24,73 +24,106 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.launch import okio.ByteString.Companion.toByteString +import org.meshtastic.core.model.Capabilities import org.meshtastic.core.model.DataPacket import org.meshtastic.core.repository.CommandSender import org.meshtastic.core.repository.MeshConfigHandler import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.ServiceRepository -import org.meshtastic.core.takserver.TAKPacketConversion.toCoTMessage import org.meshtastic.core.takserver.TAKPacketConversion.toTAKPacket -import org.meshtastic.core.takserver.fountain.CoTHandler +import org.meshtastic.core.takserver.TAKPacketV2Conversion.toTAKPacketV2 import org.meshtastic.proto.MemberRole import org.meshtastic.proto.MeshPacket import org.meshtastic.proto.PortNum import org.meshtastic.proto.TAKPacket import org.meshtastic.proto.Team import kotlin.concurrent.Volatile +import kotlin.concurrent.atomics.AtomicBoolean +import kotlin.concurrent.atomics.ExperimentalAtomicApi +import kotlin.random.Random +import kotlin.time.Clock +import kotlin.time.Duration.Companion.minutes +/** + * Bidirectional bridge between the local TAK server and the Meshtastic mesh network. + * + * Outbound traffic (TAK client -> mesh) is version-gated on the connected radio's firmware version, exposed via + * [Capabilities.supportsTakV2]: + * - Firmware **>= 2.8.0**: TAKPacketV2 on port 78 (ATAK_PLUGIN_V2) with zstd dictionary compression via TAKPacket-SDK. + * Supports all CoT payload types (PLI, GeoChat, DrawnShape, Marker, Route, Aircraft, Casevac, Emergency, Task) with + * compact typed encodings that fit under the 237B LoRa MTU. + * - Firmware **<= 2.7.x**: Legacy [TAKPacket] on port 72 (ATAK_PLUGIN) with bare protobuf encoding. Supports only PLI + * and GeoChat — shapes, markers, routes, and other typed CoT events are dropped (with a warning) because the legacy + * schema cannot represent them. + * + * Inbound traffic (mesh -> TAK client) is always dual-path tolerant — both port 72 and port 78 are dispatched + * regardless of the local radio's firmware version, so a v2-capable node can still relay legacy v1 packets received + * from older nodes in mixed-firmware mesh deployments. + */ +@OptIn(ExperimentalAtomicApi::class) class TAKMeshIntegration( private val takServerManager: TAKServerManager, private val commandSender: CommandSender, - private val nodeRepository: NodeRepository, private val serviceRepository: ServiceRepository, private val meshConfigHandler: MeshConfigHandler, - private val cotHandler: CoTHandler, + private val nodeRepository: NodeRepository, ) { - @Volatile private var isRunning = false - private val jobs = mutableListOf() - private var currentTeam: Team = Team.Unspecifed_Color - private var currentRole: MemberRole = MemberRole.Unspecifed + private val isRunning = AtomicBoolean(false) + + // Immutable list reference replaced atomically in start()/stop(); never mutated in-place. + // @Volatile only guarantees visibility of the reference itself — any in-place mutation + // would bypass the visibility guarantee and must not be added. + @Volatile private var jobs: List = emptyList() + + @Volatile private var currentTeam: Team = Team.Unspecifed_Color + + @Volatile private var currentRole: MemberRole = MemberRole.Unspecifed fun start(scope: CoroutineScope) { - if (isRunning) return - isRunning = true + if (!isRunning.compareAndSet(expectedValue = false, newValue = true)) return takServerManager.start(scope) val newJobs = listOf( // Forward incoming CoT from TAK clients to mesh - scope.launch { takServerManager.inboundMessages.collect { cotMessage -> sendCoTToMesh(cotMessage) } }, + scope.launch { + takServerManager.inboundMessages.collect { (cotMessage, clientInfo) -> + // Enrich GeoChat messages with the originating TAK client's + // callsign when the message itself lacks one. This only applies + // to messages FROM the connected TAK client — mesh-originated + // messages flow through handleMeshPacket() instead. + val enriched = + if ( + cotMessage.type == "b-t-f" && + cotMessage.contact?.callsign.isNullOrEmpty() && + clientInfo?.callsign != null + ) { + cotMessage.copy( + contact = + (cotMessage.contact ?: CoTContact(callsign = "")).copy( + callsign = clientInfo.callsign, + ), + ) + } else { + cotMessage + } + sendCoTToMesh(enriched) + } + }, // Forward incoming ATAK packets from mesh to TAK clients scope.launch { serviceRepository.meshPacketFlow .filter { - it.decoded?.portnum == PortNum.ATAK_PLUGIN || it.decoded?.portnum == PortNum.ATAK_FORWARDER + it.decoded?.portnum == PortNum.ATAK_PLUGIN_V2 || it.decoded?.portnum == PortNum.ATAK_PLUGIN } .collect { packet -> handleMeshPacket(packet) } }, - // Broadcast node positions to TAK clients. - // mapLatest cancels any in-flight broadcast loop when a new node-map emission arrives, - // preventing N×M fan-out from stacking up across rapid consecutive updates. - scope.launch { - nodeRepository.nodeDBbyNum - .mapLatest { nodes -> - nodes.forEach { (_, node) -> - takServerManager.broadcastNode( - node = node, - team = currentTeam.toTakTeamName(), - role = currentRole.toTakRoleName(), - ) - } - } - .collect {} - }, + // Track TAK config changes scope.launch { meshConfigHandler.moduleConfig .map { it.tak } @@ -102,62 +135,418 @@ class TAKMeshIntegration( }, ) - jobs.addAll(newJobs) - - Logger.i { "TAK Mesh Integration started" } + jobs = newJobs + val fw = nodeRepository.myNodeInfo.value?.firmwareVersion + val proto = if (Capabilities(fw).supportsTakV2) "v2 (port 78, zstd)" else "v1 (port 72, legacy)" + Logger.i { "TAK Mesh Integration started — firmware=$fw, outbound=$proto" } } fun stop() { - if (!isRunning) return - isRunning = false - // Cancel all tracked jobs and clear the list - val toCancel: List - toCancel = jobs.toList() - jobs.clear() + if (!isRunning.compareAndSet(expectedValue = true, newValue = false)) return + val toCancel = jobs + jobs = emptyList() toCancel.forEach(Job::cancel) takServerManager.stop() Logger.i { "TAK Mesh Integration stopped" } } + // ── Send: TAK client → mesh ───────────────────────────────────────────── + + /** + * Determine the outbound TAK protocol version based on the connected radio's firmware version. Evaluated per-send + * (not cached) so the bridge picks up firmware upgrades during a session without restart. If the firmware version + * is unavailable (radio not yet handshook), default to V2 — the v2 firmware was released widely enough that + * defaulting to legacy would be a regression for the common case. + */ + private fun useTakV2(): Boolean { + val fw = nodeRepository.myNodeInfo.value?.firmwareVersion ?: return true + return Capabilities(fw).supportsTakV2 + } + private suspend fun sendCoTToMesh(cotMessage: CoTMessage) { - val takPacket = cotMessage.toTAKPacket() - if (takPacket == null) { - cotHandler.sendGenericCoT(cotMessage) + if (useTakV2()) { + sendCoTToMeshV2(cotMessage) + } else { + sendCoTToMeshV1(cotMessage) + } + } + + /** + * v2 send path (firmware >= 2.8.0): SDK parser + zstd dictionary compression, full typed payload support + * (DrawnShape, Marker, Route, Aircraft, Casevac, Emergency, Task, plus PLI / GeoChat). Wire format: `[flags + * byte][zstd-compressed TAKPacketV2 protobuf]` on port 78 (ATAK_PLUGIN_V2). + */ + private suspend fun sendCoTToMeshV2(cotMessage: CoTMessage) { + // Prefer the sourceEventXml for shape/marker/route types — the SDK's + // CotXmlParser extracts compact typed payloads (DrawnShape, Marker, + // Route, etc.) that compress far better than raw_detail encoding. + // For PLI and GeoChat, use the enriched CoTMessage (which may have + // had callsign/contact injected by the upstream enrichment step). + val rawXml = cotMessage.sourceEventXml ?: cotMessage.toXml() + // Extend stale for static objects (routes, shapes, markers) that may + // arrive over LoRa mesh past their original TTL. iTAK uses 2-min stale + // for routes; ATAK uses 24h. 5 min ensures it survives mesh delivery. + val freshXml = ensureMinimumStaleForMesh(rawXml) + // Strip non-essential elements before compression to save wire bytes + val xml = stripNonEssentialElements(freshXml) + + // Route through the SDK parser/compressor which handles all typed + // payloads (DrawnShape, Marker, Route, Aircraft, etc.) with compact + // proto fields instead of raw_detail XML. Falls back to the app's + // own conversion only if the SDK path fails. + // + // compressWithRemarksFallback preserves text when the + // compressed packet fits under the LoRa MTU, and strips remarks + // automatically if needed to fit. Returns null if even without + // remarks the packet exceeds the limit. + val wirePayload: ByteArray = + try { + TakSdkCompressor.compressCoT(xml, MAX_TAK_WIRE_PAYLOAD_BYTES) + ?: run { + Logger.w { + buildString { + append( + "Dropping oversized TAK packet: " + + "type=${cotMessage.type} max=$MAX_TAK_WIRE_PAYLOAD_BYTES", + ) + cotMessage.sourceEventXml?.let { src -> + append('\n') + append("Source CoT event: ") + append( + if (src.length <= TAK_LOG_XML_MAX_CHARS) { + src + } else { + src.take(TAK_LOG_XML_MAX_CHARS) + "…" + }, + ) + } + } + } + return + } + } catch (e: Exception) { + Logger.w(e) { "SDK parser/compressor failed for ${cotMessage.type}, trying app conversion" } + val takPacketV2 = cotMessage.toTAKPacketV2() + if (takPacketV2 == null) { + Logger.w { "Cannot convert CoT type ${cotMessage.type} to TAKPacketV2, dropping" } + return + } + try { + TakV2Compressor.compress(takPacketV2) + } catch (e2: Exception) { + Logger.w(e2) { "V2 compression failed for ${cotMessage.type}, using uncompressed wire format" } + encodeUncompressed(takPacketV2) + } + } + + try { + val dataPacket = + DataPacket( + to = DataPacket.ID_BROADCAST, + bytes = wirePayload.toByteString(), + dataType = PortNum.ATAK_PLUGIN_V2.value, + ) + commandSender.sendData(dataPacket) + Logger.d { "Sent V2 to mesh: ${cotMessage.type} (${wirePayload.size} bytes)" } + } catch (e: Exception) { + // Something other than size — radio not connected, queue full, etc. + Logger.e(e) { + "Failed to send TAKPacketV2 to mesh (${cotMessage.type}, ${wirePayload.size} bytes): ${e.message}" + } + } + } + + /** + * Legacy v1 send path (firmware <= 2.7.x): bare protobuf-encoded [TAKPacket] on port 72 (ATAK_PLUGIN), no zstd + * compression. Only PLI and GeoChat payloads are supported by the v1 schema — shapes, markers, routes, casevac, + * emergency, and task CoT events are dropped with a warning. + */ + private suspend fun sendCoTToMeshV1(cotMessage: CoTMessage) { + val takPacket = + cotMessage.toTAKPacket() + ?: run { + Logger.w { + "Dropping CoT for legacy v1 radio: type=${cotMessage.type} not representable " + + "in v1 TAKPacket schema (only PLI and GeoChat are supported). " + + "Upgrade radio firmware to >= 2.8.0 for full payload support." + } + return + } + + val wirePayload = TAKPacket.ADAPTER.encode(takPacket) + if (wirePayload.size > MAX_TAK_WIRE_PAYLOAD_BYTES) { + Logger.w { + "Dropping oversized v1 TAK packet: type=${cotMessage.type} " + + "size=${wirePayload.size}B max=$MAX_TAK_WIRE_PAYLOAD_BYTES" + } return } - val payload = TAKPacket.ADAPTER.encode(takPacket) - - val dataPacket = - DataPacket( - to = DataPacket.ID_BROADCAST, - bytes = payload.toByteString(), - dataType = PortNum.ATAK_PLUGIN.value, - ) - - commandSender.sendData(dataPacket) - Logger.d { "Forwarded CoT to mesh as TAKPacket: ${cotMessage.type}" } + try { + val dataPacket = + DataPacket( + to = DataPacket.ID_BROADCAST, + bytes = wirePayload.toByteString(), + dataType = PortNum.ATAK_PLUGIN.value, + ) + commandSender.sendData(dataPacket) + Logger.d { "Sent V1 to mesh: ${cotMessage.type} (${wirePayload.size} bytes)" } + } catch (e: Exception) { + Logger.e(e) { + "Failed to send v1 TAKPacket to mesh (${cotMessage.type}, ${wirePayload.size} bytes): ${e.message}" + } + } } + /** + * Wrap a [org.meshtastic.proto.TAKPacketV2] into the uncompressed v2 wire format: `[0xFF flag byte][raw protobuf]`. + * Used as a fallback when the zstd native lib isn't loaded. + */ + private fun encodeUncompressed(takPacketV2: org.meshtastic.proto.TAKPacketV2): ByteArray { + val protoBytes = org.meshtastic.proto.TAKPacketV2.ADAPTER.encode(takPacketV2) + val out = ByteArray(1 + protoBytes.size) + out[0] = TakV2Compressor.DICT_ID_UNCOMPRESSED.toByte() + protoBytes.copyInto(out, 1) + return out + } + + // ── Receive: mesh → TAK client ────────────────────────────────────────── + private suspend fun handleMeshPacket(packet: MeshPacket) { val payload = packet.decoded?.payload ?: return - if (packet.decoded?.portnum == PortNum.ATAK_FORWARDER) { - cotHandler.handleIncomingForwarderPacket(payload.toByteArray(), packet.from) - return + when (packet.decoded?.portnum) { + PortNum.ATAK_PLUGIN_V2 -> handleV2Packet(payload.toByteArray()) + PortNum.ATAK_PLUGIN -> handleV1Packet(payload) + else -> return + } + } + + private suspend fun handleV2Packet(wirePayload: ByteArray) { + try { + // Decompress to CoT XML via the SDK's CotXmlBuilder, which handles + // ALL typed payloads (DrawnShape, Marker, Route, etc.) and preserves + // shape detail elements (vertices, colors, stroke weight) that the + // app's own CoTXmlParser would strip. Forward the SDK-generated XML + // directly to TAK clients without re-parsing. + val rawXml = TakV2Compressor.decompressToXml(wirePayload) + // Strip the XML declaration and collapse whitespace — ATAK's TCP + // streaming parser expects bare ... on a single + // line, not a formatted XML document with prologue. + val xml = + rawXml + .replace("""""", "") + .replace(Regex("""\s*\n\s*"""), "") + .trim() + // Logger.d { "RAW CoT IN (mesh): $xml" } + // Routes: ATAK ignores b-m-r CoT events over TCP streaming. + // Convert to a KML data package and write to ATAK's auto-import dir. + if (xml.contains("""type="b-m-r"""")) { + try { + val pkg = RouteDataPackageGenerator.generateDataPackage(xml) + if (pkg != null) { + val (fileName, zipBytes) = pkg + AtakFileWriter.writeToImportDir(fileName, zipBytes) + } else { + Logger.w { "Route data package generation failed — not enough waypoints?" } + } + } catch (e2: Exception) { + Logger.w(e2) { "Route data package write failed: ${e2.message}" } + } + } + takServerManager.broadcastRawXml(xml) + Logger.d { "V2 → TAK clients (raw XML)" } + } catch (e: Exception) { + Logger.w(e) { "Failed to handle V2 packet: ${e.message}" } + } + } + + /** + * v1 receive path (firmware <= 2.7.x): decode bare protobuf [TAKPacket] (no compression) from port 72 (ATAK_PLUGIN) + * and convert to CoT for forwarding to attached TAK clients. Kept indefinitely so users on stable 2.7.x firmware + * retain PLI + GeoChat interop; new typed payloads (shapes, markers, routes, etc.) still require a v2-capable radio + * (firmware >= 2.8.0). + */ + private suspend fun handleV1Packet(payload: okio.ByteString) { + try { + val takPacket = TAKPacket.ADAPTER.decode(payload) + val cotMessage = convertV1ToCoT(takPacket) ?: return + takServerManager.broadcast(cotMessage) + Logger.d { "V1 → TAK clients: ${cotMessage.type}" } + } catch (e: Exception) { + Logger.w(e) { "Failed to handle V1 packet: ${e.message}" } + } + } + + private fun convertV1ToCoT(takPacket: TAKPacket): CoTMessage? { + val callsign = takPacket.contact?.callsign ?: "UNKNOWN" + val senderUid = takPacket.contact?.device_callsign ?: "unknown" + val teamName = takPacket.group?.team?.toTakTeamName() ?: DEFAULT_TAK_TEAM_NAME + val roleName = takPacket.group?.role?.toTakRoleName() ?: DEFAULT_TAK_ROLE_NAME + val battery = takPacket.status?.battery ?: DEFAULT_TAK_BATTERY + + val pli = takPacket.pli + if (pli != null) { + return CoTMessage.pli( + uid = senderUid, + callsign = callsign, + latitude = pli.latitude_i.toDouble() / TAK_COORDINATE_SCALE, + longitude = pli.longitude_i.toDouble() / TAK_COORDINATE_SCALE, + altitude = pli.altitude.toDouble(), + speed = pli.speed.toDouble(), + course = pli.course.toDouble(), + team = teamName, + role = roleName, + battery = battery, + staleMinutes = DEFAULT_TAK_STALE_MINUTES, + ) } - val takPacket = - try { - TAKPacket.ADAPTER.decode(payload) - } catch (e: Exception) { - Logger.w(e) { "Failed to decode TAKPacket from mesh" } - return + val chat = takPacket.chat + if (chat != null) { + val timeNow = Clock.System.now() + // Include chatroom in UID so ATAK routes DMs correctly — the UID format + // "GeoChat..." is what ATAK uses to determine routing. + // Hardcoding "All Chat Rooms" here loses DM routing from legacy v1 nodes. + val chatroom = chat.to ?: "All Chat Rooms" + val msgId = Random.Default.nextInt().toString(TAK_HEX_RADIX) + return CoTMessage( + uid = "GeoChat.$senderUid.$chatroom.$msgId", + type = "b-t-f", + how = "h-g-i-g-o", + time = timeNow, + start = timeNow, + stale = timeNow + DEFAULT_TAK_STALE_MINUTES.minutes, + latitude = 0.0, + longitude = 0.0, + contact = CoTContact(callsign = callsign, endpoint = DEFAULT_TAK_ENDPOINT), + group = CoTGroup(name = teamName, role = roleName), + status = CoTStatus(battery = battery), + chat = CoTChat(chatroom = chatroom, senderCallsign = callsign, message = chat.message), + ) + } + + return null + } + + companion object { + /** + * Minimum stale TTL (5 min) for static CoT types sent over mesh. iTAK uses 2-min stale for routes/shapes; over + * LoRa mesh with multi-hop relay, these arrive past stale and ATAK discards them. PLI and GeoChat are left + * untouched — their stale is meaningful. + */ + private val MIN_MESH_STALE_TTL = 15.minutes + private val STATIC_COT_PREFIXES = listOf("b-m-r", "u-d-", "b-m-p-") + private val EVENT_TYPE_RE = Regex("""]*\btype="([^"]*)"""") + + // Matches the stale attribute ONLY within the opening tag to avoid + // accidentally matching a stale="..." on a or other child element. + private val EVENT_TAG_RE = Regex("""]*>""") + private val STALE_ATTR_RE = Regex("""\bstale="([^"]*)"""") + + fun ensureMinimumStaleForMesh(xml: String): String { + val type = EVENT_TYPE_RE.find(xml)?.groupValues?.getOrNull(1) ?: return xml + if (STATIC_COT_PREFIXES.none { type.startsWith(it) }) return xml + // Search for stale only inside the opening tag, not in child elements + val eventTagMatch = EVENT_TAG_RE.find(xml) ?: return xml + val eventTag = eventTagMatch.value + val staleInTag = STALE_ATTR_RE.find(eventTag) ?: return xml + val staleStr = staleInTag.groupValues[1] + val staleInstant = + try { + kotlin.time.Instant.parse(staleStr) + } catch (_: IllegalArgumentException) { + // Handle edge-case formats like missing "Z" + try { + val cleaned = staleStr.replace(Regex("""\.\d+"""), "").replace("Z", "+00:00") + kotlin.time.Instant.parse(cleaned) + } catch (_: IllegalArgumentException) { + return xml + } + } + + val now = Clock.System.now() + val remaining = staleInstant - now + if (remaining >= MIN_MESH_STALE_TTL) return xml + + val newStale = now + MIN_MESH_STALE_TTL + val newStaleStr = newStale.toString().replace(Regex("""\.\d+"""), "") // strip fractional seconds + Logger.i { + "Extended stale for $type: $staleStr → $newStaleStr " + + "(was ${remaining.inWholeSeconds}s remaining, now ${MIN_MESH_STALE_TTL.inWholeSeconds}s)" } + // Replace the stale value only within the event tag, then splice the patched tag back + val newEventTag = eventTag.replaceRange(staleInTag.range, """stale="$newStaleStr"""") + return xml.replaceRange(eventTagMatch.range, newEventTag) + } - val cotMessage = takPacket.toCoTMessage() ?: return + /** + * Strip non-essential XML elements before mesh compression to save wire bytes. These elements add 100-200 bytes + * but aren't needed for rendering shapes, routes, chats, markers, PLI, or any other payload on the receiving + * end. + */ + private val STRIP_PATTERNS = + listOf( + """]*/>""", // TAK version (self-closing) + """]*>.*?""", // TAK version (paired) + """]*/>""", // voice chat state + """]*>.*?""", + """]*/>""", // empty marti + """]*>.*?""", + """<__geofence[^>]*/>""", // geofence config + """<__geofence[^>]*>.*?""", + """]*/>""", // toggle state + """]*/>""", // archive marker + """<__shapeExtras[^>]*/>""", // shape extras + """<__shapeExtras[^>]*>.*?""", + """]*/>""", // creator info + """]*>.*?""", + """]*/>""", // empty remarks (self-closing) + """]*>""", // empty remarks (paired) + """]*/>""", // stroke style (SDK uses color fields) + """]*/>""", // precision location metadata + """]*>.*?""", + """]*/>""", // iTAK camelCase variant + """]*>.*?""", + ) + .map { Regex(it, RegexOption.DOT_MATCHES_ALL) } - takServerManager.broadcast(cotMessage) - Logger.d { "Forwarded ATAK mesh packet to TAK clients: ${cotMessage.type}" } + // Strip any attribute with value "???" — unknown/placeholder metadata + private val UNKNOWN_ATTR_PATTERN = Regex("""\s+\w+\s*=\s*"[?]{3}"""") + + // Strip specific named attributes that the SDK doesn't use (display-only) + private val STRIP_ATTR_PATTERNS = + listOf( + """\s+routetype\s*=\s*"[^"]*"""", // route display type (SDK doesn't use) + """\s+order\s*=\s*"[^"]*"""", // checkpoint order label (SDK doesn't use) + """\s+color\s*=\s*"[^"]*"""", // link_attr color (SDK uses strokeColor instead) + """\s+access\s*=\s*"[^"]*"""", // access control (not relevant for mesh) + """\s+callsign\s*=\s*""""", // empty callsign attributes (e.g. checkpoints) + """\s+phone\s*=\s*""""", // empty phone attributes + ) + .map { Regex(it) } + + // Route waypoint UID stripping — UIDs are full 36-char UUIDs that cost + // ~40 bytes each in the proto wire format. The receiving TAK client derives + // its own UIDs, so these are pure overhead. Only targets elements + // with a point= attribute (route waypoints / shape vertices). + private val ROUTE_LINK_ELEM_RE = Regex("""]*\bpoint="[^"]*"[^>]*/>""") + private val LINK_UID_ATTR_RE = Regex("""\s+uid="[^"]*"""") + + fun stripNonEssentialElements(xml: String): String { + var result = xml + for (pattern in STRIP_PATTERNS) { + result = pattern.replace(result, "") + } + // Strip ??? attributes from remaining elements + result = UNKNOWN_ATTR_PATTERN.replace(result, "") + // Strip specific display-only attributes + for (pattern in STRIP_ATTR_PATTERNS) { + result = pattern.replace(result, "") + } + // Strip uid from route waypoint elements (receiver derives UIDs) + result = ROUTE_LINK_ELEM_RE.replace(result) { LINK_UID_ATTR_RE.replace(it.value, "") } + return result + } } } diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKModels.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKModels.kt index c301a5a06..b66aa76ee 100644 --- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKModels.kt +++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKModels.kt @@ -43,6 +43,22 @@ data class CoTMessage( val chat: CoTChat? = null, val remarks: String? = null, val rawDetailXml: String? = null, + /** + * Inner XML content of `...` captured by [CoTXmlParser] when this message was parsed from an + * incoming ATAK client event. Used as the `raw_detail` fallback payload when converting to + * [org.meshtastic.proto.TAKPacketV2] for CoT types that don't fit any structured payload (PLI / GeoChat / + * Aircraft). Null for messages constructed in-app. + * + * Distinct from [rawDetailXml], which is an output-only passthrough used by [toXml] to append extension content + * during serialization. + */ + val parsedDetailXml: String? = null, + /** + * The entire original `...` XML string as received from the ATAK client, captured by [CoTXmlParser]. + * Kept solely for diagnostic logging (e.g. when a packet exceeds the mesh MTU and is dropped) so the operator can + * see what the client actually sent. Null for messages constructed in-app. + */ + val sourceEventXml: String? = null, ) { companion object { fun pli( @@ -61,7 +77,7 @@ data class CoTMessage( val now = Clock.System.now() return CoTMessage( uid = uid, - type = "a-f-G-U-C", + type = DEFAULT_PLI_COT_TYPE, time = now, start = now, stale = now + staleMinutes.minutes, @@ -130,7 +146,7 @@ sealed class TAKConnectionEvent { data class ClientInfoUpdated(val clientInfo: TAKClientInfo) : TAKConnectionEvent() - data class Message(val cotMessage: CoTMessage) : TAKConnectionEvent() + data class Message(val cotMessage: CoTMessage, val clientInfo: TAKClientInfo? = null) : TAKConnectionEvent() data object Disconnected : TAKConnectionEvent() diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKPacketConversion.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKPacketConversion.kt index 25af8abf9..4deb7cea7 100644 --- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKPacketConversion.kt +++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKPacketConversion.kt @@ -31,14 +31,27 @@ import kotlin.random.Random import kotlin.time.Clock import kotlin.time.Duration.Companion.minutes +/** + * Legacy v1 CoT <-> TAKPacket conversion for firmware <= 2.7.x. + * + * Wire format: bare protobuf-encoded [TAKPacket] on `ATAK_PLUGIN` port 72, no zstd compression (the proto has an + * `is_compressed` flag but the firmware doesn't act on it). Supports only PLI and GeoChat payloads — shape, marker, + * route, casevac, emergency, and task CoT events return null and are dropped. + * + * For the SDK-backed path that handles all payload types with zstd dictionary compression on `ATAK_PLUGIN_V2` port 78, + * see [TAKPacketV2Conversion]. + * + * [TAKMeshIntegration] picks between the two paths based on `Capabilities.supportsTakV2` (firmware >= 2.8.0). + */ object TAKPacketConversion { fun CoTMessage.toTAKPacket(): TAKPacket? { val group = this.group?.let { Group( - role = MemberRole.fromValue(getMemberRoleValue(it.role)) ?: MemberRole.Unspecifed, - team = Team.fromValue(getTeamValue(it.name)) ?: Team.Unspecifed_Color, + role = + MemberRole.fromValue(TakConversionHelpers.getMemberRoleValue(it.role)) ?: MemberRole.Unspecifed, + team = Team.fromValue(TakConversionHelpers.getTeamValue(it.name)) ?: Team.Unspecifed_Color, ) } @@ -120,7 +133,7 @@ object TAKPacketConversion { val timeNow = Clock.System.now() val staleTime = timeNow + DEFAULT_TAK_STALE_MINUTES.minutes - val (senderUid, messageId) = parseDeviceCallsign(rawDeviceCallsign) + val (senderUid, messageId) = TakConversionHelpers.parseDeviceCallsign(rawDeviceCallsign) val localPli = pli if (localPli != null) { @@ -132,8 +145,8 @@ object TAKPacketConversion { altitude = localPli.altitude.toDouble(), speed = localPli.speed.toDouble(), course = localPli.course.toDouble(), - team = teamToColorName(group?.team), - role = roleToName(group?.role), + team = TakConversionHelpers.teamToColorName(group?.team), + role = TakConversionHelpers.roleToName(group?.role), battery = status?.battery ?: DEFAULT_TAK_BATTERY, staleMinutes = DEFAULT_TAK_STALE_MINUTES, ) @@ -160,7 +173,11 @@ object TAKPacketConversion { latitude = 0.0, longitude = 0.0, contact = CoTContact(callsign = senderCallsign, endpoint = DEFAULT_TAK_ENDPOINT), - group = CoTGroup(name = teamToColorName(group?.team), role = roleToName(group?.role)), + group = + CoTGroup( + name = TakConversionHelpers.teamToColorName(group?.team), + role = TakConversionHelpers.roleToName(group?.role), + ), status = CoTStatus(battery = status?.battery ?: DEFAULT_TAK_BATTERY), chat = CoTChat(chatroom = chatroom, senderCallsign = senderCallsign, message = localChat.message), ) @@ -168,29 +185,4 @@ object TAKPacketConversion { return null } - - private fun parseDeviceCallsign(combined: String): Pair { - val parts = combined.split("|", limit = 2) - return if (parts.size == 2) { - Pair(parts[0], parts[1].ifEmpty { null }) - } else { - Pair(combined, null) - } - } - - private fun getTeamValue(name: String): Int = - Team.entries.find { it.name.equals(name, ignoreCase = true) }?.value ?: 0 - - private fun getMemberRoleValue(roleName: String): Int = - MemberRole.entries.find { it.name.equals(roleName.replace(" ", ""), ignoreCase = true) }?.value ?: 0 - - private fun teamToColorName(team: Team?): String { - if (team == null || team == Team.Unspecifed_Color) return DEFAULT_TAK_TEAM_NAME - return team.toTakTeamName() - } - - private fun roleToName(role: MemberRole?): String { - if (role == null || role == MemberRole.Unspecifed) return DEFAULT_TAK_ROLE_NAME - return role.toTakRoleName() - } } diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKPacketV2Conversion.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKPacketV2Conversion.kt new file mode 100644 index 000000000..42b4e471e --- /dev/null +++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKPacketV2Conversion.kt @@ -0,0 +1,270 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +@file:Suppress("CyclomaticComplexMethod", "ReturnCount", "LongMethod", "MagicNumber") + +package org.meshtastic.core.takserver + +import co.touchlab.kermit.Logger +import okio.ByteString.Companion.toByteString +import org.meshtastic.proto.CotHow +import org.meshtastic.proto.CotType +import org.meshtastic.proto.GeoChat +import org.meshtastic.proto.GeoPointSource +import org.meshtastic.proto.MemberRole +import org.meshtastic.proto.TAKPacketV2 +import org.meshtastic.proto.Team +import kotlin.random.Random +import kotlin.time.Clock +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +/** Conversion between CoTMessage and TAKPacketV2 (v2 wire protocol). */ +object TAKPacketV2Conversion { + + fun CoTMessage.toTAKPacketV2(): TAKPacketV2? { + val cotTypeEnum = TakV2TypeMapper.cotTypeFromString(type) + val cotTypeStr = if (cotTypeEnum == CotType.CotType_Other) type else "" + val howEnum = TakV2TypeMapper.cotHowFromString(how) + + val teamEnum = + group?.let { Team.fromValue(TakConversionHelpers.getTeamValue(it.name)) } ?: Team.Unspecifed_Color + + val roleEnum = + group?.let { MemberRole.fromValue(TakConversionHelpers.getMemberRoleValue(it.role)) } + ?: MemberRole.Unspecifed + + val battery = status?.battery?.coerceAtLeast(0) ?: 0 + + // PLI (position reports): match all atom CoT types ("a-*"). + // The original type is preserved via cot_type_id/cot_type_str so that hostile + // (a-h-*), neutral (a-n-*), and unknown (a-u-*) markers round-trip correctly. + // toCoTMessage() restores the exact type from those fields instead of defaulting + // to DEFAULT_PLI_COT_TYPE, so all atom markers are treated as PLI on the wire. + if (type.startsWith("a-")) { + val callsign = contact?.callsign ?: "UNKNOWN" + val deviceCallsign = uid + + return TAKPacketV2( + cot_type_id = cotTypeEnum, + cot_type_str = cotTypeStr, + how = howEnum, + callsign = callsign, + device_callsign = deviceCallsign, + uid = uid, + team = teamEnum, + role = roleEnum, + latitude_i = (latitude * TAK_COORDINATE_SCALE).toInt(), + longitude_i = (longitude * TAK_COORDINATE_SCALE).toInt(), + altitude = if (hae >= TAK_UNKNOWN_POINT_VALUE || hae.isNaN()) 0 else hae.toInt(), + // V2 encodes speed as cm/s (m/s × 100) and course as deg×100. + // V1 (legacy TAKPacket, port 72) uses raw integers with no scaling. + // These two paths are ALWAYS separate (different portnums) and must + // never cross-feed: a V1 packet decoded in TAKMeshIntegration goes + // through convertV1ToCoT() → CoTMessage.pli() → toXml(), NOT through + // toTAKPacketV2(). If this invariant is ever broken, speed/course + // would be silently off by ×100. + speed = (track?.speed?.coerceAtLeast(0.0)?.times(100))?.toInt() ?: 0, // m/s -> cm/s + course = (track?.course?.coerceAtLeast(0.0)?.times(100))?.toInt() ?: 0, // deg -> deg*100 + battery = battery, + geo_src = GeoPointSource.GeoPointSource_GPS, + alt_src = GeoPointSource.GeoPointSource_GPS, + pli = true, + ) + } + + // GeoChat + if (type == "b-t-f") { + val localChat = chat ?: return null + // ATAK GeoChat events often omit — the + // sender identity is only in <__chat senderCallsign="..."/>. + val callsign = contact?.callsign ?: localChat.senderCallsign ?: "UNKNOWN" + val actualDeviceUid = uid.geoChatSenderUid() + val messageId = + if (uid.startsWith("GeoChat.")) { + uid.geoChatMessageId() + } else { + Random.nextInt().toString(TAK_HEX_RADIX) + } + + val smuggledCallsign = + if (actualDeviceUid.isNotEmpty()) { + "$actualDeviceUid|$messageId" + } else { + contact?.endpoint ?: "" + } + + var toUid: String? = null + var toCallsign: String? = null + if (localChat.chatroom != "All Chat Rooms") { + if (localChat.chatroom.startsWith(uid) || uid.startsWith("GeoChat")) { + val parts = uid.split(".") + if (parts.size >= TAK_DIRECT_MESSAGE_PARTS_MIN && parts[0] == "GeoChat") { + toUid = localChat.chatroom + } + } else { + toCallsign = localChat.chatroom + } + } + + return TAKPacketV2( + cot_type_id = CotType.CotType_b_t_f, + how = CotHow.CotHow_h_g_i_g_o, + callsign = callsign, + device_callsign = smuggledCallsign, + uid = uid, + team = teamEnum, + role = roleEnum, + battery = battery, + chat = + GeoChat( + message = localChat.message, + to = toUid ?: if (toCallsign == null) "All Chat Rooms" else null, + to_callsign = toCallsign, + ), + ) + } + + // Fallback: wrap the whole detail XML in raw_detail for unmapped types + // (user-drawn shapes like u-d-c-c, markers like b-m-*, alerts, etc.) + val detailBytes = parsedDetailXml?.encodeToByteArray() + if (detailBytes != null) { + val callsign = contact?.callsign ?: "UNKNOWN" + return TAKPacketV2( + cot_type_id = cotTypeEnum, + cot_type_str = cotTypeStr, + how = howEnum, + callsign = callsign, + device_callsign = uid, + uid = uid, + team = teamEnum, + role = roleEnum, + latitude_i = (latitude * TAK_COORDINATE_SCALE).toInt(), + longitude_i = (longitude * TAK_COORDINATE_SCALE).toInt(), + altitude = if (hae >= TAK_UNKNOWN_POINT_VALUE || hae.isNaN()) 0 else hae.toInt(), + battery = battery, + raw_detail = detailBytes.toByteString(), + ) + } + + Logger.w { "Cannot convert CoT to TAKPacketV2 for type $type (no parsed detail)" } + return null + } + + fun TAKPacketV2.toCoTMessage(): CoTMessage? { + val senderCallsign = callsign.ifEmpty { "UNKNOWN" } + val rawDeviceCallsign = device_callsign.ifEmpty { uid.ifEmpty { "UNKNOWN" } } + val timeNow = Clock.System.now() + val (senderUid, messageId) = TakConversionHelpers.parseDeviceCallsign(rawDeviceCallsign) + + // PLI + if (pli != null) { + val staleMinutes = if (stale_seconds > 0) (stale_seconds / 60) else DEFAULT_TAK_STALE_MINUTES + // Restore the original CoT type and how from the packet — pli() defaults to + // DEFAULT_PLI_COT_TYPE/"m-g" but the sending node may have been hostile (a-h-*), + // neutral (a-n-*), unknown (a-u-*), etc. + val resolvedType = + cot_type_str.ifEmpty { TakV2TypeMapper.cotTypeToString(cot_type_id) ?: DEFAULT_PLI_COT_TYPE } + val resolvedHow = TakV2TypeMapper.cotHowToString(how) ?: "m-g" + return CoTMessage.pli( + uid = senderUid.ifEmpty { uid }, + callsign = senderCallsign, + latitude = latitude_i.toDouble() / TAK_COORDINATE_SCALE, + longitude = longitude_i.toDouble() / TAK_COORDINATE_SCALE, + altitude = altitude.toDouble(), + speed = speed.toDouble() / 100.0, // cm/s -> m/s + course = course.toDouble() / 100.0, // deg*100 -> deg + team = TakConversionHelpers.teamToColorName(team), + role = TakConversionHelpers.roleToName(role), + battery = battery, + staleMinutes = staleMinutes, + ) + .copy(type = resolvedType, how = resolvedHow) + } + + // GeoChat + val localChat = chat + if (localChat != null) { + // chat.to carries the recipient/room ID for DMs; null means broadcast. + // Do NOT fall through to chat.to_callsign here — despite the name, + // it holds the SENDER's callsign (the parser stores __chat[@senderCallsign] + // there), not a chatroom name. + val chatroom = localChat.to ?: "All Chat Rooms" + + val msgId = messageId ?: Random.nextInt().toString(TAK_HEX_RADIX) + val staleTime = + timeNow + + if (stale_seconds > 0) { + stale_seconds.seconds + } else { + DEFAULT_TAK_STALE_MINUTES.minutes + } + + return CoTMessage( + uid = "GeoChat.$senderUid.$chatroom.$msgId", + type = "b-t-f", + how = "h-g-i-g-o", + time = timeNow, + start = timeNow, + stale = staleTime, + latitude = latitude_i.toDouble() / TAK_COORDINATE_SCALE, + longitude = longitude_i.toDouble() / TAK_COORDINATE_SCALE, + contact = CoTContact(callsign = senderCallsign, endpoint = DEFAULT_TAK_ENDPOINT), + group = + CoTGroup( + name = TakConversionHelpers.teamToColorName(team), + role = TakConversionHelpers.roleToName(role), + ), + status = CoTStatus(battery = battery), + chat = CoTChat(chatroom = chatroom, senderCallsign = senderCallsign, message = localChat.message), + ) + } + + // Raw detail: unmapped CoT types round-tripped as opaque detail bytes. + // Emit a bare CoTMessage whose is the raw bytes verbatim. Do NOT populate + // contact/group/status here — those would be double-emitted by toXml() alongside + // rawDetailXml, corrupting the CoT stream. + val rawDetail = raw_detail + if (rawDetail != null) { + val rawXml = rawDetail.utf8() + val resolvedType = + cot_type_str.ifEmpty { TakV2TypeMapper.cotTypeToString(cot_type_id) ?: DEFAULT_PLI_COT_TYPE } + val resolvedHow = TakV2TypeMapper.cotHowToString(how) ?: "m-g" + val staleTime = + timeNow + + if (stale_seconds > 0) { + stale_seconds.seconds + } else { + DEFAULT_TAK_STALE_MINUTES.minutes + } + return CoTMessage( + uid = uid.ifEmpty { senderUid.ifEmpty { "tak-raw" } }, + type = resolvedType, + how = resolvedHow, + time = timeNow, + start = timeNow, + stale = staleTime, + latitude = latitude_i.toDouble() / TAK_COORDINATE_SCALE, + longitude = longitude_i.toDouble() / TAK_COORDINATE_SCALE, + hae = if (altitude == 0) TAK_UNKNOWN_POINT_VALUE else altitude.toDouble(), + rawDetailXml = rawXml, + ) + } + + Logger.w { "Cannot convert TAKPacketV2 to CoTMessage: no PLI, chat, or raw_detail payload" } + return null + } +} diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKServer.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKServer.kt index 7cbfea908..0a55f4fda 100644 --- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKServer.kt +++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKServer.kt @@ -14,198 +14,55 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -@file:Suppress("TooGenericExceptionCaught") - package org.meshtastic.core.takserver -import co.touchlab.kermit.Logger -import io.ktor.network.selector.SelectorManager -import io.ktor.network.sockets.ServerSocket -import io.ktor.network.sockets.Socket -import io.ktor.network.sockets.SocketAddress -import io.ktor.network.sockets.aSocket -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock import org.meshtastic.core.di.CoroutineDispatchers -import kotlin.random.Random -import kotlinx.coroutines.isActive as coroutineIsActive -class TAKServer(private val dispatchers: CoroutineDispatchers, private val port: Int = DEFAULT_TAK_PORT) { - private var serverSocket: ServerSocket? = null - private var selectorManager: SelectorManager? = null - private var running = false - private var serverScope: CoroutineScope? = null - private var acceptJob: Job? = null - private val connectionsMutex = Mutex() +/** + * Platform-agnostic contract for the Meshtastic TAK server. + * + * The production implementation on Android / JVM runs a TLS (mTLS) listener on port [DEFAULT_TAK_PORT] (8089) using the + * bundled server identity. This matches the Meshtastic-Apple (iOS) implementation so that a single exported `.zip` data + * package is valid for ATAK on Android AND iTAK on iOS without re-configuration. + * + * The interface deliberately hides the platform socket / TLS primitives so that `commonMain` code + * (`TAKServerManagerImpl`, DI, tests) can depend on it without pulling `javax.net.ssl.*` into the common source set. + */ +interface TAKServer { - private val connections = mutableMapOf() + /** Observable count of currently-connected TAK clients (ATAK/iTAK). */ + val connectionCount: StateFlow - private val _connectionCount = MutableStateFlow(0) - val connectionCount: StateFlow = _connectionCount.asStateFlow() + /** Callback invoked on the IO dispatcher for every inbound CoT message from a client. */ + var onMessage: ((CoTMessage, TAKClientInfo?) -> Unit)? - var onMessage: ((CoTMessage) -> Unit)? = null + /** Callback invoked when a TAK client connects. Use to drain queued messages. */ + var onClientConnected: (() -> Unit)? - suspend fun start(scope: CoroutineScope): Result { - // Double-start guard: prevents SelectorManager / ServerSocket leaks - if (running) { - Logger.w { "TAK Server already running on port $port" } - return Result.success(Unit) - } + /** Bind the listener and begin accepting connections. Idempotent if already running. */ + suspend fun start(scope: CoroutineScope): Result - return try { - serverScope = scope - // Close any stale SelectorManager before creating a new one - selectorManager?.close() - selectorManager = SelectorManager(dispatchers.default) - serverSocket = aSocket(selectorManager!!).tcp().bind(hostname = "127.0.0.1", port = port) + /** Stop the listener, close all client sockets, and release OS resources. */ + fun stop() - running = true - acceptJob = scope.launch(dispatchers.io) { acceptLoop() } - Result.success(Unit) - } catch (e: Exception) { - Logger.e(e) { "Failed to bind TAK Server to 127.0.0.1:$port" } - Result.failure(e) - } - } + /** Broadcast a CoT message to every currently-connected client. */ + suspend fun broadcast(cotMessage: CoTMessage) - private suspend fun acceptLoop() { - val scope = serverScope ?: return - while (running && scope.coroutineIsActive) { - try { - val clientSocket = serverSocket?.accept() - if (clientSocket != null) { - handleConnection(clientSocket) - } - // No delay on the success path — accept() is already suspending - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Logger.w(e) { "TAK server accept loop iteration failed" } - // Back-off only in the error path - delay(TAK_ACCEPT_LOOP_DELAY_MS) - } - } - } + /** + * Broadcast raw CoT XML to every currently-connected client. Used for mesh-originated messages that should be + * forwarded verbatim without re-parsing through the app's CoTXmlParser (which strips shape detail elements like + * strokeColor, fillColor, vertices, etc.). + */ + suspend fun broadcastRawXml(xml: String) - private fun handleConnection(clientSocket: Socket) { - val scope = serverScope ?: return - val endpoint = clientSocket.remoteAddress.toString() - - if (!clientSocket.remoteAddress.isLoopback()) { - Logger.w { "TAK server rejected non-loopback connection from $endpoint" } - clientSocket.close() - return - } - - val connectionId = Random.nextInt().toString(TAK_HEX_RADIX) - val clientInfo = TAKClientInfo(id = connectionId, endpoint = endpoint) - - val connection = - TAKClientConnection( - socket = clientSocket, - clientInfo = clientInfo, - onEvent = { event -> handleConnectionEvent(connectionId, event) }, - scope = scope, - ) - - scope.launch { - connectionsMutex.withLock { - connections[connectionId] = connection - _connectionCount.value = connections.size - } - connection.start() - } - } - - private fun handleConnectionEvent(connectionId: String, event: TAKConnectionEvent) { - when (event) { - is TAKConnectionEvent.Message -> { - onMessage?.invoke(event.cotMessage) - } - - is TAKConnectionEvent.Disconnected -> { - serverScope?.launch { - connectionsMutex.withLock { - connections.remove(connectionId) - _connectionCount.value = connections.size - } - } - } - - is TAKConnectionEvent.Error -> { - Logger.w(event.error) { "TAK client connection error: $connectionId" } - serverScope?.launch { - connectionsMutex.withLock { - connections.remove(connectionId) - _connectionCount.value = connections.size - } - } - } - - is TAKConnectionEvent.Connected -> { - /* no-op: logged by TAKClientConnection.start() */ - } - - is TAKConnectionEvent.ClientInfoUpdated -> { - /* no-op: TAKClientConnection tracks updated info locally */ - } - } - } - - fun stop() { - running = false - acceptJob?.cancel() - acceptJob = null - - // Close connections synchronously — TAKClientConnection.close() is non-suspending, - // so we don't need to launch into the (possibly-cancelled) serverScope. - val toClose: List - // We can't use Mutex.withLock here (non-suspending context) so we swap & clear under a - // best-effort copy — worst case a connection added concurrently is closed by socket teardown. - toClose = connections.values.toList() - connections.clear() - _connectionCount.value = 0 - toClose.forEach { it.close() } - - serverSocket?.close() - serverSocket = null - - selectorManager?.close() - selectorManager = null - serverScope = null - } - - suspend fun broadcast(cotMessage: CoTMessage) { - val currentConnections = connectionsMutex.withLock { connections.values.toList() } - currentConnections.forEach { connection -> - try { - connection.send(cotMessage) - } catch (e: Exception) { - Logger.w(e) { "Failed to broadcast CoT to TAK client ${connection.clientInfo.id}" } - connection.close() - } - } - } - - suspend fun hasConnections(): Boolean = connectionsMutex.withLock { connections.isNotEmpty() } + /** Returns true if at least one TAK client is currently connected. */ + suspend fun hasConnections(): Boolean } /** - * Returns true if this [SocketAddress] represents a loopback address (IPv4 127.x.x.x or IPv6 ::1). - * - * Ktor's [SocketAddress.toString] returns strings like "/127.0.0.1:4242" (JVM) or "127.0.0.1:4242" on other platforms, - * so we strip any leading slash and check prefixes without parsing the host. This keeps the check in commonMain without - * an expect/actual. + * Platform factory for [TAKServer]. The JVM/Android implementation lives in `jvmAndroidMain` and uses JSSE + * (`SSLServerSocket`) with the bundled `server.p12` identity and `ca.pem` client trust store. */ -private fun SocketAddress.isLoopback(): Boolean { - val addr = toString().removePrefix("/") - return addr.startsWith("127.") || addr.startsWith("::1") || addr.startsWith("[::1]") -} +expect fun createTAKServer(dispatchers: CoroutineDispatchers, port: Int = DEFAULT_TAK_PORT): TAKServer diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKServerManager.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKServerManager.kt index cc6861d17..6f13f067e 100644 --- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKServerManager.kt +++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKServerManager.kt @@ -18,39 +18,40 @@ package org.meshtastic.core.takserver import co.touchlab.kermit.Logger import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.consumeAsFlow import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import org.meshtastic.core.model.Node +import kotlin.time.Clock +import kotlin.time.Duration.Companion.minutes + +/** A CoT message received from a connected TAK client, paired with the client's identity. */ +data class InboundCoTMessage(val cotMessage: CoTMessage, val clientInfo: TAKClientInfo? = null) interface TAKServerManager { val isRunning: StateFlow val connectionCount: StateFlow - val inboundMessages: Flow + val inboundMessages: SharedFlow /** Start the TAK server using [scope]. Port is fixed at [TAKServer] construction time. */ fun start(scope: CoroutineScope) fun stop() - fun broadcastNode(node: Node, team: String = DEFAULT_TAK_TEAM_NAME, role: String = DEFAULT_TAK_ROLE_NAME) - fun broadcast(cotMessage: CoTMessage) + + /** Broadcast raw XML verbatim to TAK clients, bypassing CoTMessage parsing. */ + fun broadcastRawXml(xml: String) } -class TAKServerManagerImpl(private val takServer: TAKServer) : TAKServerManager { +internal class TAKServerManagerImpl(private val takServer: TAKServer) : TAKServerManager { private var scope: CoroutineScope? = null - private val lastBroadcastPositionsMutex = Mutex() private val _isRunning = MutableStateFlow(false) override val isRunning: StateFlow = _isRunning.asStateFlow() @@ -58,30 +59,42 @@ class TAKServerManagerImpl(private val takServer: TAKServer) : TAKServerManager // Mirror TAKServer's event-driven connection count — no polling needed override val connectionCount: StateFlow = takServer.connectionCount - private val _inboundMessages = MutableSharedFlow() - override val inboundMessages: Flow = _inboundMessages.asFlow() + private val _inboundMessages = MutableSharedFlow(extraBufferCapacity = 64) + override val inboundMessages: SharedFlow = _inboundMessages.asSharedFlow() - // Unbounded channel preserves FIFO ordering of inbound CoT messages under load. - // onMessage is a non-suspend callback, so we trySend (always succeeds for UNLIMITED) - // and a single consumer coroutine drains into _inboundMessages in order. - private var inboundChannel: Channel? = null - private var inboundDrainJob: Job? = null + // Offline message queue — buffers mesh-originated CoT messages when no TAK + // clients are connected, then drains them when a client reconnects. Entries + // expire after OFFLINE_QUEUE_TTL to avoid delivering stale situational data. + private data class QueuedMessage(val cotMessage: CoTMessage, val enqueuedAt: kotlin.time.Instant) - private var lastBroadcastPositions = mutableMapOf() + private val offlineQueue = ArrayDeque() + private val offlineQueueMutex = Mutex() + + companion object { + private val OFFLINE_QUEUE_TTL = 5.minutes + private const val OFFLINE_QUEUE_MAX_SIZE = 50 + } override fun start(scope: CoroutineScope) { - this.scope = scope if (_isRunning.value) { Logger.w { "TAKServerManager already running" } return } + // Assign scope AFTER the guard so a second concurrent start() can never + // overwrite the active scope without actually restarting the server. + this.scope = scope scope.launch { // Wire up inbound message handler BEFORE starting so no messages are lost. - val channel = Channel(Channel.UNLIMITED) - inboundChannel = channel - inboundDrainJob = scope.launch { channel.consumeAsFlow().collect { _inboundMessages.emit(it) } } - takServer.onMessage = { cotMessage -> channel.trySend(cotMessage) } + // Use tryEmit (non-suspending) with extraBufferCapacity to avoid launching a + // new coroutine per message, which would create unbounded coroutines under + // high message rates and could reorder messages. + takServer.onMessage = { cotMessage, clientInfo -> + if (!_inboundMessages.tryEmit(InboundCoTMessage(cotMessage, clientInfo))) { + Logger.w { "TAK inbound message buffer full; dropping message from ${clientInfo?.id}" } + } + } + takServer.onClientConnected = { drainOfflineQueue() } val result = takServer.start(scope) if (result.isSuccess) { @@ -91,81 +104,68 @@ class TAKServerManagerImpl(private val takServer: TAKServer) : TAKServerManager Logger.e(result.exceptionOrNull()) { "Failed to start TAK Server" } // Clear onMessage if start failed so we don't hold a reference unnecessarily takServer.onMessage = null - inboundDrainJob?.cancel() - inboundDrainJob = null - channel.close() - inboundChannel = null } } } override fun stop() { - takServer.stop() - takServer.onMessage = null - inboundChannel?.close() - inboundChannel = null - inboundDrainJob?.cancel() - inboundDrainJob = null + // Flip the running flag and null out the scope BEFORE stopping the server so + // any broadcast()/drainOfflineQueue() that races stop() sees _isRunning=false + // and exits early instead of launching coroutines on a scope that is about to + // be discarded. _isRunning.value = false scope = null + takServer.onMessage = null + takServer.stop() Logger.i { "TAK Server stopped" } } - override fun broadcastNode(node: Node, team: String, role: String) { - if (!_isRunning.value) return - val currentScope = scope ?: return - - currentScope.launch { - if (!takServer.hasConnections()) return@launch - - val position = node.validPosition - if (position == null) { - broadcastNodeInfoOnly(node, team, role) - return@launch - } - - val shouldBroadcast = - lastBroadcastPositionsMutex.withLock { - val last = lastBroadcastPositions[node.num] - if (position.time == last) { - false - } else { - lastBroadcastPositions[node.num] = position.time - true - } - } - if (!shouldBroadcast) return@launch - - val cotMessage = - position.toCoTMessage( - uid = node.user.id, - callsign = node.user.toTakCallsign(), - team = team, - role = role, - battery = node.deviceMetrics.battery_level ?: 100, - ) - - takServer.broadcast(cotMessage) - } - } - - private fun broadcastNodeInfoOnly(node: Node, team: String, role: String) { - val currentScope = scope ?: return - val cotMessage = - node.user.toCoTMessage( - position = null, - team = team, - role = role, - battery = node.deviceMetrics.battery_level ?: 100, - ) - - currentScope.launch { - if (!takServer.hasConnections()) return@launch - takServer.broadcast(cotMessage) - } - } - override fun broadcast(cotMessage: CoTMessage) { - scope?.launch { takServer.broadcast(cotMessage) } + if (!_isRunning.value) return + scope?.launch { + if (takServer.hasConnections()) { + takServer.broadcast(cotMessage) + } else { + // No TAK clients connected — queue for delivery when one reconnects + offlineQueueMutex.withLock { + // Evict expired entries + val cutoff = Clock.System.now() - OFFLINE_QUEUE_TTL + while (offlineQueue.isNotEmpty() && offlineQueue.first().enqueuedAt < cutoff) { + offlineQueue.removeFirst() + } + // Cap size to prevent unbounded growth + if (offlineQueue.size >= OFFLINE_QUEUE_MAX_SIZE) { + offlineQueue.removeFirst() + } + offlineQueue.addLast(QueuedMessage(cotMessage, Clock.System.now())) + } + } + } + } + + override fun broadcastRawXml(xml: String) { + if (!_isRunning.value) return + scope?.launch { takServer.broadcastRawXml(xml) } + } + + /** + * Drain any queued messages to the newly connected TAK client. Called by the server when a TAK client connects + * (Connected event). + */ + internal fun drainOfflineQueue() { + if (!_isRunning.value) return + scope?.launch { + val messages = + offlineQueueMutex.withLock { + val cutoff = Clock.System.now() - OFFLINE_QUEUE_TTL + val valid = offlineQueue.filter { it.enqueuedAt >= cutoff }.map { it.cotMessage } + offlineQueue.clear() + valid + } + if (messages.isNotEmpty()) { + Logger.i { "Draining ${messages.size} queued message(s) to reconnected TAK client" } + messages.forEach { takServer.broadcast(it) } + } + } } } diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TakConversionHelpers.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TakConversionHelpers.kt new file mode 100644 index 000000000..3174ece0e --- /dev/null +++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TakConversionHelpers.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.takserver + +import org.meshtastic.proto.MemberRole +import org.meshtastic.proto.Team + +/** + * Internal helpers shared by [TAKPacketConversion] (legacy v1, firmware <= 2.7.x) and [TAKPacketV2Conversion] + * (firmware >= 2.8.x). Both paths map between the SDK's [CoTMessage] model and Meshtastic's Wire-generated proto types + * using identical logic for color/role lookup and the "|" smuggled-callsign format that survives + * the wire round trip. + */ +internal object TakConversionHelpers { + + /** Split a `|` smuggled callsign back into its parts. */ + fun parseDeviceCallsign(combined: String): Pair { + val parts = combined.split("|", limit = 2) + return if (parts.size == 2) { + Pair(parts[0], parts[1].ifEmpty { null }) + } else { + Pair(combined, null) + } + } + + /** Map a [Team] proto enum back to its CoT color-name string. Unspecified -> default. */ + fun teamToColorName(team: Team?): String { + if (team == null || team == Team.Unspecifed_Color) return DEFAULT_TAK_TEAM_NAME + return team.toTakTeamName() + } + + /** Map a [MemberRole] proto enum back to its CoT role-name string. Unspecified -> default. */ + fun roleToName(role: MemberRole?): String { + if (role == null || role == MemberRole.Unspecifed) return DEFAULT_TAK_ROLE_NAME + return role.toTakRoleName() + } + + /** Reverse lookup from CoT color-name string to [Team] proto enum value (0 = Unspecified). */ + fun getTeamValue(name: String): Int = Team.entries.find { it.name.equals(name, ignoreCase = true) }?.value ?: 0 + + /** Reverse lookup from CoT role-name string to [MemberRole] proto enum value (0 = Unspecified). */ + fun getMemberRoleValue(roleName: String): Int = + MemberRole.entries.find { it.name.equals(roleName.replace(" ", ""), ignoreCase = true) }?.value ?: 0 +} diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/fountain/CoTHandler.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TakFixtureLoader.kt similarity index 57% rename from core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/fountain/CoTHandler.kt rename to core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TakFixtureLoader.kt index 544aabfad..7d2843bb3 100644 --- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/fountain/CoTHandler.kt +++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TakFixtureLoader.kt @@ -14,18 +14,12 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package org.meshtastic.core.takserver.fountain - -import org.meshtastic.core.takserver.CoTMessage +package org.meshtastic.core.takserver /** - * Handles incoming and outgoing generic Cursor on Target (CoT) messages wrapped in Meshtastic DataPackets. + * Platform-specific loader for TAK test fixture XML files bundled in test resources. * - * Defines the contract for routing Direct (unfragmented) vs Fountain-encoded packets, and processing decompressed - * EXI/Zlib XML payloads. + * On JVM/Android the resources are loaded via the classloader. On iOS the test runner is not supported and this throws + * [UnsupportedOperationException]. */ -interface CoTHandler { - suspend fun sendGenericCoT(cotMessage: CoTMessage) - - suspend fun handleIncomingForwarderPacket(payload: ByteArray, senderNodeNum: Int) -} +internal expect fun loadTakFixtureXml(name: String): String diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TakMeshTestRunner.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TakMeshTestRunner.kt new file mode 100644 index 000000000..b422a8a12 --- /dev/null +++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TakMeshTestRunner.kt @@ -0,0 +1,193 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +@file:Suppress("TooGenericExceptionCaught", "ReturnCount") + +package org.meshtastic.core.takserver + +import co.touchlab.kermit.Logger +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.sync.Mutex +import okio.ByteString.Companion.toByteString +import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.repository.CommandSender +import org.meshtastic.proto.PortNum + +/** Result of sending a single test fixture through the TAK mesh pipeline. */ +data class TakTestResult( + val fixtureName: String, + val xmlBytes: Int, + val compressedBytes: Int, + val passed: Boolean, + val error: String? = null, +) + +/** + * Debug-only test runner that sends the SDK's CoT XML test fixtures through the real TAK mesh pipeline: strip → parse → + * compress → send to mesh radio. + * + * Paces sends by waiting [sendDelayMs] between each fixture to avoid flooding the radio's TX queue. + */ +class TakMeshTestRunner(private val commandSender: CommandSender) { + private val _results = MutableStateFlow>(emptyList()) + val results: StateFlow> = _results.asStateFlow() + + private val _isRunning = MutableStateFlow(false) + val isRunning: StateFlow = _isRunning.asStateFlow() + + private val _currentFixture = MutableStateFlow(null) + val currentFixture: StateFlow = _currentFixture.asStateFlow() + + // Prevents concurrent invocations from racing the _isRunning check-then-set. + private val runMutex = Mutex() + + companion object { + /** Delay between sends to let the radio transmit and receive ACK. */ + private const val SEND_DELAY_MS = 5_000L + + /** All bundled fixture filenames. */ + val FIXTURE_NAMES = + listOf( + "aircraft_adsb.xml", + "aircraft_hostile.xml", + "alert_tic.xml", + "casevac.xml", + "casevac_medline.xml", + "chat_receipt_delivered.xml", + "chat_receipt_read.xml", + "delete_event.xml", + "drawing_circle.xml", + "drawing_circle_large.xml", + "drawing_ellipse.xml", + "drawing_freeform.xml", + "drawing_polygon.xml", + "drawing_rectangle.xml", + "drawing_rectangle_itak.xml", + "drawing_telestration.xml", + "emergency_911.xml", + "emergency_cancel.xml", + "geochat_broadcast.xml", + "geochat_dm.xml", + "geochat_simple.xml", + "marker_2525.xml", + "marker_goto.xml", + "marker_goto_itak.xml", + "marker_icon_set.xml", + "marker_spot.xml", + "marker_tank.xml", + "pli_basic.xml", + "pli_full.xml", + "pli_itak.xml", + "pli_stationary.xml", + "pli_takaware.xml", + "pli_webtak.xml", + "ranging_bullseye.xml", + "ranging_circle.xml", + "ranging_line.xml", + "route_3wp.xml", + "route_itak_3wp.xml", + "task_engage.xml", + "waypoint.xml", + ) + } + + /** + * Run all test fixtures sequentially, sending each through the mesh pipeline. Updates [results] and + * [currentFixture] as each fixture is processed. + */ + suspend fun runAll() { + // Use tryLock to prevent concurrent test runs: if another coroutine is already + // inside runAll(), tryLock returns false and we bail immediately. This is safer + // than the check-then-set pattern which has a TOCTOU race in multi-threaded + // coroutine dispatch. + if (!runMutex.tryLock()) return + try { + _isRunning.value = true + _results.value = emptyList() + + val allResults = mutableListOf() + + for (name in FIXTURE_NAMES) { + _currentFixture.value = name + val result = runSingleFixture(name) + allResults.add(result) + _results.value = allResults.toList() + + if (result.passed) { + // Wait for radio airtime + ACK before next send + delay(SEND_DELAY_MS) + } + } + + _currentFixture.value = null + + val passed = allResults.count { it.passed } + val failed = allResults.size - passed + Logger.i { "TAK Mesh Test complete: $passed/${allResults.size} passed, $failed failed" } + } finally { + _isRunning.value = false + runMutex.unlock() + } + } + + private suspend fun runSingleFixture(name: String): TakTestResult { + // Load fixture XML from bundled resources via platform-specific loader + val xml = + try { + loadTakFixtureXml(name) + } catch (e: Exception) { + Logger.w(e) { "Failed to load fixture $name" } + return TakTestResult(name, 0, 0, false, "Load failed: ${e.message}") + } + + // Apply the same pipeline as TAKMeshIntegration.sendCoTToMesh() + val freshXml = TAKMeshIntegration.ensureMinimumStaleForMesh(xml) + val strippedXml = TAKMeshIntegration.stripNonEssentialElements(freshXml) + + // Parse and compress via SDK + val wirePayload: ByteArray + try { + val compressed = TakSdkCompressor.compressCoT(strippedXml, MAX_TAK_WIRE_PAYLOAD_BYTES) + if (compressed == null) { + Logger.w { "TAK Test: $name oversized even without remarks (xml=${xml.length}B)" } + return TakTestResult(name, xml.length, 0, false, "Oversized (>${MAX_TAK_WIRE_PAYLOAD_BYTES}B)") + } + wirePayload = compressed + } catch (e: Exception) { + Logger.w(e) { "TAK Test: $name compression failed: ${e.message}" } + return TakTestResult(name, xml.length, 0, false, "Compress failed: ${e.message}") + } + + // Send to mesh + try { + val dataPacket = + DataPacket( + to = DataPacket.ID_BROADCAST, + bytes = wirePayload.toByteString(), + dataType = PortNum.ATAK_PLUGIN_V2.value, + ) + commandSender.sendData(dataPacket) + Logger.i { "TAK Test: $name → ${wirePayload.size}B (xml=${xml.length}B)" } + return TakTestResult(name, xml.length, wirePayload.size, true) + } catch (e: Exception) { + Logger.w(e) { "TAK Test: $name send failed: ${e.message}" } + return TakTestResult(name, xml.length, wirePayload.size, false, "Send failed: ${e.message}") + } + } +} diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TakSdkCompressor.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TakSdkCompressor.kt new file mode 100644 index 000000000..49f845931 --- /dev/null +++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TakSdkCompressor.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.takserver + +/** + * Expect/actual wrapper for the TAKPacket-SDK compression pipeline. + * + * On JVM/Android the SDK's [org.meshtastic.tak.CotXmlParser] and [org.meshtastic.tak.TakCompressor] are available. On + * iOS they are not, so the actual throws [UnsupportedOperationException]. + */ +internal expect object TakSdkCompressor { + + /** + * Parse CoT XML via the SDK and compress with remarks-fallback. + * + * @return compressed wire payload, or `null` if the packet exceeds [maxBytes] even without remarks. + * @throws Exception on parse or compression failure. + */ + fun compressCoT(xml: String, maxBytes: Int): ByteArray? +} diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TakV2Compressor.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TakV2Compressor.kt new file mode 100644 index 000000000..2d131c8aa --- /dev/null +++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TakV2Compressor.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.takserver + +import org.meshtastic.proto.TAKPacketV2 + +/** + * TAKPacket V2 wire format compressor/decompressor. + * + * Wire format: [1 byte flags][zstd-compressed TAKPacketV2 protobuf] Flags byte bits 0-5 = dictionary ID, bits 6-7 = + * reserved. Special value 0xFF = uncompressed raw protobuf (from TAK_TRACKER firmware). + * + * Platform-specific implementations use zstd with pre-trained dictionaries. + */ +internal expect object TakV2Compressor { + + /** Maximum allowed decompressed payload size (bytes). */ + val MAX_DECOMPRESSED_SIZE: Int + + /** Dictionary ID for non-aircraft types. */ + val DICT_ID_NON_AIRCRAFT: Int + + /** Dictionary ID for aircraft types. */ + val DICT_ID_AIRCRAFT: Int + + /** Special flags byte value indicating uncompressed raw protobuf. */ + val DICT_ID_UNCOMPRESSED: Int + + /** + * Compress a TAKPacketV2 into wire payload: [flags byte][zstd compressed protobuf]. Selects dictionary based on the + * CoT type classification. + */ + fun compress(packet: TAKPacketV2): ByteArray + + /** + * Decompress a wire payload back to TAKPacketV2. Handles both compressed (dict-based) and uncompressed (0xFF) + * payloads. + * + * @throws IllegalArgumentException if payload is malformed or exceeds size limits. + */ + fun decompress(wirePayload: ByteArray): TAKPacketV2 + + /** + * Decompress a wire payload and reconstruct CoT XML via the SDK's CotXmlBuilder. Handles ALL payload types + * (DrawnShape, Marker, Route, etc.) without going through the Wire proto intermediate. + */ + fun decompressToXml(wirePayload: ByteArray): String +} diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TakV2TypeMapper.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TakV2TypeMapper.kt new file mode 100644 index 000000000..d7956886a --- /dev/null +++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TakV2TypeMapper.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.takserver + +import org.meshtastic.proto.CotHow +import org.meshtastic.proto.CotType + +/** Maps CoT type strings (e.g. "a-f-G-U-C") to CotType enum values and back. */ +internal object TakV2TypeMapper { + + private val stringToType: Map = + mapOf( + "a-f-G-U-C" to CotType.CotType_a_f_G_U_C, + "a-f-G-U-C-I" to CotType.CotType_a_f_G_U_C_I, + "a-n-A-C-F" to CotType.CotType_a_n_A_C_F, + "a-n-A-C-H" to CotType.CotType_a_n_A_C_H, + "a-n-A-C" to CotType.CotType_a_n_A_C, + "a-f-A-M-H" to CotType.CotType_a_f_A_M_H, + "a-f-A-M" to CotType.CotType_a_f_A_M, + "a-h-A-M-F-F" to CotType.CotType_a_h_A_M_F_F, + "a-u-A-C" to CotType.CotType_a_u_A_C, + "t-x-d-d" to CotType.CotType_t_x_d_d, + "b-t-f" to CotType.CotType_b_t_f, + "b-r-f-h-c" to CotType.CotType_b_r_f_h_c, + "b-a-o-pan" to CotType.CotType_b_a_o_pan, + "b-a-o-opn" to CotType.CotType_b_a_o_opn, + "a-f-G" to CotType.CotType_a_f_G, + "a-f-G-U" to CotType.CotType_a_f_G_U, + "a-h-G" to CotType.CotType_a_h_G, + "a-u-G" to CotType.CotType_a_u_G, + "a-n-G" to CotType.CotType_a_n_G, + "b-m-r" to CotType.CotType_b_m_r, + "b-m-p-s-p-i" to CotType.CotType_b_m_p_s_p_i, + "u-d-f" to CotType.CotType_u_d_f, + "a-f-A-C-F" to CotType.CotType_a_f_A_C_F, + "a-f-A" to CotType.CotType_a_f_A, + "a-f-G-E-S" to CotType.CotType_a_f_G_E_S, + "b-m-p-s-p-loc" to CotType.CotType_b_m_p_s_p_loc, + "b-i-v" to CotType.CotType_b_i_v, + ) + + private val typeToString: Map = stringToType.entries.associate { (k, v) -> v to k } + + private val stringToHow: Map = + mapOf( + "h-e" to CotHow.CotHow_h_e, + "m-g" to CotHow.CotHow_m_g, + "h-g-i-g-o" to CotHow.CotHow_h_g_i_g_o, + "m-r" to CotHow.CotHow_m_r, + ) + + private val howToStr: Map = stringToHow.entries.associate { (k, v) -> v to k } + + fun cotTypeFromString(s: String): CotType = stringToType[s] ?: CotType.CotType_Other + + fun cotTypeToString(type: CotType): String? = typeToString[type] + + fun cotHowFromString(s: String): CotHow = stringToHow[s] ?: CotHow.CotHow_Unspecified + + fun cotHowToString(how: CotHow): String? = howToStr[how] +} diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/di/CoreTakServerModule.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/di/CoreTakServerModule.kt index 66fa34a93..67003de8c 100644 --- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/di/CoreTakServerModule.kt +++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/di/CoreTakServerModule.kt @@ -27,33 +27,22 @@ import org.meshtastic.core.takserver.TAKMeshIntegration import org.meshtastic.core.takserver.TAKServer import org.meshtastic.core.takserver.TAKServerManager import org.meshtastic.core.takserver.TAKServerManagerImpl -import org.meshtastic.core.takserver.fountain.CoTHandler -import org.meshtastic.core.takserver.fountain.GenericCoTHandler +import org.meshtastic.core.takserver.createTAKServer @Module class CoreTakServerModule { - @Single fun provideTAKServer(dispatchers: CoroutineDispatchers): TAKServer = TAKServer(dispatchers = dispatchers) + @Single + fun provideTAKServer(dispatchers: CoroutineDispatchers): TAKServer = createTAKServer(dispatchers = dispatchers) @Single fun provideTAKServerManager(takServer: TAKServer): TAKServerManager = TAKServerManagerImpl(takServer) - @Single - fun provideGenericCoTHandler(commandSender: CommandSender, takServerManager: TAKServerManager): CoTHandler = - GenericCoTHandler(commandSender, takServerManager) - @Single fun provideTAKMeshIntegration( takServerManager: TAKServerManager, commandSender: CommandSender, - nodeRepository: NodeRepository, serviceRepository: ServiceRepository, meshConfigHandler: MeshConfigHandler, - cotHandler: CoTHandler, - ): TAKMeshIntegration = TAKMeshIntegration( - takServerManager, - commandSender, - nodeRepository, - serviceRepository, - meshConfigHandler, - cotHandler, - ) + nodeRepository: NodeRepository, + ): TAKMeshIntegration = + TAKMeshIntegration(takServerManager, commandSender, serviceRepository, meshConfigHandler, nodeRepository) } diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/fountain/FountainCodec.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/fountain/FountainCodec.kt deleted file mode 100644 index 683905c78..000000000 --- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/fountain/FountainCodec.kt +++ /dev/null @@ -1,468 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.takserver.fountain - -import co.touchlab.kermit.Logger -import kotlin.math.ceil -import kotlin.math.ln -import kotlin.math.sqrt -import kotlin.random.Random -import kotlin.time.Clock - -internal object FountainConstants { - val MAGIC = byteArrayOf(0x46, 0x54, 0x4E) // "FTN" - const val BLOCK_SIZE = 220 - const val DATA_HEADER_SIZE = 11 - const val FOUNTAIN_THRESHOLD = 233 - const val TRANSFER_TYPE_COT: Byte = 0x00 - const val ACK_TYPE_COMPLETE: Byte = 0x02 - const val ACK_PACKET_SIZE = 19 -} - -internal data class FountainBlock( - val seed: Int, // UInt16 - var indices: MutableSet, - var payload: ByteArray, -) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other == null || this::class != other::class) return false - other as FountainBlock - return seed == other.seed && indices == other.indices && payload.contentEquals(other.payload) - } - - override fun hashCode(): Int { - var result = seed - result = 31 * result + indices.hashCode() - result = 31 * result + payload.contentHashCode() - return result - } -} - -internal class FountainReceiveState( - val transferId: Int, // UInt24 - val k: Int, - val totalLength: Int, -) { - val blocks = mutableListOf() - private val createdAt = Clock.System.now().toEpochMilliseconds() - - fun addBlock(block: FountainBlock) { - if (blocks.none { it.seed == block.seed }) { - blocks.add(block) - } - } - - val isExpired: Boolean - get() = (Clock.System.now().toEpochMilliseconds() - createdAt) > 60_000 -} - -internal data class FountainDataHeader( - val transferId: Int, // UInt24 - val seed: Int, // UInt16 - val k: Int, // UInt8 - val totalLength: Int, // UInt16 -) - -internal data class FountainAck( - val transferId: Int, - val type: Byte, - val received: Int, - val needed: Int, - val dataHash: ByteArray, -) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other == null || this::class != other::class) return false - other as FountainAck - return transferId == other.transferId && - type == other.type && - received == other.received && - needed == other.needed && - dataHash.contentEquals(other.dataHash) - } - - override fun hashCode(): Int { - var result = transferId - result = 31 * result + type.toInt() - result = 31 * result + received - result = 31 * result + needed - result = 31 * result + dataHash.contentHashCode() - return result - } -} - -@Suppress("MagicNumber") -internal class JavaRandom(seed: Long) { - private var seed: Long = (seed xor 0x5DEECE66DL) and ((1L shl 48) - 1) - - private fun next(bits: Int): Int { - seed = (seed * 0x5DEECE66DL + 0xBL) and ((1L shl 48) - 1) - return (seed ushr (48 - bits)).toInt() - } - - fun nextInt(bound: Int): Int = when { - bound <= 0 -> 0 - - (bound and -bound) == bound -> ((bound.toLong() * next(31).toLong()) shr 31).toInt() - - else -> { - var bits: Int - var valResult: Int - do { - bits = next(31) - valResult = bits % bound - } while (bits - valResult + (bound - 1) < 0) - valResult - } - } - - fun nextDouble(): Double { - val high = next(26).toLong() - val low = next(27).toLong() - return ((high shl 27) + low).toDouble() / (1L shl 53).toDouble() - } -} - -@Suppress("MagicNumber", "TooManyFunctions") -internal class FountainCodec { - private val receiveStates = mutableMapOf() - - fun generateTransferId(): Int { - val random = Random.nextInt(0, 0xFFFFFF + 1) - val time = (Clock.System.now().toEpochMilliseconds() / 1000).toInt() and 0xFFFF - return (random xor time) and 0xFFFFFF - } - - fun encode(data: ByteArray, transferId: Int): List { - if (data.isEmpty()) { - Logger.w { "Fountain encode: empty data" } - return emptyList() - } - - val k = maxOf(1, ceil(data.size.toDouble() / FountainConstants.BLOCK_SIZE).toInt()) - val overhead = getAdaptiveOverhead(k) - val blocksToSend = maxOf(1, ceil(k.toDouble() * (1.0 + overhead)).toInt()) - - val sourceBlocks = splitIntoBlocks(data, k) - val packets = mutableListOf() - - for (i in 0 until blocksToSend) { - val seed = generateSeed(transferId, i) - val indices = generateBlockIndices(seed, k, i) - - var blockPayload = ByteArray(FountainConstants.BLOCK_SIZE) { 0 } - for (idx in indices) { - blockPayload = xor(blockPayload, sourceBlocks[idx]) - } - - val packet = buildDataBlock(transferId, seed, k, data.size, blockPayload) - packets.add(packet) - } - - Logger.i { "Fountain encode: ${data.size} bytes -> $k source blocks -> $blocksToSend packets" } - return packets - } - - private fun splitIntoBlocks(data: ByteArray, k: Int): List { - val blocks = mutableListOf() - for (i in 0 until k) { - val start = i * FountainConstants.BLOCK_SIZE - val end = minOf(start + FountainConstants.BLOCK_SIZE, data.size) - - if (start < data.size) { - val block = data.copyOfRange(start, end) - if (block.size < FountainConstants.BLOCK_SIZE) { - val padded = ByteArray(FountainConstants.BLOCK_SIZE) { 0 } - block.copyInto(padded) - blocks.add(padded) - } else { - blocks.add(block) - } - } else { - blocks.add(ByteArray(FountainConstants.BLOCK_SIZE) { 0 }) - } - } - return blocks - } - - private fun buildDataBlock(transferId: Int, seed: Int, k: Int, totalLength: Int, payload: ByteArray): ByteArray { - val packet = ByteArray(FountainConstants.DATA_HEADER_SIZE + payload.size) - - packet[0] = FountainConstants.MAGIC[0] - packet[1] = FountainConstants.MAGIC[1] - packet[2] = FountainConstants.MAGIC[2] - - packet[3] = ((transferId shr 16) and 0xFF).toByte() - packet[4] = ((transferId shr 8) and 0xFF).toByte() - packet[5] = (transferId and 0xFF).toByte() - - packet[6] = ((seed shr 8) and 0xFF).toByte() - packet[7] = (seed and 0xFF).toByte() - - packet[8] = (k and 0xFF).toByte() - - packet[9] = ((totalLength shr 8) and 0xFF).toByte() - packet[10] = (totalLength and 0xFF).toByte() - - payload.copyInto(packet, FountainConstants.DATA_HEADER_SIZE) - return packet - } - - fun isFountainPacket(data: ByteArray): Boolean { - if (data.size < 3) return false - return data[0] == FountainConstants.MAGIC[0] && - data[1] == FountainConstants.MAGIC[1] && - data[2] == FountainConstants.MAGIC[2] - } - - fun parseDataHeader(data: ByteArray): FountainDataHeader? { - if (data.size < FountainConstants.DATA_HEADER_SIZE || !isFountainPacket(data)) return null - - val transferId = - ((data[3].toInt() and 0xFF) shl 16) or ((data[4].toInt() and 0xFF) shl 8) or (data[5].toInt() and 0xFF) - val seed = ((data[6].toInt() and 0xFF) shl 8) or (data[7].toInt() and 0xFF) - val k = data[8].toInt() and 0xFF - val totalLength = ((data[9].toInt() and 0xFF) shl 8) or (data[10].toInt() and 0xFF) - - return FountainDataHeader(transferId, seed, k, totalLength) - } - - fun handleIncomingPacket(data: ByteArray): Pair? { - cleanupExpiredStates() - - val header = parseDataHeader(data) - if (header != null) { - val payload = data.copyOfRange(FountainConstants.DATA_HEADER_SIZE, data.size) - if (payload.size == FountainConstants.BLOCK_SIZE) { - return processValidIncomingPacket(header, payload) - } else { - Logger.w { "Invalid fountain payload size: ${payload.size}" } - } - } - return null - } - - private fun processValidIncomingPacket(header: FountainDataHeader, payload: ByteArray): Pair? { - val state = - receiveStates.getOrPut(header.transferId) { - FountainReceiveState(header.transferId, header.k, header.totalLength) - } - - val indices = regenerateIndices(header.seed, state.k, header.transferId) - val block = FountainBlock(header.seed, indices.toMutableSet(), payload) - state.addBlock(block) - - if (state.blocks.size >= state.k) { - val decoded = peelingDecode(state) - if (decoded != null) { - receiveStates.remove(header.transferId) - Logger.i { "Fountain decode complete: ${decoded.size} bytes from ${state.blocks.size} blocks" } - return Pair(decoded, header.transferId) - } - } - return null - } - - fun buildAck(transferId: Int, type: Byte, received: Int, needed: Int, dataHash: ByteArray): ByteArray { - val packet = ByteArray(FountainConstants.ACK_PACKET_SIZE) - - packet[0] = FountainConstants.MAGIC[0] - packet[1] = FountainConstants.MAGIC[1] - packet[2] = FountainConstants.MAGIC[2] - - packet[3] = ((transferId shr 16) and 0xFF).toByte() - packet[4] = ((transferId shr 8) and 0xFF).toByte() - packet[5] = (transferId and 0xFF).toByte() - - packet[6] = type - - packet[7] = ((received shr 8) and 0xFF).toByte() - packet[8] = (received and 0xFF).toByte() - - packet[9] = ((needed shr 8) and 0xFF).toByte() - packet[10] = (needed and 0xFF).toByte() - - val hashLen = minOf(8, dataHash.size) - dataHash.copyInto(packet, 11, 0, hashLen) - - return packet - } - - fun parseAck(data: ByteArray): FountainAck? { - if (data.size < FountainConstants.ACK_PACKET_SIZE || !isFountainPacket(data)) return null - - val transferId = - ((data[3].toInt() and 0xFF) shl 16) or ((data[4].toInt() and 0xFF) shl 8) or (data[5].toInt() and 0xFF) - val type = data[6] - val received = ((data[7].toInt() and 0xFF) shl 8) or (data[8].toInt() and 0xFF) - val needed = ((data[9].toInt() and 0xFF) shl 8) or (data[10].toInt() and 0xFF) - val dataHash = data.copyOfRange(11, 19) - - return FountainAck(transferId, type, received, needed, dataHash) - } - - private fun peelingDecode(state: FountainReceiveState): ByteArray? { - val decoded = mutableMapOf() - val workingBlocks = - state.blocks.map { FountainBlock(it.seed, it.indices.toMutableSet(), it.payload.copyOf()) }.toMutableList() - - var progress = true - while (progress && decoded.size < state.k) { - progress = processWorkingBlocks(workingBlocks, decoded) - } - - if (decoded.size < state.k) { - Logger.d { "Peeling decode incomplete: ${decoded.size}/${state.k} blocks decoded" } - return null - } - return assembleDecodedData(state, decoded) - } - - private fun processWorkingBlocks(workingBlocks: List, decoded: MutableMap): Boolean { - var progress = false - for (i in workingBlocks.indices) { - val block = workingBlocks[i] - val toRemove = mutableListOf() - for (idx in block.indices) { - val decodedBlock = decoded[idx] - if (decodedBlock != null) { - block.payload = xor(block.payload, decodedBlock) - toRemove.add(idx) - } - } - block.indices.removeAll(toRemove) - - if (block.indices.size == 1) { - val idx = block.indices.first() - if (!decoded.containsKey(idx)) { - decoded[idx] = block.payload - progress = true - } - } - } - return progress - } - - private fun assembleDecodedData(state: FountainReceiveState, decoded: Map): ByteArray? { - val result = ByteArray(state.k * FountainConstants.BLOCK_SIZE) - for (i in 0 until state.k) { - val block = decoded[i] ?: return null - block.copyInto(result, i * FountainConstants.BLOCK_SIZE) - } - return result.copyOfRange(0, state.totalLength) - } - - private fun cleanupExpiredStates() { - val expiredIds = receiveStates.filter { it.value.isExpired }.map { it.key } - for (id in expiredIds) { - receiveStates.remove(id) - Logger.d { "Cleaned up expired fountain state: $id" } - } - } - - private fun getAdaptiveOverhead(k: Int): Double = when { - k <= 10 -> 0.50 - k <= 50 -> 0.25 - else -> 0.15 - } - - private fun generateSeed(transferId: Int, blockIndex: Int): Int { - val combined = transferId * 31337 + blockIndex * 7919 - return combined and 0xFFFF - } - - private fun generateBlockIndices(seed: Int, k: Int, blockIndex: Int): Set { - val rng = JavaRandom(seed.toLong()) - val sampledDegree = sampleRobustSolitonDegree(rng, k) - val degree = if (blockIndex == 0) 1 else sampledDegree - return selectIndices(rng, k, degree) - } - - private fun regenerateIndices(seed: Int, k: Int, transferId: Int): Set { - val rng = JavaRandom(seed.toLong()) - val sampledDegree = sampleRobustSolitonDegree(rng, k) - val expectedSeed0 = generateSeed(transferId, 0) - val degree = if (seed == expectedSeed0) 1 else sampledDegree - return selectIndices(rng, k, degree) - } - - private fun selectIndices(rng: JavaRandom, k: Int, degree: Int): Set { - val indices = mutableSetOf() - while (indices.size < degree && indices.size < k) { - val idx = rng.nextInt(k) - indices.add(idx) - } - return indices - } - - private fun sampleRobustSolitonDegree(rng: JavaRandom, k: Int): Int { - val cdf = buildRobustSolitonCDF(k) - val u = rng.nextDouble() - for (d in 1..k) { - if (u <= cdf[d]) return d - } - return k - } - - private fun buildRobustSolitonCDF(k: Int, c: Double = 0.1, delta: Double = 0.5): DoubleArray { - if (k <= 0) return doubleArrayOf(1.0) - - val rho = DoubleArray(k + 1) - rho[1] = 1.0 / k.toDouble() - for (d in 2..k) { - rho[d] = 1.0 / (d.toDouble() * (d - 1).toDouble()) - } - - val rVal = c * ln(k.toDouble() / delta) * sqrt(k.toDouble()) - val tau = DoubleArray(k + 1) - val threshold = (k.toDouble() / rVal).toInt() - - for (d in 1..k) { - if (d < threshold) { - tau[d] = rVal / (d.toDouble() * k.toDouble()) - } else if (d == threshold) { - tau[d] = rVal * ln(rVal / delta) / k.toDouble() - } - } - - val mu = DoubleArray(k + 1) - var sum = 0.0 - for (d in 1..k) { - mu[d] = rho[d] + tau[d] - sum += mu[d] - } - - val cdf = DoubleArray(k + 1) - var cumulative = 0.0 - for (d in 1..k) { - cumulative += mu[d] / sum - cdf[d] = cumulative - } - return cdf - } - - private fun xor(a: ByteArray, b: ByteArray): ByteArray { - val result = ByteArray(maxOf(a.size, b.size)) - for (i in result.indices) { - val byteA = if (i < a.size) a[i] else 0 - val byteB = if (i < b.size) b[i] else 0 - result[i] = (byteA.toInt() xor byteB.toInt()).toByte() - } - return result - } -} diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/fountain/GenericCoTHandler.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/fountain/GenericCoTHandler.kt deleted file mode 100644 index c6bfb5f1e..000000000 --- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/fountain/GenericCoTHandler.kt +++ /dev/null @@ -1,231 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.takserver.fountain - -import co.touchlab.kermit.Logger -import kotlinx.coroutines.delay -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import okio.ByteString.Companion.toByteString -import org.meshtastic.core.model.DataPacket -import org.meshtastic.core.repository.CommandSender -import org.meshtastic.core.takserver.CoTMessage -import org.meshtastic.core.takserver.CoTXmlParser -import org.meshtastic.core.takserver.TAKServerManager -import org.meshtastic.core.takserver.toXml -import org.meshtastic.proto.PortNum -import kotlin.time.Clock - -class GenericCoTHandler(private val commandSender: CommandSender, private val takServerManager: TAKServerManager) : - CoTHandler { - companion object { - private const val INTER_PACKET_DELAY_MS = 100L - private const val ACK_RETRANSMIT_DELAY_MS = 50L - private const val PENDING_TRANSFER_TTL_MS = 60_000L - } - - private val fountainCodec = FountainCodec() - private val pendingTransfersMutex = Mutex() - private val pendingTransfers = mutableMapOf() - - private data class PendingTransfer( - val transferId: Int, - val totalBlocks: Int, - val dataHash: ByteArray, - val startTime: Long = Clock.System.now().toEpochMilliseconds(), - ) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other == null || this::class != other::class) return false - other as PendingTransfer - return transferId == other.transferId && - totalBlocks == other.totalBlocks && - dataHash.contentEquals(other.dataHash) && - startTime == other.startTime - } - - override fun hashCode(): Int { - var result = transferId - result = 31 * result + totalBlocks - result = 31 * result + dataHash.contentHashCode() - result = 31 * result + startTime.hashCode() - return result - } - } - - override suspend fun sendGenericCoT(cotMessage: CoTMessage) { - val xml = cotMessage.toXml() - val xmlBytes = xml.encodeToByteArray() - - val compressed = ZlibCodec.compress(xmlBytes) - if (compressed == null) { - Logger.w { "Failed to compress CoT to Zlib" } - return - } - - val payload = ByteArray(compressed.size + 1) - payload[0] = FountainConstants.TRANSFER_TYPE_COT - compressed.copyInto(payload, 1) - - Logger.d { "Generic CoT: type=${cotMessage.type}, xml=${xmlBytes.size}B, compressed=${payload.size}B" } - - if (payload.size < FountainConstants.FOUNTAIN_THRESHOLD) { - sendDirect(payload) - } else { - sendFountainCoded(payload) - } - } - - private fun sendDirect(payload: ByteArray) { - val dataPacket = - DataPacket( - to = DataPacket.ID_BROADCAST, - bytes = payload.toByteString(), - dataType = PortNum.ATAK_FORWARDER.value, - ) - commandSender.sendData(dataPacket) - Logger.i { "Sent generic CoT directly: ${payload.size} bytes on port 257" } - } - - private suspend fun sendFountainCoded(payload: ByteArray) { - val transferId = fountainCodec.generateTransferId() - val packets = fountainCodec.encode(payload, transferId) - val hash = CryptoCodec.sha256Prefix8(payload) - - pendingTransfersMutex.withLock { - pendingTransfers[transferId] = PendingTransfer(transferId, packets.size, hash) - } - - Logger.i { "Sending fountain-coded CoT: ${payload.size} bytes -> ${packets.size} blocks, xferId=$transferId" } - - for ((index, packetData) in packets.withIndex()) { - val dataPacket = - DataPacket( - to = DataPacket.ID_BROADCAST, - bytes = packetData.toByteString(), - dataType = PortNum.ATAK_FORWARDER.value, - ) - commandSender.sendData(dataPacket) - - if (index < packets.size - 1) { - delay(INTER_PACKET_DELAY_MS) // Inter-packet delay - } - } - } - - override suspend fun handleIncomingForwarderPacket(payload: ByteArray, senderNodeNum: Int) { - if (payload.isEmpty()) return - - if (fountainCodec.isFountainPacket(payload)) { - if (payload.size == FountainConstants.ACK_PACKET_SIZE) { - handleIncomingAck(payload, senderNodeNum) - } else { - handleFountainPacket(payload, senderNodeNum) - } - } else { - handleDirectPacket(payload, senderNodeNum) - } - } - - private fun handleDirectPacket(payload: ByteArray, senderNodeNum: Int) { - if (payload.size <= 1) return - val transferType = payload[0] - if (transferType != FountainConstants.TRANSFER_TYPE_COT) return - - val exiData = payload.copyOfRange(1, payload.size) - processDecompressedCoT(exiData, senderNodeNum) - } - - private suspend fun handleFountainPacket(payload: ByteArray, senderNodeNum: Int) { - fountainCodec.handleIncomingPacket(payload)?.let { (decodedData, transferId) -> - val hash = CryptoCodec.sha256Prefix8(decodedData) - sendFountainAck(transferId, hash, senderNodeNum) - delay(ACK_RETRANSMIT_DELAY_MS) - sendFountainAck(transferId, hash, senderNodeNum) - - if (decodedData.size > 1 && decodedData[0] == FountainConstants.TRANSFER_TYPE_COT) { - val exiData = decodedData.copyOfRange(1, decodedData.size) - processDecompressedCoT(exiData, senderNodeNum) - } - } - } - - private fun processDecompressedCoT(exiData: ByteArray, senderNodeNum: Int) { - val xmlBytes = ZlibCodec.decompress(exiData) ?: return - val xml = xmlBytes.decodeToString() - - val result = CoTXmlParser(xml).parse() - val cot = result.getOrNull() - - if (cot != null) { - takServerManager.broadcast(cot) - Logger.i { "Received generic CoT from node $senderNodeNum: ${cot.type}" } - } else { - Logger.w(result.exceptionOrNull() ?: Exception("Unknown parse error")) { "Failed to parse CoT XML" } - } - } - - private fun sendFountainAck(transferId: Int, hash: ByteArray, toNodeNum: Int) { - val ackPacket = - fountainCodec.buildAck( - transferId, - FountainConstants.ACK_TYPE_COMPLETE, - received = 0, - needed = 0, - dataHash = hash, - ) - - val dataPacket = - DataPacket( - to = toNodeNum.toString(), - bytes = ackPacket.toByteString(), - dataType = PortNum.ATAK_FORWARDER.value, - ) - commandSender.sendData(dataPacket) - Logger.d { "Sent fountain ACK for transfer $transferId" } - } - - private suspend fun handleIncomingAck(payload: ByteArray, senderNodeNum: Int) { - val ack = fountainCodec.parseAck(payload) ?: return - Logger.d { "Received fountain ACK: xferId=${ack.transferId}, type=${ack.type}, from $senderNodeNum" } - - pendingTransfersMutex.withLock { - cleanupStalePendingTransfersLocked() - val pending = pendingTransfers[ack.transferId] - if (pending != null) { - if (ack.type == FountainConstants.ACK_TYPE_COMPLETE) { - if (ack.dataHash.contentEquals(pending.dataHash)) { - Logger.i { "Fountain transfer ${ack.transferId} acknowledged by node $senderNodeNum" } - } else { - Logger.w { "Fountain ACK hash mismatch for transfer ${ack.transferId}" } - } - pendingTransfers.remove(ack.transferId) - } - } - } - } - - /** Must be called inside [pendingTransfersMutex]. */ - private fun cleanupStalePendingTransfersLocked() { - val now = Clock.System.now().toEpochMilliseconds() - val stale = pendingTransfers.filter { (_, v) -> now - v.startTime > PENDING_TRANSFER_TTL_MS }.keys - stale.forEach { id -> - pendingTransfers.remove(id) - Logger.d { "Evicted stale outbound pending transfer: $id" } - } - } -} diff --git a/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/CoTDetailStripperTest.kt b/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/CoTDetailStripperTest.kt new file mode 100644 index 000000000..041050b50 --- /dev/null +++ b/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/CoTDetailStripperTest.kt @@ -0,0 +1,238 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.takserver + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Covers the allowed/stripped element contract documented on [CoTDetailStripper]. If a test here starts failing because + * a new element type was added to the strip list, update the strip-list KDoc in [CoTDetailStripper] in the same change. + */ +class CoTDetailStripperTest { + + @Test + fun empty_input_returns_empty() { + assertEquals("", CoTDetailStripper.strip("")) + } + + @Test + fun preserves_contact_group_status_track() { + val input = + """ + + <__group name="Cyan" role="Team Member"/> + + + """ + .trimIndent() + val stripped = CoTDetailStripper.strip(input) + assertTrue(stripped.contains(" + + + + + + + + """ + .trimIndent() + val stripped = CoTDetailStripper.strip(input) + assertTrue(stripped.contains(" is the biggest single bloat contributor for u-d-c-c events — it + // contains an and usually a styling child. Make sure the + // entire subtree goes, not just the opening tag. + val input = + """ + + + + + + + + """ + .trimIndent() + val stripped = CoTDetailStripper.strip(input) + assertTrue(stripped.contains(" inside is also gone because we strip the whole subtree. + assertFalse(stripped.contains(" + + + + <__video url="rtsp://example.com/stream"/> + """ + .trimIndent() + val stripped = CoTDetailStripper.strip(input) + assertTrue(stripped.contains(" + + + + <__serverdestination destinations="0.0.0.0:4242:tcp:abc-123"/> + hello world + """ + .trimIndent() + val stripped = CoTDetailStripper.strip(input) + assertTrue(stripped.contains("<__chat"), "__chat must survive stripping") + assertTrue(stripped.contains(" + + """ + .trimIndent() + val stripped = CoTDetailStripper.strip(input) + // No leading/trailing whitespace. + assertEquals(stripped, stripped.trim()) + // No line breaks / indentation between elements. + assertFalse(stripped.contains("\n"), "output must not contain newlines: $stripped") + // Elements should be directly concatenated. + assertTrue(stripped.contains("/><"), "adjacent elements must be directly concatenated: $stripped") + } + + @Test + fun handles_interleaved_strip_and_keep_elements() { + val input = + """ + + + <__group name="Cyan" role="Team Member"/> + + + + + """ + .trimIndent() + val stripped = CoTDetailStripper.strip(input) + // All four keep-elements survive in order. + val contactIdx = stripped.indexOf("= 0, "contact missing") + assertTrue(groupIdx >= 0, "group missing") + assertTrue(statusIdx >= 0, "status missing") + assertTrue(trackIdx >= 0, "track missing") + assertTrue(contactIdx < groupIdx, "contact must come before group") + assertTrue(groupIdx < statusIdx, "group must come before status") + assertTrue(statusIdx < trackIdx, "status must come before track") + // None of the stripped elements linger. + assertFalse(stripped.contains("color"), "color stripped") + assertFalse(stripped.contains("shape"), "shape stripped") + assertFalse(stripped.contains("ellipse"), "ellipse stripped") + assertFalse(stripped.contains("labels_on"), "labels_on stripped") + } + + @Test + fun strips_tog_and_flow_tags() { + // is the rectangle "toggle" flag ATAK emits; <_flow-tags_> is TAK + // Server routing metadata. Both are pure bloat over the mesh. These are + // specifically tested because their names contain regex-special characters + // (`-`, `_`) and it's easy to typo the strip-list pattern. + val input = + """ + + + <_flow-tags_ marti1="2014-10-28T22:40:15.341Z"/> + """ + .trimIndent() + val stripped = CoTDetailStripper.strip(input) + assertTrue(stripped.contains("<__group name='Cyan' role='Team Member'/>""" + + """""" + + """""" + + """""" + + """""" + + """""" + + """""" + + """""" + + """<__video url='rtsp://10.0.0.1:8554/stream'/>""" + val stripped = CoTDetailStripper.strip(realistic) + val before = realistic.length + val after = stripped.length + // Should shrink by at least 60% — most of the bytes were bloat. + assertTrue(after < before * 0.4, "expected >60% reduction; before=$before after=$after stripped='$stripped'") + // Only the three "essential" elements survive. + assertTrue(stripped.contains(", , + // and bloat is stripped by CoTDetailStripper so the packet has any + // chance of fitting in a LoRa MTU. + val shapeXml = + """ + + + + + + + + + + + + + """ + .trimIndent() + + val result = CoTXmlParser(shapeXml).parse() + assertTrue(result.isSuccess) + val message = result.getOrNull()!! + + assertEquals("u-d-c-c", message.type) + val detail = message.parsedDetailXml + assertTrue(detail != null, "parsedDetailXml must be populated for unmapped types") + // Preserved: anything the stripper doesn't explicitly match, including contact. + assertTrue(detail.contains(" + + + + + + """ + .trimIndent() + val message = CoTXmlParser(xml).parse().getOrNull()!! + // sourceEventXml is used for diagnostic logging only — it must be the exact + // bytes we received so operators can see what ATAK actually sent. + assertEquals(xml, message.sourceEventXml) + // And it MUST still contain the stripped elements (since it is untouched). + assertTrue(message.sourceEventXml!!.contains(""), "sourceEventXml must be verbatim") + } + + @Test + fun `parsedDetailXml is null for self-closed detail element`() { + val xml = + """ + + + + + """ + .trimIndent() + val message = CoTXmlParser(xml).parse().getOrNull()!! + assertEquals(null, message.parsedDetailXml) + } } diff --git a/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/CoTXmlTest.kt b/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/CoTXmlTest.kt index 7b6aa0ecd..a3cbda525 100644 --- a/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/CoTXmlTest.kt +++ b/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/CoTXmlTest.kt @@ -108,9 +108,14 @@ class CoTXmlTest { // ── Structure ───────────────────────────────────────────────────────────── @Test - fun `toXml includes XML declaration`() { + fun `toXml does not include XML declaration - CoT stream protocol`() { + // The CoT TCP streaming protocol requires a concatenated sequence of elements + // with NO XML declaration. A mid-stream tag breaks ATAK's parser and + // causes the client to disconnect as soon as the first real event arrives. val message = CoTMessage.pli(uid = "!1234", callsign = "X", latitude = 0.0, longitude = 0.0) - assertTrue(message.toXml().startsWith(" TAK_KEEPALIVE_INTERVAL_MS, - "Stale window ($staleMs ms) must exceed keepalive interval ($TAK_KEEPALIVE_INTERVAL_MS ms)", + TAK_KEEPALIVE_INTERVAL_MS < 15_000L, + "Keepalive interval ($TAK_KEEPALIVE_INTERVAL_MS ms) must be below ATAK's 15s stale threshold", ) } - - @Test - fun `idle timeout exceeds keepalive stale window`() { - val idleTimeoutMs = TAK_KEEPALIVE_INTERVAL_MS * TAK_READ_IDLE_TIMEOUT_MULTIPLIER - val staleMs = TAK_KEEPALIVE_INTERVAL_MS * TAK_KEEPALIVE_STALE_MULTIPLIER - assertTrue(idleTimeoutMs > staleMs, "Idle timeout ($idleTimeoutMs ms) must exceed stale window ($staleMs ms)") - } } diff --git a/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKMeshIntegrationTest.kt b/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKMeshIntegrationTest.kt new file mode 100644 index 000000000..f12c817a6 --- /dev/null +++ b/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKMeshIntegrationTest.kt @@ -0,0 +1,486 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.takserver + +import co.touchlab.kermit.Severity +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import okio.ByteString.Companion.toByteString +import org.meshtastic.core.model.ConnectionState +import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.model.MyNodeInfo +import org.meshtastic.core.model.Node +import org.meshtastic.core.model.NodeSortOption +import org.meshtastic.core.model.Position +import org.meshtastic.core.model.service.ServiceAction +import org.meshtastic.core.model.service.TracerouteResponse +import org.meshtastic.core.repository.CommandSender +import org.meshtastic.core.repository.MeshConfigHandler +import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.ServiceRepository +import org.meshtastic.proto.AdminMessage +import org.meshtastic.proto.Channel +import org.meshtastic.proto.ChannelSet +import org.meshtastic.proto.ClientNotification +import org.meshtastic.proto.Config +import org.meshtastic.proto.Data +import org.meshtastic.proto.DeviceMetadata +import org.meshtastic.proto.DeviceUIConfig +import org.meshtastic.proto.LocalConfig +import org.meshtastic.proto.LocalModuleConfig +import org.meshtastic.proto.LocalStats +import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.ModuleConfig +import org.meshtastic.proto.PortNum +import org.meshtastic.proto.TAKPacket +import org.meshtastic.proto.User +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Clock +import kotlin.time.Duration.Companion.minutes + +/** + * Tests for [TAKMeshIntegration] lifecycle, routing, and protocol gating. + * + * These tests use fakes for all 5 dependencies and run in commonTest. The v2 outbound SDK-dependent happy path is + * tested separately in jvmTest. + */ +@Suppress("TooManyFunctions") +class TAKMeshIntegrationTest { + + // ── Fakes ──────────────────────────────────────────────────────────────── + + private class FakeTAKServerManager : TAKServerManager { + private val _isRunning = MutableStateFlow(false) + override val isRunning: StateFlow = _isRunning.asStateFlow() + override val connectionCount: StateFlow = MutableStateFlow(0) + + private val _inboundMessages = MutableSharedFlow(extraBufferCapacity = 64) + override val inboundMessages: SharedFlow = _inboundMessages.asSharedFlow() + + val broadcasts = mutableListOf() + val rawBroadcasts = mutableListOf() + var startCount = 0 + var stopped = false + + override fun start(scope: CoroutineScope) { + startCount++ + _isRunning.value = true + } + + override fun stop() { + stopped = true + _isRunning.value = false + } + + override fun broadcast(cotMessage: CoTMessage) { + broadcasts.add(cotMessage) + } + + override fun broadcastRawXml(xml: String) { + rawBroadcasts.add(xml) + } + + suspend fun emitInbound(cotMessage: CoTMessage, clientInfo: TAKClientInfo? = null) { + _inboundMessages.emit(InboundCoTMessage(cotMessage, clientInfo)) + } + } + + private class FakeCommandSender : CommandSender { + val sentPackets = mutableListOf() + + override fun sendData(p: DataPacket) { + sentPackets.add(p) + } + + override fun getCurrentPacketId(): Long = 0L + + override fun getCachedLocalConfig(): LocalConfig = LocalConfig() + + override fun getCachedChannelSet(): ChannelSet = ChannelSet() + + override fun generatePacketId(): Int = 1 + + override fun sendAdmin(destNum: Int, requestId: Int, wantResponse: Boolean, initFn: () -> AdminMessage) {} + + override suspend fun sendAdminAwait( + destNum: Int, + requestId: Int, + wantResponse: Boolean, + initFn: () -> AdminMessage, + ): Boolean = true + + override fun sendPosition(pos: org.meshtastic.proto.Position, destNum: Int?, wantResponse: Boolean) {} + + override fun requestPosition(destNum: Int, currentPosition: Position) {} + + override fun setFixedPosition(destNum: Int, pos: Position) {} + + override fun requestUserInfo(destNum: Int) {} + + override fun requestTraceroute(requestId: Int, destNum: Int) {} + + override fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int) {} + + override fun requestNeighborInfo(requestId: Int, destNum: Int) {} + } + + private class FakeServiceRepository : ServiceRepository { + private val _meshPacketFlow = MutableSharedFlow(replay = 1, extraBufferCapacity = 64) + override val meshPacketFlow: Flow = _meshPacketFlow + + override val connectionState: StateFlow = MutableStateFlow(ConnectionState.Disconnected) + + override fun setConnectionState(connectionState: ConnectionState) {} + + override val clientNotification: StateFlow = MutableStateFlow(null) + + override fun setClientNotification(notification: ClientNotification?) {} + + override fun clearClientNotification() {} + + override val errorMessage: StateFlow = MutableStateFlow(null) + + override fun setErrorMessage(text: String, severity: Severity) {} + + override fun clearErrorMessage() {} + + override val connectionProgress: StateFlow = MutableStateFlow(null) + + override fun setConnectionProgress(text: String) {} + + override suspend fun emitMeshPacket(packet: MeshPacket) { + _meshPacketFlow.emit(packet) + } + + override val tracerouteResponse: StateFlow = MutableStateFlow(null) + + override fun setTracerouteResponse(value: TracerouteResponse?) {} + + override fun clearTracerouteResponse() {} + + override val neighborInfoResponse: StateFlow = MutableStateFlow(null) + + override fun setNeighborInfoResponse(value: String?) {} + + override fun clearNeighborInfoResponse() {} + + override val serviceAction: Flow = MutableSharedFlow() + + override suspend fun onServiceAction(action: ServiceAction) {} + } + + private class FakeMeshConfigHandler : MeshConfigHandler { + override val localConfig: StateFlow = MutableStateFlow(LocalConfig()) + override val moduleConfig: StateFlow = MutableStateFlow(LocalModuleConfig()) + + override fun handleDeviceConfig(config: Config) {} + + override fun handleModuleConfig(config: ModuleConfig) {} + + override fun handleChannel(channel: Channel) {} + + override fun handleDeviceUIConfig(config: DeviceUIConfig) {} + } + + private class FakeNodeRepository(firmwareVersion: String? = "2.8.0.0") : NodeRepository { + private val _myNodeInfo = + MutableStateFlow( + firmwareVersion?.let { + MyNodeInfo( + myNodeNum = 1, + hasGPS = false, + model = null, + firmwareVersion = it, + couldUpdate = false, + shouldUpdate = false, + currentPacketId = 0L, + messageTimeoutMsec = 0, + minAppVersion = 0, + maxChannels = 8, + hasWifi = false, + channelUtilization = 0f, + airUtilTx = 0f, + deviceId = null, + ) + }, + ) + override val myNodeInfo: StateFlow = _myNodeInfo + + fun setFirmwareVersion(version: String?) { + _myNodeInfo.value = + version?.let { + MyNodeInfo( + myNodeNum = 1, + hasGPS = false, + model = null, + firmwareVersion = it, + couldUpdate = false, + shouldUpdate = false, + currentPacketId = 0L, + messageTimeoutMsec = 0, + minAppVersion = 0, + maxChannels = 8, + hasWifi = false, + channelUtilization = 0f, + airUtilTx = 0f, + deviceId = null, + ) + } + } + + override val ourNodeInfo: StateFlow = MutableStateFlow(null) + override val myId: StateFlow = MutableStateFlow(null) + override val localStats: StateFlow = MutableStateFlow(LocalStats()) + override val nodeDBbyNum: StateFlow> = MutableStateFlow(emptyMap()) + override val onlineNodeCount: Flow = MutableStateFlow(0) + override val totalNodeCount: Flow = MutableStateFlow(0) + + override fun updateLocalStats(stats: LocalStats) {} + + override fun effectiveLogNodeId(nodeNum: Int): Flow = MutableStateFlow(0) + + override fun getNode(userId: String): Node = Node(num = 0) + + override fun getUser(nodeNum: Int): User = User() + + override fun getUser(userId: String): User = User() + + override fun getNodes( + sort: NodeSortOption, + filter: String, + includeUnknown: Boolean, + onlyOnline: Boolean, + onlyDirect: Boolean, + ): Flow> = MutableStateFlow(emptyList()) + + override suspend fun getNodesOlderThan(lastHeard: Int): List = emptyList() + + override suspend fun getUnknownNodes(): List = emptyList() + + override suspend fun clearNodeDB(preserveFavorites: Boolean) {} + + override suspend fun clearMyNodeInfo() {} + + override suspend fun deleteNode(num: Int) {} + + override suspend fun deleteNodes(nodeNums: List) {} + + override suspend fun setNodeNotes(num: Int, notes: String) {} + + override suspend fun upsert(node: Node) {} + + override suspend fun installConfig(mi: MyNodeInfo, nodes: List) {} + + override suspend fun insertMetadata(nodeNum: Int, metadata: DeviceMetadata) {} + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private data class TestHarness( + val serverManager: FakeTAKServerManager = FakeTAKServerManager(), + val commandSender: FakeCommandSender = FakeCommandSender(), + val serviceRepository: FakeServiceRepository = FakeServiceRepository(), + val meshConfigHandler: FakeMeshConfigHandler = FakeMeshConfigHandler(), + val nodeRepository: FakeNodeRepository = FakeNodeRepository(), + ) { + val integration = + TAKMeshIntegration( + takServerManager = serverManager, + commandSender = commandSender, + serviceRepository = serviceRepository, + meshConfigHandler = meshConfigHandler, + nodeRepository = nodeRepository, + ) + } + + // ── Lifecycle tests ────────────────────────────────────────────────────── + + @Test + fun `start launches TAKServerManager`() = runTest(UnconfinedTestDispatcher()) { + val h = TestHarness() + h.integration.start(backgroundScope) + + assertEquals(1, h.serverManager.startCount) + } + + @Test + fun `stop cancels jobs and stops TAKServerManager`() = runTest(UnconfinedTestDispatcher()) { + val h = TestHarness() + h.integration.start(backgroundScope) + + h.integration.stop() + + assertTrue(h.serverManager.stopped) + } + + @Test + fun `double start is idempotent`() = runTest(UnconfinedTestDispatcher()) { + val h = TestHarness() + h.integration.start(backgroundScope) + + h.integration.start(backgroundScope) // second start + + assertEquals(1, h.serverManager.startCount, "TAKServerManager should only be started once") + } + + @Test + fun `stop then inbound TAK message does not forward to mesh`() = runTest(UnconfinedTestDispatcher()) { + val h = TestHarness() + h.integration.start(backgroundScope) + + h.integration.stop() + + h.serverManager.emitInbound(createPli("after-stop")) + + assertTrue(h.commandSender.sentPackets.isEmpty()) + } + + // ── Inbound mesh → TAK client (V1) ────────────────────────────────────── + + @Test + fun `inbound V1 PLI packet is broadcast to TAK clients`() = runTest(UnconfinedTestDispatcher()) { + val h = TestHarness() + h.integration.start(backgroundScope) + + h.serviceRepository.emitMeshPacket(createV1PliMeshPacket()) + + assertTrue(h.serverManager.broadcasts.isNotEmpty(), "Expected broadcasts for V1 PLI") + assertTrue(h.serverManager.broadcasts.first().type.startsWith("a-f-")) + } + + @Test + fun `inbound packet on unrelated port is ignored`() = runTest(UnconfinedTestDispatcher()) { + val h = TestHarness() + h.integration.start(backgroundScope) + + val textPacket = + MeshPacket( + decoded = + Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = "hello".encodeToByteArray().toByteString()), + ) + h.serviceRepository.emitMeshPacket(textPacket) + + assertTrue(h.serverManager.broadcasts.isEmpty()) + assertTrue(h.serverManager.rawBroadcasts.isEmpty()) + } + + // ── Firmware gating ────────────────────────────────────────────────────── + + @Test + fun `null firmware defaults to V2 protocol`() = runTest(UnconfinedTestDispatcher()) { + val h = TestHarness(nodeRepository = FakeNodeRepository(firmwareVersion = null)) + h.integration.start(backgroundScope) + + h.serverManager.emitInbound(createPli("test-v2-default")) + + // In commonTest without TAKPacket-SDK, v2 path catches and falls back. + // Verify the code didn't crash and attempted to send. + if (h.commandSender.sentPackets.isNotEmpty()) { + val sent = h.commandSender.sentPackets.first() + assertEquals(PortNum.ATAK_PLUGIN_V2.value, sent.dataType) + } + } + + @Test + fun `legacy firmware sends on V1 port 72`() = runTest(UnconfinedTestDispatcher()) { + val h = TestHarness(nodeRepository = FakeNodeRepository(firmwareVersion = "2.7.0.0")) + h.integration.start(backgroundScope) + + h.serverManager.emitInbound(createPli("test-v1")) + + if (h.commandSender.sentPackets.isNotEmpty()) { + val sent = h.commandSender.sentPackets.first() + assertEquals(PortNum.ATAK_PLUGIN.value, sent.dataType) + } + } + + @Test + fun `legacy firmware drops non-PLI non-GeoChat types`() = runTest(UnconfinedTestDispatcher()) { + val h = TestHarness(nodeRepository = FakeNodeRepository(firmwareVersion = "2.7.0.0")) + h.integration.start(backgroundScope) + + val marker = CoTMessage(uid = "marker-1", type = "a-h-G", stale = Clock.System.now() + 5.minutes) + h.serverManager.emitInbound(marker) + + assertTrue(h.commandSender.sentPackets.isEmpty()) + } + + // ── GeoChat callsign enrichment ────────────────────────────────────────── + + @Test + fun `GeoChat without callsign is enriched from client info`() = runTest(UnconfinedTestDispatcher()) { + val h = TestHarness(nodeRepository = FakeNodeRepository(firmwareVersion = "2.7.0.0")) + h.integration.start(backgroundScope) + + val chatMsg = + CoTMessage( + uid = "GeoChat.test.All Chat Rooms.1234", + type = "b-t-f", + how = "h-g-i-g-o", + stale = Clock.System.now() + 5.minutes, + contact = null, + chat = CoTChat(message = "hello", senderCallsign = null), + ) + val clientInfo = TAKClientInfo(id = "client-1", endpoint = "127.0.0.1:8089", callsign = "ALPHA-1") + h.serverManager.emitInbound(chatMsg, clientInfo) + + // GeoChat on legacy V1 should produce a sent packet with the enriched callsign + if (h.commandSender.sentPackets.isNotEmpty()) { + val sent = h.commandSender.sentPackets.first() + assertEquals(PortNum.ATAK_PLUGIN.value, sent.dataType) + } + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private fun createPli(uid: String) = + CoTMessage.pli(uid = uid, callsign = "TEST", latitude = 33.0, longitude = -84.0) + + private fun createV1PliMeshPacket(): MeshPacket { + val takPacket = + TAKPacket( + contact = org.meshtastic.proto.Contact(callsign = "BRAVO", device_callsign = "bravo-uid"), + pli = + org.meshtastic.proto.PLI( + latitude_i = 330000000, + longitude_i = -840000000, + altitude = 100, + speed = 0, + course = 0, + ), + group = + org.meshtastic.proto.Group( + team = org.meshtastic.proto.Team.Cyan, + role = org.meshtastic.proto.MemberRole.TeamMember, + ), + status = org.meshtastic.proto.Status(battery = 85), + ) + return MeshPacket( + decoded = Data(portnum = PortNum.ATAK_PLUGIN, payload = TAKPacket.ADAPTER.encode(takPacket).toByteString()), + ) + } +} diff --git a/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKPacketV2RawDetailTest.kt b/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKPacketV2RawDetailTest.kt new file mode 100644 index 000000000..8660acf05 --- /dev/null +++ b/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKPacketV2RawDetailTest.kt @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.takserver + +import org.meshtastic.core.takserver.TAKPacketV2Conversion.toCoTMessage +import org.meshtastic.core.takserver.TAKPacketV2Conversion.toTAKPacketV2 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Verifies the `raw_detail` fallback round-trip for CoT types that don't fit any structured + * [org.meshtastic.proto.TAKPacketV2] payload (PLI, GeoChat, Aircraft). + * + * Prior to this, ATAK user-drawn elements like `u-d-c-c` would be silently dropped by + * [TAKPacketV2Conversion.toTAKPacketV2] with `"Cannot convert CoT to TAKPacketV2 for type ..."`. + */ +class TAKPacketV2RawDetailTest { + + @Test + fun udcc_round_trips_via_raw_detail() { + // Note: `` / `` / `` in the input are deliberately + // stripped by [CoTDetailStripper] before being placed in raw_detail, because + // they blow up the wire size beyond the LoRa MTU. We keep `` here so + // we have something non-trivial to verify round-tripped. + val shapeXml = + """ + + + + + + + + + + + + """ + .trimIndent() + + // Parse → convert to TAKPacketV2 + val cotMessage = CoTXmlParser(shapeXml).parse().getOrNull() + assertNotNull(cotMessage, "CoT XML must parse successfully") + val takPacketV2 = cotMessage.toTAKPacketV2() + assertNotNull(takPacketV2, "u-d-c-c must convert to TAKPacketV2 (not drop)") + + // raw_detail must be populated; structured payloads must be null. + assertNotNull(takPacketV2.raw_detail, "raw_detail must hold the detail bytes") + assertNull(takPacketV2.pli, "PLI payload must not be set for u-d-c-c") + assertNull(takPacketV2.chat, "chat payload must not be set for u-d-c-c") + assertEquals("u-d-c-c", takPacketV2.cot_type_str.ifEmpty { "u-d-c-c" }) + // Stripping must have fired: the raw_detail bytes must NOT contain the + // shape/labels_on fragments we put in the input. + val rawDetailBytes = takPacketV2.raw_detail!!.utf8() + assertFalse(rawDetailBytes.contains("shape"), "shape must be stripped from raw_detail: $rawDetailBytes") + assertFalse(rawDetailBytes.contains("labels_on"), "labels_on must be stripped: $rawDetailBytes") + assertTrue(rawDetailBytes.contains("contact"), "contact must survive: $rawDetailBytes") + + // Convert back to CoTMessage + val roundTripped = takPacketV2.toCoTMessage() + assertNotNull(roundTripped, "TAKPacketV2 with raw_detail must convert back to CoTMessage") + assertEquals("u-d-c-c", roundTripped.type) + assertEquals(45.5, roundTripped.latitude, 0.0001) + assertEquals(-90.25, roundTripped.longitude, 0.0001) + + // Serialize to XML; the surviving (stripped) content must be present. + val xmlOut = roundTripped.toXml() + assertTrue(xmlOut.contains("type='u-d-c-c'"), "type must survive: $xmlOut") + assertTrue(xmlOut.contains("ALPHA01"), "contact callsign must survive: $xmlOut") + assertFalse(xmlOut.contains(" + + + + <__group name="Red" role="Team Member"/> + + + + """ + .trimIndent() + + val cotMessage = CoTXmlParser(xml).parse().getOrNull()!! + val takPacketV2 = cotMessage.toTAKPacketV2()!! + val roundTripped = takPacketV2.toCoTMessage()!! + + assertNull(roundTripped.contact, "contact must be null on raw_detail path (lives inside rawDetailXml)") + assertNull(roundTripped.group, "group must be null on raw_detail path") + assertNull(roundTripped.status, "status must be null on raw_detail path") + + val xmlOut = roundTripped.toXml() + // Exactly one (from the round-tripped raw detail), not two. + assertEquals(1, xmlOut.split(". + */ +package org.meshtastic.core.takserver + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Clock +import kotlin.time.Duration.Companion.minutes + +/** + * Tests for [TAKServerManagerImpl] offline message queue behavior: + * - FIFO eviction at 50-message cap (T074) + * - Per-message TTL expiry after 5 minutes (T074) + * - Replay of queued messages on client reconnect (T074) + */ +class TAKServerManagerTest { + + /** Fake TAKServer that records broadcasts and simulates connection state. */ + private class FakeTAKServer : TAKServer { + override val connectionCount: StateFlow = MutableStateFlow(0) + override var onMessage: ((CoTMessage, TAKClientInfo?) -> Unit)? = null + override var onClientConnected: (() -> Unit)? = null + + var stubHasConnections = false + val broadcasts = mutableListOf() + val rawBroadcasts = mutableListOf() + + override suspend fun start(scope: CoroutineScope): Result = Result.success(Unit) + + override fun stop() {} + + override suspend fun broadcast(cotMessage: CoTMessage) { + broadcasts.add(cotMessage) + } + + override suspend fun broadcastRawXml(xml: String) { + rawBroadcasts.add(xml) + } + + override suspend fun hasConnections(): Boolean = stubHasConnections + } + + private fun createPli(uid: String): CoTMessage { + val now = Clock.System.now() + return CoTMessage( + uid = uid, + type = DEFAULT_PLI_COT_TYPE, + stale = now + 5.minutes, + latitude = 45.0, + longitude = -90.0, + ) + } + + @Test + fun `offline queue caps at 50 messages with FIFO eviction`() = runTest { + val fakeTakServer = FakeTAKServer() + fakeTakServer.stubHasConnections = false + val manager = TAKServerManagerImpl(fakeTakServer) + manager.start(this) + advanceUntilIdle() + + // Queue 55 messages (5 more than the cap) + repeat(55) { i -> manager.broadcast(createPli("uid-$i")) } + advanceUntilIdle() + + // Now simulate a client connecting — drain the queue + fakeTakServer.stubHasConnections = true + manager.drainOfflineQueue() + advanceUntilIdle() + + // Should have drained exactly 50 messages (the oldest 5 evicted by FIFO) + assertEquals(50, fakeTakServer.broadcasts.size) + // The first message drained should be uid-5 (uid-0 through uid-4 were evicted) + assertEquals("uid-5", fakeTakServer.broadcasts.first().uid) + // The last message drained should be uid-54 + assertEquals("uid-54", fakeTakServer.broadcasts.last().uid) + + manager.stop() + } + + @Test + fun `offline queue expires messages after 5 minute TTL`() = runTest { + val fakeTakServer = FakeTAKServer() + fakeTakServer.stubHasConnections = false + val manager = TAKServerManagerImpl(fakeTakServer) + manager.start(this) + advanceUntilIdle() + + // Queue 3 messages — these will be stamped with Clock.System.now() + repeat(3) { i -> manager.broadcast(createPli("msg-$i")) } + advanceUntilIdle() + + // Immediately drain (no time has passed) — all messages should still be valid + fakeTakServer.stubHasConnections = true + manager.drainOfflineQueue() + advanceUntilIdle() + + // All 3 messages should be delivered (none expired yet since <5 min elapsed) + assertEquals(3, fakeTakServer.broadcasts.size) + assertEquals("msg-0", fakeTakServer.broadcasts[0].uid) + assertEquals("msg-1", fakeTakServer.broadcasts[1].uid) + assertEquals("msg-2", fakeTakServer.broadcasts[2].uid) + + manager.stop() + } + + @Test + fun `offline queue replays messages in order on client reconnect`() = runTest { + val fakeTakServer = FakeTAKServer() + fakeTakServer.stubHasConnections = false + val manager = TAKServerManagerImpl(fakeTakServer) + manager.start(this) + advanceUntilIdle() + + // Queue messages in order + val uids = listOf("alpha", "bravo", "charlie", "delta") + uids.forEach { uid -> manager.broadcast(createPli(uid)) } + advanceUntilIdle() + + // Simulate client reconnect + fakeTakServer.stubHasConnections = true + manager.drainOfflineQueue() + advanceUntilIdle() + + // Messages should be replayed in FIFO order + assertEquals(uids, fakeTakServer.broadcasts.map { it.uid }) + + manager.stop() + } + + @Test + fun `broadcast goes directly to TAK server when clients connected`() = runTest { + val fakeTakServer = FakeTAKServer() + fakeTakServer.stubHasConnections = true + val manager = TAKServerManagerImpl(fakeTakServer) + manager.start(this) + advanceUntilIdle() + + manager.broadcast(createPli("direct")) + advanceUntilIdle() + + // Message should be broadcast directly, not queued + assertEquals(1, fakeTakServer.broadcasts.size) + assertEquals("direct", fakeTakServer.broadcasts.first().uid) + + manager.stop() + } + + // ── T076: Port conflict / start failure ───────────────────────────────────── + + /** Fake TAKServer that simulates a port-conflict failure on start. */ + private class FailingTAKServer : TAKServer { + override val connectionCount: StateFlow = MutableStateFlow(0) + override var onMessage: ((CoTMessage, TAKClientInfo?) -> Unit)? = null + override var onClientConnected: (() -> Unit)? = null + + override suspend fun start(scope: CoroutineScope): Result = + Result.failure(IllegalStateException("Address already in use: port 8089")) + + override fun stop() {} + + override suspend fun broadcast(cotMessage: CoTMessage) {} + + override suspend fun broadcastRawXml(xml: String) {} + + override suspend fun hasConnections(): Boolean = false + } + + @Test + fun `start failure due to port conflict leaves isRunning false`() = runTest { + val failingServer = FailingTAKServer() + val manager = TAKServerManagerImpl(failingServer) + manager.start(this) + advanceUntilIdle() + + // Manager should NOT be running after start failure + assertEquals(false, manager.isRunning.value) + } + + @Test + fun `start failure clears onMessage callback`() = runTest { + val failingServer = FailingTAKServer() + val manager = TAKServerManagerImpl(failingServer) + manager.start(this) + advanceUntilIdle() + + // onMessage should be cleared after failed start + assertEquals(null, failingServer.onMessage) + } + + @Test + fun `broadcast is no-op after failed start`() = runTest { + val failingServer = FailingTAKServer() + val manager = TAKServerManagerImpl(failingServer) + manager.start(this) + advanceUntilIdle() + + // Broadcast should silently do nothing (not crash) + manager.broadcast(createPli("should-be-dropped")) + advanceUntilIdle() + // No exception = pass. isRunning is false so broadcast exits early. + } + + @Test + fun `broadcastRawXml forwards to TAKServer when running`() = runTest { + val fakeTakServer = FakeTAKServer() + fakeTakServer.stubHasConnections = true + val manager = TAKServerManagerImpl(fakeTakServer) + manager.start(this) + advanceUntilIdle() + + val rawXml = """""" + manager.broadcastRawXml(rawXml) + advanceUntilIdle() + + assertEquals(1, fakeTakServer.rawBroadcasts.size) + assertEquals(rawXml, fakeTakServer.rawBroadcasts.first()) + } + + @Test + fun `broadcastRawXml is no-op when not running`() = runTest { + val fakeTakServer = FakeTAKServer() + val manager = TAKServerManagerImpl(fakeTakServer) + // Don't call start() + + manager.broadcastRawXml("""""") + advanceUntilIdle() + + assertTrue(fakeTakServer.rawBroadcasts.isEmpty()) + } +} diff --git a/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TakV2CompressorBoundaryTest.kt b/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TakV2CompressorBoundaryTest.kt new file mode 100644 index 000000000..3de7c891d --- /dev/null +++ b/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TakV2CompressorBoundaryTest.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.takserver + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Tests for [TakV2Compressor] size boundary validation (T077). + * + * Verifies that: + * - MAX_DECOMPRESSED_SIZE is a reasonable constant (4096 bytes) + * - Dictionary IDs are correctly defined + * - The uncompressed marker (0xFF) is correct + */ +class TakV2CompressorBoundaryTest { + + @Test + fun `MAX_DECOMPRESSED_SIZE is 4096 bytes`() { + assertEquals(4096, TakV2Compressor.MAX_DECOMPRESSED_SIZE) + } + + @Test + fun `MAX_DECOMPRESSED_SIZE is greater than mesh MTU`() { + // MAX_TAK_WIRE_PAYLOAD_BYTES = 225. Decompressed size must be larger than the + // compressed wire payload to be useful. Also ensures there's a reasonable + // amplification cap to prevent decompression bombs. + assertTrue(TakV2Compressor.MAX_DECOMPRESSED_SIZE > MAX_TAK_WIRE_PAYLOAD_BYTES) + } + + @Test + fun `MAX_DECOMPRESSED_SIZE is bounded to prevent memory exhaustion`() { + // A decompression bomb could expand a small payload into megabytes. The limit + // must be small enough to prevent OOM in constrained Android environments. + assertTrue(TakV2Compressor.MAX_DECOMPRESSED_SIZE <= 65536) + } + + @Test + fun `dictionary IDs are correctly assigned`() { + assertEquals(0, TakV2Compressor.DICT_ID_NON_AIRCRAFT) + assertEquals(1, TakV2Compressor.DICT_ID_AIRCRAFT) + } + + @Test + fun `uncompressed marker is 0xFF`() { + assertEquals(0xFF, TakV2Compressor.DICT_ID_UNCOMPRESSED) + } +} diff --git a/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/fountain/FountainCodecTest.kt b/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/fountain/FountainCodecTest.kt deleted file mode 100644 index 08604e926..000000000 --- a/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/fountain/FountainCodecTest.kt +++ /dev/null @@ -1,115 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.takserver.fountain - -import kotlin.test.Test -import kotlin.test.assertContentEquals -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNotNull -import kotlin.test.assertNull -import kotlin.test.assertTrue - -class FountainCodecTest { - - private fun createCodec() = FountainCodec() - - @Test - fun `test encode and decode small payload`() { - val codec = createCodec() - val originalData = "Hello, TAK! This is a test payload.".encodeToByteArray() - // Use a fixed transfer ID for deterministic peeling decode - val transferId = 42 - - val packets = codec.encode(originalData, transferId) - assertTrue(packets.isNotEmpty(), "Encoding should produce packets") - - var decodedResult: Pair? = null - for (packet in packets) { - val result = codec.handleIncomingPacket(packet) - if (result != null) { - decodedResult = result - break - } - } - - assertNotNull(decodedResult, "Should successfully decode payload") - assertEquals(transferId, decodedResult.second, "Transfer ID should match") - assertContentEquals(originalData, decodedResult.first, "Decoded data should match original") - } - - @Test - fun `test encode and decode larger payload with packet loss`() { - val codec = createCodec() - // Create a payload larger than BLOCK_SIZE (220 bytes) - val originalData = ByteArray(1024) { (it % 256).toByte() } - // Use a fixed transfer ID for deterministic peeling decode. - // Random transfer IDs cause ~14% flake rate because the robust soliton - // distribution with k=5 and 50% overhead doesn't always produce a - // decodable set of encoded blocks via the peeling algorithm. - val transferId = 42 - - val packets = codec.encode(originalData, transferId) - assertTrue(packets.size > 4, "Should have multiple packets for large payload") - - var decodedResult: Pair? = null - - // Process all packets - fountain codes are designed to handle packet loss - // by receiving enough encoded packets to reconstruct the original data - for (packet in packets) { - val result = codec.handleIncomingPacket(packet) - if (result != null) { - decodedResult = result - break - } - } - - assertNotNull(decodedResult, "Should successfully decode payload with sufficient packets") - assertEquals(transferId, decodedResult.second, "Transfer ID should match") - assertContentEquals(originalData, decodedResult.first, "Decoded data should match original") - } - - @Test - fun `test build and parse ACK`() { - val codec = createCodec() - val transferId = 123456 - val type = FountainConstants.ACK_TYPE_COMPLETE - val received = 5 - val needed = 0 - val dataHash = byteArrayOf(1, 2, 3, 4, 5, 6, 7, 8) - - val ackPacket = codec.buildAck(transferId, type, received, needed, dataHash) - assertTrue(codec.isFountainPacket(ackPacket), "ACK should be recognized as a Fountain packet") - - val parsedAck = codec.parseAck(ackPacket) - assertNotNull(parsedAck, "ACK should be parseable") - assertEquals(transferId, parsedAck.transferId) - assertEquals(type, parsedAck.type) - assertEquals(received, parsedAck.received) - assertEquals(needed, parsedAck.needed) - assertContentEquals(dataHash, parsedAck.dataHash) - } - - @Test - fun `test invalid packet handling`() { - val codec = createCodec() - val invalidPacket = byteArrayOf(0x00, 0x01, 0x02, 0x03) - assertFalse(codec.isFountainPacket(invalidPacket), "Should reject invalid magic bytes") - assertNull(codec.parseDataHeader(invalidPacket), "Should not parse invalid header") - assertNull(codec.handleIncomingPacket(invalidPacket), "Should handle invalid packet gracefully") - } -} diff --git a/core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/AtakFileWriter.kt b/core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/AtakFileWriter.kt new file mode 100644 index 000000000..9feb78cca --- /dev/null +++ b/core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/AtakFileWriter.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.takserver + +/** iOS no-op — iTAK accepts routes via TCP streaming, no data package needed. */ +internal actual object AtakFileWriter { + actual fun writeToImportDir(fileName: String, zipBytes: ByteArray): Boolean = false +} diff --git a/core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/TAKServerIos.kt b/core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/TAKServerIos.kt new file mode 100644 index 000000000..3aad75bd9 --- /dev/null +++ b/core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/TAKServerIos.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.takserver + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.meshtastic.core.di.CoroutineDispatchers + +/** + * iOS KMP stub. The real iOS TAK server lives in Meshtastic-Apple (`Meshtastic/Helpers/TAK/TAKServerManager.swift`) and + * uses Apple's `Network.framework` / `NWListener` + mTLS directly, not this KMP module. + * + * We provide a no-op implementation here so that the shared `core:takserver` module still compiles for the iOS KMP + * targets. Any iOS-side consumer of this module would never actually call into this path — iOS bypasses the KMP + * `TAKServer` interface entirely. + */ +private class NoopTAKServer : TAKServer { + private val _connectionCount = MutableStateFlow(0) + override val connectionCount: StateFlow = _connectionCount.asStateFlow() + override var onMessage: ((CoTMessage, TAKClientInfo?) -> Unit)? = null + override var onClientConnected: (() -> Unit)? = null + + override suspend fun start(scope: CoroutineScope): Result = Result.success(Unit) + + override fun stop() = Unit + + override suspend fun broadcast(cotMessage: CoTMessage) = Unit + + override suspend fun broadcastRawXml(xml: String) = Unit + + override suspend fun hasConnections(): Boolean = false +} + +actual fun createTAKServer(dispatchers: CoroutineDispatchers, port: Int): TAKServer = NoopTAKServer() diff --git a/core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/TakFixtureLoader.kt b/core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/TakFixtureLoader.kt new file mode 100644 index 000000000..6bb8a7159 --- /dev/null +++ b/core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/TakFixtureLoader.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.takserver + +/** iOS stub — the TAK mesh test runner is not supported on iOS targets. */ +internal actual fun loadTakFixtureXml(name: String): String = + throw UnsupportedOperationException("TAK fixture loading is not supported on iOS.") diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/fountain/CodecExpect.kt b/core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/TakSdkCompressor.kt similarity index 62% rename from core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/fountain/CodecExpect.kt rename to core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/TakSdkCompressor.kt index 48c635560..0d0823104 100644 --- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/fountain/CodecExpect.kt +++ b/core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/TakSdkCompressor.kt @@ -14,18 +14,10 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package org.meshtastic.core.takserver.fountain +package org.meshtastic.core.takserver -import okio.ByteString.Companion.toByteString +internal actual object TakSdkCompressor { -internal expect object ZlibCodec { - fun compress(data: ByteArray): ByteArray? - - fun decompress(data: ByteArray): ByteArray? -} - -internal object CryptoCodec { - private const val PREFIX_SIZE = 8 - - fun sha256Prefix8(data: ByteArray): ByteArray = data.toByteString().sha256().toByteArray().copyOf(PREFIX_SIZE) + actual fun compressCoT(xml: String, maxBytes: Int): ByteArray? = + throw UnsupportedOperationException("TAKPacket-SDK is not available on iOS") } diff --git a/core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/TakV2Compressor.kt b/core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/TakV2Compressor.kt new file mode 100644 index 000000000..b0d066090 --- /dev/null +++ b/core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/TakV2Compressor.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.takserver + +import org.meshtastic.core.takserver.TAKPacketV2Conversion.toCoTMessage +import org.meshtastic.proto.TAKPacketV2 + +/** + * iOS stub for TakV2Compressor. + * + * TODO: Replace with Swift SDK integration via interop. + */ +internal actual object TakV2Compressor { + + actual val MAX_DECOMPRESSED_SIZE: Int = 4096 + actual val DICT_ID_NON_AIRCRAFT: Int = 0 + actual val DICT_ID_AIRCRAFT: Int = 1 + actual val DICT_ID_UNCOMPRESSED: Int = 0xFF + + actual fun compress(packet: TAKPacketV2): ByteArray { + // iOS: Send uncompressed for now (TAK_TRACKER mode) + val protobufBytes = TAKPacketV2.ADAPTER.encode(packet) + val wirePayload = ByteArray(1 + protobufBytes.size) + wirePayload[0] = DICT_ID_UNCOMPRESSED.toByte() + protobufBytes.copyInto(wirePayload, 1) + return wirePayload + } + + actual fun decompressToXml(wirePayload: ByteArray): String { + // iOS stub: decompress the packet and convert to CoT XML via the common conversion path. + val packet = decompress(wirePayload) + return packet.toCoTMessage()?.toXml() + ?: throw UnsupportedOperationException( + "iOS stub: TAKPacketV2 could not be converted to CoT XML for packet: $packet", + ) + } + + actual fun decompress(wirePayload: ByteArray): TAKPacketV2 { + require(wirePayload.size >= 2) { "Wire payload too short: ${wirePayload.size} bytes" } + + val flagsByte = wirePayload[0].toInt() and 0xFF + val payloadBytes = wirePayload.copyOfRange(1, wirePayload.size) + + // iOS stub: only support uncompressed (0xFF) payloads + if (flagsByte != DICT_ID_UNCOMPRESSED) { + throw UnsupportedOperationException( + "iOS zstd decompression not yet implemented. Received dict ID: ${flagsByte and 0x3F}", + ) + } + + require(payloadBytes.size <= MAX_DECOMPRESSED_SIZE) { + "Payload size ${payloadBytes.size} exceeds limit $MAX_DECOMPRESSED_SIZE" + } + + return TAKPacketV2.ADAPTER.decode(payloadBytes) + } +} diff --git a/core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/fountain/ZlibCodec.kt b/core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/fountain/ZlibCodec.kt deleted file mode 100644 index b0e4f1030..000000000 --- a/core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/fountain/ZlibCodec.kt +++ /dev/null @@ -1,105 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.takserver.fountain - -import kotlinx.cinterop.ExperimentalForeignApi -import kotlinx.cinterop.addressOf -import kotlinx.cinterop.alloc -import kotlinx.cinterop.memScoped -import kotlinx.cinterop.ptr -import kotlinx.cinterop.reinterpret -import kotlinx.cinterop.usePinned -import kotlinx.cinterop.value -import platform.zlib.Z_BUF_ERROR -import platform.zlib.Z_OK -import platform.zlib.compress -import platform.zlib.compressBound -import platform.zlib.uncompress - -internal actual object ZlibCodec { - @OptIn(ExperimentalForeignApi::class) - actual fun compress(data: ByteArray): ByteArray? { - if (data.isEmpty()) return ByteArray(0) - - return memScoped { - val destLen = alloc() - destLen.value = compressBound(data.size.toULong()) - - val destBuffer = ByteArray(destLen.value.toInt()) - - val result = - destBuffer.usePinned { destPin -> - data.usePinned { srcPin -> - compress( - destPin.addressOf(0).reinterpret(), - destLen.ptr, - srcPin.addressOf(0).reinterpret(), - data.size.toULong(), - ) - } - } - - if (result == Z_OK) { - destBuffer.copyOf(destLen.value.toInt()) - } else { - null - } - } - } - - @OptIn(ExperimentalForeignApi::class) - actual fun decompress(data: ByteArray): ByteArray? { - if (data.isEmpty()) return ByteArray(0) - - var currentSize = data.size * 4 - var maxAttempts = 5 - - while (maxAttempts > 0) { - val success = memScoped { - val destLen = alloc() - destLen.value = currentSize.toULong() - - val destBuffer = ByteArray(currentSize) - - val result = - destBuffer.usePinned { destPin -> - data.usePinned { srcPin -> - uncompress( - destPin.addressOf(0).reinterpret(), - destLen.ptr, - srcPin.addressOf(0).reinterpret(), - data.size.toULong(), - ) - } - } - - if (result == Z_OK) { - return@memScoped destBuffer.copyOf(destLen.value.toInt()) - } else if (result == Z_BUF_ERROR) { - currentSize *= 2 - maxAttempts-- - null - } else { - maxAttempts = 0 - null - } - } - if (success != null) return success - } - return null - } -} diff --git a/core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/TAKClientConnection.kt b/core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/TAKClientConnection.kt new file mode 100644 index 000000000..689afbaf9 --- /dev/null +++ b/core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/TAKClientConnection.kt @@ -0,0 +1,336 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +@file:Suppress("TooManyFunctions", "TooGenericExceptionCaught") + +package org.meshtastic.core.takserver + +import co.touchlab.kermit.Logger +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import java.io.BufferedOutputStream +import java.io.InputStream +import java.io.OutputStream +import java.net.Socket +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.concurrent.Volatile +import kotlin.random.Random +import kotlin.time.Clock +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Instant +import kotlinx.coroutines.isActive as coroutineIsActive + +/** + * Per-client state machine for a connected TAK client (ATAK / iTAK / WinTAK). + * + * This is the jvmAndroidMain implementation, using plain `java.net.Socket` (which is also the base class of + * [javax.net.ssl.SSLSocket] from [TAKServerJvm]) with blocking `InputStream`/`OutputStream` I/O wrapped in + * [Dispatchers.IO] coroutines. + * + * Responsibilities: + * - TAK protocol negotiation handshake (`t-x-takp-v` / `-q` / `-r`) + * - Read loop that frames `` elements off the stream via [CoTXmlFrameBuffer] + * - Keepalive loop that emits a `t-x-d-d` event every [TAK_KEEPALIVE_INTERVAL_MS] + * - Serializing writes under a mutex so interleaved broadcasts never corrupt the XML stream + * - Lifecycle reporting up to [TAKServerJvm] via [onEvent] (`Connected`, `Disconnected`, `Error`, `ClientInfoUpdated`, + * `Message`) + */ +internal class TAKClientConnection( + private val socket: Socket, + val clientInfo: TAKClientInfo, + private val onEvent: (TAKConnectionEvent) -> Unit, + private val scope: CoroutineScope, + private val ioDispatcher: CoroutineDispatcher, +) { + private var currentClientInfo = clientInfo + private val frameBuffer = CoTXmlFrameBuffer() + + private val inputStream: InputStream = socket.getInputStream() + + // Wrap the OutputStream in a BufferedOutputStream so that multiple small writes + // (we emit a full XML event per write) coalesce into one syscall; flush() after + // each event to push the bytes through TLS immediately. + private val outputStream: OutputStream = BufferedOutputStream(socket.getOutputStream()) + private val writeMutex = Mutex() + + /** + * Per-connection child scope. Every coroutine this class launches — the read loop, the keepalive loop, and every + * single send — is attached to [connectionScope] so that [emitDisconnected] can tear the whole connection down with + * one `connectionScope.cancel()`. + * + * Why this is critical: [broadcast] in [TAKServerJvm] fires `connection.send()` on **every** connected client for + * **every** CoT event coming off the mesh (and with a 56-node nodeDB each `nodeDBbyNum` emission fans out to ~56 + * broadcasts). If [sendXml] launched those writes on the server-level [scope] — as the previous implementation did + * — a single dead connection could accumulate hundreds of in-flight write coroutines before it was removed from + * [TAKServerJvm.connections], and every one of them would spin up, hit the closed TLS socket, and log + * `SocketException: Socket closed` from `BufferedOutputStream.flush()`. Scoping writes to [connectionScope] means + * cancelling the scope wipes the entire backlog. + * + * Uses a [SupervisorJob] child of [scope]'s job so a single write failure doesn't cascade-cancel other connections + * on the same server. + */ + private val connectionScope: CoroutineScope = + CoroutineScope(SupervisorJob(scope.coroutineContext[Job]) + ioDispatcher) + + /** Guards against emitting [TAKConnectionEvent.Disconnected] more than once. */ + private val disconnectedEmitted = AtomicBoolean(false) + + /** + * Fail-fast flag checked at the top of [sendXml] so racing broadcasts against a dead connection don't even allocate + * a coroutine. + */ + @Volatile private var closed = false + + fun start() { + onEvent(TAKConnectionEvent.Connected(currentClientInfo)) + sendProtocolSupport() + + connectionScope.launch { readLoop() } + connectionScope.launch { keepaliveLoop() } + } + + private fun sendProtocolSupport() { + val serverUid = "Meshtastic-TAK-Server-${Random.nextInt().toString(TAK_HEX_RADIX)}" + val now = Clock.System.now() + val stale = now + TAK_KEEPALIVE_INTERVAL_MS.milliseconds + val detail = + """ + + + + """ + .trimIndent() + sendXmlInternal(buildEventXml(uid = serverUid, type = "t-x-takp-v", now = now, stale = stale, detail = detail)) + } + + private suspend fun readLoop() { + try { + val buffer = ByteArray(TAK_XML_READ_BUFFER_SIZE) + while (connectionScope.coroutineIsActive && !closed && !socket.isClosed) { + // Blocking read off the TLS input stream — must run on the IO dispatcher. + val bytesRead = withContext(ioDispatcher) { inputStream.read(buffer) } + if (bytesRead > 0) { + processReceivedData(buffer.copyOfRange(0, bytesRead)) + } else if (bytesRead == -1) { + break // EOF: remote peer closed the connection cleanly + } + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + if (!closed) { + Logger.w(e) { "TAK client read error: ${currentClientInfo.id}" } + emitDisconnected(TAKConnectionEvent.Error(e)) + } + return + } + emitDisconnected(TAKConnectionEvent.Disconnected) + } + + private suspend fun keepaliveLoop() { + while (connectionScope.coroutineIsActive && !closed && !socket.isClosed) { + kotlinx.coroutines.delay(TAK_KEEPALIVE_INTERVAL_MS) + if (closed) break + sendKeepalive() + } + } + + private fun sendKeepalive() { + val now = Clock.System.now() + val stale = now + TAK_KEEPALIVE_INTERVAL_MS.milliseconds + sendXmlInternal(buildEventXml(uid = "takPong", type = "t-x-d-d", now = now, stale = stale, detail = "")) + } + + /** Respond to ATAK's `t-x-c-t` ping with a pong to reset its RX timeout. */ + private fun sendPong() { + val now = Clock.System.now() + val stale = now + TAK_KEEPALIVE_INTERVAL_MS.milliseconds + sendXmlInternal(buildEventXml(uid = "takPong", type = "t-x-c-t-r", now = now, stale = stale, detail = "")) + } + + private fun processReceivedData(newData: ByteArray) { + frameBuffer.append(newData).forEach { xmlString -> parseAndHandleMessage(xmlString) } + } + + private fun parseAndHandleMessage(xmlString: String) { + // Fast-path: detect keepalive pings before full XML parsing to avoid + // both the parse overhead and the noisy RAW CoT IN log line every 4.5s. + if (xmlString.contains("t-x-c-t") || xmlString.contains("uid=\"ping\"")) { + sendPong() + return + } + + // Full raw CoT XML from the ATAK client, before any parsing happens. + // Emitted at debug level so it's always available in logcat for field + // debugging without needing a release rebuild. Not truncated — the + // reader of this log needs the complete event to reproduce issues. + // Logger.d { "RAW CoT IN (TCP ${currentClientInfo.id}): $xmlString" } + + val parser = CoTXmlParser(xmlString) + val result = parser.parse() + + result + .onSuccess { cotMessage -> + when { + cotMessage.type.startsWith("t-x-takp") -> { + handleProtocolControl(cotMessage.type, xmlString) + return + } + + else -> { + cotMessage.contact?.let { contact -> + val updatedClientInfo = + currentClientInfo.copy( + callsign = currentClientInfo.callsign ?: contact.callsign, + uid = currentClientInfo.uid ?: cotMessage.uid, + ) + if (updatedClientInfo != currentClientInfo) { + currentClientInfo = updatedClientInfo + onEvent(TAKConnectionEvent.ClientInfoUpdated(updatedClientInfo)) + } + } + onEvent(TAKConnectionEvent.Message(cotMessage, currentClientInfo)) + } + } + } + .onFailure { e -> Logger.w(e) { "Failed to parse CoT XML from TAK client ${currentClientInfo.id}" } } + } + + private fun handleProtocolControl(type: String, xmlString: String) { + if (type == "t-x-takp-q") { + sendProtocolResponse() + } else { + Logger.d { "Unhandled protocol control type: $type (raw=$xmlString)" } + } + } + + private fun sendProtocolResponse() { + val serverUid = "Meshtastic-TAK-Server-${Random.nextInt().toString(TAK_HEX_RADIX)}" + val now = Clock.System.now() + val stale = now + TAK_KEEPALIVE_INTERVAL_MS.milliseconds + val detail = + """ + + + + """ + .trimIndent() + sendXmlInternal(buildEventXml(uid = serverUid, type = "t-x-takp-r", now = now, stale = stale, detail = detail)) + } + + fun send(cotMessage: CoTMessage) { + if (closed) return + val xml = cotMessage.toXml() + // Full raw CoT XML being shipped out to the ATAK client, after the + // CoTMessage → XML round trip. This is the exact bytes the client + // will receive, so logging here closes the debugging loop with the + // matching RAW CoT IN line on the receiver. + // Logger.d { "RAW CoT OUT (TCP ${currentClientInfo.id}): $xml" } + sendXmlInternal(xml) + } + + private fun buildEventXml(uid: String, type: String, now: Instant, stale: Instant, detail: String): String { + val detailContent = if (detail.isBlank()) "" else "$detail" + val point = """""" + return """""" + + point + + detailContent + + "" + } + + /** + * Send raw XML directly to this client. Used for mesh-originated messages that bypass CoTMessage parsing to + * preserve shape detail elements. + */ + fun sendRawXml(xml: String) { + // Logger.d { "RAW CoT OUT (TCP ${currentClientInfo.id}): [raw] $xml" } + sendXmlInternal(xml) + } + + private fun sendXmlInternal(xml: String) { + // Fail-fast synchronous check BEFORE allocating a coroutine. This is the hot path + // for broadcasts — see the scope doc above for why it matters. + if (closed) return + connectionScope.launch { + // Re-check inside the coroutine: we may have been cancelled or marked closed + // between the launch and the dispatcher picking this up. + if (closed) return@launch + try { + writeMutex.withLock { + if (closed || socket.isClosed) return@withLock + val bytes = xml.toByteArray(Charsets.UTF_8) + // Blocking write on TLS output must run on the IO dispatcher + withContext(ioDispatcher) { + outputStream.write(bytes) + outputStream.flush() + } + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + // Don't spam on writes that raced a disconnect we already observed. + if (!closed) { + Logger.w(e) { "TAK client send error: ${currentClientInfo.id}" } + emitDisconnected(TAKConnectionEvent.Error(e)) + } + } + } + } + + fun close() { + frameBuffer.clear() + emitDisconnected(TAKConnectionEvent.Disconnected) + } + + /** + * Emits [event] (expected to be [TAKConnectionEvent.Disconnected] or [TAKConnectionEvent.Error]) at most once + * across all code paths, then tears down the per-connection coroutines and socket. + * + * This is the ONLY place the connection's entire coroutine scope — keepalive loop, read loop, and any in-flight + * send coroutines — gets cancelled when the *remote* peer closes the TLS stream. Without this, Java's + * [Socket.isClosed] only reports whether *our* side called close(), so the keepalive loop's `!socket.isClosed` + * guard never fires, the broadcast fanout keeps launching writes onto the dead socket via [sendXml], and every + * iteration logs `SSLOutputStream / Socket closed`. Before [closed] + [connectionScope.cancel] were added, a single + * session with a few reconnects accumulated hundreds of zombie write coroutines each spamming errors in parallel. + * + * Idempotent via [AtomicBoolean.compareAndSet], so racing calls from [readLoop], [keepaliveLoop], and [sendXml] all + * converge on a single teardown. + */ + private fun emitDisconnected(event: TAKConnectionEvent) { + if (disconnectedEmitted.compareAndSet(false, true)) { + // Set the fail-fast flag BEFORE emitting the event. [TAKServerJvm] will + // schedule an async map removal on receipt, and any broadcast racing the + // removal must see `closed = true` when it hits [send] / [sendXml]. + closed = true + onEvent(event) + // Cancel the whole scope — readLoop, keepaliveLoop, and every queued or + // in-flight sendXml coroutine. Any write blocked in the syscall will throw + // on the next iteration because we close the socket next. + connectionScope.cancel() + try { + socket.close() + } catch (_: Exception) {} + } + } +} diff --git a/core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/TAKServerJvm.kt b/core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/TAKServerJvm.kt new file mode 100644 index 000000000..c6ec0977b --- /dev/null +++ b/core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/TAKServerJvm.kt @@ -0,0 +1,299 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +@file:Suppress("TooGenericExceptionCaught") + +package org.meshtastic.core.takserver + +import co.touchlab.kermit.Logger +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.meshtastic.core.di.CoroutineDispatchers +import java.net.InetAddress +import java.net.ServerSocket +import java.net.Socket +import java.util.concurrent.locks.ReentrantLock +import javax.net.ssl.SSLServerSocket +import kotlin.concurrent.Volatile +import kotlin.concurrent.withLock +import kotlin.random.Random +import kotlinx.coroutines.isActive as coroutineIsActive + +/** + * JSSE-backed TLS TAK server. Matches the Meshtastic-Apple (iOS) implementation: + * - Binds `127.0.0.1:8089` (loopback only — no remote device can reach the server) + * - TLS 1.2+ with the bundled server.p12 identity + * - Mutual TLS: clients MUST present a certificate chaining to the bundled ca.pem + * - `SO_REUSEADDR` on the listen socket so an app restart doesn't hit `BindException: Address already in use` while the + * previous socket is in `TIME_WAIT` + * - Per-connection [TAKClientConnection] running on [CoroutineDispatchers.io] + * + * If the bundled certificates fail to load (e.g. packaging regression), the server refuses to start rather than + * silently falling back to plain TCP — that failure mode would produce exactly the symptom the user was debugging + * ("ATAK never connects"). + */ +internal class TAKServerJvm(private val dispatchers: CoroutineDispatchers, private val port: Int = DEFAULT_TAK_PORT) : + TAKServer { + + private var serverSocket: ServerSocket? = null + + @Volatile private var running = false + private var serverScope: CoroutineScope? = null + private var acceptJob: Job? = null + private val connectionsLock = ReentrantLock() + private val connections = mutableMapOf() + + private val _connectionCount = MutableStateFlow(0) + override val connectionCount: StateFlow = _connectionCount.asStateFlow() + + override var onMessage: ((CoTMessage, TAKClientInfo?) -> Unit)? = null + override var onClientConnected: (() -> Unit)? = null + + override suspend fun start(scope: CoroutineScope): Result { + if (running) { + Logger.w { "TAK Server already running on port $port" } + return Result.success(Unit) + } + + val sslContext = + TakCertLoader.getServerSslContext() + ?: return Result.failure( + IllegalStateException( + "TAK Server: bundled TLS certificates could not be loaded" + "; refusing to start", + ), + ) + + return try { + serverScope = scope + + // Bind on the IO dispatcher — bind() can briefly block. + val boundSocket = + withContext(dispatchers.io) { + val factory = sslContext.serverSocketFactory + // Use the address-specific overload so we bind to loopback only. + val loopback = InetAddress.getByName("127.0.0.1") + // backlog of 4 is plenty for local TAK clients + val tls = factory.createServerSocket(port, 4, loopback) as SSLServerSocket + configureTlsServerSocket(tls) + tls + } + serverSocket = boundSocket + running = true + Logger.i { "TAK Server listening on 127.0.0.1:$port (TLS, mTLS enforced)" } + + acceptJob = scope.launch(dispatchers.io) { acceptLoop() } + Result.success(Unit) + } catch (e: Exception) { + Logger.e(e) { "Failed to bind TAK Server to 127.0.0.1:$port" } + running = false + try { + serverSocket?.close() + } catch (_: Exception) {} + serverSocket = null + Result.failure(e) + } + } + + private fun configureTlsServerSocket(tls: SSLServerSocket) { + // Minimum TLS 1.2 — matches iOS. + val protocols = tls.supportedProtocols.filter { it == "TLSv1.2" || it == "TLSv1.3" } + if (protocols.isNotEmpty()) { + tls.enabledProtocols = protocols.toTypedArray() + } + // Require client certificate (mTLS) — matches + // `sec_protocol_options_set_peer_authentication_required` on iOS. + tls.needClientAuth = true + // Enable address reuse so restart doesn't hit TIME_WAIT on the port. + tls.reuseAddress = true + } + + private suspend fun acceptLoop() { + val scope = serverScope ?: return + while (running && scope.coroutineIsActive) { + try { + val clientSocket = withContext(dispatchers.io) { serverSocket?.accept() } + if (clientSocket != null) { + handleConnection(clientSocket) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + // Bind was lost or the socket was closed under us — back off, then retry. + if (running) { + Logger.w(e) { "TAK server accept loop iteration failed: ${e.message}" } + } + delay(TAK_ACCEPT_LOOP_DELAY_MS) + } + } + } + + private fun handleConnection(clientSocket: Socket) { + val scope = serverScope ?: return + val endpoint = clientSocket.remoteSocketAddress?.toString() ?: "unknown" + + if (clientSocket.inetAddress?.isLoopbackAddress != true) { + Logger.w { "TAK server rejected non-loopback connection from $endpoint" } + try { + clientSocket.close() + } catch (_: Exception) {} + return + } + + val connectionId = Random.nextInt().toString(TAK_HEX_RADIX) + val clientInfo = TAKClientInfo(id = connectionId, endpoint = endpoint) + Logger.i { "TAK client connected: id=$connectionId endpoint=$endpoint" } + + val connection = + TAKClientConnection( + socket = clientSocket, + clientInfo = clientInfo, + onEvent = { event -> handleConnectionEvent(connectionId, event) }, + scope = scope, + ioDispatcher = dispatchers.io, + ) + + // Launch on IO so socket reads/writes don't queue behind CPU work on Default + scope.launch(dispatchers.io) { + connectionsLock.withLock { + connections[connectionId] = connection + _connectionCount.value = connections.size + Logger.i { "TAK connection count now ${connections.size}" } + } + connection.start() + } + } + + private fun handleConnectionEvent(connectionId: String, event: TAKConnectionEvent) { + when (event) { + is TAKConnectionEvent.Message -> { + onMessage?.invoke(event.cotMessage, event.clientInfo) + } + + is TAKConnectionEvent.Disconnected -> { + Logger.i { "TAK client disconnected: id=$connectionId" } + serverScope?.launch(dispatchers.io) { + connectionsLock.withLock { + connections.remove(connectionId) + _connectionCount.value = connections.size + Logger.i { "TAK connection count now ${connections.size}" } + } + } + } + + is TAKConnectionEvent.Error -> { + Logger.w(event.error) { "TAK client connection error: $connectionId" } + serverScope?.launch(dispatchers.io) { + connectionsLock.withLock { + connections.remove(connectionId) + _connectionCount.value = connections.size + Logger.i { "TAK connection count now ${connections.size}" } + } + } + } + + is TAKConnectionEvent.Connected -> { + onClientConnected?.invoke() + } + + is TAKConnectionEvent.ClientInfoUpdated -> { + /* no-op: TAKClientConnection tracks updated info locally */ + } + } + } + + override fun stop() { + running = false + acceptJob?.cancel() + acceptJob = null + + // Guard the snapshot+clear with the same lock used by the coroutine accept/disconnect + // paths to avoid concurrent modification or a stale connectionCount during shutdown. + val toClose = + connectionsLock.withLock { + val snapshot = connections.values.toList() + connections.clear() + _connectionCount.value = 0 + snapshot + } + toClose.forEach { it.close() } + + try { + serverSocket?.close() + } catch (_: Exception) {} + serverSocket = null + serverScope = null + Logger.i { "TAK Server stopped" } + } + + override suspend fun broadcast(cotMessage: CoTMessage) { + val currentConnections = connectionsLock.withLock { connections.values.toList() } + if (currentConnections.isEmpty()) { + Logger.d { "broadcast ${cotMessage.type}: no TAK clients connected, dropping" } + return + } + Logger.d { "broadcast ${cotMessage.type} to ${currentConnections.size} TAK client(s)" } + currentConnections.forEach { connection -> + try { + connection.send(cotMessage) + } catch (e: Exception) { + Logger.w(e) { "Failed to broadcast CoT to TAK client ${connection.clientInfo.id}" } + connection.close() + } + } + } + + override suspend fun broadcastRawXml(xml: String) { + val currentConnections = connectionsLock.withLock { connections.values.toList() } + if (currentConnections.isEmpty()) return + Logger.d { "broadcastRawXml to ${currentConnections.size} TAK client(s)" } + currentConnections.forEach { connection -> + try { + connection.sendRawXml(xml) + } catch (e: Exception) { + Logger.w(e) { "Failed to broadcast raw XML to TAK client ${connection.clientInfo.id}" } + connection.close() + } + } + } + + override suspend fun hasConnections(): Boolean = connectionsLock.withLock { connections.isNotEmpty() } +} + +/** + * `actual` factory for the KMP `expect fun createTAKServer` declared in `commonMain`. Both the Desktop JVM target and + * the Android target share this source set, so both run the same JSSE-based TLS listener. + * + * Also wires [TAKDataPackageGenerator]'s bundled-cert provider so that the exported `.zip` data package contains the + * real `server.p12` / `client.p12` bytes from the classpath rather than an empty fallback. + */ +actual fun createTAKServer(dispatchers: CoroutineDispatchers, port: Int): TAKServer { + TAKDataPackageGenerator.bundledCertBytesProvider = TakCertBundledBytesProvider + return TAKServerJvm(dispatchers = dispatchers, port = port) +} + +/** Bridges [TakCertLoader] bytes into [TAKDataPackageGenerator] via the commonMain interface. */ +private object TakCertBundledBytesProvider : BundledCertBytesProvider { + override fun serverP12Bytes(): ByteArray? = TakCertLoader.getServerP12Bytes() + + override fun clientP12Bytes(): ByteArray? = TakCertLoader.getClientP12Bytes() +} diff --git a/core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/TakCertLoader.kt b/core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/TakCertLoader.kt new file mode 100644 index 000000000..7da10ab69 --- /dev/null +++ b/core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/TakCertLoader.kt @@ -0,0 +1,160 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +@file:Suppress("TooGenericExceptionCaught") + +package org.meshtastic.core.takserver + +import co.touchlab.kermit.Logger +import java.io.ByteArrayInputStream +import java.security.KeyStore +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate +import javax.net.ssl.KeyManagerFactory +import javax.net.ssl.SSLContext +import javax.net.ssl.TrustManagerFactory + +/** + * Loads the bundled TAK server certificates from the classpath and builds an [SSLContext] suitable for running a TLS + * TAK server with mutual TLS (mTLS). + * + * Bundled resources (under `tak_certs/` on the module classpath): + * - `server.p12` — PKCS#12 containing the server's identity (cert + private key). Used as the server's identity during + * the TLS handshake. + * - `client.p12` — PKCS#12 containing an example client identity, included in the exported data package so ATAK / iTAK + * have a certificate it can present. + * - `ca.pem` — PEM-encoded CA certificate used to validate the presented client certificate during mTLS. Only clients + * whose certificate chains back to this CA are accepted. + * + * All files are the same bytes as the iOS Meshtastic-Apple bundle, so the same exported data package works for both + * platforms with no re-import. + */ +internal object TakCertLoader { + + private const val RESOURCE_SERVER_P12 = "tak_certs/server.p12" + private const val RESOURCE_CLIENT_P12 = "tak_certs/client.p12" + private const val RESOURCE_CA_PEM = "tak_certs/ca.pem" + + @Volatile private var cachedSslContext: SSLContext? = null + + @Volatile private var cachedServerP12: ByteArray? = null + + @Volatile private var cachedClientP12: ByteArray? = null + + @Volatile private var cachedCaPem: ByteArray? = null + + /** + * Build (and cache) an [SSLContext] for the TAK server. + * + * The context uses the bundled `server.p12` for its identity and the bundled `ca.pem` to validate client + * certificates during mTLS. If anything fails to load (missing resources, bad password, corrupt keystore) this + * returns `null` and callers should fall back to a non-TLS listener or refuse to start. + */ + @Synchronized + fun getServerSslContext(): SSLContext? { + cachedSslContext?.let { + return it + } + return try { + val serverP12 = + loadResourceBytes(RESOURCE_SERVER_P12) ?: error("Bundled $RESOURCE_SERVER_P12 not found on classpath") + val caPem = loadResourceBytes(RESOURCE_CA_PEM) ?: error("Bundled $RESOURCE_CA_PEM not found on classpath") + + // Load the server identity (cert + private key). + val serverKeyStore = + KeyStore.getInstance("PKCS12").apply { + ByteArrayInputStream(serverP12).use { input -> + load(input, TAK_BUNDLED_CERT_PASSWORD.toCharArray()) + } + } + val kmf = + KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()).apply { + init(serverKeyStore, TAK_BUNDLED_CERT_PASSWORD.toCharArray()) + } + + // Load the CA certificate(s) used to verify incoming client certs. + val caCerts = parsePemCertificates(caPem) + if (caCerts.isEmpty()) error("No certificates found inside $RESOURCE_CA_PEM") + val trustKeyStore = + KeyStore.getInstance(KeyStore.getDefaultType()).apply { + load(null, null) + caCerts.forEachIndexed { index, cert -> setCertificateEntry("tak-client-ca-$index", cert) } + } + val tmf = + TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()).apply { init(trustKeyStore) } + + val sslContext = SSLContext.getInstance("TLSv1.2").apply { init(kmf.keyManagers, tmf.trustManagers, null) } + + Logger.i { "TAK: loaded bundled TLS server identity and ${caCerts.size} CA certificate(s)" } + cachedSslContext = sslContext + sslContext + } catch (e: Throwable) { + Logger.e(e) { "TAK: failed to build SSLContext from bundled certificates: ${e.message}" } + null + } + } + + /** Returns the raw bytes of the bundled `server.p12`. Used by the data package generator. */ + @Synchronized + fun getServerP12Bytes(): ByteArray? { + cachedServerP12?.let { + return it + } + val bytes = loadResourceBytes(RESOURCE_SERVER_P12) + cachedServerP12 = bytes + return bytes + } + + /** Returns the raw bytes of the bundled `client.p12`. Used by the data package generator. */ + @Synchronized + fun getClientP12Bytes(): ByteArray? { + cachedClientP12?.let { + return it + } + val bytes = loadResourceBytes(RESOURCE_CLIENT_P12) + cachedClientP12 = bytes + return bytes + } + + /** Returns the raw bytes of the bundled `ca.pem`. */ + @Synchronized + fun getCaPemBytes(): ByteArray? { + cachedCaPem?.let { + return it + } + val bytes = loadResourceBytes(RESOURCE_CA_PEM) + cachedCaPem = bytes + return bytes + } + + private fun loadResourceBytes(name: String): ByteArray? { + val stream = TakCertLoader::class.java.classLoader?.getResourceAsStream(name) ?: return null + return stream.use { it.readBytes() } + } + + /** + * Parse every `-----BEGIN CERTIFICATE----- ... -----END CERTIFICATE-----` block in the given PEM bytes into + * [X509Certificate]s. Tolerates multiple certs in one file. + */ + private fun parsePemCertificates(pem: ByteArray): List { + val factory = CertificateFactory.getInstance("X.509") + // CertificateFactory.generateCertificates handles PEM bundles directly on all + // standard Java providers, so we don't need to split ourselves. + return ByteArrayInputStream(pem).use { input -> + factory.generateCertificates(input).filterIsInstance() + } + } +} diff --git a/core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/TakFixtureLoader.kt b/core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/TakFixtureLoader.kt new file mode 100644 index 000000000..0822feff8 --- /dev/null +++ b/core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/TakFixtureLoader.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.takserver + +/** JVM/Android: load fixture XML from bundled test resources via the classloader. */ +internal actual fun loadTakFixtureXml(name: String): String { + val stream = + object {}::class.java.classLoader?.getResourceAsStream("tak_test_fixtures/$name") + ?: throw IllegalStateException("Fixture not found: tak_test_fixtures/$name") + return stream.bufferedReader().readText() +} diff --git a/core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/TakSdkCompressor.kt b/core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/TakSdkCompressor.kt new file mode 100644 index 000000000..e000ac8f7 --- /dev/null +++ b/core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/TakSdkCompressor.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.takserver + +import org.meshtastic.tak.CotXmlParser +import org.meshtastic.tak.TakCompressor + +internal actual object TakSdkCompressor { + + actual fun compressCoT(xml: String, maxBytes: Int): ByteArray? { + val sdkParser = CotXmlParser() + val sdkData = sdkParser.parse(xml) + val compressor = TakCompressor() + return compressor.compressWithRemarksFallback(sdkData, maxBytes) + } +} diff --git a/core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/TakV2Compressor.kt b/core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/TakV2Compressor.kt new file mode 100644 index 000000000..97de4e269 --- /dev/null +++ b/core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/TakV2Compressor.kt @@ -0,0 +1,481 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.takserver + +import okio.ByteString.Companion.toByteString +import org.meshtastic.proto.TAKPacketV2 +import org.meshtastic.tak.TakPacketV2Data +import org.meshtastic.proto.AircraftTrack as WireAircraftTrack +import org.meshtastic.proto.CasevacReport as WireCasevacReport +import org.meshtastic.proto.CotGeoPoint as WireCotGeoPoint +import org.meshtastic.proto.DrawnShape as WireDrawnShape +import org.meshtastic.proto.EmergencyAlert as WireEmergencyAlert +import org.meshtastic.proto.GeoChat as WireGeoChat +import org.meshtastic.proto.Marker as WireMarker +import org.meshtastic.proto.RangeAndBearing as WireRangeAndBearing +import org.meshtastic.proto.Route as WireRoute +import org.meshtastic.proto.TaskRequest as WireTaskRequest +import org.meshtastic.proto.Team as WireTeam +import org.meshtastic.tak.TakCompressor as SdkCompressor + +/** + * JVM/Android implementation of TakV2Compressor. Delegates to TAKPacket-SDK's TakCompressor for zstd dictionary + * compression. + * + * The SDK compressor is constructed lazily and its result is cached in a nullable field so that a native-library + * failure (e.g. missing Android .so) does NOT poison this object. Without lazy/try-catch, a failure inside a top-level + * `val` initializer runs at class `` time, marks the class ERRONEOUS, and turns every subsequent reference into + * `NoClassDefFoundError`. + */ +internal actual object TakV2Compressor { + + actual val MAX_DECOMPRESSED_SIZE: Int = 4096 + actual val DICT_ID_NON_AIRCRAFT: Int = 0 + actual val DICT_ID_AIRCRAFT: Int = 1 + actual val DICT_ID_UNCOMPRESSED: Int = 0xFF + + @Volatile private var sdkCompressorOrNull: SdkCompressor? = null + + @Volatile private var sdkCompressorInitFailure: Throwable? = null + + @Synchronized + private fun getSdkCompressor(): SdkCompressor { + sdkCompressorOrNull?.let { + return it + } + sdkCompressorInitFailure?.let { cached -> + throw IllegalStateException("zstd-jni unavailable on this platform", cached) + } + return try { + SdkCompressor().also { sdkCompressorOrNull = it } + } catch (e: Throwable) { + sdkCompressorInitFailure = e + throw IllegalStateException("zstd-jni unavailable on this platform", e) + } + } + + actual fun compress(packet: TAKPacketV2): ByteArray { + val data = wireToSdkData(packet) + return getSdkCompressor().compress(data) + } + + actual fun decompress(wirePayload: ByteArray): TAKPacketV2 { + val data = getSdkCompressor().decompress(wirePayload) + return sdkDataToWire(data) + } + + /** + * Decompress a V2 wire payload and reconstruct CoT XML via the SDK's CotXmlBuilder. This handles ALL payload types + * (DrawnShape, Marker, Route, etc.) without going through the Wire proto intermediate, avoiding the gap where + * `toCoTMessage()` only handles PLI/GeoChat. + */ + actual fun decompressToXml(wirePayload: ByteArray): String { + val data = getSdkCompressor().decompress(wirePayload) + return org.meshtastic.tak.CotXmlBuilder().build(data) + } + + /** Convert Wire-generated TAKPacketV2 → SDK's TakPacketV2Data. */ + private fun wireToSdkData(packet: TAKPacketV2): TakPacketV2Data { + val cotTypeId = packet.cot_type_id.value + val cotTypeStr = if (cotTypeId == 0 && packet.cot_type_str.isNotEmpty()) packet.cot_type_str else null + + val payload = + when { + packet.pli != null -> TakPacketV2Data.Payload.Pli(true) + + packet.chat != null -> + TakPacketV2Data.Payload.Chat( + message = packet.chat!!.message, + to = packet.chat!!.to, + toCallsign = packet.chat!!.to_callsign, + receiptForUid = packet.chat!!.receipt_for_uid, + receiptType = packet.chat!!.receipt_type.value, + ) + + packet.aircraft != null -> + TakPacketV2Data.Payload.Aircraft( + icao = packet.aircraft!!.icao, + registration = packet.aircraft!!.registration, + flight = packet.aircraft!!.flight, + aircraftType = packet.aircraft!!.aircraft_type, + squawk = packet.aircraft!!.squawk, + category = packet.aircraft!!.category, + rssiX10 = packet.aircraft!!.rssi_x10, + gps = packet.aircraft!!.gps, + cotHostId = packet.aircraft!!.cot_host_id, + ) + + // Typed geometry variants added by takv2_geometry (tags 34-37). + // All GeoPoint fields on the wire are delta-encoded from the + // event anchor; the SDK data class stores absolute lat/lon, so + // we add packet.latitude_i / longitude_i here. + packet.shape != null -> { + val s = packet.shape!! + TakPacketV2Data.Payload.DrawnShape( + kind = s.kind.value, + style = s.style.value, + majorCm = s.major_cm, + minorCm = s.minor_cm, + angleDeg = s.angle_deg, + strokeColor = s.stroke_color.value, + strokeArgb = s.stroke_argb, + strokeWeightX10 = s.stroke_weight_x10, + fillColor = s.fill_color.value, + fillArgb = s.fill_argb, + labelsOn = s.labels_on, + vertices = + s.vertices.map { v -> + TakPacketV2Data.Payload.Vertex( + latI = packet.latitude_i + v.lat_delta_i, + lonI = packet.longitude_i + v.lon_delta_i, + ) + }, + truncated = s.truncated, + bullseyeDistanceDm = s.bullseye_distance_dm, + bullseyeBearingRef = s.bullseye_bearing_ref, + bullseyeFlags = s.bullseye_flags, + bullseyeUidRef = s.bullseye_uid_ref, + ) + } + + packet.marker != null -> { + val m = packet.marker!! + TakPacketV2Data.Payload.Marker( + kind = m.kind.value, + color = m.color.value, + colorArgb = m.color_argb, + readiness = m.readiness, + parentUid = m.parent_uid, + parentType = m.parent_type, + parentCallsign = m.parent_callsign, + iconset = m.iconset, + ) + } + + packet.rab != null -> { + val r = packet.rab!! + val anchor = r.anchor + TakPacketV2Data.Payload.RangeAndBearing( + anchorLatI = packet.latitude_i + (anchor?.lat_delta_i ?: 0), + anchorLonI = packet.longitude_i + (anchor?.lon_delta_i ?: 0), + anchorUid = r.anchor_uid, + rangeCm = r.range_cm, + bearingCdeg = r.bearing_cdeg, + strokeColor = r.stroke_color.value, + strokeArgb = r.stroke_argb, + strokeWeightX10 = r.stroke_weight_x10, + ) + } + + packet.route != null -> { + val rt = packet.route!! + TakPacketV2Data.Payload.Route( + method = rt.method.value, + direction = rt.direction.value, + prefix = rt.prefix, + strokeWeightX10 = rt.stroke_weight_x10, + links = + rt.links.map { link -> + val pt = link.point + TakPacketV2Data.Payload.Route.Link( + latI = packet.latitude_i + (pt?.lat_delta_i ?: 0), + lonI = packet.longitude_i + (pt?.lon_delta_i ?: 0), + uid = link.uid, + callsign = link.callsign, + linkType = link.link_type, + ) + }, + truncated = rt.truncated, + ) + } + + packet.casevac != null -> { + val c = packet.casevac!! + TakPacketV2Data.Payload.CasevacReport( + precedence = c.precedence.value, + equipmentFlags = c.equipment_flags, + litterPatients = c.litter_patients, + ambulatoryPatients = c.ambulatory_patients, + security = c.security.value, + hlzMarking = c.hlz_marking.value, + zoneMarker = c.zone_marker, + usMilitary = c.us_military, + usCivilian = c.us_civilian, + nonUsMilitary = c.non_us_military, + nonUsCivilian = c.non_us_civilian, + epw = c.epw, + child = c.child, + terrainFlags = c.terrain_flags, + frequency = c.frequency, + ) + } + + packet.emergency != null -> { + val e = packet.emergency!! + TakPacketV2Data.Payload.EmergencyAlert( + type = e.type.value, + authoringUid = e.authoring_uid, + cancelReferenceUid = e.cancel_reference_uid, + ) + } + + packet.task != null -> { + val t = packet.task!! + TakPacketV2Data.Payload.TaskRequest( + taskType = t.task_type, + targetUid = t.target_uid, + assigneeUid = t.assignee_uid, + priority = t.priority.value, + status = t.status.value, + note = t.note, + ) + } + + packet.raw_detail != null -> TakPacketV2Data.Payload.RawDetail(packet.raw_detail!!.toByteArray()) + + else -> TakPacketV2Data.Payload.None + } + + return TakPacketV2Data( + cotTypeId = cotTypeId, + cotTypeStr = cotTypeStr, + how = packet.how.value, + callsign = packet.callsign, + team = packet.team.value, + role = packet.role.value, + latitudeI = packet.latitude_i, + longitudeI = packet.longitude_i, + altitude = packet.altitude, + speed = packet.speed, + course = packet.course, + battery = packet.battery, + geoSrc = packet.geo_src.value, + altSrc = packet.alt_src.value, + uid = packet.uid, + deviceCallsign = packet.device_callsign, + staleSeconds = packet.stale_seconds, + takVersion = packet.tak_version, + takDevice = packet.tak_device, + takPlatform = packet.tak_platform, + takOs = packet.tak_os, + endpoint = packet.endpoint, + phone = packet.phone, + payload = payload, + ) + } + + /** Convert SDK's TakPacketV2Data → Wire-generated TAKPacketV2. */ + private fun sdkDataToWire(data: TakPacketV2Data): TAKPacketV2 { + val cotType = + org.meshtastic.proto.CotType.fromValue(data.cotTypeId) ?: org.meshtastic.proto.CotType.CotType_Other + val how = org.meshtastic.proto.CotHow.fromValue(data.how) ?: org.meshtastic.proto.CotHow.CotHow_Unspecified + val team = org.meshtastic.proto.Team.fromValue(data.team) ?: org.meshtastic.proto.Team.Unspecifed_Color + val role = org.meshtastic.proto.MemberRole.fromValue(data.role) ?: org.meshtastic.proto.MemberRole.Unspecifed + val geoSrc = + org.meshtastic.proto.GeoPointSource.fromValue(data.geoSrc) + ?: org.meshtastic.proto.GeoPointSource.GeoPointSource_Unspecified + val altSrc = + org.meshtastic.proto.GeoPointSource.fromValue(data.altSrc) + ?: org.meshtastic.proto.GeoPointSource.GeoPointSource_Unspecified + + return TAKPacketV2( + cot_type_id = cotType, + cot_type_str = data.cotTypeStr ?: "", + how = how, + callsign = data.callsign, + team = team, + role = role, + latitude_i = data.latitudeI, + longitude_i = data.longitudeI, + altitude = data.altitude, + speed = data.speed, + course = data.course, + battery = data.battery, + geo_src = geoSrc, + alt_src = altSrc, + uid = data.uid, + device_callsign = data.deviceCallsign, + stale_seconds = data.staleSeconds, + tak_version = data.takVersion, + tak_device = data.takDevice, + tak_platform = data.takPlatform, + tak_os = data.takOs, + endpoint = data.endpoint, + phone = data.phone, + pli = if (data.payload is TakPacketV2Data.Payload.Pli) true else null, + chat = + (data.payload as? TakPacketV2Data.Payload.Chat)?.let { chat -> + WireGeoChat( + message = chat.message, + to = chat.to, + to_callsign = chat.toCallsign, + receipt_for_uid = chat.receiptForUid, + receipt_type = + WireGeoChat.ReceiptType.fromValue(chat.receiptType) + ?: WireGeoChat.ReceiptType.ReceiptType_None, + ) + }, + aircraft = + (data.payload as? TakPacketV2Data.Payload.Aircraft)?.let { ac -> + WireAircraftTrack( + icao = ac.icao, + registration = ac.registration, + flight = ac.flight, + aircraft_type = ac.aircraftType, + squawk = ac.squawk, + category = ac.category, + rssi_x10 = ac.rssiX10, + gps = ac.gps, + cot_host_id = ac.cotHostId, + ) + }, + shape = + (data.payload as? TakPacketV2Data.Payload.DrawnShape)?.let { s -> + WireDrawnShape( + kind = WireDrawnShape.Kind.fromValue(s.kind) ?: WireDrawnShape.Kind.Kind_Unspecified, + style = + WireDrawnShape.StyleMode.fromValue(s.style) + ?: WireDrawnShape.StyleMode.StyleMode_Unspecified, + major_cm = s.majorCm, + minor_cm = s.minorCm, + angle_deg = s.angleDeg, + stroke_color = WireTeam.fromValue(s.strokeColor) ?: WireTeam.Unspecifed_Color, + stroke_argb = s.strokeArgb, + stroke_weight_x10 = s.strokeWeightX10, + fill_color = WireTeam.fromValue(s.fillColor) ?: WireTeam.Unspecifed_Color, + fill_argb = s.fillArgb, + labels_on = s.labelsOn, + // Delta-encode vertices relative to the event anchor. + vertices = + s.vertices.map { v -> + WireCotGeoPoint( + lat_delta_i = v.latI - data.latitudeI, + lon_delta_i = v.lonI - data.longitudeI, + ) + }, + truncated = s.truncated, + bullseye_distance_dm = s.bullseyeDistanceDm, + bullseye_bearing_ref = s.bullseyeBearingRef, + bullseye_flags = s.bullseyeFlags, + bullseye_uid_ref = s.bullseyeUidRef, + ) + }, + marker = + (data.payload as? TakPacketV2Data.Payload.Marker)?.let { m -> + WireMarker( + kind = WireMarker.Kind.fromValue(m.kind) ?: WireMarker.Kind.Kind_Unspecified, + color = WireTeam.fromValue(m.color) ?: WireTeam.Unspecifed_Color, + color_argb = m.colorArgb, + readiness = m.readiness, + parent_uid = m.parentUid, + parent_type = m.parentType, + parent_callsign = m.parentCallsign, + iconset = m.iconset, + ) + }, + rab = + (data.payload as? TakPacketV2Data.Payload.RangeAndBearing)?.let { r -> + WireRangeAndBearing( + anchor = + WireCotGeoPoint( + lat_delta_i = r.anchorLatI - data.latitudeI, + lon_delta_i = r.anchorLonI - data.longitudeI, + ), + anchor_uid = r.anchorUid, + range_cm = r.rangeCm, + bearing_cdeg = r.bearingCdeg, + stroke_color = WireTeam.fromValue(r.strokeColor) ?: WireTeam.Unspecifed_Color, + stroke_argb = r.strokeArgb, + stroke_weight_x10 = r.strokeWeightX10, + ) + }, + route = + (data.payload as? TakPacketV2Data.Payload.Route)?.let { rt -> + WireRoute( + method = WireRoute.Method.fromValue(rt.method) ?: WireRoute.Method.Method_Unspecified, + direction = + WireRoute.Direction.fromValue(rt.direction) ?: WireRoute.Direction.Direction_Unspecified, + prefix = rt.prefix, + stroke_weight_x10 = rt.strokeWeightX10, + links = + rt.links.map { link -> + WireRoute.Link( + point = + WireCotGeoPoint( + lat_delta_i = link.latI - data.latitudeI, + lon_delta_i = link.lonI - data.longitudeI, + ), + uid = link.uid, + callsign = link.callsign, + link_type = link.linkType, + ) + }, + truncated = rt.truncated, + ) + }, + casevac = + (data.payload as? TakPacketV2Data.Payload.CasevacReport)?.let { c -> + WireCasevacReport( + precedence = + WireCasevacReport.Precedence.fromValue(c.precedence) + ?: WireCasevacReport.Precedence.Precedence_Unspecified, + equipment_flags = c.equipmentFlags, + litter_patients = c.litterPatients, + ambulatory_patients = c.ambulatoryPatients, + security = + WireCasevacReport.Security.fromValue(c.security) + ?: WireCasevacReport.Security.Security_Unspecified, + hlz_marking = + WireCasevacReport.HlzMarking.fromValue(c.hlzMarking) + ?: WireCasevacReport.HlzMarking.HlzMarking_Unspecified, + zone_marker = c.zoneMarker, + us_military = c.usMilitary, + us_civilian = c.usCivilian, + non_us_military = c.nonUsMilitary, + non_us_civilian = c.nonUsCivilian, + epw = c.epw, + child = c.child, + terrain_flags = c.terrainFlags, + frequency = c.frequency, + ) + }, + emergency = + (data.payload as? TakPacketV2Data.Payload.EmergencyAlert)?.let { e -> + WireEmergencyAlert( + type = WireEmergencyAlert.Type.fromValue(e.type) ?: WireEmergencyAlert.Type.Type_Unspecified, + authoring_uid = e.authoringUid, + cancel_reference_uid = e.cancelReferenceUid, + ) + }, + task = + (data.payload as? TakPacketV2Data.Payload.TaskRequest)?.let { t -> + WireTaskRequest( + task_type = t.taskType, + target_uid = t.targetUid, + assignee_uid = t.assigneeUid, + priority = + WireTaskRequest.Priority.fromValue(t.priority) + ?: WireTaskRequest.Priority.Priority_Unspecified, + status = + WireTaskRequest.Status.fromValue(t.status) ?: WireTaskRequest.Status.Status_Unspecified, + note = t.note, + ) + }, + raw_detail = (data.payload as? TakPacketV2Data.Payload.RawDetail)?.bytes?.toByteString(), + ) + } +} diff --git a/core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/fountain/ZlibCodec.kt b/core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/fountain/ZlibCodec.kt deleted file mode 100644 index fca9f0f52..000000000 --- a/core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/fountain/ZlibCodec.kt +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.takserver.fountain - -import java.io.ByteArrayOutputStream -import java.util.zip.Deflater -import java.util.zip.Inflater - -internal actual object ZlibCodec { - actual fun compress(data: ByteArray): ByteArray? { - val deflater = Deflater(Deflater.DEFAULT_COMPRESSION, false) - return try { - deflater.setInput(data) - deflater.finish() - - val outputStream = ByteArrayOutputStream(data.size) - val buffer = ByteArray(1024) - while (!deflater.finished()) { - val count = deflater.deflate(buffer) - outputStream.write(buffer, 0, count) - } - outputStream.close() - outputStream.toByteArray() - } catch (e: Exception) { - null - } finally { - deflater.end() - } - } - - actual fun decompress(data: ByteArray): ByteArray? { - val inflater = Inflater(false) - return try { - inflater.setInput(data) - - val outputStream = ByteArrayOutputStream(data.size * 2) - val buffer = ByteArray(1024) - while (!inflater.finished()) { - val count = inflater.inflate(buffer) - if (count == 0 && inflater.needsInput()) { - break - } - outputStream.write(buffer, 0, count) - } - outputStream.close() - outputStream.toByteArray() - } catch (e: Exception) { - null - } finally { - inflater.end() - } - } -} diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_certs/ca.pem b/core/takserver/src/jvmAndroidMain/resources/tak_certs/ca.pem new file mode 100644 index 000000000..1dc6e36f6 --- /dev/null +++ b/core/takserver/src/jvmAndroidMain/resources/tak_certs/ca.pem @@ -0,0 +1,23 @@ +-----BEGIN CERTIFICATE----- +MIID4zCCAsugAwIBAgIUeM9XhqZCtta+QorYNjZSdAk3gkMwDQYJKoZIhvcNAQEL +BQAwgYAxCzAJBgNVBAYTAlVTMRMwEQYDVQQIDApDYWxpZm9ybmlhMRYwFAYDVQQH +DA1TYW4gRnJhbmNpc2NvMRMwEQYDVQQKDApNZXNodGFzdGljMRMwEQYDVQQLDApU +QUsgU2VydmVyMRowGAYDVQQDDBFNZXNodGFzdGljIFRBSyBDQTAeFw0yNTEyMzEx +OTQwMDJaFw0yODA0MDQxOTQwMDJaMIGAMQswCQYDVQQGEwJVUzETMBEGA1UECAwK +Q2FsaWZvcm5pYTEWMBQGA1UEBwwNU2FuIEZyYW5jaXNjbzETMBEGA1UECgwKTWVz +aHRhc3RpYzETMBEGA1UECwwKVEFLIFNlcnZlcjEaMBgGA1UEAwwRTWVzaHRhc3Rp +YyBUQUsgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC2F6/n1CI2 +4dGtLt0irkfiU+PRmqkkuE7m49i7/FeH+38SEn9+0B4egW0kYRoRXmYdPzRsVttu +23LZ3RLjwB6fFI3tiA27mxD58AuEMfwVR7J29oHqFwuVhuqDyjkNpUPFUomKwzvK +SPJvoiHGkbQwWTMNP6T06tCg9llSE7SIgJWjzikQ+JsI37SqVGZ8K2evs7LTuyQh +ssJfYVB7aE1kNNyi8YFHLoCWQMB7h8qJ3hRd7QGFG9gfWuNrWtim61iiHgBAPTRw +gMn+YSIZiV9/iOytBKxFppNTxffEowF/iKBvgXwd9KHxYkk1Nvtcz5NJynSL75PT +8B7XiHCGhcgzAgMBAAGjUzBRMB0GA1UdDgQWBBRRe/o9Raj93Fq22ArNSNrpsye3 +AzAfBgNVHSMEGDAWgBRRe/o9Raj93Fq22ArNSNrpsye3AzAPBgNVHRMBAf8EBTAD +AQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAsuSQ+j/1Bm7HbZWzN5qChH554vucWoqI0 +sVRHThvCASC6+wSosWZlx/Ag5KnRmBVsYA6CX5ztoF5keiSRy5G7qyRQVjITOq1o +4XUAHBtGxKdRCEzS84GnsW9qeWX7t/xxf2fFr9gPZ7Z4nuyNg7QyX5FM01BtAlZC +HbBhXvJyHRqJkMe7keYU7GmiAs1RZa+7593uEQ8DQ/kRvCzU0XswFSguJrd4Fnpi +PGesGOk0NHFQY9pIu9oshgPgMA9dEWnhhvAF3PZ3sLRn9sSuslj5oumFsTYboByE +aOKQshFe5xEX/4O7DI+wsD1Pt5gdT75nAuG7GEAIFKKGjQtUUYfH +-----END CERTIFICATE----- diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_certs/client.p12 b/core/takserver/src/jvmAndroidMain/resources/tak_certs/client.p12 new file mode 100644 index 0000000000000000000000000000000000000000..2f27bff2d6a5e102bb7f6c343c6a1bacd530a01f GIT binary patch literal 3827 zcmai%Ra6uV*M(siy1OKXZf59~lvYv(kZ$RghCv!6hejG1X(T13q)S2=QV9`|9vVKs z_5bUAFTabk&f0rloV&fA1BMYD0njnPFrsoST%KsP=u1L$Z1f@+Q6>;Zl=jzt2ZrI? z{2O5v!EpBfTDt&rw7+ZP-vk|O@sGeH1nYqz|4ITd8<6B>YXf4p8|-+3jMm>|GPjeHRQ?95lw%h$R&(@2y#h=y^xPoE3(OBv+Y2 zD0|sQwbe&|WtF?V*63pki3&NW6zs6_NyalKY_BSDTLK}&&Cd=uJ?qJED9On0zr-;Z zSL=#op>@>1l3EJ&)F{HD%Lv>0Gr7#R{ARcX^dt3xmh%{^xOdXJ03p#)ik;&mbmM0w zw_RX=O@dRWT_w)X6vyUColxL6;`puz)f_Q5QB?yUj2+a>Rmt?QPZ@ll-%zgpv-J?o z0sE<=u|O*p(6Z@$&)ze1xrdATQ^SLlvoi_G=Gu&}_V=GDPbIMjys!Ij{Do z(@yPqYxj7N^m7+@@*g#|O;YA{L|kEyUWGvcMp5foof+3`O^U8iDF4CBI#9*FjSpTK z(kV;RlA;rXRbWFF7|Eljyx(zSoj7{sAI7MnGDMujI9(0p>zjZxu>EdPcO}n?VaC5x z-9iNPB9BygVFRC;Zjs!L5KsBSmCbd>v=)U4nR%Sa$sIb)qS{R4Hz=zOYolC}#)wij zd;{U&2&sXWs_pL5V~EOH|9Yl5takQmjiqebepv;l__d^Hq9y%h)=mOV zzm$_k9ZSD#Lh^N$AVxV(IsiH*sTaNXkE7X+-~krY8iq52t{_#I`JS3fYi∈>}Zi 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 z + + + <_radio rssi="-19.4" gps="true"/>000001 ICAO: 000001 REG: NTEST1 Flight: TST100 Type: A321 Squawk: 3456 DO-260B Category: A3 #adsbreceiver<_flow-tags_ TAK-Server-00000000000000000000000000000001="2026-03-15T17:45:00Z"/> + diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/aircraft_hostile.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/aircraft_hostile.xml new file mode 100644 index 000000000..226586fcb --- /dev/null +++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/aircraft_hostile.xml @@ -0,0 +1,5 @@ + + + + TST200 NTEST2 000002 Cat:A6 Type:HAWK sim-host@example.test<_aircot_ flight="TST200" reg="NTEST2" cat="A6" icao="000002" cot_host_id="sim-host@example.test" type="HAWK"/><_flow-tags_ TAK-Server-00000000000000000000000000000001="2026-03-15T18:20:00Z"/> + diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/alert_tic.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/alert_tic.xml new file mode 100644 index 000000000..00fc76f12 --- /dev/null +++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/alert_tic.xml @@ -0,0 +1,8 @@ + + + + + + Troops in contact, requesting support at grid reference + + diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/casevac.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/casevac.xml new file mode 100644 index 000000000..9bcbae011 --- /dev/null +++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/casevac.xml @@ -0,0 +1,10 @@ + + + + + + + 2 urgent surgical, 1 priority. LZ marked with green smoke. No enemy activity. + <_flow-tags_ TAK-Server-00000000000000000000000000000001="2026-03-15T20:00:00Z"/> + + diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/casevac_medline.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/casevac_medline.xml new file mode 100644 index 000000000..0c169f2b7 --- /dev/null +++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/casevac_medline.xml @@ -0,0 +1,10 @@ + + + + + + <_medevac_ precedence="Urgent" hoist="true" extraction_equipment="true" ventilator="false" blood="false" litter="2" ambulatory="1" security="N" hlz_marking="Smoke" zone_prot_marker="Green smoke" us_military="2" us_civilian="0" non_us_military="1" non_us_civilian="0" epw="0" child="0" terrain_slope="true" terrain_rough="false" terrain_loose="true" terrain_trees="false" terrain_wires="false" terrain_other="false" freq="38.90"/> + + + + diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/chat_receipt_delivered.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/chat_receipt_delivered.xml new file mode 100644 index 000000000..d66d9c3cd --- /dev/null +++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/chat_receipt_delivered.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/chat_receipt_read.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/chat_receipt_read.xml new file mode 100644 index 000000000..86d8bfb90 --- /dev/null +++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/chat_receipt_read.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/delete_event.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/delete_event.xml new file mode 100644 index 000000000..4c43a27ea --- /dev/null +++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/delete_event.xml @@ -0,0 +1,5 @@ + + + + <_flow-tags_ TAK-Server-00000000000000000000000000000001="2026-03-15T19:30:00Z"/> + diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/drawing_circle.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/drawing_circle.xml new file mode 100644 index 000000000..a94353b0f --- /dev/null +++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/drawing_circle.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/drawing_circle_large.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/drawing_circle_large.xml new file mode 100644 index 000000000..d155be57d --- /dev/null +++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/drawing_circle_large.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/drawing_ellipse.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/drawing_ellipse.xml new file mode 100644 index 000000000..232877e83 --- /dev/null +++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/drawing_ellipse.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/drawing_freeform.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/drawing_freeform.xml new file mode 100644 index 000000000..9787f0741 --- /dev/null +++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/drawing_freeform.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/drawing_polygon.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/drawing_polygon.xml new file mode 100644 index 000000000..6efcd9ee0 --- /dev/null +++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/drawing_polygon.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/drawing_rectangle.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/drawing_rectangle.xml new file mode 100644 index 000000000..cb22fca8d --- /dev/null +++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/drawing_rectangle.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/drawing_rectangle_itak.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/drawing_rectangle_itak.xml new file mode 100644 index 000000000..0197969d2 --- /dev/null +++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/drawing_rectangle_itak.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/drawing_telestration.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/drawing_telestration.xml new file mode 100644 index 000000000..ca9b1f22b --- /dev/null +++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/drawing_telestration.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/emergency_911.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/emergency_911.xml new file mode 100644 index 000000000..88225bc63 --- /dev/null +++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/emergency_911.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/emergency_cancel.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/emergency_cancel.xml new file mode 100644 index 000000000..6f4f0257d --- /dev/null +++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/emergency_cancel.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/geochat_broadcast.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/geochat_broadcast.xml new file mode 100644 index 000000000..30872f9ff --- /dev/null +++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/geochat_broadcast.xml @@ -0,0 +1,12 @@ + + + + + <__chat parent="RootContactGroup" groupOwner="false" messageId="a1b2c3d4" chatroom="All Chat Rooms" id="All Chat Rooms" senderCallsign="ETHEL"> + + + + <__serverdestination destinations="0.0.0.0:4242:tcp:ANDROID-0000000000000003"/> + at breach + + diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/geochat_dm.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/geochat_dm.xml new file mode 100644 index 000000000..f3fcd1828 --- /dev/null +++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/geochat_dm.xml @@ -0,0 +1,12 @@ + + + + + <__chat parent="RootContactGroup" groupOwner="false" messageId="e5f6a7b8" chatroom="ANDROID-0000000000000004" id="ANDROID-0000000000000004" senderCallsign="ETHEL"> + + + + <__serverdestination destinations="0.0.0.0:4242:tcp:ANDROID-0000000000000003"/> + cover by fire + + diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/geochat_simple.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/geochat_simple.xml new file mode 100644 index 000000000..6fdbf123e --- /dev/null +++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/geochat_simple.xml @@ -0,0 +1,12 @@ + + + + + <__chat senderCallsign="TESTNODE-01" chatRoom="All Chat Rooms" id="All Chat Rooms" parent="RootContactGroup"> + + + + Roger that, moving to rally point + <__serverdestination destinations="0.0.0.0:4242:tcp:ANDROID-0000000000000002"/> + + diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/marker_2525.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/marker_2525.xml new file mode 100644 index 000000000..da28fee7e --- /dev/null +++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/marker_2525.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/marker_goto.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/marker_goto.xml new file mode 100644 index 000000000..7cc637331 --- /dev/null +++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/marker_goto.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/marker_goto_itak.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/marker_goto_itak.xml new file mode 100644 index 000000000..e1d4548c3 --- /dev/null +++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/marker_goto_itak.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/marker_icon_set.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/marker_icon_set.xml new file mode 100644 index 000000000..76739bd71 --- /dev/null +++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/marker_icon_set.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/marker_spot.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/marker_spot.xml new file mode 100644 index 000000000..2f3499c4b --- /dev/null +++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/marker_spot.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/marker_tank.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/marker_tank.xml new file mode 100644 index 000000000..530ef51f6 --- /dev/null +++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/marker_tank.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/pli_basic.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/pli_basic.xml new file mode 100644 index 000000000..51435cf5a --- /dev/null +++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/pli_basic.xml @@ -0,0 +1,5 @@ + + + + <_flow-tags_ TAK-Server-00000000000000000000000000000001="2026-03-15T14:22:10Z"/> + diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/pli_full.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/pli_full.xml new file mode 100644 index 000000000..9283cf94b --- /dev/null +++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/pli_full.xml @@ -0,0 +1,5 @@ + + + + <__group role="Team Member" name="Cyan"/><_flow-tags_ TAK-Server-00000000000000000000000000000001="2026-03-15T15:30:00Z"/> + diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/pli_itak.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/pli_itak.xml new file mode 100644 index 000000000..cf86918d8 --- /dev/null +++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/pli_itak.xml @@ -0,0 +1,11 @@ + + + + + + <__group name="Cyan" role="Team Member"/> + + + + + diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/pli_stationary.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/pli_stationary.xml new file mode 100644 index 000000000..6b4da149b --- /dev/null +++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/pli_stationary.xml @@ -0,0 +1,12 @@ + + + + + + + <__group role="Team Member" name="Cyan"/> + + + + + diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/pli_takaware.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/pli_takaware.xml new file mode 100644 index 000000000..14992583a --- /dev/null +++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/pli_takaware.xml @@ -0,0 +1,11 @@ + + + + + + <__group role="Team Member" name="Cyan"/> + + + + + diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/pli_webtak.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/pli_webtak.xml new file mode 100644 index 000000000..ea13b008b --- /dev/null +++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/pli_webtak.xml @@ -0,0 +1,5 @@ + + + + <__group name="Cyan" role="Team Member"/><_flow-tags_ TAK-Server-00000000000000000000000000000001="2026-03-15T16:10:00Z"/> + diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/ranging_bullseye.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/ranging_bullseye.xml new file mode 100644 index 000000000..e23bf3fb6 --- /dev/null +++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/ranging_bullseye.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/ranging_circle.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/ranging_circle.xml new file mode 100644 index 000000000..544b33a7b --- /dev/null +++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/ranging_circle.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/ranging_line.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/ranging_line.xml new file mode 100644 index 000000000..93d170565 --- /dev/null +++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/ranging_line.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/route_3wp.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/route_3wp.xml new file mode 100644 index 000000000..ede8bed8c --- /dev/null +++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/route_3wp.xml @@ -0,0 +1,16 @@ + + + + + <__routeinfo/> + + + + + + + + + + + diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/route_itak_3wp.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/route_itak_3wp.xml new file mode 100644 index 000000000..9be1f6169 --- /dev/null +++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/route_itak_3wp.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/task_engage.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/task_engage.xml new file mode 100644 index 000000000..602ae5cbf --- /dev/null +++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/task_engage.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/waypoint.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/waypoint.xml new file mode 100644 index 000000000..3f41333cc --- /dev/null +++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/waypoint.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/core/takserver/src/jvmMain/kotlin/org/meshtastic/core/takserver/AtakFileWriter.kt b/core/takserver/src/jvmMain/kotlin/org/meshtastic/core/takserver/AtakFileWriter.kt new file mode 100644 index 000000000..ec7f7727e --- /dev/null +++ b/core/takserver/src/jvmMain/kotlin/org/meshtastic/core/takserver/AtakFileWriter.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.takserver + +/** + * Desktop JVM no-op — writing data packages to ATAK's monitored directory is Android-only behaviour. On desktop, data + * packages are shared via the export launcher (file chooser) instead. + */ +internal actual object AtakFileWriter { + actual fun writeToImportDir(fileName: String, zipBytes: ByteArray): Boolean = false +} diff --git a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt index e9163df08..a5d55d774 100644 --- a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt +++ b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt @@ -250,10 +250,18 @@ actual fun rememberRequestNotificationPermission(onGranted: () -> Unit, onDenied return remember(launcher) { { launcher.launch(android.Manifest.permission.POST_NOTIFICATIONS) } } } +// API level at which ACCESS_LOCAL_NETWORK became a real runtime permission (Android 17 / API 37). +// Hardcoded as an integer literal because Build.VERSION_CODES does not yet expose a named constant +// for API 37 in the SDK we compile against (current max named constant is VANILLA_ICE_CREAM / API 35). +// On older API levels the permission string is unknown to the system and requestPermission() returns +// an immediate denial, which would incorrectly disable any caller that disables itself on denial. +private const val LOCAL_NETWORK_PERMISSION_API = 37 + @Composable actual fun rememberRequestLocalNetworkPermission(onGranted: () -> Unit, onDenied: () -> Unit): () -> Unit { - if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.S) { - // Pre-Android 12, no local network permission required + if (android.os.Build.VERSION.SDK_INT < LOCAL_NETWORK_PERMISSION_API) { + // Pre-Android 17, ACCESS_LOCAL_NETWORK is not a runtime permission. Localhost / LAN access + // works implicitly under the INTERNET permission, so report granted without prompting. return remember { { onGranted() } } } val currentOnGranted = rememberUpdatedState(onGranted) @@ -267,8 +275,8 @@ actual fun rememberRequestLocalNetworkPermission(onGranted: () -> Unit, onDenied @Composable actual fun isLocalNetworkPermissionGranted(): Boolean { - if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.S) { - // Pre-Android 12, no runtime local-network permission exists; access is implicit via INTERNET. + if (android.os.Build.VERSION.SDK_INT < LOCAL_NETWORK_PERMISSION_API) { + // Pre-Android 17, no runtime local-network gate; access is implicit via INTERNET. return true } val context = LocalContext.current diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/TakPermissionUtil.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/TakPermissionUtil.kt index b9c118824..8c55e36d6 100644 --- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/TakPermissionUtil.kt +++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/TakPermissionUtil.kt @@ -17,6 +17,8 @@ package org.meshtastic.feature.settings.tak import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import org.meshtastic.core.ui.util.isLocalNetworkPermissionGranted import org.meshtastic.core.ui.util.rememberRequestLocalNetworkPermission @Composable @@ -26,13 +28,21 @@ actual fun TakPermissionHandler(isTakServerEnabled: Boolean, onPermissionResult: // when targetSdk >= 37, and is requested up-front from the Connections screen, so it will usually // already be granted by the time the user enables TAK. This composable handles the standalone case // (e.g. user opens TAK settings before ever tapping the network-scan toggle). + val isPermissionGranted = isLocalNetworkPermissionGranted() val requestPermission = rememberRequestLocalNetworkPermission( onGranted = { onPermissionResult(true) }, onDenied = { onPermissionResult(false) }, ) - if (isTakServerEnabled) { - requestPermission() + // The launcher must run as a post-composition side effect — invoking it directly in the composition + // body crashes with "Launcher has not been initialized" because the underlying + // ActivityResultLauncherHolder is not linked to the activity until composition completes. Keying on + // both inputs also guarantees we only re-prompt when state actually transitions, not on every + // recomposition. + LaunchedEffect(isTakServerEnabled, isPermissionGranted) { + if (isTakServerEnabled && !isPermissionGranted) { + requestPermission() + } } } diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/ModuleConfigurationScreen.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/ModuleConfigurationScreen.kt index a5f4ca650..7701f7192 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/ModuleConfigurationScreen.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/ModuleConfigurationScreen.kt @@ -50,12 +50,13 @@ fun ModuleConfigurationScreen( val state by viewModel.radioConfigState.collectAsStateWithLifecycle() val destNode by viewModel.destNode.collectAsStateWithLifecycle() + val deviceRole = state.radioConfig.device?.role val modules = - remember(state.metadata, excludedModulesUnlocked) { + remember(state.metadata, deviceRole, excludedModulesUnlocked) { if (excludedModulesUnlocked) { ModuleRoute.entries } else { - ModuleRoute.filterExcludedFrom(state.metadata, state.userConfig.role) + ModuleRoute.filterExcludedFrom(state.metadata, deviceRole) } } diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt index 0183085e1..d0e4f4a00 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt @@ -66,6 +66,7 @@ import org.meshtastic.feature.settings.radio.component.SerialConfigScreen import org.meshtastic.feature.settings.radio.component.StatusMessageConfigScreen import org.meshtastic.feature.settings.radio.component.StoreForwardConfigScreen import org.meshtastic.feature.settings.radio.component.TAKConfigScreen +import org.meshtastic.feature.settings.radio.component.TakServerScreen import org.meshtastic.feature.settings.radio.component.TelemetryConfigScreen import org.meshtastic.feature.settings.radio.component.TrafficManagementConfigScreen import org.meshtastic.feature.settings.radio.component.UserConfigScreen @@ -235,6 +236,8 @@ fun EntryProviderScope.settingsGraph(backStack: NavBackStack) { } } + entry { TakServerScreen(onBack = { backStack.removeLastOrNull() }) } + entry { val viewModel: DebugViewModel = koinViewModel() DebugScreen(viewModel = viewModel, onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() }) diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfig.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfig.kt index 8ffa6125d..868c5ff84 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfig.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfig.kt @@ -52,6 +52,7 @@ import org.meshtastic.core.resources.nodedb_reset import org.meshtastic.core.resources.radio_configuration import org.meshtastic.core.resources.reboot import org.meshtastic.core.resources.shutdown +import org.meshtastic.core.resources.tak_server import org.meshtastic.core.ui.component.ListItem import org.meshtastic.core.ui.icon.AdminPanelSettings import org.meshtastic.core.ui.icon.AppSettingsAlt @@ -209,6 +210,13 @@ private fun AdvancedSection(isManaged: Boolean, isOtaCapable: Boolean, enabled: onClick = { onNavigate(SettingsRoute.CleanNodeDb) }, ) + ListItem( + text = stringResource(Res.string.tak_server), + leadingIcon = MeshtasticIcons.Settings, + enabled = enabled, + onClick = { onNavigate(SettingsRoute.TakServer) }, + ) + ListItem( text = stringResource(Res.string.debug_panel), leadingIcon = MeshtasticIcons.BugReport, diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigItemList.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigItemList.kt index 55fdca66c..fda67356a 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigItemList.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigItemList.kt @@ -14,39 +14,83 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ +@file:Suppress("LongMethod", "ModifierMissing", "ParameterNaming") + package org.meshtastic.feature.settings.radio.component +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import org.koin.compose.koinInject +import org.meshtastic.core.common.BuildConfigProvider import org.meshtastic.core.model.getColorFrom import org.meshtastic.core.model.getStringResFrom +import org.meshtastic.core.repository.CommandSender import org.meshtastic.core.repository.TakPrefs import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.back import org.meshtastic.core.resources.export_tak_data_package import org.meshtastic.core.resources.tak import org.meshtastic.core.resources.tak_config import org.meshtastic.core.resources.tak_role +import org.meshtastic.core.resources.tak_server import org.meshtastic.core.resources.tak_server_enabled import org.meshtastic.core.resources.tak_server_enabled_desc +import org.meshtastic.core.resources.tak_server_export_data_package_desc +import org.meshtastic.core.resources.tak_server_loading +import org.meshtastic.core.resources.tak_server_section +import org.meshtastic.core.resources.tak_server_test_card_title +import org.meshtastic.core.resources.tak_server_test_idle +import org.meshtastic.core.resources.tak_server_test_result_bytes +import org.meshtastic.core.resources.tak_server_test_result_unknown_error +import org.meshtastic.core.resources.tak_server_test_results +import org.meshtastic.core.resources.tak_server_test_run +import org.meshtastic.core.resources.tak_server_test_running import org.meshtastic.core.resources.tak_team import org.meshtastic.core.takserver.TAKDataPackageGenerator +import org.meshtastic.core.takserver.TakMeshTestRunner +import org.meshtastic.core.takserver.TakTestResult import org.meshtastic.core.ui.component.DropDownPreference import org.meshtastic.core.ui.component.SwitchPreference import org.meshtastic.core.ui.component.TitledCard +import org.meshtastic.core.ui.icon.ArrowBack import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.PlayArrow import org.meshtastic.core.ui.icon.Share import org.meshtastic.feature.settings.radio.RadioConfigViewModel +import org.meshtastic.feature.settings.radio.ResponseState import org.meshtastic.feature.settings.tak.TakPermissionHandler import org.meshtastic.feature.settings.tak.rememberDataPackageExporter +import org.meshtastic.proto.MemberRole import org.meshtastic.proto.ModuleConfig +import org.meshtastic.proto.Team + +// ── TAK Config Screen (Module Settings) ───────────────────────────────────── +// Shows only the firmware module config: team and role dropdowns. @Composable fun TAKConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { @@ -54,13 +98,77 @@ fun TAKConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { val takConfig = state.moduleConfig.tak ?: ModuleConfig.TAKConfig() val formState = rememberConfigState(initialValue = takConfig) + LaunchedEffect(takConfig) { formState.value = takConfig } + + val effectiveResponseState = + when (state.responseState) { + is ResponseState.Loading -> ResponseState.Empty + else -> state.responseState + } + + RadioConfigScreenList( + title = stringResource(Res.string.tak), + onBack = onBack, + configState = formState, + enabled = state.connected, + responseState = effectiveResponseState, + onDismissPacketResponse = viewModel::clearPacketResponse, + onSave = { + val config = ModuleConfig(tak = it) + viewModel.setModuleConfig(config) + }, + ) { + item { + TakConfigCard( + team = formState.value.team, + role = formState.value.role, + enabled = state.connected, + onTeamSelected = { formState.value = formState.value.copy(team = it) }, + onRoleSelected = { formState.value = formState.value.copy(role = it) }, + ) + } + } +} + +/** Stateless TAK team/role config card — previewable without DI. */ +@Composable +internal fun TakConfigCard( + team: Team, + role: MemberRole, + enabled: Boolean, + onTeamSelected: (Team) -> Unit, + onRoleSelected: (MemberRole) -> Unit, +) { + TitledCard(title = stringResource(Res.string.tak_config)) { + DropDownPreference( + title = stringResource(Res.string.tak_team), + enabled = enabled, + selectedItem = team, + itemLabel = { stringResource(getStringResFrom(it)) }, + itemColor = { Color(getColorFrom(it)) }, + onItemSelected = onTeamSelected, + ) + HorizontalDivider() + DropDownPreference( + title = stringResource(Res.string.tak_role), + enabled = enabled, + selectedItem = role, + itemLabel = { stringResource(getStringResFrom(it)) }, + onItemSelected = onRoleSelected, + ) + } +} + +// ── TAK Server Screen (Settings → Advanced) ───────────────────────────────── +// App-local TAK server controls: enable/disable, export data package, debug test harness. + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TakServerScreen(onBack: () -> Unit) { val takPrefs: TakPrefs = koinInject() val isTakServerEnabled by takPrefs.isTakServerEnabled.collectAsStateWithLifecycle() - val exportLauncher = rememberDataPackageExporter { TAKDataPackageGenerator.generateDataPackage() } - LaunchedEffect(takConfig) { formState.value = takConfig } - TakPermissionHandler( isTakServerEnabled = isTakServerEnabled, onPermissionResult = { granted -> @@ -70,68 +178,188 @@ fun TAKConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { }, ) - RadioConfigScreenList( - title = stringResource(Res.string.tak), - onBack = onBack, - actions = { - IconButton(onClick = { exportLauncher("Meshtastic_TAK_Server.zip") }) { - Icon( - imageVector = MeshtasticIcons.Share, - contentDescription = stringResource(Res.string.export_tak_data_package), - ) - } - }, - configState = formState, - enabled = state.connected, - responseState = state.responseState, - onDismissPacketResponse = viewModel::clearPacketResponse, - onSave = { - val config = ModuleConfig(tak = it) - viewModel.setModuleConfig(config) - }, - ) { - item { - TAKConfigCard( - formState = formState, - isTakServerEnabled = isTakServerEnabled, - isConnected = state.connected, - onTakServerEnabledChange = { takPrefs.setTakServerEnabled(it) }, + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(Res.string.tak_server)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + imageVector = MeshtasticIcons.ArrowBack, + contentDescription = stringResource(Res.string.back), + ) + } + }, + actions = { + if (isTakServerEnabled) { + IconButton(onClick = { exportLauncher("Meshtastic_TAK_Server.zip") }) { + Icon( + imageVector = MeshtasticIcons.Share, + contentDescription = stringResource(Res.string.export_tak_data_package), + ) + } + } + }, ) + }, + ) { padding -> + Column(modifier = Modifier.padding(padding).padding(horizontal = 16.dp)) { + TakServerSection( + isTakServerEnabled = isTakServerEnabled, + onEnabledChange = { takPrefs.setTakServerEnabled(it) }, + onExport = { exportLauncher("Meshtastic_TAK_Server.zip") }, + ) + TakMeshTestCard() } } } +/** Stateless TAK server enable/disable section — previewable without DI. */ @Composable -private fun TAKConfigCard( - formState: ConfigState, - isTakServerEnabled: Boolean, - isConnected: Boolean, - onTakServerEnabledChange: (Boolean) -> Unit, -) { - TitledCard(title = stringResource(Res.string.tak_config)) { +internal fun TakServerSection(isTakServerEnabled: Boolean, onEnabledChange: (Boolean) -> Unit, onExport: () -> Unit) { + TitledCard(title = stringResource(Res.string.tak_server_section)) { SwitchPreference( title = stringResource(Res.string.tak_server_enabled), summary = stringResource(Res.string.tak_server_enabled_desc), checked = isTakServerEnabled, enabled = true, - onCheckedChange = onTakServerEnabledChange, - ) - HorizontalDivider() - DropDownPreference( - title = stringResource(Res.string.tak_team), - enabled = isConnected, - selectedItem = formState.value.team, - itemLabel = { stringResource(getStringResFrom(it)) }, - itemColor = { Color(getColorFrom(it)) }, - onItemSelected = { formState.value = formState.value.copy(team = it) }, - ) - HorizontalDivider() - DropDownPreference( - title = stringResource(Res.string.tak_role), - enabled = isConnected, - selectedItem = formState.value.role, - itemLabel = { stringResource(getStringResFrom(it)) }, - onItemSelected = { formState.value = formState.value.copy(role = it) }, + onCheckedChange = onEnabledChange, ) + if (isTakServerEnabled) { + HorizontalDivider() + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(Res.string.export_tak_data_package), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = stringResource(Res.string.tak_server_export_data_package_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + IconButton(onClick = onExport) { + Icon( + imageVector = MeshtasticIcons.Share, + contentDescription = stringResource(Res.string.export_tak_data_package), + ) + } + } + } + } +} + +// ── Debug-only TAK Mesh Test Card ──────────────────────────────────────────── + +@Composable +private fun TakMeshTestCard() { + val buildConfig: BuildConfigProvider = koinInject() + if (!buildConfig.isDebug) return + + val commandSender: CommandSender = koinInject() + val testRunner = remember { TakMeshTestRunner(commandSender) } + val results by testRunner.results.collectAsStateWithLifecycle() + val isRunning by testRunner.isRunning.collectAsStateWithLifecycle() + val currentFixture by testRunner.currentFixture.collectAsStateWithLifecycle() + val scope = rememberCoroutineScope() + + TakMeshTestCardContent( + results = results, + isRunning = isRunning, + currentFixture = currentFixture, + fixtureCount = TakMeshTestRunner.FIXTURE_NAMES.size, + onRunTests = { scope.launch { testRunner.runAll() } }, + ) +} + +/** Stateless TAK test results card — previewable without DI. */ +@Composable +internal fun TakMeshTestCardContent( + results: List, + isRunning: Boolean, + currentFixture: String?, + fixtureCount: Int, + onRunTests: () -> Unit, +) { + val loadingLabel = stringResource(Res.string.tak_server_loading) + val passed = results.count { it.passed } + val failed = results.count { !it.passed } + + TitledCard(title = stringResource(Res.string.tak_server_test_card_title)) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = + if (isRunning) { + stringResource(Res.string.tak_server_test_running, currentFixture ?: loadingLabel) + } else { + stringResource(Res.string.tak_server_test_idle, fixtureCount) + }, + style = MaterialTheme.typography.bodyLarge, + ) + if (results.isNotEmpty()) { + Text( + text = + stringResource( + Res.string.tak_server_test_results, + passed, + failed, + results.size, + fixtureCount, + ), + style = MaterialTheme.typography.bodySmall, + color = + if (failed > 0) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } + if (isRunning) { + CircularProgressIndicator() + } else { + Button(onClick = onRunTests) { + Icon(imageVector = MeshtasticIcons.PlayArrow, contentDescription = null) + Text(stringResource(Res.string.tak_server_test_run)) + } + } + } + + // Results list + for (result in results) { + HorizontalDivider() + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = result.fixtureName.removeSuffix(".xml"), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.weight(1f), + ) + Text( + text = + if (result.passed) { + stringResource(Res.string.tak_server_test_result_bytes, result.compressedBytes) + } else { + result.error ?: stringResource(Res.string.tak_server_test_result_unknown_error) + }, + style = MaterialTheme.typography.bodySmall, + color = if (result.passed) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error, + ) + } + } } } diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigPreviews.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigPreviews.kt new file mode 100644 index 000000000..265b5fed7 --- /dev/null +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigPreviews.kt @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +@file:Suppress("PreviewPublic") + +package org.meshtastic.feature.settings.radio.component + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.PreviewLightDark +import org.meshtastic.core.takserver.TakTestResult +import org.meshtastic.core.ui.theme.AppTheme +import org.meshtastic.proto.MemberRole +import org.meshtastic.proto.Team + +@PreviewLightDark +@Composable +fun TakConfigCardPreview() { + AppTheme { + TakConfigCard( + team = Team.Cyan, + role = MemberRole.TeamLead, + enabled = true, + onTeamSelected = {}, + onRoleSelected = {}, + ) + } +} + +@PreviewLightDark +@Composable +fun TakServerSectionDisabledPreview() { + AppTheme { TakServerSection(isTakServerEnabled = false, onEnabledChange = {}, onExport = {}) } +} + +@PreviewLightDark +@Composable +fun TakServerSectionEnabledPreview() { + AppTheme { TakServerSection(isTakServerEnabled = true, onEnabledChange = {}, onExport = {}) } +} + +@PreviewLightDark +@Composable +fun TakTestCardIdlePreview() { + AppTheme { + TakMeshTestCardContent( + results = emptyList(), + isRunning = false, + currentFixture = null, + fixtureCount = 40, + onRunTests = {}, + ) + } +} + +@PreviewLightDark +@Composable +fun TakTestCardRunningPreview() { + AppTheme { + TakMeshTestCardContent( + results = + listOf( + TakTestResult("a-f-G-U-C.xml", xmlBytes = 412, compressedBytes = 87, passed = true), + TakTestResult("a-h-G.xml", xmlBytes = 389, compressedBytes = 92, passed = true), + ), + isRunning = true, + currentFixture = "b-m-p-s-m.xml", + fixtureCount = 40, + onRunTests = {}, + ) + } +} + +@PreviewLightDark +@Composable +fun TakTestCardResultsPreview() { + AppTheme { + TakMeshTestCardContent( + results = + listOf( + TakTestResult("a-f-G-U-C.xml", xmlBytes = 412, compressedBytes = 87, passed = true), + TakTestResult("a-h-G.xml", xmlBytes = 389, compressedBytes = 92, passed = true), + TakTestResult( + "u-d-f-m.xml", + xmlBytes = 1850, + compressedBytes = 310, + passed = false, + error = "Exceeds MTU", + ), + TakTestResult("b-m-p-s-m.xml", xmlBytes = 520, compressedBytes = 115, passed = true), + ), + isRunning = false, + currentFixture = null, + fixtureCount = 40, + onRunTests = {}, + ) + } +} diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigPermissionDeniedTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigPermissionDeniedTest.kt new file mode 100644 index 000000000..bb8ba8fbe --- /dev/null +++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigPermissionDeniedTest.kt @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.settings.radio.component + +import app.cash.turbine.test +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.test.runTest +import org.meshtastic.core.repository.TakPrefs +import kotlin.test.Test +import kotlin.test.assertFalse + +/** + * Tests for TAK permission denied behavior (T075). + * + * Verifies that when ACCESS_LOCAL_NETWORK permission is denied on Android 17+, the TAK server is disabled (not crashed) + * and the UI reflects the disabled state. + * + * The actual UI composition test requires a Compose test rule, but the behavioral contract can be validated at the + * state level: when the permission handler reports denial while the server is enabled, setTakServerEnabled(false) is + * called. + */ +class TAKConfigPermissionDeniedTest { + + /** Minimal TakPrefs that tracks calls to setTakServerEnabled. */ + private class FakeTakPrefs : TakPrefs { + private val _isTakServerEnabled = MutableStateFlow(true) + override val isTakServerEnabled: StateFlow = _isTakServerEnabled + + override fun setTakServerEnabled(enabled: Boolean) { + _isTakServerEnabled.value = enabled + } + } + + @Test + fun `permission denied disables TAK server`() = runTest { + val prefs = FakeTakPrefs() + + // Simulate the exact logic from TAKConfigItemList.kt: + // onPermissionResult = { granted -> if (!granted && isTakServerEnabled) takPrefs.setTakServerEnabled(false) } + val granted = false + if (!granted && prefs.isTakServerEnabled.value) { + prefs.setTakServerEnabled(false) + } + + prefs.isTakServerEnabled.test { assertFalse(awaitItem()) } + } + + @Test + fun `permission denied when already disabled is no-op`() = runTest { + val prefs = FakeTakPrefs() + prefs.setTakServerEnabled(false) // Already disabled + + // Simulate permission denied — should not crash or throw + val granted = false + if (!granted && prefs.isTakServerEnabled.value) { + prefs.setTakServerEnabled(false) + } + + prefs.isTakServerEnabled.test { + assertFalse(awaitItem()) // Still false, no crash + } + } + + @Test + fun `permission granted does not disable TAK server`() = runTest { + val prefs = FakeTakPrefs() + + // Simulate permission granted + val granted = true + if (!granted && prefs.isTakServerEnabled.value) { + prefs.setTakServerEnabled(false) + } + + prefs.isTakServerEnabled.test { + // Server should still be enabled + kotlin.test.assertTrue(awaitItem()) + } + } +} diff --git a/screenshot-tests/src/screenshotTest/kotlin/org/meshtastic/screenshots/feature/SettingsScreenshotTests.kt b/screenshot-tests/src/screenshotTest/kotlin/org/meshtastic/screenshots/feature/SettingsScreenshotTests.kt index 647509578..b1e30c3e9 100644 --- a/screenshot-tests/src/screenshotTest/kotlin/org/meshtastic/screenshots/feature/SettingsScreenshotTests.kt +++ b/screenshot-tests/src/screenshotTest/kotlin/org/meshtastic/screenshots/feature/SettingsScreenshotTests.kt @@ -22,6 +22,12 @@ import com.android.tools.screenshot.PreviewTest import org.meshtastic.feature.settings.component.AppInfoSectionPreview import org.meshtastic.feature.settings.component.AppearanceSectionPreview import org.meshtastic.feature.settings.component.PersistenceSectionPreview +import org.meshtastic.feature.settings.radio.component.TakConfigCardPreview +import org.meshtastic.feature.settings.radio.component.TakServerSectionDisabledPreview +import org.meshtastic.feature.settings.radio.component.TakServerSectionEnabledPreview +import org.meshtastic.feature.settings.radio.component.TakTestCardIdlePreview +import org.meshtastic.feature.settings.radio.component.TakTestCardResultsPreview +import org.meshtastic.feature.settings.radio.component.TakTestCardRunningPreview @PreviewTest @PreviewLightDark @@ -43,3 +49,45 @@ fun ScreenshotPersistenceSection() { fun ScreenshotAppInfoSection() { AppInfoSectionPreview() } + +@PreviewTest +@PreviewLightDark +@Composable +fun ScreenshotTakConfigCard() { + TakConfigCardPreview() +} + +@PreviewTest +@PreviewLightDark +@Composable +fun ScreenshotTakServerSectionDisabled() { + TakServerSectionDisabledPreview() +} + +@PreviewTest +@PreviewLightDark +@Composable +fun ScreenshotTakServerSectionEnabled() { + TakServerSectionEnabledPreview() +} + +@PreviewTest +@PreviewLightDark +@Composable +fun ScreenshotTakTestCardIdle() { + TakTestCardIdlePreview() +} + +@PreviewTest +@PreviewLightDark +@Composable +fun ScreenshotTakTestCardRunning() { + TakTestCardRunningPreview() +} + +@PreviewTest +@PreviewLightDark +@Composable +fun ScreenshotTakTestCardResults() { + TakTestCardResultsPreview() +} diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakConfigCard_Dark_d19fbf1f_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakConfigCard_Dark_d19fbf1f_0.png new file mode 100644 index 0000000000000000000000000000000000000000..a740fcdfe4edf0936d6eb9ff2674a3bbcdff994d GIT binary patch literal 23863 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 literal 0 HcmV?d00001 diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakConfigCard_Light_b29dc7a7_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakConfigCard_Light_b29dc7a7_0.png new file mode 100644 index 0000000000000000000000000000000000000000..bfa18e380114405b565f6271ceecd30c09d40c98 GIT binary patch literal 23710 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 literal 0 HcmV?d00001 diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionDisabled_Light_b29dc7a7_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionDisabled_Light_b29dc7a7_0.png new file mode 100644 index 0000000000000000000000000000000000000000..023b84a01e9302683246ec322902aa54c82d638e GIT binary patch literal 27506 zcmc$`cRX9||36L@Ek(E1Y^yb@sM=!GttcvLwZ z1sJlAleg#U(6g^1Ps8;tm%Dv)d+_1rO}Edj*Y16|^Q~3yb?Y^^vkEV+-#>Ns!_C0e z4!gaTnNGPW3E;O!La}Ez%tTWGx$}q;D=~$vL;het*ltnw0k#4BJSEfbvi<$2`kLx`|nzZ9%98oLT4B zO(f($D{G%9u?vgfIo`kvgD!g6au{x{%wec~cZSV>5jn$bA-EI@T5F|O1}DhQ%+Vj; z_$|fu1Q*C_ZELM|6D;uhoJNUlS8arVuDtg#k*}AHFVKei?eEBa|L6vBZMuZ5_FPHm z`9tK8@Fs0&q9Q_Gg=s>jo~lF4$t~D$u342PF6S1K>qz-9Ip|C6uXkLPn2#SYL_cDb z&D2d=JIU{RctAW{W8sqRov8P(FG;c&YzGl*XQiCR2ggk6c7_UlIAxqY$bT3;Bv3-j zN-tQqzX}7rIooWcNFU^M0oeZ)&ygUL_rLs20l+N2@~rasXy?9^(L!e$4cx_wQmJcs6gM1Hi4>!_xzSd zvy)iSg(`)e#k6knaK0%hiG2u3nZnPFWN5PsKtAV1UOjqKvb@c`hGN%Qb@aL0fNHw- z4+}^|*1%Vnxl9a~i(Lq#;sO??PptP_zwlu%=qk+YO}zmBl!svz>}tL?V_vPNZpLP0 zX}tFVjZ&Sas)sZk+n>eK9xa?>z)#1Oge1pOLSVj)&<_rRjezDQY>`x2E)MeDC~z#q z41?SkK1I|oO;)*g`}G%FYv5thCB+5m{*UDxLNDw}XvZoct0Q`J{WdNLpoD_|vIY#W z<{_}dgn%qxN)3(#Ct_FAbtfWpm6o`IeV4Q?+PehAJr*Amr@U~a%VOpU@zMXNs=W14 zXk`|wot}d+t%RI?KlKDQloJ)9zG*g^^i=C{^7G%*-@)<*#vNyl_Wl@ok8^r^dp69y zQHao-rR%PIQ+&z(;%sw>yOPCV$^8i1@g?Lgh1jEG4hGjeJ(}-bgQj>4)+vuOUgf7M@C`AvP@Eiv}h7w>wbyQn{{K6;JkO&w! zUb<^c;L}3GHkML{pDS?~x-H-o1Ly;2m)-Q4%*tP;DaEq7JnVfCt0MVj?$cF?S_3p# z&f73mBaElpPN?X-K-M?wme9$fGa%mlJI~QkMa?^hI~Iq+GPJp7ng&6 zM-}=uQP0dfp+)A)M$FwemU>g(MSy>BtQ6KCR7XfNv79d6{3!oOO;MD9y>ffD$Wkr; zuZC6+K6u?Y0@yQ}*0dc%!LEovst4PIKc2q8FH04=o9xX=D9x}xI0Y)?-CxuI%b#}4 z6YjM}hb#jwE+hgMC*_#nQ4ZLt{BCx|CsenI_TO#uNL`vf%c9q7#tK(0k8~`k;;(of zh_Vn@QI)6$3?M!DpN<%Z%70N7k99u)3?Z*hpi1>ZcofImbQNzBJj!jm;^cNko)k4` z;&U_Z9eX~*usCLy?NY;+XfUd_{&2V6i=(XR$l;`H%Em~_q509JlrYYl;`;{8&Y3pc{zoGf9i$tqo>jSJjC`}{ zc=A2;`#VMuHHMW}i9q0s>RefGpktGz(&w*zF}B|S&dBSraYy=*&Db{^@Q$?8xa^W| z*%R9%&3?VyD|mv7zpLu^LOjez`HCr+xP ztpvGC2FqLI8s{f$tqkY8_fs8;JC7Ek8A|D+mwn_g=^$rV)92{3yIO(Tf6?^u5_nwl z42NVZl~BsD(r%*qs8gXaAlv@^B`1jiKOzFh{AeY&=`XVa)w!pCb1S^M>k&If^rO_g zao6fhRGiR^4^Tqx$R?p|t@r1nE#Mn0%TdKgdJXAGR781XJ`<5R+Q9urzJ_)G1SRBn z4d5IT7>XVxDNSh>){iaqKgNZSk{i?lS-BHDo>?`4O-xMi=@LT@e=LpzfB}tf=on8# zb0hcGTTrFJ>|*eKsM?n@#}R%lj5eVM40xgI{5Wd27t;pj*GFNdfW*ba)fu4?3x7;R zbpBs2(fj+9%acST`6>F`p4j1r7`U3WG6Gtv+sY7=Tk8J|x85g>Sa@v=Q+Fobbt4C} z^uS8x$@>l&fB45D>`ohsPegP>(*X$$YMK;$;nR?(2BVxQYg1qjJ+ZSOr~ZEDJjbjc zlvhhA_vQo;^v#M^gDv(_DeEn~AnK??)qgFOjHNe$ct%AOm z9T$63zz54IE;B#q1@bw_@Nt18-+s;Lqm>v*FT4-;DHsvca#??WJjyF!zj5%dOXC19 zoZtdHv>QkEJ)bRm0Vlac?}e)ZFp*1+=nGvSE40zwoA%)yH-|K=A*x^xQQHGZwa2>8 z)}r3;ibCIc;i^mag#X`aZkt^)AQq$iX4l2u>jF+(am@cjn2&oaAMR~}pHb%`blq5d zLHml7$=UP}2SDwshSUl5@yofze!wQ#=MMLY{63cJ&@<_g(Wym5%%e4FYzHQPd>*Ss@vqom-uZ&B*{WM^n(AtHP~FXFN-{z zI|934I_c0tKT>X0(Cwis<(pwg*a{mWgI%2Ce(xiA=O}Ir7#}$YO%Oby` zlncP>x=X^wuK;IAa;}_VK#OxePzjlEuIY+b@OE?dV+7T^9eI1iuONY1nC~oSx83+4 z=ku+_zAtpbq^T#ka;Nysr_c09z$V$xZvSyn`#DuKflh$+V#9A~*9sYAzETZjC8>vS z%y-3z^Rb^A1F&?25|ratF?1kvvcLQS|D)dwEF6*XlZF9oykI&I%^xa1w?q(yBo{;*|tR_#v?Ie4m! zG_+tcVIlsyzWIa_r90_xGKl@ANEA&VtC@hy%$`L;#GmvmB<%S~CaQw$>@+{D{ri)5mvn;Mg7pGg*Ok=eF zBvOVAY#yVofwJvPE@ri_`au5Vo^IM>cuf&K6iZezABUHKf|Y8>>mXW zCzKsq3k{9E{l}*#yIN*W%3g^(&i`m~p7uVDdP~y+z&A{fSTEu)Tf~|~Ma}P=al3d@ z_KDP=-M)A;70kywUFkC8PTXW(`;*$y45^Es33=dpf@=%=XNyn6gKc7HS&hMexboEf z`xCue?8izFaq7>0$LF7O_`QCYVRd}V{maydkb^SDma2N$(|^{;frw_N(6lr$|7X#$ zjL~NHZj{Xd%kd4LAs?N5-%w?azuf;SRKEUxGOn1G^Uo{4-7gLiOHkYuu=_*Ied7d! z*Ry+L${@}^%YXYdIi`%TsBAc2e0XxBD8$UtZ^3Z#Z7Z{&9^L?}l;y<6-qktr~*(-nMX%3l-h1i_hNVmh#bB+hf0DHUm^2 z+H!MudxeDm<@>W~9WpSF;Du14TEUxof&bm4En~B0wZ#H`5u!+*>;6XW_ zf2%{6brS2wN~!HYUs`a=VbqZ0xg-^X(8CxVzj{ zjNCQHgK~}jzSibxk<0smELI7R(O0mQ@_le4_j)#0;2Cawq-#uu_J|w~B68!8&_1l@ zSMbZREM0@Ka;!Rye`|rJr@3*;49)xe9JS$jvA6A&Kr9+QJAKU02bo6lJ|rH3WU>=- zH{>@KSJVNG4`^`*{(<8F4wh~qfLpPMCOxKF800s7qf)pZsPVzDu3>BeC4Cs<`!oClSW zY~1oBk2=1A?b8woMA?;7E`wQ(fHGO^?%?Jzdv2xzI_K9glrL|4iYw@>vOpn?c=m%9 z&`w`QJmD(@%Lt<24-Nstl3N0@1y9p&9{B_JrJ)Cbiij|I7AMJsDMV;wLbNp%v4SGX zcN|rb;&I9f2WB^x;XndaNPklHW64S{LVH9O`nf=Ixm%(~(#vmFQ;ynZ*Nwll*%z2} zk)Z6YZ*y+r<}$$)rX$#@I-l|S9jZXz(FC^ftlYP&=0K+6)giH>KyW(|8Fnyp6-Fkg zVeQb$USvifg|b=sDv!2$$sz6*K2WJnx}xNkx`~P{cbb^>#XpT|qiJb)!TbC%Kl0dc;f0Z#u8ubrbdO( z2xpgU;>Ab@g@?ODW^$60V@gE9`CT#>`Czt1*{fTQiF;zZ*dRfH7D(LWk;|zgb=GWX z6~X`)nkHU=I&! zh!+W<7p;N-VtoiE}5tp^2Sl|ycY>*>yiN@ zhtXbf`XpC8gfegJ_H7_mG-0=dva_?%LMIw~lfNJRL9k02x2r%7f>DLTP3FuHx zezzA?o+E9mcSY6r52;$m_fIiHN%QUh4p4DUIs=C`j&&e774Wzx2anc}x9!9jvwwrwIh6Wn9s@3kl7Iu)beX z;6^!3z^Qmk*kV>fxTMBbCSd=LWNQ;A6|SWo5-0p0ILG%3=3|sU#>@CuZK8ItK91oA z?B4)xgaBLP^30l%GMv%`HLQ1oHkme*J*HzQTLWVAhf{M4H3`Vn4+eH&JaS8recT9p zdi~*>mf8}#a9)#a?@ygQ-h?1LO0voR61%%Ui)ZlwP{G(K^T+9-XI41xx|l z*xmxulCgLtq`C8_s;@9#*w`}xh@7N4geAb+9oH3NV1j+-w*GVdmA2tmCYMEbkIo4G z?$*A;me>b<(T04Ulma^JTlx++d>%t;x7^yd1lM#(;1Bx^P1H(Fu!#>$HzpAgUq3e$ zg-xth1&_OBpQ~|gB>Qs5-Shrhu{C8>jLiwu%<Je;rl6w+|XvWR!XX}lmH2Ltf%}!r4ywODy$WdvFjS%SR+=qmyyN`Y>xInJUc#vJP!-Z_Il_$+T4r2v5 z>0?c+Gp9GlD~Jo}LN>k0U#i;7k$NZSHO-X?jt47Ky9);eG(YP74qNCasy&vXaJnjd zo0QBTgVox`TB_{~B^wumYBprSGt<~}!Ph2F1}fG@3HN9m+`j&Y&TkfrnzsA4ng!MJB@DYTK57dRaDsK*c~WysF2DHs#_y1 ziZ`@r5T*?`7o3>O<1yDiQinWsHg*MnXIDKUNY**XThQEJT{+mHK^F^mnU+%E1cf=t z@Q##*#uY^d`J^wUJc^9oHE>>6C0_rs{eB9yzhgDQEy5fbAnVD;e9Gly5%0m4C6qi~ zIg>eo=|&-Z6cKf?2y1e_|8~LV}*<0n%ZSFo`7i|}IhdH^o;7pN+cVyJlHL9bkNAdlZY6S9xHPmkhz8$qT_HSY3M zs~hA4hx?Ay7brBxu&s4F#$}@VSk6j2$Mp@t0aZeVrOnn$PukT{p+frrBG6!)B&7M%2Ss7v| zb;6%g!^lj&de^dY;EH}4bR+$0l?`84sVhSIU(FiUAZ)M2VZ14mlE#I*Ip3e7^Cyr+ zmTL@psM};?6Uo8=?pl2q>n_5;8>m_RSbA@G=j<2T{m4pB0U>~aK(`n2xlr-q!|&-p z2=3S;D=U!mA~eLCmS5uP-Z7VFvkz}jhUL7|ZHDvS(_)XcXTKK7cyzYuSA*V@cr=6*2X4X@9iPWw9{$#X;jW-~HakBy1(S zW6^5w4pcLc||0TJSXYJ6wUV-vX46@rJ-5PO%h<(fgx zdcOGy+Z}z(38m30Wr{6Mw{U$AZmrp0kaWIc?PgPfK}NgmZPJJJO~ZFG_NTNC4;q=2 zKwz3{dEE|+u~e@BfOAHllfweM*n=`P;3$sTblBU z0rU$3fU$O~dWh5*o4<1X&d3`K&r2UZ$2D|wI;t7%eYLZcqrehf46S@u6~=K9&XL4D zy}ed{AlQFrAE-bJkLt4FnmFcNIkK6~#=_){3iKA<+{IbuxH?n7f`O30a8{SBx|lcr zn!#dDZJxL9W^fNzjxu?YW{pd1q{kGZzwKvjSs2_4Z5GVlt2A#)<)l*{6- zG%hi)tI?WG=ZeC%h=5K^vOLI+{OfIfP(UvLN;6c1VgKs<$g>O#Xbj<%kutVyiC!Qz z)-B){&Ygb9uAX~2cGvM67!;zbSlL;+EN+GV;-CcZ#;ITH3!HAG!fog>T92#cfG+9P z5hmWqhaq=ZI=F+8z9CZxVQG1rc@SbD3=GM(Jt=!)l+FK{Tod@h&ozFYL6^LLvB~k& zm5_K;vmK*sYT~=m+`KG3Tl9JpG~Ge%v_w9KDm?DmZ1W@0QP@yB1tsY^<|()YggIH; zgA5;u7{bt{(ocmb_0o-hV+#gvpHTvxJx4+a-U9DO%6bZDLQl#Ph&7{Hmn*P4yT0-) z2;s~3CC5C;V|EtsfScQr#E%$%WS@)hF30%?s=u}%CrKWs!;eX?uwT!K$h?9Ktgl=M z*gJ?bX#vj;6y4n{`EjCW$hVH>^?0dmmo3^=Smc?f3&&5_t{7551WdKBc(f((LQ13I zaKY5C!6*my*L<({f8)i9pMBh)&S}FFB%>ayHgsCRR97XHKn>?eTtA)jE^(620_}u; z?ZS)cL*Hruf}b4>P8JRp#R?MSBRyWvR^gdUO75Br2sW+7o+D4iMl^oqIB%mbFSU$x zGgH%osO<;8o?3KUiohFWid8NIvH*cP+MBZI~n>QpexEL$# z306@xgv5631xsBMKJAicR$OOkq4UA+SkQ68BKB*H2*ef7Lm6e&*{Rk4NEzF?>+MK? z0f7+W(4QmvH-=QmO{h(^?@Q`9*L92~2Ba^ee`bR;wd5O8PT%O-Gpry;@Xm_8QHcwUoS<5kq& ztX2u@l35qS%gLd8>Cgejja^5z^N1RyMumf{O;{vf$c~WS3b-GzdHXsR31umdRY_?U z-1@-EZpgisFo+$J!#ug|9Z3;-z~UZ%0MT(5w%>RbRX;Y{bWz$%bNrYpX^9VMMuCJ? zj+_P9h}4Zcy@^qq^fZH^Q#Gt|ET*_bOiMzuiy@~pfo$$Wl@jO)$h02+=2`Dv8xreN zHab5yf!Y0RV3%_V2%CUNqb^MMavIm@oi{3GcyWuC6>(lcM8pxjuI7}UV`a_jUzK|2 zyQx;3=fjZ85=7gsaapKVv57&k$=zaQRbiu1@0-{2|I&2E#r~eCyZ)2>e_o(Z--E5y z(6>A$=+yNTl%x`;%;+_~UveL_4BDJ16mw|EW&bS48>FjhO4x#0D7Rx4CL}FFW5d3l zm8o!)DtO*9)6squ!dk+;Oh6&emWBXleKs_W0|DD!Y?@i_b1X6de|yo!f$laGKEXxX zj@%CBobG+HbOiS3ckN|W!+7Z2rs9WAJ*%*F_j?k;FCsN?+3~*d`RUP$in|T#0KQ5% zmYw2a{L<|d*M0bbYsS++!nEpnfCINK)zEJ3F^ixSZbt!uJIhl5s>vX0+hcC{*`zA< zjQF>eJfAoAP&?4n9#tE)2aLMl({mY?A*qS13&q%@?f~SJUyGNN+-%}`Z2Ui3ES+v6_X8?Ys-5Y zjFLCV2jHV1WkwAAT9xHONqjkvuN2j?SbWiy-!<=MgO#p6rI~Xdbxh0dD2aanY>?ES zr|OgDys+PzrVD~<>GAd8EQD=FBaFvY4co6W7(5y$8Ke%Q(r;azzANC9KS*}+1RrF| zdX@vIk{&8`CUex`A&X|Cdnfh9&&1?`0#nUn0Wo^7Q9CZ2iEqg!b{y6$Xb4Gr2xubNF=%Ly=j|9o8uw$(YrDy(niICgI2UwJBh@tzhNBIesutHtvNDA-4t`W6%=v7Gt zd)oASj`V{As|#o1BF^5vVg)OF0MPPl5Owp`rRgGn>nI-#!U8Yx$aOidE*M0tF1)r+ zs;|nhPpjr)3dpJWqN^P_4o$Vk);fJJE?|2SdZ%keqiSUD8jx9b;L{qCl^>~X&Taeq zlZN^uJZkTdEhW_(&DVb6$3~ ze&5$XNm>zjB*p4R$2Ixl%=&fH%z4OnziOpJly8BjH1+jqS!-CKa$rPRrvAm+4T{O* zM>h8U;8A>3PDwR`FA?XbU!YQoNI0e2ixA-N3ewSW^E&={0s%xH< z9qd>@A5w#Tc^rB#GlTzK!udb>Ufh{f@l$hF7~Ng)q^{1AH|Smp)R3e71%uuJwMO|) zZuEz#7+j@QO$7fRaoz=Ty2Kvs#unSIXEBEq|GjN|l@W}9eEIY!k0X1oVtb&2?bV*3 z1a%ifpjqIykt_X2rbCVuAe)<0=4?sdlE=?ZpLz`V{{$KhpzOuezwA_iYPO~12@byo z3KoaI!w#1LeB7&P7Y{-OWFRp*pvfm-T+CK%HzOZK8tc+8>px8rTuy9ds|D?S-yG zp5xD;S?BvAf7!A!;w01x%q5AAkjya*@=NbtECCumMscWF1E(36J1I{~zD;B5D2cn< zXP?q#N(R(>M|AbSJ&^U!53cl3QuAcu!+x}SE{Bz^kN5AC-$8f+O`!cREmS-(!4nX` zliG!B4nzG)muPZI2g(R(*D%Imy*J;%Xjvc^y=U3*`)yCJ0$o!m zRvcbkx`O_h57N}U(a;BE z74+WrQddDbhGhNhoz-z@`lNiW`BPguQ$N=(mLL}Bc$K?_=xc{)=o6&4(4rl0e1Q4b zk}ni;6mJ6i>;$u*3rhoMB|*rTyaX|tYr6!7{2phTmi7LQLOH5Y{MUStY|B*4(5l>W zeNXk7I`Y>MTtil9qNF{W4KdLFT{}%*(>+smhQ##K2ql6O^TgJO5^(GPfVS?=! zP^NCppR|I%88N%y@OX^c9A(HYUqt*6QK4&t4&v_W(ev_@x=$!whHN&x)4iC!?-Wt< z4-gn7MR8oWuQP=M1E2HR(?J|R54TZ7_H>lEbzKDd$eTD%X=-HvaOQ58rlnERYBguz za-}ygpA_oa{{do`2247PlpdE4{PW3HXyK79s5|qI{iF^7Yst{EWa|Z>maKhYq>^opxi5LwOU|mc+qf{f#6I}1NkP|jH zb9mvXY-0dgW&KPKS__u}z(><*mxn;8PM8f+RlQ13K*C?EVq`kkx>Ff+2Qt~_s`D7< zAr50&Lg+8ma8l-5hE!b`ciH)O96Lt|yIoDFg{nPaulJBA-&u@|Php^6FW`7g2GMu4!9F~bY@8w$97`@i{cQkLUx zLyGz%e77RXn#rwErvmQ-8P!&H$SXq?j?%Jn$Mfe(+HJ6HT9{a{S8;k)g&5N7N-F#~ zAlzL(VUeRS`!I(!is}4^h4xolY(28O0p37?jhb;QO+PF+`M%~^%uvX^f3J1z2+a?p zw2U~Wd+*Y^`%Aqz0^zC=!+GP2>BTEACusslRf7!K%U|v^Xqt#d<@5JRD)TW(4a>kL zm*&{{myQFZK4C^f&zO(t4{au4iF82N$a{wA0%&WxnKoA9Kr%*ua+@D?{EI6Y&H!V8 zFOsK5H-$!%t!Vt%VLFfPoY_pzeP?yq7Jivk^G)&M5A^kOKAhZkFYAz#Wb$)KN!!KV zzBd?V-{t=NHsN;xo`yHFG+}uH(viF9E4k@3keL?ITu?H9QubnL|AS0hrJa3x zo#m&Z5M{}&r6Cg#s1f}rj#=G3HY-|1PPp)<_pw@kT)4ZjX$ZTGKyW>k1;PgWy~tAa z_LLNuPd2AWA@_90&&$s$UxgT`-?Ml5eRkO)vW-R~f84-JaO9M6g{#z$PVal+ncgSA z)>m}LD?ruPNQdu4YG!kFL@W&db|4f+zmUJr3G#RGtzbplx` z5W*df;FjNzdW$`;mo>S&vkAanqpmZ#B}X=R)CBGQpyO}%W*{r7LZBPY@ma25TbG_n#5MQjg^_S!1;CO_<{wFX(JTK{B^RrbGAU)I}(LVCDI;2s(ZZ$IE z&eLzrfJ^*H12pq!Xe6Jn4Abk1j;)doj_%Y;L(6&_2N%y`T|+C1SNijl48R))yP$;Q zfO7P}eLnbBs}NdmWrF%W5UeVDTLiT8OWO&hz!?_`?HEROuS?tRB(Az`a4W;d|LZ+& zsOl{^NBKQ3tQ%UKtL68X3&)Qf=WygnN`hOs=+ut9XGYwgR{d*r=U?eh!L!B;tUR-l z0kflZv&odB0A`bSI+AkSY31Fa;T7}*XeSF~zJ34CpMq)k}_RYR`Yirq!cHdutj z-)$6d&G-L>=JcXFD+(Rri*VC=wi1&dqhEk=)IIz{i< ztsG8R?8KL6<6FWrR#5wTlY}aw94;HSJm?LEw4VPv6L7*#3A0%Ca_T#jW_MBKi%zaF z%)L(ABG{PQ9ajT6Y7QRTOoO;x_j&X=g5y|qM1|)BEt)oDIpda(DKL=X8(rtU`Bl#I zn&aPjkQ11L=6JB`=t!|U3`R-h1FLcj*^R96neVbHCJ=i^((w23H9a@}Zs5LvvK$_> z;a~zZnO6p2F#|E`q@_Oh&zn=!@w(xu-K{zL|GN9JCql`Cqbc(XOvjC5FpCFBYdq>C zaN1%Oo8&b)0?>~CJFxP2uste1p}9C)w|)kD6y%$wI6?#1mJr4D!UNTwrbm+mfA_0( zyWAts9?Z(mlh3-tBk!@i?_Dlr4HrX#&S7tdzO;jHqnH@u011MO$*^!Kz4FxO$H=D-s@X`0s#aG;M2oPmi( ztd4t8)-XgiY++%26JmKOx>gCv0D{s=Cfw2WR2~e>OmS@v6Jqa&vW1uz^rwWPrS_9 z)fmVqT)n zHn)Imr|55%gMDaN99M=F&4HN+l)C3k( z_v3RT$0FuTMyA3KYy4Ko_~>9q#@ChQu>4J64x-FG|2M?H(Wxqt9_acK37qQQO8YO} z1;lW4Y)VUA2~`as%2-kY@?E;?C&v+IviU1&yl7BV@+_7Z-WvwH3h1NfbHCoOlYjQO5lvLZ1@A+K zz5&z2#z1(T^4V4FelOSARk4RPE%wDkge}bp*kN=y;d4eCnQ2Nkq{Mq8^N; z@4FbS(LEN2nt^B}YbA;A-SM?6W}H7Jy+`)H@bJsPcCN8sE26M%jLwS9ohX6xKzY-A z{6}{gs!EpVst`coyGl9_XZ+c}hII-9R0IbJpAPG+HNf77$gU9T=J4SP3@&d0kbTWH zO#G1?g~CFFQ3|Ta71Vv6SBq_<-=(H{X8lBe>D`AfqVPj|V(E!y%`E2sQV&NqOnWqbt)NO#z-At6Sig3+DPcOKax$4 zZm#TR1dw_>CNO_XEdovol~dMggx#n3(T`$R%aHlGlnx6ip@-W7 z^gJFf#xUsN-22?=5rWC8E3*ymO76S|Ra?hyt7McFOC9C8@9g&sE975Qm#NCT|Orm&H?R zK_0$o*Lq}p&bVx7ZeGD5U=Wp^31V%}%7oX43XdfY*l_22z|9u2OL~xu_m0wfYGj&S zkIW`j+G!`*>)_?=tNUzBf|z|W2skbBAKp&x?j}~VxhsC&LtZo#7AWekCe0_R1hR2^ zmGjA=!3mUR-B&HpSA590Xw`X`zd(o@aBhHuy)Zb=-?hKXb7Sr)SU!ngU$Vyd_I`Q3 zCsDuHYc91QX>Rj(0oD=_J&frkg!)gxoHxsz##YnM zu?*c+woowug%p|;zM*=^NL6)AkW^fVjPw_QgDL&Dke_^lVXQL<>bwaZ;Tk6FB&yW?fE_MFBxdkYg3ecsNZr0e^7u`XDXsD8Og8_lz*iQtI@J3 zvki$OK>h1|GZEO&*R4_Nvf(l>7M~F9ufC~&7Ewjh@`KvJB$}g#&B@W8MN$;M;sgi` z3B!$grV|WYz*2OjS?7>_4{k#H1@Bclgy-m(N1@AUK5xj$>(a!NbqHm zw}tH01u?1ylEuY9>#d;4HW5h1-TDEuUoiEde>7;&0G^Fka&rET7hbeN2d$)NBzEIv z;GGxk5F+E>sPFdVa7YSYRif}geOVWn*XS0-(lor~3hAK0GXAL@X=1DjSr{S8gbYmz zOFT_jeHiUCQ;VI|ISL9Sf|Q_19XwXXIF{K(Fq~+S;9Rqx*lPXKt}`YHryI%(T20rS zoyk&qab(R%sb3{ob?uqqfw@_1_w~}fS7~~G_gAvVvB(QaVg38lW2vPMdt(z#2{-j1 zYub?D((@ma%vH}2R-s&JU!Ri(&TwpfEt%S)zCT=F^Pdj#-I-}@k%Ru~pJeg_PD|># zmyg_Ve-av?lf$*|&(4`>sP&hWd4QDZS>3uAT}rS8HgXp=v6T8)k%UOHmo>`o!RvRi za_^1CgS@gwN+_1-l+Z;V$Zh4QwDtkf*=A99T2@HHJ~OI0Vtk@;aEQB|C~&~ZAZ6}h z6$OCV%jtbh6F4&V+piS{$5eC^FzAz}uCpNzUz;gImIYH5;`!WQwyDw|%AK)Mhqctr zuL}p6jJ!G@(w%dB27Y-BJzB0DnlgeRwrJAgEi0UnZo7_Weve5k-HC{O@KntR%x5m6 z5@I(T(+?b3AeK2yUH38=QMH;{dk5ahGE8(Fe%T%c!;50v=BIK;k|$$U^do%6!UY@M z+B3PL6wK7TX+GXwa@4pLelWF)^SViCHnLtDGD0@Ip25~uY2O&il^x**Bx{^{C!+>N zoOz4<7(x}JDEvxLy%k<99n+FaJ+oKuNT|WyUXC#m+nV5!b$KDGldbM@F>bK`&}>L* zUq|s|=@$N9?U08kgLo79`2cbIr+t+yuO-{jgOu1o%-v5{KlO64li~60{knx#PGtC1 zX@Xl?kx?$W8y-KFZw{a-vm(VMYeP$*#I79jHNI?Mv^gX`PWN!mpetcj&%QPJ5GnL| z7C%x;&860Nv_}y^yKx+;JjU&7Lz^=hL|-d1bwZ1t4*Q{+1`p2L<1Tdj*w4 z7OP*NgS9TO?NTF|##0XLvzLZ)rM(*i!YIIm@0{c>*VwN<+_F^6^}kC*k_;5!?>=BlTBGF4tODlM-Ffp2;($bRIJ8JXd;}?u_+v! z_MKzvxjdI9{y~$A^8H;)tzDAiOu7v8`R&b_8hy>LMux7O>pm`njj_l_{86TpL4#W~ z4oXF1TX)Cs{d5MsSL_VJZLUTVsgV9HrRAQ)}nS>g#q5UcX=I#j&}GSt+LA``roFDOWI8iYO#0L&dzv zfJZytIh0R_UOFdN4k5X7qq}N=ha8RE62DwizjBaAQ}}ckIM3p>@j+Gb(OxZ__>pJv zpZ$J^C!U_XJ}V^SU6{t?y;Gs1?ZR7ju+bKOlL)2Q>tNB*PEm(^@mj?&h zdf+V(3VvY4sRpAMwD|$S>Us<5bT`c84KoD2>igD%i0x?;W|e4I|DDz|R}Gy=I`?9( z^)%Q#Q?diFn5S)+yG#f_=6byL71EX80|8xQ*4<*~JV`-}Jhi@wpO6NUg?*QROB>RP6p_4 zlZo}u+{Uo1$>qS=z7&}!3+d{q1=RVvvHk~Y%jNtR1iA%VO<-z!PRq7VYv{#|V6pNx z^pL3u3)^EaMNjdOE_2mWgZqP>22&iX*1zdRFt@6lKSz@XSnh|EYK59YzIO(G;Pgk> zt)-YiqDh(0U|U;Py*NV7_tAwRYxOepjP=x)*)oCHIbjQ=hL*Kx8G5Y}t^|#=8vK@Rc#F-XiRU%I{qb`e{q;4J5s07Z z?Jy;Bf{gpZ+sO%SbM8wIrOtn3$sPlszf%6&D`@b=x;GD( z6@DeEazV{F--eoO{#azmFs^@G$HA1*TxdLccVKDU3eO&Ob^3QUV@oO2-3_18Go+;= z&svj`RqcQmAQ>s0-$H&xh<$40+S%B?AoedA+-qv(uJ(m=9dBtaX|{8ljE zt>xKuhrj!zjiWK_&chn-S+GC|acHD^VIUjfHCpMpYnvmJr1v%Bll$bd(jt0*MJSIp zwxpIBL z_x|I?p2!p72|(_Tc_ zRMgpD=3a(iTQ6^V1%jlKdjTy=)7^?&-aM{7*KSX5d#rM*Hq>=(*ZLS6xV3!vN` zvQR{mw7`2;d4v$YmfN!tw1I4{VsB1qu8|AMT>dtdt3oRzabND$Z$ThsIx*-cFZE+} zRVC4XW+Qw^Ws~uy_op4FIF-+Ur;Fz^9uyt10& zef^?NQO4D1lCi>Ce1R;RhEK!qfvDwhw2Yf=ofRyc5Dy(>{8zTcDKyX|j0REDMnL}V zZPW!#q-mw)rGD)HkR)fV;0<=B5jsFkN|0EZg|N615JDIq1@-f{DN0piTh#r0Y<6Nj;b~L z=Zeb6;)26x>b<(?YvENQZJUC4uGEJsmo#1sW(9R0@JoDn3VzL(SK}An zV&9#cd|G~WRaz(62^hGDssvi0IRI|~5nW9<9p?{i3m>AcYH?P4w`fy*(hsx}4+_}j z$jl#PBYp(v72Z;=e@+pmDdPkFDM+8zc|{=05dFX}#lICG-rgjgAIOJ++}&hB#7Mz1 zIbe=3whQ=5lI0wl*+C(8>+`hNX`{3;Y+^u`%_XBq^;+sQntG40s?yn3_``VaE^n{; zrz^yl71;^|4~;PWty>xj;O7&w8)$OUbd>}(X%&`+PicQe+1{FR_D#U zB+b6#G``=!td|YM%x(|$ek;`0Ow^FWpH+J{!m#=3Qe|<)M5nj!+khf98wObq#OVQrBdS=vyC+(1`*wJV1bgA$@y0x8R`#hSL-*>gO1pxkNR{v*G~liAsrMvZnr z(iUfz>KtHr(VhqrB~r9#iKr1GqL(2)q6A^o=s|Q* zGK>rcA&ErvsDp?Wy+xTY#t<#hdx%>ZDVeSJ&G(yYAkdTwS9|rb?Q-bjd0l=l=6x1lq zx~Yo%4xQd*q^Mz<_|{81b`a%+=&xIk#K06B!%rCI?uEru(Q9s}K(0{IU%CASc*W)T zMC>Xk*<_t>Y!+ml!(y3USFQ(_qiZ=wvzxf|4>{8oe;nM_=ZXOL;3E#` zb!$omVE3b#lk!RW)^)Z*)2;4+W&qALtv$zqWWy{{6gerB*QNEQqIma4`0(rxt^&$d zpjHn`Q?U%dD}*GwVm|QSyOUT3a(s{r_}lvSALAc!l>(g>$wIZz__(3iASe&z)) zZns&rwsQ#?t@)_mas5kl4!rJ|6%PjB zNB5K_R^f*KN~<|dMO{K$gWj3mevm_*Eh^K9ugi*#LGjS=kB~79^WUWoplkBQGk^bq zcGmd3kT}GK0j7PI(@OLLoojMRO3m1OGgy`0)6fnS@d%=v49W@bF0tQ53@Ump#^%p+ zr7en4QOZ=kMkU%xub{?MII;)e6TK=sBY>MAHEa>woI{(+^`q-U&fPj2OOZ~P%)mDT z^svy#@D%gNjPRZV+upaG^6a&J8!hqW=i2R$Jye%{F003#3hc0hDG6n@{0->dA8UTF zYQOJ2xwy;wR#qd<{z;)*oIc>~!l`7my~;Sjyql;&Pac2Fx77vqPV|}+2MUt`(thaS zarE3T+imnDr#E{NBFJAt64mruI?v=gY{^=-#s>R;e^1k9_qAY_#pZDZ7d-&9Ati0s zf(6P4LJ?$pH=RSaaWkOkP!Ul-I+@recF;BJWAiDn239(|a$?@;^=rR{NwN_fxpS{j z^P|HeVbr7ONzBBWq+_p&p*G*%j6`(*CC@)xe|l>(ryojV{bZe>Kr-QutGh6Idy~+& z{9qSKR$cG~U~Hi+<*yWP-vtrfHh3ewZbWTMwP=g_{ax+bjGSa=KIzU*l$02JEh*E9 zPH6+VV}V2nI?1|00cLqw$H{c*iJ4|yahA~VyV1CkV=0~c&g(bSB4x#`Q6Cy=Jp!Fs0rwIif1cxe zj?eR$Z|xH?K(B>0=$%N>#Nq-wLvn$_w*BgjjkPsiK%1lw^Q6r#n)FVR? z{;3l5PIf78YDRaCRfk5S2FOGW-W>LM)S0ki=G-$F)8cdAB{-XH#m3O#vgNCp0MV|? ziyPU3kiEZv*OCW|aDeLv_gG~*WQ&QWIPIj$V4NYfb_uW0oDcAY`($Cg5&vlQ(Sv%L zV~>JAQ)!JQ59UK|#rum#LMD}~(w4rz>4Y$7E~>oIwWP|166xa9fGD`|N?9pkaaW(= zt)dTFVy_a{RgU?XzyDS<`@aP8gDgz6qdJ>o;c#s`v0ppe&!K1H>YFK_`pK0j?NsPK zzG0t#ENxzIr)ygDzX|73<#CbC+6fxb(e7yNJ2y=mnoO@yB4p>mFfjD;LrG&5+LD|s z2=%sgd?Hlg?}kAp?2A_f03o_l{teP@<(Qv({mHsPQ`*Ibe88R;hXDa=%>MquSRE|s zqesk(^(G)22@7Ia@saTJo~2C%k_K|l-~XgEVMPUlDL+H)g&H$UCyvdR^)0b(b+=Pl zetzBz@ZyTI zsD8pRlqk*hWsHUiaH1?0@AoWc+IvLbInxqG0Dtx=rqTkOxSnt-47+$D>XYLtSM;*v zv%JazlUOBe#A5hGvwg}3_zuHhyS9k^ecH4(Gb#}sR<)1!5iA-$Amm^ROy>R#sYf6^ z)#sjxr`qC`tmRrjd|ILABjy}$WSGmg>G;@MIQg3L$HH;gc`(m7R$Ya)2X6d?Wd}Om zCEWpIG=Ink_&!Ebnz#36A7v?!J&fJ0=Fw~o^ktKFA1NBIWR@qk19rMS4*4eP)U zs%rOx!X&lh6r&&Ya%-54zQufb?^{m~fCR(1Q_$Xp1pxs-O3njAXb1=d<2w89w;d~* z<^G&jbe|kLgo=bZdmzV^jdzakBlEl>ynx!UTt*T@bI1aSrVbU=rY5tyY4!a>D%5Ngs$6~J)?$02u^F0|?u-i{SM4gMB;u2bE z$n$30O_L@pO7c2GVLT{u(`A{}Z5#TY7am{0(G!7oV3+TFD1Td7^yO8EZsDo-Db4PL z_|ZM>KA8F{>;{}46P>76Iry$kfaqLx?lpSK;c+5#Zz_8J#63w<-<~zsEl#heIYD%p zLhrV-wS2hdfiKYg@_cI|YiLxz1HJYasD!6rtvj0#MSmI1-`@X8$lpG-I`91J>6rj1 zWgHV~^N9%X&i5n5OOLPCC=_XqT34#S;dibc%%5n0r9m6NfBF0-W4GrEgUrOBOk2K0 zMCsBxR|k#>8D^23PkyPO#_vgZt=qQ{aiE|B8F&r;Wqn+z85mUZ-9E1(0m4ukZ5IIv z8g-FfoL^oR%XyE&dn=~rWf-G*@OUy8^DjUJ>Gsj_S{Q*vMCXf<+k5ehx-S5uo+2iL zKa0kUF!`xZP%yGLg5R}3-5;gvL0QH1Eyd$WXyqi}$j=#@rbu(@a5?6Qp^{zk3=Yy{i_HPU73F%f!hn`0} z8y^{c{;qpX;!2tBs5j(qdknYxFNr8I8i4Yr^hcKFP5g>c?U}otXiMemgQ-C#*-A}k zpZD&lr51@IVSU&chLE=J4!ct0hTyjm$GSQH zZY}Q}l`&FEXS+(xz#*dX@hk|9CwMM=Ad(qsBSxe2;|`|^8dBPGk*)-sh|j=#Huc4q zC#1Y-&h5QAyRR#G@ykLP^DthoOUeUvQh*mBagHPFa|T8@Tk{nN5~A4uGCE$hvdSUPpwdHJH<%G-GOzB(?XIp|IEE*50OMxbW$g!fEnr7{+ zs^oOt&N&Y{B}b2?GNKn{HMY2}3N(VB@07Db>>2un?m-dyt48^cy5+C#aamZmv+f<2 zuK!#u;ZYW)17a7gsW7skFI|)}stiqw>|n6nLL?W(*0qb_iM>^W&de`~BJsNP>8S{b z?z~}kW}Sn)dH|YX6k|L!(!KT~i~Td(eG2L*K_G=ln_5v1C~w-_NNuV|=yqccHAVN+ zl1+k~lq7E#dvvjB<zxIvaq0Ei z^2qh>671S~_ST3wKw6ME-(l`CeZ29PK)FvPxiuizoNsSX@QuNQzmm&kEDFtQDOT7H z4Rj_&4M2`;=EHvKUZ=5-k=^H`Vbd;T3mYxxP_hW=VVK<48+Ts2DxWS=K+yON7!9xI zfYAWr09Bs~I~-RnOmC!kIyv>mce<|dCm^O1_j@J=DGy6QlyOKg0I#B4b6cywbHW#zBy0BjQJ{%E2s(rP{o3GsO;S?S$(#) zZji2U^{U=&lQgn0Lj#bo+Gv21Zg+`M&wSFa_F`P~+en{*oZ2Sb5BK4PVDjy;Q$LJv!i^8?D0>@cc4}cYLn2Gxp!iW1Wo>*_-~*JmZ*h!i6kphs9@> zHWG)KV>FzaNj7O;3sfh=?tGk@iEd77Z;2lPV%MA?-wxd(GoZfNAL=kR$(>-~8{ik} zGE!SEhF6Z0iRSIeOwK9$*JOmSmQ~?bIWRgZTp@n+$!v82+FomKya4Q8)5ZH?wS-dQ zhr7{B2DK@#dTqTq8CINQ7RAC%PqY!MU}YBnA5X6Srjp4)EZbc6chc7@%g;a9<&5i9 zM>~eYslC>kc3=?zh)lJ}GX))1Wfra^tOYVX!mPBuL5l{rag(C^2{!cXpvb&jt2z-m-pl%U4}f?ZOjVF;6?(%er`@Y)~6;`xQU#cvI#k8#Qq1r z(A$mrZd)p;)Av@8*PxsO6Hb&3T9ncOK*@@8rVp;|Q!w=UXM&(WBloaALukH0BuqF} z8a|Wr-X&G?dbQuVU(Nyl%8^Q@Wvya9Bk8&!SoDFv5?Q1Q5LEDkhMZ)AYYh{OMfH#m z<)1SmK-0bIEPwT{U0t44v6R?RQzdc$STIe48^5`b?ptOy--V?U?9#$Az7^&chhHqz z^>clSc_kRuVZo3IU>>>_%$M;_F~adS#^>Rq2^R8M8o)k1C17BmlVA4d3Q*g#K-db4)Y9Iu&{ViP2>d6`3KKQR5cHH9t2Z7C>dCW1bT4N z1I{FclL84K(|6$%=Tv=)%8Dc-A*S?J{)Zo&Y7I;7-V}G}@~r``_UvC|7TNz3o$vpZ ztqLGm1)KQ(AaM<>VRog+e*WGgQ8nG91*%VD8^_S~W$Vcpq|H%H2wq)D&a@nKQ6MU4 zks}PBP?Jtr0zewO`hh-EBKt?}5h<%~43%!3XmSSdWEbO#ShB4iEfvAj41bX`snDl;cXo3<- zFupYtiXhmu?4`k$_viYnyQYcuBw0HU_YxLv`bxoXLiH#kd+VyrX_lP}rWJDo zJxCxoJrO_%F#xFxZd!2#vs)u*hFnYbm?6PbDnX^Fb=c5F&p#f}I$OYpiQ-V4oeIRk z_qRz{8qTqx7ES6glEE6lo-!v5g#kudDB&5&V`jKZEx8uh;`(d5>F~7B&F>Pd(rmwm zTCWHI@DV{DkPxDk0O#eLv@;wU0NIP8q2M5OrT4CsAg6Z$`n9B!J$4Y4%u=)JMb9)@ zc)$PQPZq*b;AUs)3wmRD&dYC4PYpIp04h?8DSBj$d79Vo61lugGkT56Ai=|WMFsCk zperF8xC0&M)uWJj8lXSyN0N15^=Gd-r`Z<*mij%P_9x9N9SrU|4c2OvS)}YNOa36e znty7Vga!o82Guh_S3A3PwtyBVv~h|2e25Crm08mcdF@K7G80MPhNH>U0a^>(C)bo-fV6UP;RN6hojk-2$9&zR)fa9Q za~n$zffzH8`JeFSlaH5S`$Bq!)UFl0-C^4hvG`W+6guQRwS%PEy|s}-ZzcI`_$HEY zVF2x~Bv2+nh$rP;&RyCKR5pz6f$s_kFj(hewctRu^RSdJa@}F>Ehl9WLxSybDG9A< zD1D9t$SYU5yw3<@`%1#Ec?{}TJIqOS&?07}GrR9)lntxz(eOpOJHmj@8bC zVxz7P?29v{%rsS;PxrMmLON*pXFJk%{~+WLalFRuvD@`<8h}Z^*k3{oZM-*kN1<+7 zj@92%?%bS)5<#`o!V@*CJqxvPS4(%N79E{6Aq^H|-GLv3BcBPH0nou1WV$Pb_63^# zaYkKCYJhR`qb>;m9`jU5421%M_hEiIRi z@G4#rVsZPGL0f=d zH{d+l;Qz;0%C_ZofL!7lbPQ?)CFkI z@zbb{JCk(B@CNX}fDWyvlI-5fb~{PJ`zI5qL8aT_@=sM4VUtNk0FEH6mPDd;Y2_Gn0w?B-t(LA55x@$>S-eF;UoYZv)F=@j-EOw6;J7 z^*XiLaJ9HWSLngx;j)^C_+@j~SoW_JQTU17w!qjV2O0^f&PhJc(RX6Q)+oFpKa&p~ zu}JaVRD;6~5lo!P%nqSFIUZTE)7POQh-(yD+KRs1TYbT@!B6>*jWs@?Vxp22a#Yt1 zMk%U89HUDN>$wj9HeCdt6{B7}cD3%cSurapP;=_6^7>_7NgPk&C_!a81=k7t}N&2*_(%An` zqZrWy^Wjq&5O@H`mvj&%4&##pJ-eywY>#(io@ zA>-NHTL!z{aimQ$t(C|j9{0SgeoJi#Tfe|xB*w%%DdqHJXgdGhmp@N5$E@28yXFKM za1R5hIZV5&|E96`4}V?64nm-5mR#K!BBF@n_uhHI_j0pUX}JEo2^NLzN=b4-(w#n~e@>-S zNt~^$`?`fQ2r^ANczv`oeB~a+B~GI;%(_M~ybNrv?0xNFcB`}5ji7pQHWOMU6xy`z zy99?vAHF$#*UX#Z{XQ4Ij?Yy1B|OSGFskJK35fWKWs}qMndInih`SpApuhAf@W25) zz^kis_^>x?2(1|It1z6W+6h7QTuHB7#-6JNH1@fme5iy%Oizdt{CfnU)j|I_HK1%ZX#lQzMysj!}El z2aj?4yoF%fz{jmcqs7n@TB{7Z!qV6K4WlJfs|r{;Ea6rIMO5)(U0F1y)_LT2Yfxm{ zfUhvVhmo{&_WAFhgj;z!>U8xp^6DqCO z@PXZW=vST!Q>!*Wwe@$LUAyA%s$PT-LHv`1^V>mp1LYB~xr~TU{YLfB3}NPaz)0M; zH#S&DUBME7Z4e_&l0_uiZoVtYTDCWJb$;nYc;S|bP7 zUT;8gj|6>oAKm~hw%@?NLq~Vt2>87%wp3|R{?yB7GRN{<1FrS1agn6}^o9Xq-JF@U z85SqBKov1wc!W+5f#092``0OV>aT0$>fymEm|aAWfwcM4 zAGNML&QRO#>YL5Z_DZ}92aQa-HFC=xyukj+Q0rY{S>_4Tl|35J2Y1S^aa@ngYpsR8 zb$f#0`?LIuQ`q`=Z>Im^N85~`l2%l}(Qv}pKVW_ZDcL90E|%4wS~?P3ZAaI_nZG98 zWlpPT*$a^_5(7fiG}nHNINEWz^@0bURq`M+-_l3Wr8~VB*1uD{=sM`C2i7@C^(W%1 zAD>iVrLho^2AtZCt9RO+)$CsI?3)Jc0Y`PE>Z_0Ymht!rC7`bWSXN))1sS&U90xwX zEZ!~iGof_NXjzW=*c@AaR8;U>EIAM8sJuJ7gs>lUm12&aOFrMRxAdsOwy>J64*s%_ zWWqfLnNoMZ*CcK#rLtPod$wNZnfGr>9Zt?d&_^qY4a1@}8D;XN-h$r##MRdyBaM0> zS?qqR4c!6QfS&4SV$z5tbuM@Z45Yb@eq=5|@=hB|)EoP6&h(Wb7qKhHtF(E#X2g>3 zZw`iK+*~DRVtl7fZ?Gf#{asY$RgCNRqnEudKqh0NnEuG=5Npj0f9NB7=&mQ$KNvlC zGHhaE=*u$Ji}?Cv!$HhJgM1{M>7@9r>8^jGm|eS(FM}X!d4|q!SF7zhlJSEzV93M% zB3ysExEt!){ft|{ViJjIqXKf%gjEQJA&L`^#j650NJeG6cJ30Lu!t&!4^mMXX_5amGavdP)w39Pt-x!t-DLKFJ(eyJa<=}d~ntrgb z1%&Nv=B+HfZ_M6|rMq%xJilbHOt;YgyxCtt6j9*-KOyNjo5!!?@o5_!*#j%Vi9h*< zLc=SDt7@kMf}wfZD&uPE9v&V$#o;6ZRN(kLWh5RsJ>3wT{bVMY^nE43h-J7eMc$kF zuRl{aUOr;<^wd71Jf7EisldnV8UJYEv;GVDvujnc+p~7uPYcfWU{P;{olXKwY$#60 zGx&~@W8no~etdrG+4X)UeWkT$m87@?^*S9F40T+e)}WL520oj<>JDKszK%SMk;dNf zY@%TElKie)+?~LnLT+kxqtpLBVAqsG4BVUfLo4f--gGvQ%1km_bQ`rOy5B+cR@E-qe6U)9riD9XEQ4OScm`lo{k@Pk(46(`N?X z9mUpOdBo)Tt|mn9cr{>^xH#u95sgT9<0N&OS??&!ANR58oZ2j5X z!Nv*x?uO#j@?jd;xxp&lRB9+$A-+Azv$Jw?-ITAZ{PJVhG`l48&w0+%b3QXEhD7RN zD5CVN`}*1A!zXLb*r;6$pr7fQ)XT4IF?%L^mlR)3ww+~@C%K_yWPg?N+#Xzi_4S-R z6*Ou_g`YgEK!1roytTnj_>m)xiDAyRpPo=4uHNQ!bd0sxifeaSb&w=Q-(r|;E`ojx c_R8ag{2!lK#uYA$aMB>nhmTcD|9KknAE|Ep;Q#;t literal 0 HcmV?d00001 diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionEnabled_Dark_d19fbf1f_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionEnabled_Dark_d19fbf1f_0.png new file mode 100644 index 0000000000000000000000000000000000000000..b6019533b7ad4f4873a583f563de37f4e45bcf3e GIT binary patch literal 42840 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 literal 0 HcmV?d00001 diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionEnabled_Light_b29dc7a7_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionEnabled_Light_b29dc7a7_0.png new file mode 100644 index 0000000000000000000000000000000000000000..0d2cc98188ea88697fa581a2e74dbd627b7129a2 GIT binary patch literal 42564 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 CPVIdF literal 0 HcmV?d00001 diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakTestCardIdle_Dark_d19fbf1f_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakTestCardIdle_Dark_d19fbf1f_0.png new file mode 100644 index 0000000000000000000000000000000000000000..e5039f99d43d1000882da3a4391a34e18a58bab3 GIT binary patch literal 22354 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�zS1B;1sMP}F=fCU$bc}%0i5k$?E z4jrUhIxsNn`izegF)Fl0hEZphiyt)8z}eL;y%SiyRf-_(Y;#n_X;1z)k3>|JAw>vk zoSadOIuFmi{k#TZ#_v@-hY}Rk-BNnX986KwEG9M3ck&Hs8kcnZLb`BixkOgQA(om? zqN;V4MqJ%mFQJB1CA_2x8!2_mdff1AH_{j|9f^Ke`(20XukV$e;)toh*pxX~{GHGo z^RMD^&M)he-AUgHUsqx6{wxB8iyADZJUjGk-;Ij)V{_%G9Uv)>SwIc6N)@-kpS^No zzPLE&o!|q#Wj8sQ_}BV#W}dvwA}YgUy(jmxG~yXoyS`#|>MHPwf0w5MD-40ERf|R{ z1y;?a>1Q(J9X#pr`#sDfK>;v{?3~m^nv)G_#=on){Ys&AlJ=^)Hu1keh#v}Gl9uazBqR4&wVC~Ez8t`dW>B`1zGXq;3!HjJW=3n1vfc3 z&WDqa#Na9|2}mU35VUUdb=rEiqt%F&ao{U)bZ3fr_5Nk1+`M=H*fII^TVKuHog1(8 z@StG4-=;gWD8M+HzrK>C*!x(v^~q&;UdzmFiXlx?>ju>GAFhpiO#o#>%zJLsWp>__ zsw_?|2oiPr5G30HsCP<)jQFPhw(N6Cma~+{XNbRKh3OrQu?}p5AL4)t?v5-!4p940 zA@zPsrDDD_p!_F^0y_UA^pW%TvBVVRcKT_5#susCK{O!}<)K0e8zdQS1@9dGH-3R+h08p+9fdE2^4p5Gzev z>uK%mGXzA|ULW6!q~z9B_!29m2U%g(y<99LWI-#kt~1 zW($==?nt+4{d?x^u~lSL5NDO;Y_t+>E{}w5m(ftC+^^B(X;H^>k&Ds0 zfifBKP{*e9I~K;Rs^{PR-3A0=$n;Y%JM`%ju)7aj9sKoaPP`L~iDv5dIEjcn> zj(sza#@xKY|3M#`C1Vf^ce;7RSizMlk<-U(aF#XZNDPwQN4-xlsLyy;H0!6w!Q+kP z0c*!&Wec9j!%}mt36x!52 zuC|m=smfD))Ui*Y!swrKq>1+dzuwuWO`aONUOciI za|KvC2P<`edYve2nK*)K*sRn-KA!@$;!E4 zIguyl030YEcrJ-%hf`}RB(rIO6m`ViLDI)^u4zmbljj+%{`?*QNSJcd*bN;eEVuyG zRTvq!O1U>EnAxPgd!NS%>VNG6`%>od;uB!S4XYL0176^&;u#_c+BWa1%{3;4l@h!* zgG^DDAA64Z4$I`r%LeRQcLN&D+{q7Qg~!J7>br7`D8AGiar(M)KuVOH6|%KSE6ohq z8#@96>>>-ON|tmpr7KEWPp^E0fIMl%O6T0__xDn2y2ybQnW<1AF>cw7)BJsXAxKaZ z09X5iuvpn@zab#r>`^^Y;P65kQQ~X(B_%8Fy-Vv~0HQh`*mLGtWkuK5paOB;h^CZM zrsSnZ(UzxTgj9RHzxCaStLydz#LQ!7A`+z)wkuH}*a+!D5?t*2imYg>TwtTuj1C}c z6k?6z+I6_iS51yR$hcb$XWHuKgN@L2(d7C9sHRHWexrOBwZTh3I z;b2Pe28Mv#r5fh~*lou&mabCwH-yj|cp8#V*ygl^HtT z^wL(px|2CK_flb1eZU(PB=I&&p@+xu%wg|#7K4Y!QEvzMh#n+CkH2=HnBrs%U{G)Z zXkrz5x3_A#uj~s9t-(UYTi4|5XR85aD#Oct;c>X2WQwY1Ch^BWnL{A6qzkB!bBpw= z5hq)}tH-1_A6gyuHeJ|gMjSo(pGgO@CT_v$h8nKwEHnbvSo|+*Nxfrbxy$9uxjsDH zb>GmoTAR68IUWTh-pG{C#?&PH$~3Nl&E8YD>?d)JU^@IcBC+`Fe|&{(R$(!z1&*Q? zBPgVE%H$_*d9As|$u)x?oe@5x09->;*Nw$KtoFEhcHaAqu&OS9!Qf;MLWNs9T{hG* z9mMJ=-_Jt)dhh*XiBVvPz6AO5;BKGrPi}4%xCISdU10z~ZOVU=5XI`B2T()t-w%kP zaW?{sD>8G15+kHBUkgQCK5NMVk#WKuT*K;vBk`v_$|?@1jykE0RV^Q zH9|(mg00OZp7{MeZN-Pnv)Xs)o}NgLr2?ULt$=7eP#Z+Hgq!r#hmp#hng5s2nYGJH z^ya{AOD=uy!VilTyB>GJBvZ=<&ps0=tSTM+$Chw860o&SICUYItwB&sUyx%wst0C; zGU#PdY(0bj0Ia5VM<(Pv;hF?ISr-VCiU^WF6Ehia`QMyZ02bqjc$hyGhHqm4Sp~at z324OB{4D#z9g<#>;`}V>#5ez=Ty)=%PqdS+={JzqUTWVejv4@lBkBu@#psiVqZM*o zX*JayRpndK9X@G(fpIw{C4O`FP|ygl*K^BLJHRvL-L?DuIdepc{ZA&LZq?c4YAkiY zjXgI+{byH`)@L#1KZ=d~XaNpvAdkhcEM!-?0|hdMQK?vglR0&2H zS3dY1*(q09{625_F_0{An+T|P=VG5~ZmloVFeiYL6e#>9R+ASoJ@69i>jHrPIXd?B z%E+xbb(Z5ZeBm=t3{0YkWai+)-&;G~=OuJ!cI`x~RB1NgGP|&{#U=at0Ob0!#ABF5 z?IfRQBqrLhn6i40Pzl};|5Tomq<>+a!e&z2hf?Q%&16#h5)MFKe*&5+bG-mKfSD^r z?U<03>&UuPSt*`bNqA!5hTwYpb5ny=L01#bSxxjdN5V}N*!fjhot3*U@k;hzUml~W zXAcL2aPf$XK=~dW)D9CrcXtf01FT<5WIzrvx?yLSPUU6Xk2Kh^n1{Pnfg}Nff+p?! z)(3Qt3U;a9zP^8U zM->QG2YcIeq@?xBH;_jjtb*IkX>ECP#!M)bf6zX&=x#}aq_%GCc|AnEOADN=0HYss zqB^l$OIo975qL2^+{&Q+=&tDvZh%})=(amjS!uC4;2?wIIPV!Go%n~F+XS)hjK6Bi zqdnqH*_!jpQGSpnFXr(T=;l~XLmwszccU*n{^NnNIwSGku!kOz+3xD1umRNuCXr&# z59obEWF6>)%qV>DxXzCo!We~gXKvQ+CrjHyHp* zN5M1aiz+STDbu)v=%s+!l4kKH*%DU#3&4f}K#EyZRx`=(?3iyvM4U9~+35)MZz|n} zekn+Hl(9fO5a)EWInzQ5{EHfM@&u^))0 z3;jXwv9ig2{VEOIh>aQm@Y$S>eS4%GZpq$@E$g>0wC&0^q^*jL{nH4D+9>)^>{u@wFjLm@>(?HkAMFTLVkTXXUMnx#_L5M)eoGeqKjG zCk66%0+k@pKPn#$<^(X!B&^_;BF--zu@EvehUV@f2Q;y|fWq}zin{ObzO#%>7v@

Ne#54$^hdVP@XZacMBr@*<`OFdePaJ{Avx4a5%ao9^8TN_ z1N6$IarnaD6Vpc{;}#VGxzGlEQm}y}4qX)AWl;(bLN-8RlF%2WbkSTe{mj-{BW}W z{gp7oVg=OU)A(4Baa|hn*K3gns1+UWb(=+U(s2hn`>fiy$q(ng`$V}l9W%PUS#$O*k?E7)&blwB05hZzB?BfB;2#ok;(I~g1OU; zH>U9u<|hT^q1dVW7??)nljZa+2VQ}EN?ba}`a-#@~p_QQmf z>BBaG#s0-h+qY)Empuh>?6V8baX1!48h?o{n3Z{mFi6B=&dZblrl`0CWEX>^B823% z8NR#P+{V&Syq{(K%^?u^9j?sTDIP$vWvyycUMoBr(!RE4muwWGtxj|bvzLj&xc2$W z!nX@5U8C<&(0NNtc;ZLr}Jne8~v*nXTY!L zl~Dn^IA6$^Zr?Att?#%RZ*=DaCZ7m_pT>f$0(K)eNED_gs8H#PYfIxDsolwA=+W2o-%j0N2_*@=u)VG7QVYYJ=e9SGUFZ-T7m!PS*X6{ z2Df7q{fN;oSYM{nPqv0kwrOyJC~2R@$a*dq*=+Uu&fc6Tv|(&9_Xias)ZDT|x(mFg z97Bn+c3VMJ8I#m)FqW0RW2t;r!mCZlghr`-^lOeSrl7e*C&U!if~5jvVuXRhV2M81Q321zDz$lT#PmC|V5iFrb^eDyOG_f7#+M|d(!||+XTy^$EGZ)C9 z%oJbVVv4zZ@s>l-)q$o%{9^xBO()l81!JiT>$y}D(5G*$$lrRR-*T1AjVMmmRPcXXZF@kc7+!4IH4b&Yu|Z3-FLLvf18 z#1}j>`dV*w-`!9Ikjh&~#}|S|GWri*;aOeS0)fDHkz2HjS#T9`pH{{iZ0yJphh)W2 z-ozyD<33vZ55dZssWO&pEB)W$8o{g*{e{?k5Y(v@yc0gTOu$)7nV7pbv;8hfl>Goy zfb$w}a=y}|wbQsy7GC`ivKD2?RV^K7Cx5K~TCQ>f(6)0@l=ZXc9q+?r-eO!Y@o1*n zU$G!B0i3`m*oOteEnZJ1gwMDXz=P64)cfp2nk%P`rWf@af|b2o9KN>+me$2omcEDr zTp8tQt#rOexT5sTtee?>#X}Q)oaIZBux~iPDEIE|^#-}D%_WG;L^BCOj1K&^UGpJ4 zTM34lIhG|#@=6*ZlmCm^?U(l%kV)->t82E< zvnM6BK3dC`P+AoHftgp&CaJ3B4vsjz)b8$5cp6k8!HRKan8Syl9 z$#3}+_{hUZS=IQc7Wq4cl}}lGmf)msCjf&q^i8s&>XLQURl8p%}YgLG$Ju7oxI?VP8KHMt4i#I%2dFW;h3Hh1nEQK%4wuGVNJo^Vy zm85%g913UnTBrbsG(;_iF85_AkI-D;K{Z*fCouMOJ>e*g4whO4RZ7}(;pMvOrUZ9Q zD#FaKh46vK+khtg{CP2F-neJ(eioD8CLx!4pu$1V_s`=#-8LP6w`VV~r2!J7Z%VS% zgcxo+k4qHO_0&K858;15m+hkG4@rJ&wd!iGQ??vc002TT0Js8{>TF6hs6E?0uUDr5 zJmgKbH5lF+QO)6yjmQW?P+>xGE>&A@6M1E9jY#TVVIxuTCdN1vXUl8Vp z@b?yG2Il|-3$>AGR|lggk3gDlK(e*a^9!Os`dUu+6npH=Cz!PV(%Xm!Re4OK^9Fe< zU`nmx0FZtSm!t||3dGNgISk|?ciAM4!xvgbS1#nFOD*(Wy^!L^U^bC2_7(CU(Q#d%b^^f$-x#cJY1Z zr2ka<$byrzPFIdy9EuGVQC%D@mNKmMvUOmGwjdB4mgHbZ^|%IBTo|2PHp(QE(!PG`l^O*$K-saOZ$2qUSXsdvKZGP=6%L#halX@)q-^XFsb@qI_Os(XG~%l#R<=ED zyI<%`;#ANvLb~tPDVpA!Rg*u4hKDhawjBM8d$z>Tn| zwoNDFNmIvbk}iX!!k}-APSDFTwOAgnUDfN!A~}9r3y>%Ds-q=l+rLC1kr3GaiZ zr|SK0D<1xOY48syXWs{nGX;K}L5F37nMyB_p>r>&&LKVSUZz#TY!5s_{koRmf~%1w zc^^E@V6kB-lIXv^MDOtM+D1{L%P>yM1{vdsROV#E z-U6Gil^zwJ(3x{&!4cpFvQx3|vUNf2)OTc>jI2rV|9vA6sVC_FCmluq|C}i9&Ww!T zrh(m;W*h9QaJ@8o?shrE{uF+)FDu29Ac;yl##D2W1SBiC@7Z(jCx0i- zl|Ssbw+I?-!s4`Eg)_Px?iH7&HV{vnocY^I#uxWRS*1GZRgxa%H#WDrfK7wN@sTUY zmnTn?eMlBNYr|k`ZV5<^L*hn|FPy+(52^#CpSA^@!1B|U(bk1tcBVS$kg-_zWIT9Z zDzI5!u17qmxnCUwXPY(3N5YiZ-O5KC8`Ti-6yGSKKDWU9t@>pA2T| zY9vbBygl=W)Cea5>&FWG6;ZnoY}JM>Yf|uBUV^4^$jQxQ0%>9qkDIc>WxM~riYyMB z;xt4nQ?k)FEb#Xh?d1;+)#UAAoPpNPlqW+E&)Klqtxch)$N%0D{ZFS9wy4;D@_+wM z-kA}C{oS6s#(Wbpus>_QP`1Ah4E(_M&!wW_+jXt~K7w#%Sxp_r*F=u|wYlI5?a%Hb zHxeYvr2amX8&V#D?pFLOXD;pk=gotp>Hfd)83GTBN77RBx<+gtr%H4QoFa4YPQ)}p z%e^#0F;~byr4+Q{&^u2||J+{oPa*hz9n3RP1pd4c`wTuxUwoeItNUt_VR?z*f)5#B z&E9tgKW$S!m-aHuyaRUpd@*(HHk}M?RDM~=$Y}9=4S}v$n9&v6@+j?s;(lkn`&8vY z;^0h78-(RPXsEpEKx&xGgyf3N7#UP?t=Qg-)N>736*4X#$7EZI7n^?_{iO3~al6*S z8qxN&P4jan7b*Cto%B*xW-n0C^}ZFUUz?ot|70NjLq~gi>L93l-|4Ij4QR6bzsdCh zXX(Eo4rLIBLy++1u^L2%=TgU8ESqbfQwY0AqsO?z;1N%8;MbOmaX&$bf5=MOngS*R zdbjR$^j86fpONo(<#;Xpq-)7BTb~T*_?Z7khL03<%cDWnTSsStI#-L+&q#Z%TE?B~ zd>(x2?f@z3-bEE)f|VtlS{skIS@gxH}q|4$XiZN+iugDceTDn z03{W!FyCxkQB*0BmBG&uD3@_|uf;7lmDfGK6q&SFe{`rtiV@FDru&)Um!FPUItD3A zFD@wbW5WwJsn|!S`*6fN9D&xDN5`nfB*Jx#jJRX}mB~Ec5g)>fnuBzHY^v-dT<@PR z%Uh|nZJHkA6)T|Su%EJ0IV{4RBT;G;XE*S33>&QEE2EM8xvY~1+CJnO%k?~v*_FQk+CC_(eSzN)aZhl-RsAl};R&3x&l5vRloan1f4K=L%)X7_D z+;CuWCaN3GX;wRJ6?az5zM}w1bXf<*nvVmD++YXDv;`f-X0+#q8;_?Dy+$^<$4&Im zc`27khq$v=4GOr9WScvV5BIu+Ey|{d{$ZN(Y5D02sgkK}+h5!ACqtv4c8C#?_6$Es zSflBqUnA`+zxC$s#(qoo{^qa90h`z%CaJXb=kSV8G-J=|1m~=ok0od|#D2G~v;?kSZBE_6dKU&n7w*WUxf>(kyY8PC?eR|wCxSp*9$HIy80z`4ptqVX-HqR+K@oE{b zKM#r@Yh(67Yeb6m(9-WL>8g(rDZWte5j&#p<0hRqquez2y0acyYNqXXut)_Uw+6a4 ztdHUaXKtMm^K;t|C^qHl4y|GpgTsqMi5@{ z-(O{hc#G8IQ&cJ-s2c4?FZ$|=pWR4FsPP4NX%}iU+ zTB6^5;GXHDt!!j|>Ea{pGCq$q+s*ZlElM#C^3ySdqo?+ZEZ&@zQU49U53>70;hX%( zw)P#~-P?Mu3(eM7cn1r6e5l8T%R~b8k98P0r3&3TgO2wersj)S3CrD8)|bfB{n4cdy);Z zXG$KHzFPEtISv;zBb)!AYX#*Ia*J(>Bg9RqHZ5ybu%l(BX8r6oni!6Sl?Fu{KsL0twi9h) zGt+M$J>~DawfK&{hwx-a?AD?)Kco_|n_Sx#^bSM?BO5Q2cGFA{?xkz(pul3`b}uO# zARBspLrMN>bGlG`O(4J2%)@;`qC8tO z&wIjBVaaiL@lP6=I|R{h1qCLE5{&B5)u5gGJ9YG!nknXM1>^}EzWIo5j?*!&&)N^E zyrJ_|P7|;=en{UUvaJA$)v|oC$B1Jyj=rv;zIr^l{Z+A`Y^DEaK0Ib4$V^QbVdgwt zPoYi-{LN|iea+V{PUk9qy2PB(Rz5K9h=qjZgN=3c$lJfy@|tM%ct~n@(z`7YLa2lT z@%J{Swa_amHu#)Cex4JVr^kv*>odg9|1R*a4r&CQCj5!CuHMkmqC2*-gH1}@=p8-R zm!`#IUu~pj7;6%3UaU?BU4to7iQ2NnvaZ74tgYp6+_6ovO3FYlSoC)afO>rB@t@tn z`*CW4SgW18oy>jTk}D_BJ1mm>fyIxriq>iq*5%LG(vs`G{u%O`^~FLEEo*X&>*a_4 z+7$?jS$ounkGB0J$WvHLyrW#sv#XZ=?!lzLOOBWauA?I6HI(PI>~q|bC<9WApwr06 z;C7t|k4G6L7_7hF6GVwAF_!|M(kpO0SeJ*( zAC2bUFeUf~U5<9Of0O^Ovo8YGfLCxz$_R&DLDeSu%B(yL#o<9jhRmRV9w*_ED7orD zX?tTpaRC=B{;X~xx$01_YWL}hp)-rN&ZBkgj&~LrhHiiCJDm#Q{k@SdOW7@^bjToz zSF6RCz423Q4ZyON%uJ7_4pkZ zwEXW@^Ft3nT{-9=dP2p{889(dZE+z|GyjN~rB!B_=RkQnJbN>e{updACQjrf?&@_f z-tp^PMIweD5?n$i=cf*T80 zl7IZq83X-Dxq^5GF|L^oi$>5JIcu?ZS4*~M&9aDV=GNluE#4mFXB8LGjr_!3*=cr` z);i%Rc!Gn2wkKy)pl9+!Dg3QmD|5XYfxBvA{yNXP!&N>1J9!bcjE9DpoW~FXYD3U3 znKihH^iZOf*&G##yX0127#XxFj~hy{X(s9JS!#mb86GGPqo2H8JmU#)|_-GPkV`tP}CAXjZX zYANq*^xPNEw<)HB$i@ywbInle^OJ{J)9UywJHz}F9jRqp*x-gE4J#uXpE?31rt`7EF+fXIJ!va@L``p$fU`!77xKHaBAFhz`xbdzdaG-a_rDHe=y6kj<-tv; z*6m^Hj;dGo(}Bw9NCt1y&wIc*VEG6uq6y)&#yesUB`Miy)(#IellDIueAJGc3VvIh z*z4EQmXF>^BL(}%2tC(R$FO8c|1$9Z;T@cRH~WwH z1AfmNKPgyzSYpcIO6k0?hg03PxQ`U9R={> z|B-a$w?|r4`Zv8r>osP(YZC>Bg7BsNL6GhgzjNsR{ta6Cs0Gq376RNSabBwNhbP`gp{wVp}=W3R3LjnwD*^sD#6&6No(nzi?Id ze)YACE+1rlf#Au$%8fQbES}fK0+bE@i&PgVB0u&_$htNV)JBfxc(E0oehVTUE=G++ zZ2o#ogmKJ}V9xPK#4-Tj=uSAWxPqwB!P4Vj$UVr^_ z=0uRwpbpVmOlQ5^>!Ft<5Jz9r`+wSe$)4X@gV9r0Q5dYZgS8pbAWH|%3hm#uF zxRgqQpUz~yht#uPvx*mdHTDal+;%KCAHm7y&YSKpzrtUks{HBnv`@Z)YnebjIdj6E zY|3`Zh#$)D7Nd3I1e2HJJ$3X{Zo!#QzsG53NLV{5L-~185Oo?K@@>tgs(#!dPViHo z>v&Kqfi1nf)34%3L?beiX=FutH1N25@RF2%UW_6)*S}c?t}pp1^2F0F>b%s^dO)0S zVVB=|Ga=Y?EDgS-0_o>7M!7`VrmOodgrTY9D!{)Coq5FIwuFF5GNM)0FKPrP!8HFp z3y|K3wAx7H3Oco3E1la39aF)Qf_QWs!n-yM^j3#<7shOewSwnenXy{{XCZ>>x!y!rdw*g6) zxvA9G(ZO@?_MgcII(E2BRtSE|Drm!wtQ}19RxSt#U?3v>rXY=xK?~(KU!-Lg?P6+n zdZv|8)X`X$t&us~G) z=>moCLw%!;JoDShCVZoW;>*B%R-}K>iVwPG`S(7DBxPptV?tB^B#l-{!m3Pc{yibr z;>cBHE1huth;aZbuGvi;8m#Lu3P;T;O4$QQ@xAx)~b$ecHHcE z@Cb6IM7(B#jlN;x$PgED zqEO{}WG!b|^Si4Pbb&5wCWX{8+LaYTtLyc>MoOW}fWeY^lu(+ts6u;ki0-0lcc04% zc2k$T?}m*ufX^FG8E8d72}|D_5RIO{`p#jGh`gz&iCtN5Z?-i5jg$q6nwq%E3q>aX zFWembRYpKURp~~nlNXZ~imX7?GhqV_Cd*|72KOc|Bz(k;(d%!)RG)WzfIx3q?NraYL|!x|}$Z0Q4tSRKPEo z)qRYq2X&RZyN>A$k|@aXACgq^M;nJSX}G|B>wkx>nzt(<(exh&HD03r=kA%9u4FmV zK2Wh(vYNsp3{-FY=j-dt)8{WseWMX=7BWXnjyDecpOmK{&N2?*?lw&$$2E)P|I^9C z*=6Zz$Yo50%jjTH4!Aor+k16#Fln=i-H?>rPYf1)$R$i;Ged4ZpBpEOX#>28Pj;6et1Mu(v%1LP64y@qdlW+N9(2j8Fs+1d#`=8D%9p8cY-}_k z@3|=4RTtkt?BwJdTJ+^qEo^AiHR}ajz|YY8u{^yV!uu@-BRi&mM3-j}i>P%is+50- zAVvb=FQ)M@ODexJ-9OK6H~ju|A!ymEF2{(&u$LBpy=O+LgsN)k-S%B}UVH0RSgNw# zWTGnPzk9r;77%?!>xvhA>Q3Ky>BXp{@6QN$9A@h^`iV}OxDB3t@yl`^^yz-taEf@l z-l_4)bUE?b@E;@YVfG9yvZEG#U{PhT^9<7JnJ~o;l&M7X`mk`8Cb?- z^=hnQksEeg@zAUZ7?DOA;4$oQ5}XQ~FZ{V(ri?gco;5oJAqM$ zdEM?ioHDa_%4Qv^EUTRl8oH-1aKNV=20y>83}PSsy#B#EPzp9SB@kNy2y^R5S;uYS zh!1Ew4O9yX%4U9oOOfy&w+_>+SCr zRhgFjBCyp|5hgytEDRqS=V*6UR<**vm^SAZz>p4)#O6O#{`CfCQxol8v?`rb1u_tx^Qb4cIt ztSj4n{P}YN$cF;e0!i!P@H#y7m3>B-tw{OjMP0+HFF38NkWoQy!zP{SMs6U&eb1G( z3r6HB!r}|<{+SR5T_(3lxo6f3Jp0cJFPKaaZ51l`^dQpH3qHBZg=`peWzFr1$;tJ8 z$_R6eY0I6>{`(Q;^1cyi_Rb=_6p@E9hezrN`ITYchqf7Pu5T2A+WRb@iXG5`P~*Au z>)d+KB8?3Gz!#K#f)9rno~|7ABEe=Xpa$eW$QXc6)gN|GK0E&YN3`mo@KJ-I&mlCYHsf+6a!+l+HF~El#>07D-RvdUbu}qPK=&4ru@6#g!2N zpkHmsuxx3+Z(gRvz))gDcy++&ztLca>kZiN-dksw(v|k8_oLfpNt`&85Y66|TM#j_ zaqfK}n6M=OE%r8@HGb^q=TY}`rDG^2Bmeu>Lc_sFo5;aDm?EqeG@!l*dBc{?10s>DlEvUN1;}N z!tdLlPIdRrh3+wHs>_&nEEb0sS3MpXfLy3ncwJA;VL)QHI@3SMX~<7{z|k#fBBw9$ zSrymT(;Stjo%P@LUv#G?`Cp6Y03?)T8r*abA{MC} zI_YDFg%4eJo!UGNQ;fn}G*|%!L>0G5u%0+LR+I1`CRf^L6q*P=O`ITBZ08w5BCI_9 zg^v&|d{4x7W3OXVkTcg*pE2>yJe=??jaKD)=f@ru`sQ7YV701}=(L4!UR}D`AQE+u ztgoP{#SmgY-mXY*-w>SB5U2{Q@pXwq7ZwuyzH9u7>+J+np9ll3@mztK*UDO)-V7-1 zd(sCBJA8G)%3ab$9Jr+w2Xym!)Zgp~Eli2QnajPdYY+A7op z7I`aEbiJMY5C(v zUa|Px(Su^%S5!Clh3Y-BqM(dvm-QDL?>VPdl%UPv6P3xmc~k3*6xj-H6t83eAldwXvJMI0=` zQ_#sf6Vjao-Lzlggc#x!XF4R+G8!#qV3G1JYMu=CDKAc;-{Y63Jnw#_>v?PG!tDC6 zse#Bl>BrK&CPw(8Dn{T75Ut|XX@6QQZTI<%D3+z^2D&+1<@KJr&f=E4R=a!VE`HC-W!GMqp){4J zTcXPUM?jDo-%t2QSllkF?iy6b0lx^764M&Y7a{t zV;aNUjP=bx&*1B+o*m*`zRASguQTkHw2vJojy^t?m0SfMau#sS4VPKY4UYKeJk^;P zo~i!PIpeaJEY#078f2EjhFWVK$qHw!A1$<`T4)Gl{X7$Az}<0hK)6z4cT^It(L+3p zNw*&;Wv4~wvUTWSQ*tRHD-gS=aj!Q4LLrEwSxI=QEsNUzL05kCN>b>4LEV(rhi~S? zFWXFLAXzb(G!oRinkKcj_nk)K@k`am=$%LCA{rUnI~7+wb5LcQpyA&$oYn1hqq~=P z>g|Heug-!mAJ=znlLFqLTBetVv?)Dnr(fUxf6DvLsHVCuOn4O$ zX(}QbnnUI5nNM_WW~J3Ea3Rkxmm_ zj3fiIi!L|z94Fmr9k<^SXa`jnDgfstf1n|H$e@E-FuTDLQ&9SH&TU=dckvC=FQ*{= zMV>uMPXFJaN%-?RcKuv6%z2h$tqa#4U%UD!;WzNPo6=#?3VrWZ5?V&38vHSjcN-M$pE@-vJ=75xA6$nMw%JMakoKRai;-z;lDXz7 zIrxxeEjyFP0$+5CI1ZaX+F6kvAN}uwzpR9A%ub~hpR8$#pj@!8`mP-SBx(<1uiIz^ zyJFoczF)ho++60~ZJJn)^Q7w<&0WU}S@QMN^}tPZWwnI4-VA)!OO*3Z?^uLh{pp2z z6j~swg6s9p>L0W!p*vw=ubH!UzdqL0+upP<`?>oHJ>ONq zT%!og<}xswN-ucL`O2V$gfS(TMq^9;ubqbz5)&>@|II(a8%xgpVI|Roo~pAtzF!>| z8Zs$~ka*JlZ9I$MwDREdXwV^^oS zA<}X`Uh%Wop)Achi{wNhXDCpG?-~;HX`zAEo0LV28wyYQ=@4^!>pIhytZQ_%B@KbS z{@m`?9gn{|pviLlT;fq@c&eEl#QLgA$>E~lw@IZT((sMZ4cJ_62bW{ulobx|gRuv0 zE#L1vMb{TUjrwJ`S3%6aNN5FkO(FKu`#VSy9MSaP`DsPqtr*2_o8m81c=2Oi_#v=6Yc>wG>vqmWR0+=1n?Zu!o;vq5|HJ@~fwcItc> z8$o8nSpL`G(cP@a(XnCrR1iheMvdl+`-)C1Q2Xkmjuy_kpe{;f-K=HKZxiZ5yD=woNk8Xs3V7ld;6td?r!yd;~*T7 zuoszs4*!&D_8hP}%MvM|E3Ii za4Jx+sNb9P)*M9_al$)8<^xIG9Swe43dWE7N6+Ji2(M6Hl_$Bq-Dv0)?*GIdm$?AE zZ65;unCixfupgL+4)ZELDEnqU93Le;745ODBSvDs)L5IJlYBGLIP=8fphHRgoWm$; zeJrb60<4!qL=L?kug8zTXC$hGYB{FyG9}o@ulqw@p+5yh=p&sky3<~C;l&SBnJ6r) z&ODE>E@7jPFvTaXzF!;~^gf9<_K6T$EN2FS^}!ws3mYPN=@8Gc%68!LxGQ0c&4V>x(^53m9bf;aa9JU1yh1x!|O}pais- ze)S@%EwPY*_$x^<72%!d&QC);nXUGW91gzN!Ok%+q=`(LNGTYhuRZcu$Tv>+NO}95 zN+d_hmxMl_L~8=Q=CUpIPa_p$Et3dyGxQ=u&B7}?gX7F=C3qVQ2M$KpC499dKXWlo zt=Vxet{3iEY(4t8sOAO#a^RcwbWPT*j_K;P5pvbbV4D_g|Jtu78$E^eZ-(JxX?qS8 zG0B?EnXm7Bhk4i%EZpX9I-iLA7AEa3pwlYnYU6>(dt8W9A1}P2+T{+^x9RUthhPf6 zg9`<5nmseucx{^1(ob>5t&8OHbjN#slVy z4B)Bg{r$ggn9*^lCPP6E<9#%593W)W4#xV`neZ?BmmCdw8{qZ;9hAkYd?}^jJS~Xu zod7+abgt$858?V{GI&J>mg`%MmFdL9LNy0_`ho5Fe-rVR`{7ff>c0n|_~C5d`Kobu z4fdi}qFjLIEijz)&g_{i@ox}@)G~f@=a@stu2)vaD}FuA52)gR(kY-Dq$+yN#(gph zq%rTBF>38^lzNiX5AJ;mw2P2MpZ_P41<-qnN4CIPfw&-mMA8GuPX@r9fDo0TzLPqy zONh4h!-O7HG$#)dHqmsg)< z-M&8|5pICL{|#2*>HP&HZNvYczsM2{@bk`00Q7v(o!TPW(-I6MZ%<847H!eR}B0ISdMm1{QDk zDo_Y?gu(>H+?LjQysUmE5*xQm4<;{t27F`R{ZqY8KX!8YXE35NQOl z^hm|#^_5jKWtp!;_I32u!-*@zz9oSa`FTX?E(K|3&9co`{`F)s1C8N**hDXfyj?66 z>?_bN9bVlK>Y<2IQEU5f`%=(`Kc7TvpjDl)_g&QxtXSnWv>8iCJ%As$%+9qHeI z*w7L*0($($UO*FV959}C(-=o8Nz#BGeFvzw6|WM3LshKRzG=*3eGl?zPWXL0oVVEN zG*|uO5@ctBa?;gu80G-rQ|HbuSSp~AWci&>SJx6lGD?Ok;igF6NW%$(<-(@JKZx;- z^1M7=cIw~;GmXz2z=>@uM0cN2}6Fc+5@zm=djVdWx%qn8Z zL<>qbX4gi%u(b^_De77I=}Wxn9;L<4 z2P|m!KQVKO9e)5WE_^Mpwhle@Dix19kzd$nMjDvEy2RMvU5yucZ5NfDLM|WN2eAyA zNCCJOFFH*s&0OYxk%*F~9yp(mmf2*%R(vCI?BV3QS+p|RC%dBBWU{WQ;?Jh#qX?o0 zZzxo@pr$>@i2YC~LH8u93dipFD$x#&$v5UY7p(lA&C4u#d#9{K$h=cY&pHl4e<}EI zI%xc{6H<#`NuAj-za}6k(ssOdkW^Y0ADRL9LC99f(4Sp;m%AUduZy}bQpC>(Y=E#J z?dcDv?;Cdn;*lDlD${@efw5!}`{Y|Y`T z=Y8(G;%@QYwKN5K#E+Y=p1u3YUhFh1vG>KG6PY(bl~OCL+|03(E|z+4vRp|A3M;Vk ziLj%TMqh$h2oy(Bu&OgTzOO5YVt(LGYo^p#FsF}4IV4qh%`x;*3pB=!h*ZG;Qyor9DdT;Cbf69Z*u{ex-UBSZPep{3QaQV|orZoP0|k7ZXmG zEA}8EL9$ytEuE3(phd(ep|l!yC@HyKdvFB`yLQaRl-{4eXS$TckhsNKfBo1| zijH8VS8>z}cQhRtT}ZhQuPtByz=gaMdB@cZG@@eb^&J#Cuu*tj55@T@;hON0sL1GK zKc5ERbA4>>O6UQlJ>>h9_yXCY?1V9rk#2=1Zm0JM4`lC7^*1m8DbFt@ux~gMNp}AF z^13P`pw;m>Qz#<2x~4k5g_C#JGLV^>=%pL;WBB@g;8y`|BmQyZqzBQNcYK3}^)b(2 zh|ls;>+o*vCpwWK>h>ahoX%uLH8vQaX+Vfbum+{GHEGUwhnnBS3egf0sng4=!#p2I zlx<7a^z6!-i0Oc>p>f@L8Z{h=Gp|z0`bO73GC2N$eXzQ%2~<6-^z?meGe0WV6F}B+ ziWNXfMa|0nBQ`{8w}%(_(W~?#UZ@+w@ga3M=YyvC(Tf^A{a?x{SQ;^=k<#0RRj1xH ztG|$EqTzuEt%LyR#>Au^?V1tSbeQ7 z`!c@7;Q5xsV4M8V`gG~3{tbrSvT5R<>eV`d9i+qRB#OV7Ho(_ZMq#T^g4=FNA8dx_ z0L!+CHUde=HqmOJB(`a|>voD%-r@Q^{3^^R^Ym!{z&j2{7se;p=6%$93`;pRq9RT0 ziQAMcYCXo~^~Uo}H^gk@hTmae&m;kTD$Fex@?dS8?$Mv?ZiFip9IS0LB61OIc-xU` zh~|%BR)nb>mB{a*F`x+~5t|O>j>+J-uYWuY<2wdSvpIKCK4xzkLnk2YqpFlxAH)LiD{9cX;Zq<&MCu~#qa>QxOda0IKG2jkO! zB6U5^1@%muA~vmNb>(yXIJJ9eMdteqEFvgf2%`d?X|wz#6Y@aSitNc4cc=V#ZiowW;W*M`pUS6h1fpiA)+jC$M06)vNEzC6UNsMGW&$&dD z6N;~=%ka27FI4dq{;fQJHh2T zdYCO{^Oa7%&A>%}E9L(aDUgT8gpGfh2tC*L^wsj+&nku@$b+MesO&4$_7+lp zcO2-?hNrGlUq}LY+}wG4_GyXYC(xpfxKUII-VN#X{G%2FZ(?gI{yQceQ`#e_9RWQG z;zMfhyLDMYprr9uvO1xbi_Wu*MM+e4ymAU}PlXNp_YS)Ymt9~vcOeQ(fI`5!GfFXy ztm}-`#l7OiV&cl`jw}}H{*D+*J(Mqzs4e2_ef85lK-qrqI<-(K`=$Vg93yGo?tklf>d!Q;^fO&J=;`19yiG*7t*` z`_}HB6Dm&`Q{L*B6Bjpccp75(V@$9h8LR`Jt!EX{Em??b1eqt>U=~w8_8o2=S)%m{ z8^w16ieoP4i=w1SlWLs88%!pYK}>UDB)1Vp_+#fg_4Uk9A6)VCLchK zON6E_Kgu&?g8D>R(Xopn)YSt{!iI0`**~4+%M4wef4JbT<#f=S=G}=PK66^z7qHQu zz70K%8SP>}Fs|P=E`AN}3|w{krR)3@+x?TaqJtM|8&gbMF4V?r)y%;Wx>~-m-uPk# zJXA>C!u;vkmP_}Wl|=XJNI%)G$t_satBX{RW-Xb26sEG#%SZZ@box&WUFlt+6L}Vu zb}l9RMtN9^Wc6=uahBQL7`^8LeVSEVBYb71*qHpMolAHDadGjU1^rMKObju(-l1(T z-M#=k`MJufl$4#kb@-?v@8J42wa%KRZ24AdvW)s5Wr3PUyNxN~hhz3`|2d3cT^=|p z9zqk7%0BOI+CVOEP4EDl8Yim!%;HlPQX|%e^ZL4e1nZ~*)rT?}T5MV@I^{yqywK)@ z&*Z_1o~m6xKdYJY6tpQ947=-gU!h%6m}W!8@T5?&>K_}M#qJ2dn_RMiL*x`=O?kcd zX`Nk{x?r3H-P7nAJ9`uAb;OfZAbzOoJxZir@hAy@!{Bm+>@6dE6@W9r> z^TTgJPHpABa*_XU^#$i-=Z4Tsy+OEJ9M(viy^`MbKvCkfmISOc3Q_;t z*u33Up+_5p;?0bPb#v)Yp{ZCcrN`|Wd8G}iVy#JQ^o)BFDfx|ihq9^Hj`bpAayvA zmeS5Bx9dx-)6ZmYVeAH|o?oOeXbLz|0&o+{$*^eg%Pn&;!s9|)RombM(DUsR;yy=FQ)|gDM zgZHI26|A2ejze}^qp(^pZ5*I%q^}KeW9P}qFJoXT2;_AutV#wA=@hvYDNqz?s>lBt zexWX1<(RsC2uWVXV43NPS^^nuy1ELMQS=}Oy?)E78^E^K(`4Av$q(;QDAZUOuA!DT z{q-zv)HHeTDdtG%NFRnu9Hzuo}?}0kNXbauM*Y|QsEvGl`*0f$J?m%C3N-nAJe$wk&8wAG#5pZHXZ{T zS9siwP09)Rb3V5KcTo642fwo4@NQ3=lyW6&%ML7Z-S-bzJ|b#bV?4BAi~r&m%IjOb zh6zs(_-(c$c^f_THsUeHmHx4*oyWNYp0ri-mjR?xJg~f7=pC#AubcQRur)8j5q;n> zi%uLFL?BB^(@&-o2{M~sBm&2UCShN=wNQ`|ZBm>*QnersJ>3nzBUU9GndZTizS^jL zi@lCH-R@#jm6T1~owP#!f+M)-v!7WA&osVzhy3G!?V)m% z4ZJ*(s=L?D5|v}B4#CCBN)5vBO^5NPN&vrOR!f0dT(wA;x$QQfgOrQRNSG;y$-0_% z)voTpco{j)F6(0qTV#@qznL2XxKRNI5zcz$o=xo9b|wRSMuejMtG3av509q0SmR54 zkdBI!!TOA!szQe8^k9<@rW?LbO)>$Gcr7RZnq3OCv z(!ovAlgt1sDjXOJ^kD65N;{134e^URebHhgy>HkkH!si*7!G<(6uB$J6|+Lj3&3oO z%^cNhD8$O#LBdQ)*bdfFmK9s>LWw{=1djQizE5(IE(c|Fk(5Gl&Dr!@U~veJ^$&^;S|J316lSxiSzCVtJ6VW zm|Is0`<*-kFlmte!{!T&EA4gTo^K;z-LyRRYau^{yiK>@;*UKKC06tVtSy#@=b7Sk zX8@?hT%GchknTNk*pxyqlT=_o;(}wc|7lDB+oP^sE4$C+`bMqxNlUgi?*1ln}^2bx|hC9{Kd?(Nfs3J87D$bb_5mPOy&#cO+$cHgE0Ivt%wrvvJPX zLJv=fqai`rfQSnayyy-8x{Xpz7j5bR5toG=!;6g$dyN5nM;_3*BAP`gx_H%*i|4EO zk@z^<4e=(}?D)Xj;i}4^N}4Lbo0v3Tp;vmY^zNo(S05eZZnR&E_+!4NUv<{ppbE=i zkFd}^{`m3v-@{5t>VID1nK$+}o{$H_OkWB+Vc>hGY8-9UdZqQ$1OO=O&KSp97JC2+ znj@wWkmvUL1;6&P9NI~M$du0_0>CRyhtFKS-0Pfcm+7Q6k1ovXZ#U3!kNIq%434+O z@vf!IE&P?_oeMWPF&*VLfc_5!VX%FyFNV=MQ=rFB*3&8hY>blX-M~N~Qz3KSIM~jh zbo_;ad2z$DGk8=C4=&5T(^J#&98aD-R6L{d9_P17=AhQteZ(1>t({c7BH)Vzn2+Wz}{0}Vq* z#=r^W+*q{7c4a9f)G$o(6z^mpHYRdzhwY|NQSX3WM*4Tr-U9+%0^fSrb-Sv0>z)+u zxcw2}Gy>rWQ?w#4i_qdVHSWEJr3#4qN6L0%zVg^^luYF{-(mp4R&8ld|IoAe8>DP<#)0q8h4YI~ z8#Mf|k0?hf+5oA59rPE3c*e;CNM?fbvl$fls}r8@j<6uK!GCAU(cFS*5nXFeq+Qt8 zuE?#M@NSA7)WjSd#7xHPF{#KfI&!%w=+xjPT(56BahZ3Oc2Vy=q+|hCQSL~D#lx#Q zvd>^)|L}{48<=HW1-FMOJ(a3CiE&Y-=+7b7@_{lyn zER)6lKs=mxS1tdM)39E?4cj;#KR^YpUbnuVLsbt1Sx&;OMr~O8sPQ`O5290d69i4r z&V^EcXdnK4(uF%{x%?&PmxOoGO+Iz%-k%ee!@J9_xy4LYjV%mU@YHF$qPrKAkAPE0 z6MsCSt;7Xv_JAOjp9JW4W&IhYC81mLaoVX^=dQ@;oLMGGNi$;5D$Tpj*@GQ+iM}Mx zi4XUu{y6O$qC!f>ucHp^fN)uc+3^K?Bs&_{@2n83UcqzPrb9+n8iz$$$&C)#@n-2v z#_V6xsT1Tj{bP0(ZrLvdxS&{y1Ur*|qmGBUX`h?JU$4teqPaH&)1&0qKGpA(`ux(5 z==wew_UDJrx&iIezj5zdt8;W5;#%Vt*;{DQ_8)Li3F$;Yj76OR=4R8j#YE4T$sxh$ zzVw|zT2XVz2MOq~Fb|LqXr9_c8iNv&fEad0^kDg9o%+#ZeC>WZx~v5o@a!~<CY;RX|Lx{fJ1OP^vlU4y9mLdvkU+MSV+cp^g0d#cg%q;+v6hfYD#OI~a| z^>SGVcfE_T4k^wQ{I*3}}OkPPx*LojdsgNAwm62@8f-w5K0@iKDy<;i0l!7Qy+m2CWQ z3}m$?xA1C*RrbnNvo7@GFIq)Wv4fbH?8y~D>v8M8D?$za{gAVtrRoLqBVbX!rDD79ui2I>zk zvyI5Xeqd3$3AXt3a&;$5)?Q*IVMvMg2TOL;YK;Z?E9L+@_Vsc`wa6QQWCmdS~4 zd66pO#JuJ_*}U`9?zIOKbwCJv*{MRobXrk$ps6**peM=19@8NNED1XVyzBz$ZnBH# zs6<@Zd{=j}b(`j3TDAEywB6rz0Ni1PNt!Tg=7^9k`ft2wNetZcFmCUMTMyThvSRe3 zZe=zziDt}eed$fFPmw5@pQB?*3nQa=Nzv@*Ms1~rSlet|(=kB+QwK$Y&ynLIctf(Mq}uySPH zqTdW4g+H4>dJQ8md`6;sD4mE%?%5SBa^*A-Y(IG?P~O8(vh(P*pI$|Vh%meH$y}E_ zSQlvzZ66Da4+Ajseb1xwS(asA5|ugciYzQl@8YPnzQXomeHgv~L8z}#5HMq2b_ zWLA7!^$)jc03E5Bs1q%AH{}YpGb6f>aaBaGw)6at4C9p1D|grP9G#0*+eC<0N~@d9h*1NzQ-E0y)5QR zXVcusrD6TDW=2xY5}CTJk}jojuOMAN_7sDVHQ0Yrc=7nnJYy~(r?R>Db8;v;(?nO` zG_OGBZghdRTOFM)UE>t`;vWi@-+=Kn2e&Ns1kP@@vaYzaAg1>rQRNwf$dseqsA0kJ z3hLR)U&HlsC(3FOTzR9cUiOd&i)i`kdkBON+xMTGUGr3-y3}msjf#eUVz7N9>TvsIRT}nTS`v z>x;(^AG>`T)z_ObyI>L7f7MvAd72>YdzjfMy&X_kJW{M(te>})$5hyJMf85vf#opM z*<+jLhI#|h-l%W;_o_a-yPv3DJbpb-!MAooruORUz(*O_>17Jfm7f*My!QVek(bL< literal 0 HcmV?d00001 diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakTestCardRunning_Light_b29dc7a7_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakTestCardRunning_Light_b29dc7a7_0.png new file mode 100644 index 0000000000000000000000000000000000000000..27dd257d0dc584d967f89550138cd5166b8aa04a GIT binary patch literal 29437 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 100ms" (NFR-003) measurable — what's the measurement point (start/end boundaries)? [Measurability, Spec §NFR-003] +- [ ] CHK038 — Is "30+ minutes" (NFR-002, SC-004) a minimum requirement or typical expectation? Are test conditions specified (Doze, App Standby, background restrictions)? [Measurability, Spec §NFR-002] +- [ ] CHK039 — Is SC-006 ("65+ test methods") a minimum or approximate — does the requirement define what "passing" means for round-trip encoding? [Clarity, Spec §SC-006] +- [ ] CHK040 — Can SC-002 ("compressed PLI < 100 bytes") be measured independently of radio hardware? [Measurability, Spec §SC-002] + +## Scenario Coverage — Edge Cases + +- [ ] CHK041 — Are requirements defined for handling corrupted zstd dictionary data at runtime? [Gap] +- [ ] CHK042 — Is the behavior specified when ATAK sends CoT with coordinates outside valid ranges (lat > 90, lon > 180)? [Coverage, Gap] +- [ ] CHK043 — Are requirements defined for handling duplicate CoT messages arriving via both port 72 and port 78 simultaneously? [Coverage, Gap] +- [ ] CHK044 — Is the XML sanitization scope (FR-010) limited to the 5 characters listed, or does it also address CDATA injection, entity expansion (XXE), or DTD attacks? [Clarity, Spec §FR-010] +- [ ] CHK045 — Are requirements defined for behavior when the mesh network is unavailable but ATAK is connected (buffering vs immediate failure)? [Gap] +- [ ] CHK046 — Is the "50-message cap" (FR-014) justified with sizing analysis — could 50 large CoT messages cause memory pressure? [Clarity, Spec §FR-014] + +## Non-Functional Requirements Coverage + +- [ ] CHK047 — Are memory consumption requirements defined for zstd dictionary loading and decompression buffers? [Gap, NFR] +- [ ] CHK048 — Are battery impact requirements quantified beyond "partial wake lock" — expected drain rate, or max acceptable CPU usage? [Gap, NFR] +- [ ] CHK049 — Are startup time requirements defined for the TAK server initialization? [Gap, NFR] +- [ ] CHK050 — Are requirements specified for TAK server behavior under low-memory conditions (Android LMK threshold)? [Gap, NFR] +- [ ] CHK051 — Is thread/coroutine model specified for concurrent TAK client handling and mesh I/O? [Gap, NFR] + +## Cross-Platform Requirements (KMP Boundaries) + +- [ ] CHK052 — Are iOS stub behaviors explicitly specified — what does TAKServerIos return/throw for each operation? [Completeness, Spec §Source-Set Impact] +- [ ] CHK053 — Is iOS "uncompressed TAK_TRACKER mode" payload size limitation (~225 bytes) clearly stated as a functional constraint (not just an edge case note)? [Clarity, Spec §Edge Cases] +- [ ] CHK054 — Are desktop (JVM) TAK capabilities explicitly defined — is TLS server supported? File writing? What's excluded? [Completeness, Spec §Source-Set Impact] +- [ ] CHK055 — Are expect/actual contract requirements documented — what guarantees must each platform `actual` provide? [Gap] +- [ ] CHK056 — Is the iOS "pending Swift SDK integration" timeline or gating criteria defined, or is it an open-ended assumption? [Assumption, Spec §Assumptions] +- [ ] CHK057 — Are error/fallback requirements defined for when platform actuals are stubs (iOS) — do callers get exceptions, no-ops, or degraded functionality? [Clarity, Gap] + +## Dependencies & Assumptions + +- [ ] CHK058 — Is the TAKPacket-SDK v0.1.3 dependency stability assessed — is it alpha/beta, and are version pinning requirements defined? [Assumption] +- [ ] CHK059 — Is the "firmware version available at connection time" assumption validated — what if radio metadata is delayed or partial? [Assumption, Spec §Assumptions] +- [ ] CHK060 — Is the ATAK "15-second stale threshold" assumption documented with a source reference? [Assumption, Spec §FR-015] +- [ ] CHK061 — Are the bundled certificate requirements specified (validity period, rotation strategy, self-signed vs CA-signed)? [Gap] +- [ ] CHK062 — Is the Android 17+ ACCESS_LOCAL_NETWORK permission requirement validated against published API 37 documentation? [Assumption, Spec §FR-016] +- [ ] CHK063 — Is the "237-byte raw LoRa MTU" assumption sourced to specific radio hardware documentation or Meshtastic firmware specs? [Assumption, Spec §Assumptions] + +## Security Requirements + +- [ ] CHK064 — Are mTLS certificate validation requirements specified — does the server verify client certificates, or only encrypt? [Clarity, Spec §FR-006] +- [ ] CHK065 — Are certificate storage security requirements defined (KeyStore, encrypted preferences, plaintext resources)? [Gap] +- [ ] CHK066 — Is the threat model for local-network-accessible TAK server documented (LAN adversary, rogue ATAK client)? [Gap, Security] +- [ ] CHK067 — Are requirements defined for certificate expiration handling and renewal? [Gap, Security] +- [ ] CHK068 — Is the CoT XML sanitization (FR-010) scope sufficient for the threat model — are XXE and billion-laughs attacks addressed? [Clarity, Spec §FR-010] + +## Cross-Artifact Consistency + +- [ ] CHK069 — Do task IDs in tasks.md map 1:1 to plan.md phase structure without gaps or orphans? [Consistency] +- [ ] CHK070 — Does the plan's "99 files, +4698 lines" scope align with the task count and complexity in tasks.md? [Consistency] +- [ ] CHK071 — Are all 16 bloat elements listed in FR-013 consistent with the CoTDetailStripper implementation described in plan.md? [Consistency, Spec §FR-013] +- [ ] CHK072 — Do research.md technology decisions align with spec dependency choices (TAKPacket-SDK, xmlutil, Ktor)? [Consistency] + +--- + +## Notes + +- Focus: Full-breadth requirements quality across all spec dimensions +- Depth: Standard (PR review gate) +- Audience: PR Reviewer evaluating requirement completeness before merge +- 72 items covering: Constitution (6), Wire Protocol (8), Type Mapping (4), Server Lifecycle (7), Legacy Fallback (5), Consistency (5), Acceptance Criteria (5), Edge Cases (6), NFRs (5), Cross-Platform (6), Dependencies (6), Security (5), Cross-Artifact (4) +- Items reference spec sections where applicable; `[Gap]` markers indicate missing requirements diff --git a/specs/005-tak-v2-protocol/checklists/requirements.md b/specs/005-tak-v2-protocol/checklists/requirements.md new file mode 100644 index 000000000..d3d22f441 --- /dev/null +++ b/specs/005-tak-v2-protocol/checklists/requirements.md @@ -0,0 +1,37 @@ +# Specification Quality Checklist: TAK v2 Protocol Integration + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2025-07-22 +**Feature**: [spec.md](./spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- Spec was derived from existing PR #5434 and code analysis — implementation is already in progress +- All CoT types identified from test fixtures in the codebase +- Success criteria reference observable user-facing outcomes rather than internal metrics +- Architecture section includes implementation references for traceability but requirements remain behavior-focused diff --git a/specs/005-tak-v2-protocol/contracts/wire-protocol.md b/specs/005-tak-v2-protocol/contracts/wire-protocol.md new file mode 100644 index 000000000..71307bc60 --- /dev/null +++ b/specs/005-tak-v2-protocol/contracts/wire-protocol.md @@ -0,0 +1,184 @@ +# Wire Protocol Contract: TAK v2 Protocol Integration + +## Overview + +The TAK v2 protocol defines two wire formats for transmitting Cursor-on-Target (CoT) events over the Meshtastic LoRa mesh network. This contract documents the encoding, port assignment, and interoperability guarantees. + +--- + +## Port Assignments + +| Port | Protobuf PortNum | Protocol | Firmware Requirement | +|------|-----------------|----------|---------------------| +| 72 | `ATAK_PLUGIN` | TAKPacket (v1) | Any version | +| 78 | `ATAK_PLUGIN_V2` | TAKPacketV2 (v2) | ≥ 2.8.0 | + +--- + +## TAKPacketV2 Wire Format (Port 78) + +### Byte Layout + +``` +Offset Size Field +0 1 Flags byte +1 N Payload (compressed or raw TAKPacketV2 protobuf) + +Total: 1 + N bytes, where (1 + N) ≤ 225 bytes +``` + +### Flags Byte Encoding + +| Value | Meaning | +|-------|---------| +| 0x00 | Compressed with non-aircraft dictionary (ID 0) | +| 0x01 | Compressed with aircraft dictionary (ID 1) | +| 0x02-0xFE | Reserved for future dictionaries | +| 0xFF | Uncompressed (raw TAKPacketV2 protobuf, no zstd) | + +### Compression + +- **Algorithm**: Zstd (Zstandard) with pre-trained dictionaries +- **Dictionary selection**: Based on CoT type — aircraft types (`a-*-A-*`) use dict 1, all others use dict 0 +- **Library**: `meshtastic/TAKPacket-SDK` v0.1.3 (wraps zstd-jni) +- **Max decompressed size**: 4096 bytes (reject larger to prevent memory exhaustion) + +### TAKPacketV2 Protobuf Schema + +The protobuf is defined upstream in `meshtastic/protobufs`. Key payload types: + +| Payload Type | CoT Types Covered | +|-------------|-------------------| +| `pli` | All `a-*` position reports (friendly, hostile, unknown, neutral) | +| `chat` | `b-t-f` GeoChat messages | +| `raw_detail` | All other CoT types (shapes, markers, routes, etc.) | + +#### PLI Payload Fields + +| Field | Type | Scaling | Notes | +|-------|------|---------|-------| +| latitude_i | int32 | × 1e7 | WGS84 degrees | +| longitude_i | int32 | × 1e7 | WGS84 degrees | +| altitude | int32 | meters | HAE | +| speed | uint32 | × 100 | m/s → cm/s | +| course | uint32 | × 100 | degrees → centidegrees | +| cot_type_id | enum | — | Mapped CoT type | +| cot_type_str | string | — | Original type for round-trip (CotType_Other) | + +#### GeoChat Payload Fields + +| Field | Type | Notes | +|-------|------|-------| +| message | string | Chat message text | +| to | string | Recipient UID or chatroom name | +| device_callsign | string | `\|` (UID smuggling) | + +#### Raw Detail Payload + +| Field | Type | Notes | +|-------|------|-------| +| raw_detail | bytes | Stripped inner `` XML (16 elements removed) | + +--- + +## TAKPacket Wire Format (Port 72, Legacy) + +### Byte Layout + +``` +Offset Size Field +0 N Raw TAKPacket protobuf (no header, no compression) +``` + +### Supported Payload Types (v1) + +| Type | Support | +|------|---------| +| PLI (position) | ✅ Full | +| GeoChat | ✅ Full | +| Markers/Shapes/Routes | ❌ Not representable in v1 schema | + +--- + +## TAK Server Protocol (Port 8089) + +### Transport + +- **Protocol**: TCP with TLS 1.2+ (mTLS required) +- **Port**: 8089 (configurable via TAK server standard) +- **Binding**: `127.0.0.1` (loopback) + LAN interfaces +- **Authentication**: Mutual TLS — server presents `server.p12`, client presents `client.p12`, both trust `ca.pem` + +### Message Framing + +CoT events are sent as complete XML documents over the TLS stream: + +```xml + + + + + + + +``` + +Events are framed by detecting the closing `` tag via `CoTXmlFrameBuffer`. + +### Keepalive + +- **Interval**: 10 seconds +- **Format**: Empty ping CoT event (`t-x-d-d` type) +- **Purpose**: Maintain connection below ATAK's 15-second stale threshold + +--- + +## Data Package Format (.zip) + +### Connection Data Package + +Exported for ATAK/iTAK client configuration: + +``` +Meshtastic_TAK_Server.zip +├── meshtastic-server.pref # ATAK connection preferences (XML) +├── truststore.p12 # CA certificate for server verification +├── client.p12 # Client identity for mTLS +└── manifest.xml # MissionPackageManifest v2 +``` + +### Route Data Package + +Generated on receiving end for ATAK route import: + +``` +{route_uid}.zip +├── {route_uid}.kml # KML LineString with waypoints +└── manifest.xml # MissionPackageManifest v2 +``` + +--- + +## Inbound Processing Rules + +| Source Port | Local Firmware | Action | +|------------|---------------|--------| +| 78 (V2) | Any | Decompress → decode → broadcast to TAK clients | +| 72 (V1) | Any | Decode raw protobuf → broadcast to TAK clients | +| 78 (V2) + route type | Any | Decompress → decode → generate KML → broadcast + write file | + +--- + +## Outbound Processing Rules + +| CoT Type | Local Firmware ≥ 2.8.0 | Local Firmware < 2.8.0 | +|----------|----------------------|----------------------| +| PLI (`a-*`) | V2 (port 78, compressed) | V1 (port 72, raw proto) | +| GeoChat (`b-t-f`) | V2 (port 78, compressed) | V1 (port 72, raw proto) | +| All others | V2 (port 78, compressed) | **DROPPED** (log warning) | + +### MTU Enforcement + +- Max wire payload: 225 bytes (after protobuf framing within 237-byte LoRa MTU) +- If compressed payload exceeds 225 bytes: attempt remarks stripping, then drop with warning +- Never fragment; never queue oversized packets diff --git a/specs/005-tak-v2-protocol/data-model.md b/specs/005-tak-v2-protocol/data-model.md new file mode 100644 index 000000000..a049b0048 --- /dev/null +++ b/specs/005-tak-v2-protocol/data-model.md @@ -0,0 +1,249 @@ +# Data Model: TAK v2 Protocol Integration + +## Core Entities + +### CoTMessage (Central Domain Model) + +The primary data model flowing between TAK clients, the TAK server, and the mesh network. + +```kotlin +@Serializable +data class CoTMessage( + val uid: String, // Unique event ID (e.g., "ANDROID-device-uuid") + val type: String, // CoT type string (e.g., "a-f-G-U-C", "b-t-f") + val time: Instant, // Event creation time + val start: Instant, // Event validity start + val stale: Instant, // Event expiry time + val how: String, // How generated (e.g., "m-g", "h-e") + val latitude: Double, // WGS84 latitude + val longitude: Double, // WGS84 longitude + val hae: Double, // Height above ellipsoid (meters) + val ce: Double, // Circular error (meters) + val le: Double, // Linear error (meters) + val contact: CoTContact?, // Contact info (callsign, endpoint) + val group: CoTGroup?, // Team/role info + val status: CoTStatus?, // Battery status + val track: CoTTrack?, // Speed/course + val chat: CoTChat?, // Chat routing info + val remarks: String?, // Free-text remarks + val rawDetailXml: String?, // Preserved inner XML for raw_detail + val parsedDetailXml: CoTDetailXml?, // Parsed structured detail + val sourceEventXml: String?, // Full source XML for round-trip fidelity +) +``` + +### Supporting Detail Models + +```kotlin +@Serializable data class CoTContact(val callsign: String?, val endpoint: String?) +@Serializable data class CoTGroup(val name: String?, val role: String?) +@Serializable data class CoTStatus(val battery: Int?) +@Serializable data class CoTTrack(val speed: Double?, val course: Double?) +@Serializable data class CoTChat(val chatroom: String?, val id: String?, val senderCallsign: String?) +``` + +### TAKClientInfo + +Tracks connected TAK client state. + +```kotlin +data class TAKClientInfo( + val uid: String?, // Client's self-reported UID + val callsign: String?, // Client's callsign (from first PLI) + val connectionTime: Instant, // When client connected +) +``` + +### InboundCoTMessage + +Wraps a CoT message with its source client info for routing decisions. + +```kotlin +data class InboundCoTMessage( + val cotMessage: CoTMessage, + val clientInfo: TAKClientInfo?, // null if from mesh (not local TAK client) +) +``` + +--- + +## Enum Mappings + +### CotType (23 mapped values + fallback) + +| Enum Value | CoT String | Category | +|-----------|------------|----------| +| CotType_PLI_Friendly | "a-f-G-U-C" | Position (friendly ground) | +| CotType_PLI_Hostile | "a-h-G" | Position (hostile) | +| CotType_PLI_Unknown | "a-u-G" | Position (unknown) | +| CotType_PLI_Neutral | "a-n-G" | Position (neutral) | +| CotType_Aircraft_Friendly | "a-f-A" | Aircraft | +| CotType_Aircraft_Military | "a-f-A-M" | Aircraft (military) | +| CotType_Aircraft_Helicopter | "a-f-A-M-H" | Aircraft (helicopter) | +| CotType_Aircraft_FixedWing | "a-f-A-C-F" | Aircraft (fixed wing) | +| CotType_GeoChat | "b-t-f" | Chat | +| CotType_DrawnShape | "u-d-f" | Drawing | +| CotType_Route | "b-m-r" | Route/Navigation | +| CotType_Marker | "b-m-p-s-p-i" | Marker (spot) | +| CotType_Waypoint | "b-m-p-s-p-loc" | Waypoint | +| CotType_Delete | "t-x-d-d" | Control | +| CotType_Casevac | "b-r-f-h-c" | Medical | +| CotType_Emergency | "b-a-o-pan" | Emergency | +| CotType_Alert | "b-a-o-opn" | Alert | +| CotType_Task | "b-i-v" | Tasking | +| ... (+ additional variants) | | | +| CotType_Other | *(any unrecognized)* | Fallback | + +### CotHow (4 mapped values) + +| Enum Value | String | Meaning | +|-----------|--------|---------| +| HOW_HUMAN_ENTERED | "h-e" | Human-entered | +| HOW_MACHINE_GENERATED | "m-g" | Machine-generated | +| HOW_HUMAN_OSINT | "h-g-i-g-o" | Human-generated OSINT | +| HOW_MACHINE_REPORTED | "m-r" | Machine-reported | + +--- + +## State Machines + +### TAK Server Lifecycle + +``` + ┌──────────┐ + │ STOPPED │ + └────┬─────┘ + │ start() + ▼ + ┌──────────┐ + │ STARTING │ (bind port 8089, load certs) + └────┬─────┘ + │ success + ▼ + ┌──────────┐ accept() ┌────────────────┐ + │ RUNNING │◄────────────────────│ CLIENT_CONNECTED│ + │(0 clients)│ │(n clients) │ + └────┬─────┘ └───────┬─────────┘ + │ stop() │ stop() / all disconnect + ▼ ▼ + ┌──────────┐ + │ STOPPED │ + └──────────┘ +``` + +### TAK Client Connection Lifecycle + +``` + ┌──────────────┐ + │ CONNECTED │ (mTLS handshake complete) + └──────┬───────┘ + │ first PLI received + ▼ + ┌──────────────┐ + │ IDENTIFIED │ (callsign + UID known) + └──────┬───────┘ + │ socket error / timeout / client close + ▼ + ┌──────────────┐ + │ DISCONNECTED │ (scope cancelled, removed from list) + └──────────────┘ +``` + +### Protocol Version Selection (per-send) + +``` + ┌─────────────────────────────┐ + │ CoT message from TAK client │ + └──────────────┬──────────────┘ + │ + ┌─────▼──────┐ + │ useTakV2()?│ + └──┬─────┬───┘ + yes │ │ no + ▼ ▼ + ┌───────────┐ ┌───────────┐ + │ V2 Path │ │ V1 Path │ + │ (port 78) │ │ (port 72) │ + └─────┬─────┘ └─────┬─────┘ + │ │ + ┌─────▼─────┐ ┌─────▼─────┐ + │All CoT │ │PLI + Chat │ + │types OK │ │only; else │ + │ │ │drop+warn │ + └─────┬─────┘ └─────┬─────┘ + │ │ + ┌─────▼─────┐ ┌─────▼─────┐ + │Compress │ │Bare proto │ + │(zstd+dict)│ │(no compr.)│ + └─────┬─────┘ └─────┬─────┘ + │ │ + ┌─────▼─────┐ ┌─────▼─────┐ + │Check MTU │ │Send on │ + │≤225 bytes │ │port 72 │ + │else drop │ └───────────┘ + └─────┬─────┘ + │ + ┌─────▼─────┐ + │Send on │ + │port 78 │ + └───────────┘ +``` + +--- + +## Validation Rules + +| Field | Rule | Error Handling | +|-------|------|----------------| +| Compressed payload size | ≤ 225 bytes | Drop packet, log warning | +| Decompressed payload size | ≤ MAX_DECOMPRESSED_SIZE (4096) | Reject packet (memory exhaustion prevention) | +| CoT XML structure | Valid `` root with `` | Parse returns `Result.failure` | +| Latitude/Longitude | -90/90 and -180/180 respectively | Passed through (ATAK validates) | +| Stale time | Must be in future (or extended to MIN_MESH_STALE_TTL for static types) | Extended automatically for routes/shapes | +| Offline queue | ≤ 50 messages, ≤ 5 min TTL | Oldest evicted on overflow; expired purged on drain | +| Port 8089 binding | Must succeed | Server start returns `Result.failure` with user-visible error | + +--- + +## Wire Format + +### TAKPacketV2 (Port 78) + +``` +┌─────────┬────────────────────────────────┐ +│ Flags │ Compressed TAKPacketV2 Protobuf │ +│ (1 byte)│ (variable, ≤224 bytes) │ +└─────────┴────────────────────────────────┘ + +Flags byte: + Bits 0-5: Dictionary ID (0=non-aircraft, 1=aircraft) + Bits 6-7: Reserved + 0xFF: Uncompressed (raw protobuf follows) +``` + +### TAKPacket (Port 72, Legacy) + +``` +┌──────────────────────────────┐ +│ Raw TAKPacket Protobuf │ +│ (no compression, no header) │ +└──────────────────────────────┘ +``` + +--- + +## Key Constants + +```kotlin +const val DEFAULT_TAK_PORT = 8089 +const val MAX_TAK_WIRE_PAYLOAD_BYTES = 225 +const val MAX_DECOMPRESSED_SIZE = 4096 +const val TAK_COORDINATE_SCALE = 1e7 // lat/lon → int scaling +const val DICT_ID_NON_AIRCRAFT = 0 +const val DICT_ID_AIRCRAFT = 1 +const val DICT_ID_UNCOMPRESSED = 0xFF +val OFFLINE_QUEUE_TTL = 5.minutes +const val OFFLINE_QUEUE_MAX_SIZE = 50 +val KEEPALIVE_INTERVAL = 10.seconds +val MIN_MESH_STALE_TTL = 15.minutes +``` diff --git a/specs/005-tak-v2-protocol/plan.md b/specs/005-tak-v2-protocol/plan.md new file mode 100644 index 000000000..9f1c890d7 --- /dev/null +++ b/specs/005-tak-v2-protocol/plan.md @@ -0,0 +1,155 @@ +# Implementation Plan: TAK v2 Protocol Integration + +**Branch**: `tak_v2` | **Date**: 2026-05-13 | **Spec**: `specs/005-tak-v2-protocol/spec.md` +**Input**: Feature specification from `/specs/005-tak-v2-protocol/spec.md` +**Status**: Retroactive — documents existing implementation (PR #5434, 99 files, +4698 lines) + +## Summary + +Upgrades Meshtastic Android's TAK integration from legacy v1 (port 72, PLI + GeoChat only) to TAK v2 (port 78, ATAK_PLUGIN_V2) with zstd dictionary compression and full CoT type coverage. The implementation uses a bidirectional bridge pattern (`TAKMeshIntegration`) that version-gates output based on firmware capability (`Capabilities.supportsTakV2` ≥ 2.8.0) while always accepting inbound traffic on both ports. Compression is provided by the `meshtastic/TAKPacket-SDK` (JitPack) with platform abstraction via expect/actual for iOS stubs. + +## Technical Context + +**Language/Version**: Kotlin 2.3+ targeting JDK 21 (KMP multi-target) +**Primary Dependencies**: TAKPacket-SDK v0.1.3 (zstd compression), xmlutil (CoT XML parsing), Ktor Network (TCP), zstd-jni 1.5.7-7, Okio (I/O), Koin 4.2+ (DI), Kermit (logging) +**Storage**: App-private filesystem for route KML data packages; bundled .p12/.pem certificates for TLS +**Testing**: `commonTest` (9 test classes, 65+ test methods), 40 XML fixture files in `jvmAndroidMain/resources/tak_test_fixtures/` +**Target Platform**: Android (primary), JVM Desktop (secondary), iOS (stubs only) +**Project Type**: Mobile app — KMP module (`core:takserver`) + UI integration (`feature:settings`) +**Performance Goals**: CoT processing < 100ms; compressed PLI < 100 bytes; fits within ~225-byte usable LoRa payload +**Constraints**: 237-byte raw LoRa MTU (~225 usable after protobuf framing); PARTIAL_WAKE_LOCK for CPU keepalive; mTLS on port 8089 +**Scale/Scope**: 28 CoT type mappings, 2 protocol versions, 3 platform targets, 1 new KMP module + UI screen + +## Constitution Check + +*GATE: ✅ All six principles evaluated and satisfied.* + +- **I. Kotlin Multiplatform Core**: ✅ All business logic (TAKMeshIntegration, conversions, type mapper, CoT parser, detail stripper, server manager, models, DI module) resides in `commonMain`. Platform-specific code isolated to: + - `jvmAndroidMain`: TAKServerJvm (JSSE TLS), TakV2Compressor (zstd-jni via SDK), TakCertLoader, TAKClientConnection + - `androidMain`: AtakFileWriter (SAF/private dirs), TakPermissionUtil (runtime permissions) + - `jvmMain`: AtakFileWriter (desktop filesystem), TakPermissionUtil (no-op) + - `iosMain`: TAKServerIos (no-op), TakV2Compressor (uncompressed stub), AtakFileWriter (stub) + +- **II. Zero Lint Tolerance**: ✅ Verification commands: + ```bash + ./gradlew spotlessApply spotlessCheck detekt :core:takserver:allTests :feature:settings:allTests + ``` + +- **III. Compose Multiplatform UI**: ✅ `TAKConfigItemList.kt` uses Compose Multiplatform components (`DropDownPreference`, `SwitchPreference`, `TitledCard`). No direct Android Jetpack Compose imports. Float values use `NumberFormatter.format()` where displayed. + +- **IV. Privacy First**: ✅ No PII/location/crypto keys logged. CoT data stays local to device/mesh. `core/proto` submodule not modified (read-only upstream). Certificates bundled as resources, not logged. + +- **V. Design Standards Compliance**: ✅ TAK config UI uses M3 components (SwitchPreference, DropDownPreference). Cross-Platform Spec: TAKPacket-SDK defines shared wire protocol behavior; Android-specific UI is N/A for cross-platform spec (ATAK integration is Android/JVM-only; iOS uses stubs). + +- **VI. Verify Before Push**: ✅ Local verification: + ```bash + ./gradlew spotlessApply spotlessCheck detekt assembleDebug :core:takserver:allTests :feature:settings:allTests + gh pr checks 5434 + ``` + +## Project Structure + +### Documentation (this feature) + +```text +specs/005-tak-v2-protocol/ +├── plan.md # This file +├── research.md # Phase 0: Technology decisions and rationale +├── data-model.md # Phase 1: Entity models and state machines +├── quickstart.md # Phase 1: Developer onboarding guide +├── contracts/ # Phase 1: Wire protocol contracts +│ └── wire-protocol.md +└── tasks.md # Phase 2 output (/speckit.tasks command) +``` + +### Source Code (repository root) + +```text +core/takserver/ +├── build.gradle.kts # Module config + TAKPacket-SDK dependency +└── src/ + ├── commonMain/kotlin/org/meshtastic/core/takserver/ + │ ├── di/CoreTakServerModule.kt # Koin DI wiring + │ ├── TAKMeshIntegration.kt # Bidirectional bridge (main orchestrator) + │ ├── TAKServer.kt # Platform interface (expect) + │ ├── TAKServerManager.kt # Lifecycle + offline queue + │ ├── TAKPacketV2Conversion.kt # CoT ↔ TAKPacketV2 (v2 protocol) + │ ├── TAKPacketConversion.kt # CoT ↔ TAKPacket (v1 legacy) + │ ├── TakV2Compressor.kt # Zstd compression (expect) + │ ├── TakV2TypeMapper.kt # CoT type string ↔ enum (28 mappings) + │ ├── CoTDetailStripper.kt # Strip 16 bloat elements for MTU + │ ├── CoTXmlParser.kt # Streaming XML → CoTMessage + │ ├── CoTXmlDataClasses.kt # Serializable CoT data model + │ ├── CoTXmlFrameBuffer.kt # TCP stream framing + │ ├── CoTXml.kt # CoTMessage → XML serialization + │ ├── CoTConversion.kt # Shared conversion helpers + │ ├── TakConversionHelpers.kt # Coordinate scaling utilities + │ ├── RouteDataPackageGenerator.kt # Route → KML data package + │ ├── TAKDataPackageGenerator.kt # Connection .zip export + │ ├── TAKModels.kt # Domain models + │ ├── TAKDefaults.kt # Constants and defaults + │ ├── TAKPrefXmlDataClasses.kt # ATAK preference XML schema + │ ├── TakFixtureLoader.kt # Test fixture loading (expect) + │ ├── TakMeshTestRunner.kt # In-app diagnostic runner + │ ├── AtakFileWriter.kt # Filesystem abstraction (expect) + │ ├── XmlUtils.kt # XML escaping (5 special chars) + │ └── ZipArchiver.kt # ZIP creation (expect) + ├── commonTest/kotlin/.../ + │ ├── CoTConversionTest.kt + │ ├── CoTDetailStripperTest.kt + │ ├── CoTXmlFrameBufferTest.kt + │ ├── CoTXmlParserTest.kt + │ ├── CoTXmlTest.kt + │ ├── TAKDefaultsTest.kt + │ ├── TAKPacketConversionTest.kt + │ ├── TAKPacketV2RawDetailTest.kt + │ └── XmlUtilsTest.kt + ├── jvmAndroidMain/kotlin/.../ + │ ├── TAKServerJvm.kt # JSSE mTLS implementation + │ ├── TAKClientConnection.kt # Per-client state machine + │ ├── TakCertLoader.kt # Certificate loading + │ ├── TakV2Compressor.kt # Zstd actual (via TAKPacket-SDK) + │ ├── TakFixtureLoader.kt # JVM resource loading + │ └── ZipArchiver.kt # java.util.zip actual + ├── jvmAndroidMain/resources/ + │ ├── tak_certs/ # Bundled mTLS certificates + │ └── tak_test_fixtures/ # 40 CoT XML fixtures + ├── androidMain/kotlin/.../ + │ └── AtakFileWriter.kt # SAF/private directory writer + ├── jvmMain/kotlin/.../ + │ └── AtakFileWriter.kt # Desktop filesystem writer + └── iosMain/kotlin/.../ + ├── TAKServerIos.kt # No-op server + ├── TakV2Compressor.kt # Uncompressed stub (flags=0xFF) + ├── AtakFileWriter.kt # No-op + ├── TakFixtureLoader.kt # No-op + └── ZipArchiver.kt # No-op + +feature/settings/src/ +├── commonMain/kotlin/.../radio/component/ +│ └── TAKConfigItemList.kt # Compose UI (team, role, server toggle) +├── commonMain/kotlin/.../tak/ +│ └── TakPermissionUtil.kt # Permission interface (expect) +├── androidMain/kotlin/.../tak/ +│ └── TakPermissionUtil.kt # ACCESS_LOCAL_NETWORK (API 37+) +├── jvmMain/kotlin/.../tak/ +│ └── TakPermissionUtil.kt # No-op +└── iosMain/kotlin/.../tak/ + └── TakPermissionUtil.kt # No-op + +core/model/src/commonMain/kotlin/.../ +└── Capabilities.kt # supportsTakV2 (>= 2.8.0) + +core/service/src/androidMain/kotlin/.../ +└── MeshService.kt # PARTIAL_WAKE_LOCK for TAK server +``` + +**Structure Decision**: KMP multi-module architecture. New `core:takserver` module contains all TAK business logic in `commonMain` with platform actuals for TLS, compression, and filesystem. UI lives in the existing `feature:settings` module. Wake lock integration in existing `core:service`. + +## Complexity Tracking + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| `jvmAndroidMain` shared source set | TAKPacket-SDK (JitPack) provides JVM-only zstd + protobuf bindings; Android and Desktop share the same JSSE TLS and compression code | Separate `androidMain`/`jvmMain` actuals would duplicate ~500 lines of identical code | +| Regex-based XML stripping (not DOM) | `CoTDetailStripper` uses regex instead of XML DOM to remain dependency-free in `commonMain` and avoid xmlutil dependency for a simple element-removal task | Full DOM parsing adds allocation overhead and requires xmlutil import for a ~30-line utility | +| Branch name `tak_v2` (no prefix) | Pre-existing feature branch; retroactive documentation | N/A — not a new branch | diff --git a/specs/005-tak-v2-protocol/quickstart.md b/specs/005-tak-v2-protocol/quickstart.md new file mode 100644 index 000000000..53ab487bb --- /dev/null +++ b/specs/005-tak-v2-protocol/quickstart.md @@ -0,0 +1,121 @@ +# Quickstart: TAK v2 Protocol Integration + +## Prerequisites + +- JDK 21 +- Android Studio (latest stable) +- `ANDROID_HOME` environment variable set +- Git submodule initialized: `git submodule update --init` +- `local.properties` exists: `cp secrets.defaults.properties local.properties` + +## Build & Test + +```bash +# Full verification +./gradlew spotlessApply spotlessCheck detekt assembleDebug :core:takserver:allTests :feature:settings:allTests + +# TAK module tests only +./gradlew :core:takserver:allTests + +# Quick compile check (no tests) +./gradlew :core:takserver:compileKotlinJvm +``` + +## Module Overview + +The TAK v2 implementation lives primarily in `core/takserver/`: + +| Layer | Location | Purpose | +|-------|----------|---------| +| Business Logic | `core/takserver/src/commonMain/` | All conversion, compression, parsing, server management | +| Platform (JVM) | `core/takserver/src/jvmAndroidMain/` | TLS server, zstd compression, cert loading | +| Platform (Android) | `core/takserver/src/androidMain/` | File writer (SAF) | +| Platform (iOS) | `core/takserver/src/iosMain/` | Stubs (uncompressed mode) | +| UI | `feature/settings/.../TAKConfigItemList.kt` | Config screen | +| Wake Lock | `core/service/.../MeshService.kt` | CPU keepalive | +| Version Gate | `core/model/.../Capabilities.kt` | `supportsTakV2` flag | + +## Key Entry Points + +### Starting the TAK Server + +The TAK server is started via `TAKMeshIntegration.start(scope)` which is called from `MeshService` when a device connects. The lifecycle: + +1. `MeshService.onStartCommand()` → acquires wake lock +2. `TAKMeshIntegration.start(scope)` → wires up three collection jobs: + - Inbound: TAK client → mesh + - Outbound: mesh → TAK clients + - Config: team/role sync +3. `TAKServerManager.start(scope)` → starts TLS listener on port 8089 + +### Adding a New CoT Type + +1. Add enum value to `TakV2TypeMapper` bidirectional map +2. Add test fixture XML in `src/jvmAndroidMain/resources/tak_test_fixtures/` +3. Verify round-trip in `TAKPacketV2RawDetailTest` or `CoTConversionTest` +4. Run: `./gradlew :core:takserver:allTests` + +### Testing with ATAK + +1. Enable TAK server in Settings → Module Configuration → TAK +2. Tap "Export Data Package" → share to ATAK +3. In ATAK: Settings → Network → TAK Servers → Import +4. Verify connection count shows 1 in Meshtastic UI + +## Architecture Flow + +``` +ATAK Client ──TLS──▶ TAKServer (port 8089) + │ + ▼ + TAKServerManager + │ + ▼ inboundMessages + TAKMeshIntegration + │ │ + ┌─────────┘ └──────────┐ + ▼ ▼ + sendCoTToMeshV2() sendCoTToMeshV1() + (fw ≥ 2.8.0) (fw < 2.8.0) + │ │ + ▼ ▼ + TakV2Compressor TAKPacketConversion + (zstd + dict) (raw protobuf) + │ │ + ▼ ▼ + Port 78 (ATAK_PLUGIN_V2) Port 72 (ATAK_PLUGIN) + │ │ + └────────────┬────────────────────┘ + ▼ + Mesh Network (LoRa) +``` + +## Test Fixtures + +38 XML fixture files in `src/jvmAndroidMain/resources/tak_test_fixtures/` covering: +- PLI: 6 variants (basic, full, itak, stationary, takaware, webtak) +- GeoChat: 3 variants (broadcast, DM, simple) +- Markers: 6 variants (2525, goto, goto-itak, icon-set, spot, tank) +- Drawings: 7 variants (circle, ellipse, freeform, polygon, rectangle, rectangle-itak, telestration) +- Routes: 2 variants (3wp, itak-3wp) +- Aircraft: 2 variants (ADSB, hostile) +- Ranging: 3 variants (bullseye, circle, line) +- Others: casevac (2), emergency (2), alert (1), delete (1), chat-receipt (2), task (1), waypoint (1) + +## Dependency Graph + +``` +core:takserver +├── core:repository (mesh packet access) +├── core:common (dispatchers, utilities) +├── core:di (Koin module registration) +├── core:model (Capabilities, domain models) +├── core:proto (protobuf definitions) +├── xmlutil (XML parsing/serialization) +├── TAKPacket-SDK v0.1.3 (zstd compression, JVM only) +├── zstd-jni 1.5.7-7 (native zstd, platform-specific) +├── okio (I/O) +├── ktor-network (TCP) +├── kotlinx-datetime +└── kermit (logging) +``` diff --git a/specs/005-tak-v2-protocol/research.md b/specs/005-tak-v2-protocol/research.md new file mode 100644 index 000000000..7f8991837 --- /dev/null +++ b/specs/005-tak-v2-protocol/research.md @@ -0,0 +1,164 @@ +# Research: TAK v2 Protocol Integration + +**Status**: Complete (retroactive — documents decisions made in PR #5434) + +## R1: Zstd Compression Strategy for LoRa MTU + +**Decision**: Use pre-trained zstd dictionaries via `meshtastic/TAKPacket-SDK` (JitPack v0.1.3) with a 1-byte flags header encoding dictionary ID. + +**Rationale**: +- LoRa MTU is 237 bytes raw (~225 usable after protobuf framing). Standard zstd without dictionaries performs poorly on small payloads. Pre-trained dictionaries (trained on representative CoT samples) achieve 60-80% compression on typical payloads. +- Two dictionaries: `DICT_ID_NON_AIRCRAFT` (0) for general CoT, `DICT_ID_AIRCRAFT` (1) for aircraft tracks (different statistical distribution). +- Fallback: `0xFF` flags byte = uncompressed (for TAK_TRACKER firmware and iOS stubs). + +**Alternatives Considered**: +- LZ4: Faster but worse compression ratio at small payload sizes; doesn't fit complex CoT within MTU +- Custom delta encoding only: Insufficient for free-text fields (remarks, callsigns) +- No compression: PLI fits (~80 bytes) but shapes/routes (500-2000 bytes raw) would never fit + +--- + +## R2: Platform Abstraction for Compression + +**Decision**: Use `expect object TakV2Compressor` with `jvmAndroidMain` actual (full SDK) and `iosMain` actual (uncompressed stub, flags=0xFF). + +**Rationale**: +- TAKPacket-SDK is JVM-only (depends on zstd-jni native library). iOS has no Kotlin/Native zstd binding available. +- iOS stubs emit uncompressed payloads that receiving nodes handle via the 0xFF flags check. +- The `jvmAndroidMain` shared source set avoids duplicating ~500 lines between Android and Desktop. + +**Alternatives Considered**: +- Pure Kotlin zstd implementation: None exists with dictionary support +- Multiplatform C interop for zstd: Too complex for initial release; iOS can add later via Swift interop +- Separate `androidMain`/`jvmMain` actuals: Identical code, maintenance burden + +--- + +## R3: CoT XML Processing Approach + +**Decision**: Use `xmlutil` library for structured XML parsing (`CoTXmlParser`) and regex for detail stripping (`CoTDetailStripper`). + +**Rationale**: +- `xmlutil` is the standard KMP-compatible XML library, supports serialization and streaming. +- Detail stripping needs only element removal (not DOM traversal), making regex simpler and allocation-free. +- The regex approach handles multi-line elements with dot-matches-all mode. + +**Alternatives Considered**: +- Full DOM for everything: Higher memory allocation, unnecessary for simple element removal +- String manipulation without regex: Fragile with nested elements and attributes +- SAX streaming for stripping: Overcomplicated for "remove known elements" pattern + +--- + +## R4: TLS Server Architecture + +**Decision**: JSSE `SSLServerSocket` with mTLS in `jvmAndroidMain`, exposed via `TAKServer` expect interface. + +**Rationale**: +- ATAK/iTAK clients expect standard TAK Server protocol: TLS on port 8089 with mutual certificate authentication. +- JSSE is available on both Android and Desktop JVM; no additional dependencies needed. +- Per-client `TAKClientConnection` with its own `CoroutineScope` (SupervisorJob) prevents one dead connection from cascading. +- `BufferedOutputStream` + `writeMutex` prevents XML stream corruption from concurrent broadcasts. + +**Alternatives Considered**: +- Ktor server: Heavier dependency for a simple TLS socket listener +- Netty: Not KMP-compatible, overkill for <10 concurrent connections +- Raw sockets + manual TLS: Error-prone certificate handling + +--- + +## R5: Version Gating Strategy + +**Decision**: Runtime firmware version check via `Capabilities.supportsTakV2` (≥ 2.8.0) evaluated per-send to pick up firmware upgrades without app restart. + +**Rationale**: +- Mixed-firmware deployments are the common case during upgrade cycles. +- Per-send evaluation (not cached at connection time) handles the edge case of firmware OTA during an active session. +- Inbound path always listens on both ports (72 and 78) regardless of local firmware — ensures no data loss. + +**Alternatives Considered**: +- Compile-time feature flags: Can't handle mixed deployments +- User-configurable protocol selection: Too error-prone; auto-detection is strictly better +- Connection-time-only check: Would miss mid-session firmware upgrades + +--- + +## R6: Route Interoperability (KML Bridge) + +**Decision**: Generate KML data packages for route CoT events because ATAK's route CoT handling has limitations. + +**Rationale**: +- ATAK can import routes from KML files placed in its monitored data package directory. +- Route CoT over mesh preserves the waypoint data; KML generation on the receiving end enables ATAK to display the full route with navigation capabilities. +- Uses `MissionPackageManifest v2` format compatible with both ATAK and iTAK. + +**Alternatives Considered**: +- Rely on ATAK's native route CoT handling: Incomplete (doesn't render full route UI) +- Custom ATAK plugin: Out of scope; Meshtastic is a standalone app +- Route fragmentation across multiple mesh packets: Exceeds complexity budget and unreliable over LoRa + +--- + +## R7: Offline Message Queue Design + +**Decision**: 50-message cap with 5-minute TTL, auto-drained on client reconnect. + +**Rationale**: +- Brief disconnections (screen off, ATAK restart) are common in tactical environments. +- 50-message cap prevents unbounded memory growth if mesh traffic is high. +- 5-minute TTL ensures stale tactical data doesn't replay (CoT has its own stale mechanism but queue provides defense-in-depth). +- `onClientConnected` callback triggers immediate drain. + +**Alternatives Considered**: +- No queue (drop all): Loses critical tactical updates during brief disconnects +- Unlimited queue: Memory exhaustion risk on constrained Android devices +- Persistent (disk) queue: Overkill; 5-minute window doesn't warrant I/O complexity + +--- + +## R8: Wake Lock Strategy + +**Decision**: `PARTIAL_WAKE_LOCK` held for entire MeshService lifecycle when device is connected. + +**Rationale**: +- Android's Doze mode throttles CPU, which kills keepalive timers and socket I/O for the TAK server. +- `PARTIAL_WAKE_LOCK` keeps CPU active without keeping screen on. +- Foreground service alone is insufficient — OEMs (Samsung, Xiaomi) aggressively throttle even foreground services. +- Reference-counted=false allows unconditional release on service stop. + +**Alternatives Considered**: +- WorkManager periodic wakeup: 15-minute minimum interval, too slow for 10-second keepalives +- AlarmManager: Deprecated for this pattern; complex and battery-unfriendly +- No wake lock (foreground service only): Tested and fails on multiple OEM devices + +--- + +## R9: CoT Detail Stripping for MTU Compliance + +**Decision**: Strip 16 known bloat XML elements before compression to maximize payload fit. + +**Rationale**: +- ATAK adds many display-only elements (color, strokeColor, usericon, model, __video, fileshare) that are irrelevant for mesh relay. +- Stripping before compression (not after) gives the compressor cleaner input and smaller output. +- Elements stripped are purely cosmetic; receiving ATAK clients reconstruct display from CoT type and position. + +**Alternatives Considered**: +- Strip nothing, rely on compression alone: Shapes with rich detail regularly exceed MTU even compressed +- Strip everything except position: Loses contact/callsign/remarks which are tactically critical +- Configurable strip list: Over-engineering for v1; the 16 elements are well-established as non-essential + +--- + +## R10: Android 17+ ACCESS_LOCAL_NETWORK Permission + +**Decision**: Request `ACCESS_LOCAL_NETWORK` on API 37+ with user-visible error if denied, using platform-specific `TakPermissionUtil`. + +**Rationale**: +- Android 17 restricts apps from binding to localhost/LAN ports without explicit permission. +- The TAK server binds to `127.0.0.1:8089` — requires this permission. +- Graceful degradation: server simply doesn't start if permission denied; UI shows actionable error. + +**Alternatives Considered**: +- Always request regardless of API level: Permission doesn't exist pre-API 37; would crash +- Skip permission and catch bind failure: Unclear error message for users +- Use a different IPC mechanism: ATAK expects standard TCP/TLS on 8089; non-negotiable diff --git a/specs/005-tak-v2-protocol/spec.md b/specs/005-tak-v2-protocol/spec.md new file mode 100644 index 000000000..4946edcc2 --- /dev/null +++ b/specs/005-tak-v2-protocol/spec.md @@ -0,0 +1,220 @@ +# Feature Specification: TAK v2 Protocol Integration + +**Feature Branch**: `tak_v2` +**Created**: 2026-05-13 +**Status**: Draft +**Input**: User description: "TAK v2 protocol support with zstd compression, extended CoT types, legacy fallback, and ATAK/iTAK interoperability" +**Cross-Platform Spec**: Android + JVM (desktop) with iOS stubs; wire protocol defined by [TAKPacket-SDK](https://github.com/meshtastic/TAKPacket-SDK) (external SDK, not a `design/features/` doc) + +## Summary + +This feature upgrades the Meshtastic Android app's TAK (Team Awareness Kit) integration from the legacy v1 protocol (port 72, PLI + GeoChat only) to the new TAK v2 protocol (port 78, ATAK_PLUGIN_V2) with zstd dictionary compression and support for all CoT payload types. The upgrade enables Meshtastic mesh radios to relay rich tactical data—markers, routes, drawn shapes, emergencies, tasks, and ranging—between ATAK/iTAK clients, not just position reports and chat messages. The system auto-detects firmware capability and falls back to v1 for older radios, ensuring backward compatibility in mixed-firmware deployments. + +## Goals + +1. **Full CoT type coverage**: Support all standard CoT event types over mesh — including PLI, GeoChat, Marker, Route, DrawnShape (circle, ellipse, freeform, polygon, rectangle, telestration), Aircraft, Casevac, Emergency, Task, Ranging, Alert, Delete, Chat Receipts, and Waypoints — not just PLI and GeoChat. TakV2TypeMapper covers 23 CoT types + 4 HOW types + a default CotType_Other fallback (28 total mappings) +2. **Efficient wire encoding**: Use zstd dictionary compression and CoT detail stripping to fit rich CoT payloads within the LoRa MTU constraint (237 bytes raw, ~225 bytes usable after protobuf framing overhead) +3. **Backward compatibility**: Auto-detect firmware version and gracefully fall back to legacy TAKPacket (v1) for radios running firmware < 2.8.0 +4. **Reliable TAK server operation**: Maintain a local TLS/mTLS TAK server that ATAK and iTAK clients can connect to, with wake lock protection against Android battery optimization +5. **Route interoperability**: Bridge ATAK's route CoT limitation by generating KML data packages for auto-import into ATAK's monitored directory + +## Non-Goals + +- Implementing a full TAK Server with mission sync, federation, or enterprise features +- Supporting TAK protocols over non-mesh transports (WiFi direct, Bluetooth peer-to-peer) +- Modifying the Meshtastic firmware or protobuf schema (consumed read-only from upstream) +- Providing a standalone TAK client UI within the Meshtastic app (ATAK/iTAK remain the clients) +- Supporting CoT streaming to remote TAK servers over the internet + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Send Rich Tactical Data Over Mesh (Priority: P1) + +A TAK operator using ATAK connects to the Meshtastic app's built-in TAK server and drops a marker, draws a shape, or creates a route on the ATAK map. The Meshtastic app converts the CoT event into a compressed TAKPacketV2 and transmits it over the mesh. All other mesh nodes with TAK-connected clients see the marker/shape/route appear on their maps. + +**Why this priority**: This is the core value proposition — extending TAK's situational awareness beyond PLI to include all tactical overlays over LoRa mesh. + +**Independent Test**: Connect two Meshtastic radios (firmware >= 2.8.0) each with ATAK connected. Drop a hostile marker on one ATAK instance and verify it appears on the other with correct type, icon, and position. + +**Acceptance Scenarios**: + +1. **Given** two mesh nodes running firmware >= 2.8.0 with ATAK clients connected, **When** a user places a marker (type a-h-G) on one ATAK instance, **Then** the marker appears on the remote ATAK with correct hostile type, position, and callsign within one mesh transmission cycle +2. **Given** a mesh node with firmware >= 2.8.0, **When** an ATAK user creates a route with 3 waypoints, **Then** the route is transmitted over port 78 and a KML data package is generated on the receiving node for ATAK import +3. **Given** a TAKPacketV2 payload exceeding the ~225-byte usable mesh payload after compression, **When** the system attempts transmission, **Then** it drops the packet and logs a warning rather than fragmenting or corrupting the message + +--- + +### User Story 2 - Legacy Fallback for Mixed Firmware (Priority: P1) + +A team has a mix of older radios (firmware 2.7.x) and newer radios (firmware 2.8.0+). The app detects each radio's capability and uses the appropriate protocol version. Legacy radios still relay PLI and GeoChat. Newer radios exchange all CoT types. + +**Why this priority**: Mixed-firmware deployments are the reality during any firmware upgrade cycle; breaking backward compatibility would render the feature unusable for most teams. + +**Independent Test**: Connect one node with firmware 2.7.x and verify that PLI and GeoChat still work via legacy port 72, while markers/shapes are dropped with a user-visible warning. + +**Acceptance Scenarios**: + +1. **Given** a radio running firmware < 2.8.0, **When** an ATAK user sends a PLI update, **Then** the app encodes it as a legacy TAKPacket on port 72 (ATAK_PLUGIN) +2. **Given** a radio running firmware < 2.8.0, **When** an ATAK user drops a marker (non-PLI, non-GeoChat CoT), **Then** the app drops the event and logs a warning indicating the legacy protocol does not support this type +3. **Given** a radio running firmware >= 2.8.0, **When** a legacy TAKPacket arrives on port 72 from an older mesh node, **Then** the app correctly decodes and forwards it to the connected ATAK client + +--- + +### User Story 3 - TAK Server Lifecycle and Reliability (Priority: P2) + +A user enables the TAK server from the Meshtastic app settings. The server starts, accepts ATAK/iTAK connections via mTLS on port 8089, and remains operational even when Android applies battery optimizations. Users can also export connection data packages so clients can configure themselves. + +**Why this priority**: Without a reliable always-on local server, TAK clients cannot maintain connectivity, making all other features unreliable. + +**Independent Test**: Enable TAK server from settings, connect ATAK via the exported data package, verify connection persists after screen off for 10 minutes. + +**Acceptance Scenarios**: + +1. **Given** the TAK server is disabled, **When** the user enables it in settings, **Then** a TLS listener starts on port 8089 and the UI shows the server as active with 0 connected clients +2. **Given** the TAK server is running with ATAK connected, **When** the device screen turns off and battery optimization activates, **Then** the partial wake lock keeps the CPU active and the ATAK connection is maintained +3. **Given** the TAK server is running, **When** the user taps "Export Data Package," **Then** a valid .zip data package is generated containing server certificates and connection configuration importable by both ATAK and iTAK + +--- + +### User Story 4 - TAK Configuration UI (Priority: P2) + +A user navigates to the TAK module configuration screen to enable/disable the TAK server, select their team color and role, export data packages, and run diagnostic tests to verify mesh-to-TAK connectivity. + +**Why this priority**: Users need a way to configure and troubleshoot TAK integration without command-line tools. + +**Independent Test**: Navigate to Settings > Module Configuration > TAK, toggle server on/off, change team/role, and verify settings persist across app restart. + +**Acceptance Scenarios**: + +1. **Given** the user is on the TAK configuration screen, **When** they select a team color and role, **Then** outgoing PLI packets include the selected team and role values +2. **Given** the user taps "Run Test," **When** the test runner executes, **Then** results show per-CoT-type byte sizes and success/failure status for each fixture + +--- + +### User Story 5 - Inbound Dual-Path Tolerance (Priority: P3) + +A v2-capable node receives packets from both v1 (port 72) and v2 (port 78) mesh traffic. All inbound traffic is decoded and forwarded to connected TAK clients regardless of the local radio's firmware version. + +**Why this priority**: Ensures no tactical data is lost in mixed deployments where some nodes only send v1. + +**Independent Test**: Send a legacy TAKPacket (port 72) to a node running firmware 2.8.0+ and verify the connected ATAK client receives the PLI. + +**Acceptance Scenarios**: + +1. **Given** a node with firmware >= 2.8.0, **When** it receives a TAKPacket on port 72, **Then** the packet is decoded and broadcast to connected ATAK clients as valid CoT XML +2. **Given** a node with firmware >= 2.8.0, **When** it receives a TAKPacketV2 on port 78, **Then** the packet is decompressed, decoded, and broadcast to connected ATAK clients + +--- + +### Edge Cases + +- What happens when zstd decompression produces a payload exceeding MAX_DECOMPRESSED_SIZE? → Packet is rejected to prevent memory exhaustion +- What happens when the TAK server port 8089 is already in use? → Server start fails gracefully with user-visible error +- What happens when a CoT XML contains malformed or hostile content (XML injection)? → XML is sanitized/stripped before processing; CoTDetailStripper removes 16 known bloat elements before mesh transmission +- What happens when ATAK sends a CoT type not recognized by TakV2TypeMapper? → Falls back to CotType_Other with the raw type string preserved for round-trip fidelity +- What happens when an uncompressed packet arrives (flags byte 0xFF from TAK_TRACKER firmware)? → Treated as raw protobuf, bypassing zstd decompression +- What happens when the user denies the ACCESS_LOCAL_NETWORK permission on Android 17+? → TAK server cannot bind to localhost; server start fails with a user-visible error explaining the permission requirement +- What happens when a connected ATAK client disconnects and reconnects within 5 minutes? → Offline message queue (50-message cap, 5-minute TTL) replays missed messages on reconnect +- What happens on iOS where zstd compression is not yet available? → iOS uses uncompressed TAK_TRACKER mode (flags=0xFF); payloads exceeding ~225 bytes (the usable mesh MTU) are dropped, which in practice limits iOS to PLI, simple GeoChat, and small markers + +## Architecture + +### Key Components + +| Component | Module / File | Purpose | +|-----------|---------------|---------| +| TAKMeshIntegration | `core/takserver/…/TAKMeshIntegration.kt` (commonMain) | Bidirectional bridge between TAK server and mesh network | +| TAKServer | `core/takserver/…/TAKServer.kt` (commonMain expect) | Platform-agnostic TLS listener interface for ATAK/iTAK connections | +| TAKServerJvm | `core/takserver/…/TAKServerJvm.kt` (jvmAndroidMain actual) | JVM/Android TLS server implementation | +| TAKServerIos | `core/takserver/…/TAKServerIos.kt` (iosMain actual) | iOS stub server implementation | +| TAKServerManager | `core/takserver/…/TAKServerManager.kt` (commonMain) | Lifecycle manager for the TAK server (start/stop/broadcast) with 10-second keepalive interval | +| TAKPacketV2Conversion | `core/takserver/…/TAKPacketV2Conversion.kt` (commonMain) | CoTMessage ↔ TAKPacketV2 conversion for all CoT types | +| TAKPacketConversion | `core/takserver/…/TAKPacketConversion.kt` (commonMain) | Legacy CoTMessage ↔ TAKPacket (v1) conversion (PLI + GeoChat) | +| TakV2Compressor | `core/takserver/…/TakV2Compressor.kt` (expect/actual) | Zstd dictionary compression (JVM/Android via TAKPacket-SDK); iOS stub (uncompressed only) | +| TakV2TypeMapper | `core/takserver/…/TakV2TypeMapper.kt` (commonMain) | CoT type string ↔ enum mapping: 23 CoT types + 4 HOW types + default fallback | +| CoTDetailStripper | `core/takserver/…/CoTDetailStripper.kt` (commonMain) | Strips 16 bloat XML elements from CoT before mesh transmission to fit MTU | +| RouteDataPackageGenerator | `core/takserver/…/RouteDataPackageGenerator.kt` (commonMain) | Converts route CoT to ATAK-importable KML data packages | +| CoTXmlParser | `core/takserver/…/CoTXmlParser.kt` (commonMain) | Streaming XML parser for inbound CoT from ATAK clients | +| XmlUtils | `core/takserver/…/XmlUtils.kt` (commonMain) | XML escaping/sanitization utilities (5 special characters) | +| AtakFileWriter | `core/takserver/…/AtakFileWriter.kt` (expect/actual) | Platform filesystem access: androidMain (SAF/private dirs), jvmMain (desktop filesystem), iosMain (stub) | +| TAKConfigItemList | `feature/settings/…/TAKConfigItemList.kt` (commonMain) | Compose UI for TAK module configuration | +| TakPermissionUtil | `feature/settings/…/TakPermissionUtil.kt` (expect/actual) | Platform-specific permission handling (Android, iOS, JVM) | +| MeshService (wake lock) | `core/service/MeshService.kt` (androidMain) | Partial wake lock for reliable TAK server operation | +| Capabilities | `core/model/Capabilities.kt` (commonMain) | Firmware version detection for v2 support gating | + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: System MUST detect firmware version and use TAKPacketV2 (port 78) for radios >= 2.8.0, falling back to TAKPacket (port 72) for older firmware +- **FR-002**: System MUST support encoding and decoding of all mapped CoT types (23 CoT types + 4 HOW types + CotType_Other fallback), including but not limited to: PLI, GeoChat, Marker, Route, DrawnShape subtypes (circle, ellipse, freeform, polygon, rectangle, telestration), Aircraft, Casevac, Emergency, Task, Ranging, Alert, Delete, Chat Receipts, and Waypoints +- **FR-003**: System MUST compress TAKPacketV2 payloads using zstd with pre-trained dictionaries (separate dictionaries for aircraft vs non-aircraft types) +- **FR-004**: System MUST reject compressed payloads exceeding MAX_DECOMPRESSED_SIZE to prevent memory exhaustion attacks +- **FR-005**: System MUST accept inbound packets on both port 72 (legacy) and port 78 (v2) regardless of local firmware version +- **FR-006**: System MUST run a local TLS/mTLS server on port 8089 accepting ATAK and iTAK client connections +- **FR-007**: System MUST hold a partial wake lock while the TAK server is active to prevent Android CPU throttling +- **FR-008**: System MUST generate valid KML data packages for route CoT events for ATAK file import +- **FR-009**: System MUST export .zip data packages containing connection certificates (server.p12, client.p12, ca.pem) and server configuration for client setup, importable by both ATAK and iTAK +- **FR-010**: System MUST sanitize inbound CoT XML to prevent XML injection attacks (escaping &, <, >, ", ') +- **FR-011**: System MUST preserve the full CoT type string for round-trip fidelity when the type maps to CotType_Other +- **FR-012**: System MUST drop unsupported CoT types (non-PLI, non-GeoChat) on legacy firmware with a logged warning +- **FR-013**: System MUST strip bloat XML detail elements (16 known element types: color, strokeColor, strokeWeight, fillColor, labels_on, usericon, model, shape, height, height_unit, fileshare, __video, archive, precisionlocation, tog, _flow-tags_) from outbound CoT before mesh transmission to maximize payload fit within MTU +- **FR-014**: System MUST maintain an offline message queue (50-message cap, 5-minute per-message TTL) to replay missed messages when ATAK clients reconnect after brief disconnections. When the cap is reached, the oldest message is evicted (FIFO). Messages older than 5 minutes are silently discarded on dequeue. +- **FR-015**: System MUST send keepalive packets to connected TAK clients at 10-second intervals (below ATAK's 15-second stale threshold) to maintain connection liveness +- **FR-016**: System MUST request ACCESS_LOCAL_NETWORK permission on Android 17+ (API 37) for TAK server localhost binding, and display a user-visible error if the permission is denied + +### Non-Functional Requirements + +- **NFR-001**: Compressed TAKPacketV2 payloads MUST fit within the usable mesh payload (~225 bytes after protobuf framing within the 237-byte raw LoRa MTU) for single-packet transmission +- **NFR-002**: TAK server connection MUST survive screen-off and Doze mode for at least 30 minutes without disconnection +- **NFR-003**: CoT message round-trip (ATAK → mesh → remote ATAK) MUST complete within the mesh network's standard transmission latency (no added processing delay > 100ms) +- **NFR-004**: Route data packages MUST be written to app-private or cache directories (no MANAGE_EXTERNAL_STORAGE required); ATAK integration relies on content sharing or documented import paths + +## Source-Set Impact + +| Source Set | Impact | Justification | +|-----------|--------|---------------| +| `commonMain` | All business logic: TAKMeshIntegration, conversions, models, parser, server manager, detail stripper, XML utils, config UI | All business logic and UI per Constitution §I, §III | +| `androidMain` | MeshService wake lock, AtakFileWriter (Android filesystem/SAF), TakPermissionUtil (runtime permissions) | Platform-specific Android APIs | +| `jvmAndroidMain` | TAKServerJvm TLS implementation, TakV2Compressor (zstd via TAKPacket-SDK), TakCertLoader, TakFixtureLoader | Shared JVM/Android TLS, compression, and I/O | +| `jvmMain` | AtakFileWriter (desktop filesystem), TakPermissionUtil (no-op) | Desktop platform support for file operations | +| `iosMain` | TAKServerIos, TakV2Compressor (stub — uncompressed TAK_TRACKER mode only), AtakFileWriter (stub), TakFixtureLoader | Platform stubs pending Swift SDK integration | + +## Design Standards Compliance + +- [x] New screens reviewed against [design standards](https://raw.githubusercontent.com/meshtastic/design/refs/heads/master/standards/meshtastic_design_standards_latest.md) +- [x] M3 component selection verified (SwitchPreference, DropDownPreference, TitledCard used) +- [x] Accessibility: TalkBack semantics on TAK config controls, standard touch targets +- [x] Typography: M3 scale for hierarchy in TAK config screen + +## Privacy Assessment + +- [x] No PII, location data, or cryptographic keys logged or exposed (CoT data stays local to device/mesh) +- [x] No new network calls that transmit user data (TAK server is local-only, listening on loopback + LAN) +- [x] Proto submodule (`core/proto`) not modified (read-only upstream, pegged to master) + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: All mapped CoT types (28 total mappings across 23 CoT types, 4 HOW types, and default fallback) successfully round-trip encode/decode through the test fixture suite +- **SC-002**: Compressed payload size for typical PLI messages stays below 100 bytes (well within ~225B usable mesh payload) +- **SC-003**: Mixed-firmware mesh (2.7.x + 2.8.0+) maintains PLI and GeoChat exchange without errors for legacy nodes +- **SC-004**: TAK server maintains ATAK client connections for 30+ minutes with device screen off +- **SC-005**: Route CoT events result in importable KML data packages appearing in ATAK within one mesh cycle +- **SC-006**: Test fixture suite (40 XML fixtures across 9 test classes, 65+ test methods) covering all CoT types passes with correct round-trip encoding/decoding + +## Assumptions + +- All business logic and UI composables reside in `commonMain` source set +- String resources added to `core/resources/src/commonMain/composeResources/values/strings.xml` +- Icons use `MeshtasticIcons` (from `core/ui/icon/`) +- Float values pre-formatted with `NumberFormatter.format()` (CMP constraint) +- Firmware version is available from the connected radio's metadata at connection time +- ATAK clients support standard TAK Server protocol (TLS on port 8089, data package import) +- Zstd dictionaries are pre-trained and bundled as binary resources (not trained at runtime) +- The 237-byte raw LoRa MTU is a hard limit imposed by the radio hardware; usable payload is ~225 bytes after protobuf framing +- Route data packages are written to app-private/cache directories (no broad filesystem permissions required) +- iOS implementation uses uncompressed TAK_TRACKER mode (flags=0xFF) pending platform-specific zstd library integration via Swift SDK interop +- Desktop (JVM) has partial TAK support: filesystem operations via `jvmMain` AtakFileWriter, TLS server via `jvmAndroidMain` +- Android 17+ (API 37) requires ACCESS_LOCAL_NETWORK permission for TAK server localhost binding diff --git a/specs/005-tak-v2-protocol/tasks.md b/specs/005-tak-v2-protocol/tasks.md new file mode 100644 index 000000000..e265a9d77 --- /dev/null +++ b/specs/005-tak-v2-protocol/tasks.md @@ -0,0 +1,294 @@ +# Tasks: TAK v2 Protocol Integration + +**Input**: Design documents from `/specs/005-tak-v2-protocol/` +**Prerequisites**: plan.md ✅, spec.md ✅, research.md ✅, data-model.md ✅, contracts/ ✅ +**Status**: Retroactive — documents work completed in PR #5434 (99 files, +4698 lines). Use as verification checklist. + +**Tests**: Included — the implementation ships with 12 test classes and 89+ test methods covering all CoT types. + +**Verification**: Constitution-required validation tasks included (spotlessCheck, detekt, compile, allTests). + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3) +- Include exact file paths in descriptions + +--- + +## Phase 1: Setup (Module & Dependencies) + +**Purpose**: Create the `core:takserver` KMP module, configure dependencies, and establish project structure. + +- [X] T001 Create `core/takserver/build.gradle.kts` with TAKPacket-SDK v0.1.3, xmlutil, zstd-jni 1.5.7-7, Ktor Network, Okio, Koin, and Kermit dependencies +- [X] T002 Register `:core:takserver` module in `settings.gradle.kts` +- [X] T003 [P] Create source set directory structure: `commonMain`, `commonTest`, `jvmAndroidMain`, `androidMain`, `jvmMain`, `iosMain` under `core/takserver/src/` +- [X] T004 [P] Add bundled mTLS certificates to `core/takserver/src/jvmAndroidMain/resources/tak_certs/` +- [X] T005 [P] Add 40 CoT XML test fixture files to `core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/` +- [X] T006 [P] Create Koin DI module in `core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/di/CoreTakServerModule.kt` + +--- + +## Phase 2: Foundational (Shared Models, Constants & Utilities) + +**Purpose**: Core domain models, constants, and utilities that ALL user stories depend on. + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete. + +- [X] T007 Define domain models (CoTMessage, CoTContact, CoTGroup, CoTStatus, CoTTrack, CoTChat, TAKClientInfo, InboundCoTMessage) in `core/takserver/src/commonMain/kotlin/.../TAKModels.kt` +- [X] T008 [P] Define constants (DEFAULT_TAK_PORT, MAX_TAK_WIRE_PAYLOAD_BYTES, MAX_DECOMPRESSED_SIZE, TAK_COORDINATE_SCALE, DICT_IDs, OFFLINE_QUEUE_TTL, KEEPALIVE_INTERVAL, MIN_MESH_STALE_TTL) in `core/takserver/src/commonMain/kotlin/.../TAKDefaults.kt` +- [X] T009 [P] Implement XML escaping utilities (5 special characters: &, <, >, ", ') in `core/takserver/src/commonMain/kotlin/.../XmlUtils.kt` +- [X] T010 [P] Implement CoT XML data classes for serialization in `core/takserver/src/commonMain/kotlin/.../CoTXmlDataClasses.kt` +- [X] T011 [P] Implement ATAK preference XML schema classes in `core/takserver/src/commonMain/kotlin/.../TAKPrefXmlDataClasses.kt` +- [X] T012 [P] Implement shared coordinate scaling and conversion helpers in `core/takserver/src/commonMain/kotlin/.../TakConversionHelpers.kt` +- [X] T013 [P] Implement `TakV2TypeMapper` with 23 CoT types + 4 HOW types + CotType_Other fallback in `core/takserver/src/commonMain/kotlin/.../TakV2TypeMapper.kt` +- [X] T014 [P] Implement `CoTDetailStripper` to strip 16 bloat XML elements (regex-based) in `core/takserver/src/commonMain/kotlin/.../CoTDetailStripper.kt` +- [X] T015 [P] Implement streaming `CoTXmlParser` (XML → CoTMessage) in `core/takserver/src/commonMain/kotlin/.../CoTXmlParser.kt` +- [X] T016 [P] Implement `CoTXml` (CoTMessage → XML serialization) in `core/takserver/src/commonMain/kotlin/.../CoTXml.kt` +- [X] T017 [P] Implement TCP stream framing in `core/takserver/src/commonMain/kotlin/.../CoTXmlFrameBuffer.kt` +- [X] T018 [P] Implement shared conversion helpers in `core/takserver/src/commonMain/kotlin/.../CoTConversion.kt` +- [X] T019 [P] Add `supportsTakV2` capability flag (firmware >= 2.8.0 check) in `core/model/src/commonMain/kotlin/.../Capabilities.kt` +- [X] T020 [P] Define `AtakFileWriter` expect interface in `core/takserver/src/commonMain/kotlin/.../AtakFileWriter.kt` +- [X] T021 [P] Define `ZipArchiver` expect interface in `core/takserver/src/commonMain/kotlin/.../ZipArchiver.kt` +- [X] T022 [P] Define `TakFixtureLoader` expect interface in `core/takserver/src/commonMain/kotlin/.../TakFixtureLoader.kt` +- [X] T023 [P] Define `TakV2Compressor` expect interface in `core/takserver/src/commonMain/kotlin/.../TakV2Compressor.kt` +- [X] T024 [P] Define `TAKServer` expect interface in `core/takserver/src/commonMain/kotlin/.../TAKServer.kt` + +**Checkpoint**: Foundation ready — all shared types, constants, and interfaces established. + +--- + +## Phase 3: User Story 1 — Send Rich Tactical Data Over Mesh (Priority: P1) 🎯 MVP + +**Goal**: Convert all CoT types from ATAK clients into compressed TAKPacketV2 and transmit over port 78. Receiving nodes decompress and forward to connected TAK clients. + +**Independent Test**: Drop a hostile marker on one ATAK instance → appears on remote ATAK with correct type, icon, and position. + +### Tests for User Story 1 + +- [X] T025 [P] [US1] Implement `CoTXmlParserTest` (XML parsing for all 23 CoT types) in `core/takserver/src/commonTest/kotlin/.../CoTXmlParserTest.kt` +- [X] T026 [P] [US1] Implement `CoTXmlTest` (CoTMessage → XML round-trip) in `core/takserver/src/commonTest/kotlin/.../CoTXmlTest.kt` +- [X] T027 [P] [US1] Implement `CoTConversionTest` (conversion helper correctness) in `core/takserver/src/commonTest/kotlin/.../CoTConversionTest.kt` +- [X] T028 [P] [US1] Implement `CoTDetailStripperTest` (16-element stripping) in `core/takserver/src/commonTest/kotlin/.../CoTDetailStripperTest.kt` +- [X] T029 [P] [US1] Implement `TAKPacketV2RawDetailTest` (raw_detail round-trip for shapes/markers/routes) in `core/takserver/src/commonTest/kotlin/.../TAKPacketV2RawDetailTest.kt` +- [X] T030 [P] [US1] Implement `XmlUtilsTest` (escaping correctness for 5 special chars) in `core/takserver/src/commonTest/kotlin/.../XmlUtilsTest.kt` +- [X] T031 [P] [US1] Implement `TAKDefaultsTest` (constants validation) in `core/takserver/src/commonTest/kotlin/.../TAKDefaultsTest.kt` + +### Implementation for User Story 1 + +- [X] T032 [US1] Implement `TAKPacketV2Conversion` (CoTMessage ↔ TAKPacketV2 for all CoT types including pli, chat, raw_detail payloads) in `core/takserver/src/commonMain/kotlin/.../TAKPacketV2Conversion.kt` +- [X] T033 [P] [US1] Implement `TakV2Compressor` actual (zstd dictionary compression via TAKPacket-SDK) in `core/takserver/src/jvmAndroidMain/kotlin/.../TakV2Compressor.kt` +- [X] T034 [P] [US1] Implement `TakV2Compressor` iOS stub (uncompressed, flags=0xFF) in `core/takserver/src/iosMain/kotlin/.../TakV2Compressor.kt` +- [X] T035 [US1] Implement `RouteDataPackageGenerator` (Route CoT → KML data package with MissionPackageManifest v2) in `core/takserver/src/commonMain/kotlin/.../RouteDataPackageGenerator.kt` +- [X] T036 [US1] Implement `TAKMeshIntegration` outbound path (TAK client → CoT parse → detail strip → compress → MTU check → port 78 send) in `core/takserver/src/commonMain/kotlin/.../TAKMeshIntegration.kt` +- [X] T037 [US1] Implement `TAKMeshIntegration` inbound path (port 78 receive → decompress → decode → broadcast to TAK clients) in `core/takserver/src/commonMain/kotlin/.../TAKMeshIntegration.kt` +- [X] T038 [P] [US1] Implement `TakFixtureLoader` actual (JVM resource loading for test fixtures) in `core/takserver/src/jvmAndroidMain/kotlin/.../TakFixtureLoader.kt` +- [X] T039 [P] [US1] Implement `TakFixtureLoader` iOS stub in `core/takserver/src/iosMain/kotlin/.../TakFixtureLoader.kt` +- [X] T040 [P] [US1] Implement `ZipArchiver` actual (java.util.zip) in `core/takserver/src/jvmAndroidMain/kotlin/.../ZipArchiver.kt` +- [X] T041 [P] [US1] Implement `ZipArchiver` iOS stub in `core/takserver/src/iosMain/kotlin/.../ZipArchiver.kt` +- [X] T042 [P] [US1] Implement `AtakFileWriter` actual (SAF/private directory) in `core/takserver/src/androidMain/kotlin/.../AtakFileWriter.kt` +- [X] T043 [P] [US1] Implement `AtakFileWriter` actual (desktop filesystem) in `core/takserver/src/jvmMain/kotlin/.../AtakFileWriter.kt` +- [X] T044 [P] [US1] Implement `AtakFileWriter` iOS stub in `core/takserver/src/iosMain/kotlin/.../AtakFileWriter.kt` +- [X] T045 [US1] Implement `TakMeshTestRunner` (in-app diagnostic runner for per-CoT-type byte size and round-trip verification) in `core/takserver/src/commonMain/kotlin/.../TakMeshTestRunner.kt` + +**Checkpoint**: All 28 CoT type mappings encode/decode correctly. Compressed PLI < 100 bytes. Shapes/markers/routes fit within 225-byte MTU after stripping + compression. + +--- + +## Phase 4: User Story 2 — Legacy Fallback for Mixed Firmware (Priority: P1) + +**Goal**: Auto-detect firmware version and use TAKPacket v1 (port 72) for radios < 2.8.0. Drop unsupported CoT types on legacy with a warning. Always accept inbound on both ports. + +**Independent Test**: Connect a node with firmware 2.7.x and verify PLI/GeoChat work on port 72 while markers are dropped with warning. + +### Tests for User Story 2 + +- [X] T046 [P] [US2] Implement `TAKPacketConversionTest` (v1 PLI + GeoChat encode/decode) in `core/takserver/src/commonTest/kotlin/.../TAKPacketConversionTest.kt` + +### Implementation for User Story 2 + +- [X] T047 [US2] Implement `TAKPacketConversion` (CoTMessage ↔ TAKPacket v1 for PLI and GeoChat only) in `core/takserver/src/commonMain/kotlin/.../TAKPacketConversion.kt` +- [X] T048 [US2] Implement version-gating logic in `TAKMeshIntegration` — check `Capabilities.supportsTakV2` per-send to select port 72 vs port 78 path in `core/takserver/src/commonMain/kotlin/.../TAKMeshIntegration.kt` +- [X] T049 [US2] Implement dual-port inbound listener in `TAKMeshIntegration` — accept and decode packets from both port 72 and port 78 regardless of local firmware in `core/takserver/src/commonMain/kotlin/.../TAKMeshIntegration.kt` +- [X] T050 [US2] Implement CoT type filtering for legacy path — drop non-PLI/non-GeoChat types with logged warning on firmware < 2.8.0 in `core/takserver/src/commonMain/kotlin/.../TAKMeshIntegration.kt` + +**Checkpoint**: Mixed-firmware mesh maintains PLI/GeoChat for legacy nodes. Advanced CoT types dropped cleanly with user-visible warning. + +--- + +## Phase 5: User Story 3 — TAK Server Lifecycle and Reliability (Priority: P2) + +**Goal**: Run a reliable local TLS/mTLS server on port 8089, maintain ATAK/iTAK connections through screen-off and Doze mode, support data package export. + +**Independent Test**: Enable TAK server, connect ATAK via exported data package, verify connection persists for 10+ minutes with screen off. + +### Tests for User Story 3 + +- [X] T051 [P] [US3] Implement `CoTXmlFrameBufferTest` (TCP stream framing correctness) in `core/takserver/src/commonTest/kotlin/.../CoTXmlFrameBufferTest.kt` + +### Implementation for User Story 3 + +- [X] T052 [US3] Implement `TAKServerJvm` actual (JSSE SSLServerSocket with mTLS on port 8089) in `core/takserver/src/jvmAndroidMain/kotlin/.../TAKServerJvm.kt` +- [X] T053 [P] [US3] Implement `TAKServerIos` actual (no-op stub) in `core/takserver/src/iosMain/kotlin/.../TAKServerIos.kt` +- [X] T054 [US3] Implement `TAKClientConnection` (per-client coroutine scope, SupervisorJob, BufferedOutputStream + writeMutex, XML stream framing) in `core/takserver/src/jvmAndroidMain/kotlin/.../TAKClientConnection.kt` +- [X] T055 [US3] Implement `TakCertLoader` (load bundled .p12/.pem certificates for mTLS) in `core/takserver/src/jvmAndroidMain/kotlin/.../TakCertLoader.kt` +- [X] T056 [US3] Implement `TAKServerManager` lifecycle (start/stop, client list, broadcast, 10-second keepalive interval, offline message queue: 50-msg cap, 5-min TTL) in `core/takserver/src/commonMain/kotlin/.../TAKServerManager.kt` +- [X] T057 [US3] Implement `TAKDataPackageGenerator` (export .zip with server certificates and connection config for ATAK/iTAK import) in `core/takserver/src/commonMain/kotlin/.../TAKDataPackageGenerator.kt` +- [X] T058 [US3] Add `PARTIAL_WAKE_LOCK` acquisition in `MeshService` when TAK server is active in `core/service/src/androidMain/kotlin/.../MeshService.kt` +- [X] T059 [US3] Wire `TAKMeshIntegration.start(scope)` call from `MeshServiceOrchestrator` in `core/service/src/commonMain/kotlin/.../MeshServiceOrchestrator.kt` + +**Checkpoint**: TAK server starts on port 8089, accepts mTLS connections, maintains keepalive, survives screen-off. Data package exportable. + +--- + +## Phase 6: User Story 4 — TAK Configuration UI (Priority: P2) + +**Goal**: Compose Multiplatform settings UI for TAK module — server toggle, team/role selection, data package export, diagnostic test runner. + +**Independent Test**: Navigate to TAK config, toggle server, change team/role, verify settings persist across app restart. + +### Implementation for User Story 4 + +- [X] T060 [US4] Implement `TAKConfigItemList` Compose UI (SwitchPreference for server toggle, DropDownPreference for team/role, TitledCard for status, export button, test runner button) in `feature/settings/src/commonMain/kotlin/.../radio/component/TAKConfigItemList.kt` +- [X] T061 [P] [US4] Implement `TakPermissionUtil` expect interface in `feature/settings/src/commonMain/kotlin/.../tak/TakPermissionUtil.kt` +- [X] T062 [P] [US4] Implement `TakPermissionUtil` Android actual (ACCESS_LOCAL_NETWORK for API 37+) in `feature/settings/src/androidMain/kotlin/.../tak/TakPermissionUtil.kt` +- [X] T063 [P] [US4] Implement `TakPermissionUtil` JVM actual (no-op) in `feature/settings/src/jvmMain/kotlin/.../tak/TakPermissionUtil.kt` +- [X] T064 [P] [US4] Implement `TakPermissionUtil` iOS actual (no-op) in `feature/settings/src/iosMain/kotlin/.../tak/TakPermissionUtil.kt` + +**Checkpoint**: TAK config screen functional — server toggle works, team/role persists, test runner shows per-CoT-type byte sizes. + +--- + +## Phase 7: User Story 5 — Inbound Dual-Path Tolerance (Priority: P3) + +**Goal**: V2-capable nodes decode and forward ALL inbound mesh traffic (both port 72 and port 78) to connected TAK clients. + +**Independent Test**: Send a legacy TAKPacket (port 72) to a firmware 2.8.0+ node → connected ATAK client receives the PLI as valid CoT XML. + +### Implementation for User Story 5 + +- [X] T065 [US5] Verify inbound handler in `TAKMeshIntegration` correctly decodes port 72 legacy packets and broadcasts to TAK clients (implemented as part of T049, verify independently) +- [X] T066 [US5] Verify inbound handler in `TAKMeshIntegration` correctly decompresses port 78 packets (including 0xFF uncompressed from TAK_TRACKER) and broadcasts to TAK clients + +**Checkpoint**: No tactical data lost in mixed deployments. Both v1 and v2 inbound traffic decoded and forwarded correctly. + +--- + +## Phase 8: Polish & Cross-Cutting Concerns + +**Purpose**: Verification, compliance, and documentation tasks that span all user stories. + +- [X] T067 [P] Add TAK-related string resources to `core/resources/src/commonMain/composeResources/values/strings.xml` +- [X] T068 [P] Review `TAKConfigItemList.kt` against [Meshtastic design standards](https://raw.githubusercontent.com/meshtastic/design/refs/heads/master/standards/meshtastic_design_standards_latest.md) — verify M3 components, accessibility (TalkBack), touch targets +- [X] T069 [P] Confirm no logs, telemetry, or config changes expose PII, location data, secrets, or modify `core/proto` submodule +- [X] T070 [P] Run constitution-required verification: `./gradlew spotlessApply spotlessCheck detekt assembleDebug :core:takserver:allTests :feature:settings:allTests` +- [X] T071 [P] Verify all 12 test classes pass (89+ methods): `./gradlew :core:takserver:allTests` +- [X] T072 [P] Validate quickstart.md instructions produce successful build from clean checkout +- [X] T073 Run `gh pr checks 5434` to confirm CI passes all checks +- [X] T074 [P] [US3] Add test for offline message queue: verify FIFO eviction at 50-message cap, per-message TTL expiry after 5 minutes, and replay of queued messages on client reconnect +- [X] T075 [P] [US4] Add test for ACCESS_LOCAL_NETWORK permission denied on Android 17+: verify TAK server displays user-visible error and does not crash +- [X] T076 [P] [US3] Add test for TAKServerJvm port-conflict error: verify graceful failure with user-visible error when port 8089 is already in use +- [X] T077 [P] [US1] Add test for MAX_DECOMPRESSED_SIZE boundary: verify `TakV2Compressor` rejects payloads exceeding the decompression size limit +- [X] T078 [P] [US1] Create `TAKMeshIntegrationTest.kt` in `core/takserver/src/commonTest/` with lifecycle, inbound mesh, firmware gating, and GeoChat enrichment tests (10 tests) +- [X] T079 [P] [US3] Add `broadcastRawXml` tests to `TAKServerManagerTest.kt`: verify forward-to-TAKServer when running and no-op when not running (2 tests) +- [X] T080 [P] Restrict `TAKServerManagerImpl` visibility to `internal` in `TAKServerManager.kt` — consumers use the `TAKServerManager` interface via Koin + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies — can start immediately +- **Foundational (Phase 2)**: Depends on Phase 1 — BLOCKS all user stories +- **User Story 1 (Phase 3)**: Depends on Phase 2 — the MVP +- **User Story 2 (Phase 4)**: Depends on Phase 2 + shares `TAKMeshIntegration` with US1 +- **User Story 3 (Phase 5)**: Depends on Phase 2 — independent of US1/US2 +- **User Story 4 (Phase 6)**: Depends on Phase 5 (needs TAKServerManager for toggle state) +- **User Story 5 (Phase 7)**: Depends on Phase 3 + Phase 4 (verification of dual-path handling) +- **Polish (Phase 8)**: Depends on all user stories being complete + +### User Story Dependencies + +- **US1 (P1)**: After Phase 2 — no dependencies on other stories +- **US2 (P1)**: After Phase 2 — shares `TAKMeshIntegration.kt` with US1 (co-implemented) +- **US3 (P2)**: After Phase 2 — independent (server infrastructure) +- **US4 (P2)**: After US3 (needs server lifecycle for toggle state) +- **US5 (P3)**: After US1 + US2 (verification of combined behavior) + +### Within Each User Story + +- Tests written alongside implementation (retroactive — both exist in PR) +- Models/interfaces → actual implementations → integration → verification +- Platform actuals can be implemented in parallel (jvmAndroidMain, androidMain, jvmMain, iosMain) + +### Parallel Opportunities + +- All Phase 1 tasks T003-T006 can run in parallel +- All Phase 2 tasks T008-T024 can run in parallel (independent files) +- All US1 test tasks T025-T031 can run in parallel +- Platform actuals within US1 (T033/T034, T040/T041, T042/T043/T044) can run in parallel +- US3 server tasks T052/T053 can run in parallel (JVM vs iOS) +- All US4 permission actuals T062/T063/T064 can run in parallel + +--- + +## Parallel Example: User Story 1 + +```bash +# Launch all tests for US1 together: +Task: "CoTXmlParserTest in commonTest" +Task: "CoTXmlTest in commonTest" +Task: "CoTConversionTest in commonTest" +Task: "CoTDetailStripperTest in commonTest" +Task: "TAKPacketV2RawDetailTest in commonTest" +Task: "XmlUtilsTest in commonTest" +Task: "TAKDefaultsTest in commonTest" + +# Launch all platform actuals together: +Task: "TakV2Compressor jvmAndroidMain actual" +Task: "TakV2Compressor iosMain stub" +Task: "ZipArchiver jvmAndroidMain actual" +Task: "ZipArchiver iosMain stub" +Task: "AtakFileWriter androidMain actual" +Task: "AtakFileWriter jvmMain actual" +Task: "AtakFileWriter iosMain stub" +``` + +--- + +## Implementation Strategy + +### MVP First (User Stories 1 + 2) + +1. Complete Phase 1: Module setup + dependencies +2. Complete Phase 2: Shared models, constants, utilities +3. Complete Phase 3: US1 — Rich tactical data over mesh (v2 path) +4. Complete Phase 4: US2 — Legacy fallback (v1 path) +5. **STOP and VALIDATE**: Run all tests, verify PLI/marker round-trip on actual hardware + +### Incremental Delivery + +1. Setup + Foundational → Module compilable +2. US1 + US2 → Full bidirectional mesh ↔ TAK bridge (MVP!) +3. US3 → Reliable TAK server lifecycle +4. US4 → User configuration UI +5. US5 → Dual-path tolerance verification +6. Polish → CI green, design compliance confirmed + +### Retroactive Verification (PR #5434) + +Since this implementation already exists, use this task list as: +1. **Verification checklist** — confirm each task's output exists in the PR diff +2. **Code review guide** — trace requirements → implementation → tests +3. **Regression detection** — if future changes break a task's output, identify which user story is affected + +--- + +## Notes + +- [P] tasks = different files, no dependencies between them +- [Story] label maps task to specific user story for traceability +- This is a **retroactive** task list — PR #5434 implements all tasks +- Verification: `./gradlew spotlessApply spotlessCheck detekt assembleDebug :core:takserver:allTests :feature:settings:allTests` +- Total implementation: 99 files changed, +4698 lines, 1 new KMP module (`core:takserver`)

#fCnnpYWG(Hh#~6l>A_S)B}jQEam1qfNirR% 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 literal 0 HcmV?d00001 diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakTestCardIdle_Light_b29dc7a7_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakTestCardIdle_Light_b29dc7a7_0.png new file mode 100644 index 0000000000000000000000000000000000000000..0e1f2103b843602931e8cc1457099068b0a90318 GIT binary patch literal 22112 zcmc$`c{r5s`!{YWiYTRIY@q7Mzj^pu%W88DkHRpL==XEZx^L2Y+ZfeNK1L9$0 zW8*WrdgTTi+dc>z8@t#6F5t?v5e3V}W-ViM<&xDyhuM)3#&Krjj^B=3&pVaQ&Q6nF zd#8qiX_Q-UvWJsNQlOFDP*IH?mgl&-z4^sfn3cUR`~Lj`_80xw<-Y#&JxezKR7u7^ zryhR?fBolq#_a5$hnQ`y$p+nY4VMRmRA)j+c)Vd&D(gpBmxl5(rprTTd7Z)RyXCka zkOcVA$0coX|9usiB}x1Hg6*kcGyC6HJm^Tyzb^#E+4pXe)#Bj)d&T_WKGDA~G8PU1 zssTS2e+u~ieG&OdipIv)yvC^LN{!&OZ6RarFcH8yJC}iFn|D|fNse4n2Coj(1x}AO zaH$Nnp+urM`5~tsF189`D8LfC-*ws!=XuanW8&?d(#F+uHu$hwOHP$uDYRC(%TB{A zZpipiDwdrsBOdt9`~i>B#@`t+x-aiDXZl)7Bev+;C_`bLuo}x~|J9nw{?gK8rjSfe ziP_#fvyV>g{CWIrk*501>=|d$Bep{Z_QR(dS)&2&kE|;nbSBf2WGqa$@eyuq`B6C@6{?iYaiq$iP_`Z(m~D(&NEez_s| zVa=(GJAH*WO%=Wi_^Px&-p~6O_=b9h@;s$9-IAN_qRcs~W@1xB#N_3|IGv3tsY$r; z%qwMrFPYWvgw_bEdbr%Fj`8{moo^T?=}jQA z7%Ml$+aD)UxAa{OvtfGkLuB^X$s@na#)VP?2pY~*V#M}j1lP4hc|Z5;bC~QgM$^uA zOIO79GVQrk>;VL8PVAM0uFmbI@WzU{eS0T4#pD&erXA0CMB&AjAT) zfbt3ZY(q`oUXjVeo$wPHsp1k%QTO2wUrwPy=n1@v4d+>x2xFs$uHWCDC0q!xe4cf_ z)f;-Hc~O-k_E&kDp1A~Cce`d;Onc|gvMJ`~tZ2eB1p0cq&d7j)z!6^+^!6NJ&nia1 z&TsW5VR={`sbD{_xkr3Iysq-|pKoMo(s?FABu~PLuxrhkZS=BsaeA@k%J4h^1I0MI zA7|-Y2Ho!j$5(%gmw^tU{-Ldb*bV({x+K3+Wzxfr0-mVjOzk>;%l;>6-3n^xoVh$W z)*6HHs=B}t`@6@mKf7jUr-0K1>1#ob|gY1SpHX<@JiJp|>p!0VkEz@rf>%+;B zdQpj(kl*hK8^5~s4)fu>hJMS(N7t|fm_MT>G&?4y*T&IsoUs1?=>5{P7dzN>Iay=q z8B%3}TX*f!#~`|O9zo`t#yQ@RM--Ke;85(#$n16T@U^>>%aazz0q2mjYF2U~r1n%I z-BN?dHUx=?vLGYR!OCyzn8J`PCI#bVIIZ+t5x@4y1{_lpGgr*s4xFjE;%odmYqXvb zGrRO8K%@kh^YRZyHg&|C=}ePaUqB6Ep|e#4 zI2cYv!hJ`ADEqZTR}XD3MC(F2Y$%QqjTN2NSDJ}oGuXu<$E`xf@(y}u4gCo{&%t)l z`Y8{j{3JBEIAAfyyoKBsQ>QgFYZsy&AqFR6PSa}WCH1MyuMs<}2+9^XaCq~1;pCm{ z1TG;y0pBp}Rd&M@ojTE#QP#W=m2Q_6R<~f8n|O5$)bO-6WroNx;=Gf-vo7HxCW-R7 zpX8ysPZaxeIxjsKA2sA%-&w%PmT~NeFDVYZ%~&qOKax}Z{1T=XW2;!Fhwr+sQh!kj zY(G94o#S_bQC?N3R^$3Sa@=E|Xnt-2M{I4tq-Ikzmq=Am!WT?-Pnt&%MAz2N+?IHX7c&Fk1J;Hetxz;S|a@6K$F|E~*gT@Wr|1EKScQMVkCeFdd}SKRG$`bcz-ig|Vbf;VV>33!2!W4*s$5c5dY|$ z?)I9~e8!!+7?&ioB65uP{*!eXxQ-4|fhA@G5kkjSr_QGPe?LoTdePSLv4ir7g~0^& z7!jIaL47v~<(QX|oPCwX&5La!K9Hu*Kn|EC-L$0Fl3gagC$zwuHeoK9I%OXZyZ!8j z^zIBQC{$`gn^8ZH=hJOtV{=slEU*acmIxwQc6v#2n5cqDrqRHj>uG z1QGpp?(P~C>!Xw?#(+zDkh>i_yP?M-YCWgP@vr&@%@#87VZZ2#ktvcF2>u!GK$kjY zqgZ#V)IsbI;HiO$@micuK7_FwvgSsvrK7i4THGQ`K5(IVt9qmz7M&lq(8Ar4Z;}1D z1Xj=0wAQL<4ELVM)7^6OWt6pZQS1Xu7KQ`5Cdh20w@sxDOU7<$+_Rxg+sx7^}i+Guu764vX6(w5|@3{7UC9S45;DW(r!O4(q&gTX63*zR!{vz$5D|?K`hfPR)_P!~m}w_%cChFQ;2Gjb=A_A%dWOVUKguBlYmY#eBh+{Xx$O z8aNzC`LOJ25%_5aZFqXsHmphG0FZdVle)a-Kk#?Hb$nV3qUkwNPuFf0{ek&yvS3x02~ z(1>1G6sNA8ZWn{6R>W3@Nk01|*r?_`44JH|WRC`Gsd}Zu%x>^xGQ^tP1qE~58fJsIC|;Db~R<1s>& zo2=)EPt#Uv5Iq);MfK(*3vVL|TN6P3|>1GidmSb$5 z+ke&xf~_`FbB=FpL>c}|Kt|NqNqkoKvDCKe`+#+J-?C^=?505xm#F*IL9f$pUpChW z7GqG#1HS?1Bn>)$XxYu)ugG&?+Jg4u_ifvo^XXG@3DyT8VLz{ccq+qMt|Uf&hT{vX z;IcrOdbv4H8FssX?iLt|NE@L{-0Er^qMGn|*(F)^#CVU4)GvJE#b|1*(V~A4iw1#8p+4!kwh!jf&cc58Vc^tg22Wr?$73YikTjb4y7OgL52DLmrigG`>s%xtfWJNY@1lRWXW0280!Qx(5m5p1& znR@R|--%7=Jyrkom%I@9{rd;G+t3n@nycx+Der+}o78b?Z|@JsSs#Fq>{`J>Il|4K zoQ%`!A3KV<;JEY55+j`!$65)9a8^B4(a!!H2bGe>Z4P|ikjD6g1^ktjX3$K+cI^~ z!bdxv$g4tgc!$$j&;^{%xsu5zm$Ak|+X#~=w!w)jSux<==Pc!5GrzJ=RHIjRIyf}pf#G5&E{jOKA(`1;I<$mXh>WuRpM>Al(e}>?bc6_bULMcF1yUJf^>-8g!y1iOW zxL{N->oI(M#ILd=VsnTJ7Lisjv~7-CiEZ$CtxbeE+6`T0LZSn|;|1Ji>2?9pq8)~fGRI2+pxDpJZ2hyl0?1=y*Z$uF+J`xqD8f4B(KDhBnd=Q&JC z+BR^=>_#%+?tz&i*pNSq?U-bFG;57Zs0xplPz5usTZNlsB_*}nKt#x7v8k8Z_fO9Z=l@S6nPpE*jZw@autQ;Q-#tNQ1q-t zy>m?K+5dwt^qZIPX$xQ}I9}sL!fy zYSkp}WY2z^!5AW932@ibvE-@PU%TZ=9(eAuR&1#q&g-osd?d)!q$*NRs2}fATw#CT zA!3V3Kas0Et9 z)In(=$)-c--$A}>54vCI1RrF(2$iCJeE|x=^Y$2bX-9{R2fGS}+1*0rhF8)Bn86FM zKL!OJ{%^d0ppH-;ehk9XBu%OVsI)8|6UJo5`Ojn^vxRA2$M0;-yUe`L_MQ%gxgY4) zBq~#J);(&{eQOm_x^1cl#yMi;QQB0`eTd>uWYrs^ldnw&)h~DQLm0uQJGO;iLJj-M zO|OmL>Sxj|MC@cb2lK`_`1u;#JmMGlO3wK;nkNhU%7%SpZfwQ{rRy+BmKdfpA4EKC z;gSmsbcaj^+P>HW5dX2zSlqQFuqg(e=D5?Qj<>)S&lTr3UX7`rGA$f@zK%M|((zoN z@yZHbxS29|{+9@?Q|PsQ*kY?GtST}aK0ivhP*?PT{-zrui7)U&3TZL?`L${x)nh@-!$@hb*I zPYs=`HEraw#042m2>Y&aDDpjX-z{YffhaId#->zYRJtCfhi^<1mJ-smS6=|)W(%M& z?qj3T^BhC!j~fx}{hHS#pO?0y<^_bb-S5)R?H8R{nzyUYJy2O?w9ZwE67e-h2lm@C zX+(Iv2L+smw+`VF#TUf)^_K^s-cD+K=v_sbD|7Pi{~BEXCXnrVdQE^;BM>;p4|fjd zE%8Hsp(FleSLiVPD}#D|M2;KkZp2xZPWyPlZUF08=3e(Bw_23}2*jo4xTu>&)*9O9 zRgO4Tz2Bb|CUnbhdnn+Ej`c>CA(3^NeNtK~U z>@n&)&IAsA)l|>MVpu)7z~nm%dOca?Tfs3^PfxF@-QxDV|F5e)uj6k5!j=8Ej9>u>1%iJqp{nN^D6`$Ul&D}LlWze}DN6*-#uMQ;yhVwS$C*RJ$k zI-A86VASH>|Cqd6c`8_>?qX$7LVnzoh>V_44^GV#=#;Gj4PIV~;?gMX=n7x0c&4H? z_NDRgej}XqNC9ieM>h{=E7eI^mYSOz`X-akAOW>ZV4Me?eyOWy$SZoTVc9fqv}b?m zDz~=7&1+7jfGQ^!snL&Zy!s0FR_aRh``&;!kpl;(iXSxd9<7rBfP0mQx2G%vmxIK*H1y)xqcINJbSL{EfPHayT$b3vjDfMs<(w{cCU4mmaSh zS9-3s0Ek!OOCPIMm8b`8u)u3*aqWh#{vweIF273VQPyxkV{0S2#z7b#Gi;om;sd(E zd{7pY3BVgY1*L+%`HQY}y5u*jSBUGzoOv5*M09pLRwegU?g<=yxd7fwG}VS^dQlIw zs`iTr_`05vtA`Q8Jv`!p*fMe4cMX(32F*?WYwhm-o54s!QNJgaGWdWd5|q}Rk%zNZ z&IdGHIs^%uBtAGyKE!r08)($0-dqFwk<-;t%6F^j$KXV4y<O*{!LhIATGb1LdtyZ-Y@&$j^N)LplKR zWdv5+MFL$kh!ty9D*MWxhe`m)kgC#XC>HeAQvL3yeR-xri8$~%ujZfiHNya^f)R&S z+1C2!RLn}Rgf;1uE)$@offV9)N#ZIybxlSRK*)Bjt*T6WE^@>lfH41+8d%j+-7kWB z&(wQh$}afy*RdD0f6L<8&K2x>2=#M~!+Ahp5kTNqBI*Pic%!u26_P&@Tc%MLJUb&V zx51j~7h|4a=>WkFWcG!XkAfLwMq`Q3mxGZOZk+s%tR>s|xxN_0s;pJbcr5hs=fk?g z>y3I%8-1nsSnU5x!Xu7dg4%WW=x+dDI1RMy6>hy}e9)RtyK&b4dPI$7FcLsQgJwS1 z0No&Xz}9F9u4$SEyjC0xnkHVb1K>|6&~QB}EwfjQ;yxUV^XWqnv1uUPKR*v+CiHs^ z2R9HeLM7CSKa!4Abl3xlKGcCV60}J~hg((oC92VG<2>J6DkhiokZ~ZSf!`*x*rp;C zAxXPhvKyEyIAiA{b~XvP_%tCTaLlTTm46LUuUvYDP1rkTrkc~zF39BdcJl5)TV#%s z#?x?Mz>u+q;vrvb08F7VRg;ZvP=MXg0N+;b+N?fQrH;&bV}-+w=)mz{q+2%`0`1_b54Zo`0?Tb&71~6GYyur*07nIxbg; z^gpZn@m{&T0QH@owfTr!+?Ong*eBib)XCOwKHIig5@g;7&YP>lx9A@qln&7aBqv6q zg5UFkYnlr>;^!W?ziiDf7WxXn_P*gepZTFb3XXBa25PziZVRQ=%oG--dQmQ1vl`Yo zH(N_yGC2+gpmw->c5<1S5fHqGQBlX`tRYod&7EZ9;kRQ6ANU|)k7~I_tNvzj*F$3U zC1)*j|8wdNt$5av?@~5*8_Fsh=h{1+%UO0&bpy=jTy-(^@$Ue1GftZJ5a@nWCxF(~ ze4dt7cc8Lk^FGkrRh*KoT;d}oC7r&O2pM|Iq*I_(eQR7`66U6z%s$cSz@ERQ`p?ZD zz+AyL4_p8wZvbHU%DVkXT6p{mYa4(VjO?400)Ai5Q+ojv8|^lf!{G~@dEgISt#D-; z0JH|X7rj?;dsC4HH1wm{SW0sP56si+C=fjWz`-XqNWr0@e;1w<6Y#xH^SDC)=bE;n zrS`+JZ=}Z`k^Y%ON*{z=K`-|^Bj+b_k{tXKnct53 z<`>n@3(vYM#&n$AFW(l*X8bn*1kUc;3>G6bqC;Pl8(4%Q$q{cSUJOotIOwPvmRMYGx; zdm>M}r20Lp;4cUsZDN#nq&kNK-Fo{rL5D?+XaX*u@I7?$e6x+g&&Qu6?L-VG%^a^v z*>J?>TuaiV6Xmb|MIrT@Cx_=eo#~ zl5Iq!jl^tu>0mw{P_X_&_I+&(9 zfgMTAUwmCjuSDSbC-j@5vB&>abDFgd0K*gVdF>ZGy&C`glF{|Um9}MIul)n;>WY26 zb4Oy26be83KoYYv>z}aKYF9Jzy;xSZjMnN!Ah`}uu zXwJ|vZH_aMVTf4#h1(c9n+!|$1JBUP`2yH>8%B9Z&GrTzGb`5&1QGzsS1E}W`*Cw+ zV7dyJNPQGMv8L0WUyNo~L!=j;@O{3jpp7f@{rRcf`FNPt;+JEiw6kyT7usRlS*|gz z!{?Z4ub#T6C<~40hiOCQdpIA1%uVB~dD)W{m%Qme}rw`Z`<{`Ur*31>%m` zRy;6q6AP|=O~(2fI~{pK${8Z-(n7AAm(t4!wy|3Yo~m1wt}!b-EJJu7nstGBZdD{h5%sJE{t0O99@U9~r%+=A!M zicP!r%cC;Dq-|cUj{m6}ke}Jux=n#$U^nJuJ-A@Do}(GaOa4T zmBC^&s`vjzzR#|4Q7jL~R7S1jWEPAt)SPEX1*TrWW{I;$Ypwr^C)DYqo-}}eoSs8| z0AjOHH1OdU_vnS+q-aNiVsvKmvr~gkXF{_Ae*f?z+q}sAy#iB_$tidouLZu?Y*8OP zqF%g&ker&(|=EvJN)>5ly9S_I)rpj)OtTRg^W*Zz( zxgJ$RMwgYBtmsBRmx1(Mval;2?VihLezoM> zp#a&^NU0CJgyk;XCY9|-SZPlEzy`)+-IG}p`>9qM-ldN|{b70naP9QqA@#6IN2|J_ zlC)dF?;G-69*sCq@kmUaJ5XZzk%sge4OK8Db1S>JObbwt9Yw)z6d~p;fq?-qZVs?P zwcXaB7gwa#$v*v+QtN>F zCjVBvV)64Eib*2gHJOa9jW%6k4J3HWkJQ_y1Nty~e>!s?+r_vlWHWo^IoNGF;U`ez zM72vqGz3Pmn)$C1b{JsDJNTGY}R|NEL4lc&RTomgkkSm z+s^C0qFc}^9nijE{&Ud#UyuA{FkA%a6jQQzw-4=TiFpz`>bx)QqD#be<&^HzN%l8z z#cg5VHsda^vAui@usFK?Jd?utx@(*}PUP|DUl~WH!bd}L%3$Ry96huFyjx1VX+n{b zw&y$_inZI_PYM2&BpW&$Jj`h_;6{%$R*^ET*#Uqw<$j!3isdmwA=vme;{I&8nM%JNL575(|0C2 z4Jfesr{Bk$>V*SwAxWVc7#VR(ge^S!OOn{-qXZVmDB^31<7xot;Vl=kGAOd@UhB&n z8Fx8fb^~p{u+~bqUQR)Xsy4PQL|E&guW$DyZjGwU-AemKt^!%GKG?=&(Q{WJmA?vA zIkm@T0Kk?rpFZEh*N384FI(0Q8e+-^0i5;mUtHu_j#v0D)N8l8%dMCJJ_#Vqz^34U zGu3k{o5(c|)Nponn^FR(q{V36KP?C4dmz&v;*X;0l|0DBHx#p{%7M|;Vc_=vA&9Vv z`RvYE0SB}v`-<)|Q@FnnrDUT;{huFmu<~E}Cc|x+XBtF4K1s4PQ?SE(P)c5iy8-+b z%Oil-d>pC^qCckYY_mc^XbI#>oF%7K#r0IEuW181_=^6$=^M<&NJ z&Wp*jv4MB#J%_l2^@|39;n=bdaJ%FGKfedy>HOaz3jM!aXhz(Z(O;(AUl|hm(Qh0_ zi{~4997M0?@*sO3w8oB#hBf#f+m(;a^_V-osIwH;bRiOz?k(rc0SyKwUXL64PSg4q zTTAZr=FYavsM@8f^ZvbE6McchdH?rAp0Tf^hdE+lj^VZEQrKD)EG z!2LYWyL1<5>aZm0I(s_-Q~$V%{!nm_)Ah^HCh7urik@_wCaoX06?13e94qPXs%-fQ z7r31dtD*DH+-%NdzsPvD$Magkxh+qcf-Q`)BK5M9OYExq22TF{z{SR5$0pUq>vA_a zW4m;z!o{|o7qkApld<^LNH*nT$kUrjyV5HPUl;s)U8Qd@$nKi((@uq>+@d0C-T~IT zBsidaaQ!ap=^ZCa>u*>OaB=>7jjc1Q_|tEp&2+@ z8seYa+nC5Kb6goEQ6U|wxVMJj({fa!j&tOmp5}MHtF3O_|Lj88b0RkVOi!oMDmqT{ zKexBmG3}xntxNB|y#03pb9>+uYf)oPSt5Ve|5HzQuuZzhA*)^aRr-A5iCd+ZPIzw0a$LW`o<_0A~c zxb957JLx<5wnyn6I>mI4O_||byyolVyCbEzuRX0LTlV5`GN|Ll=2O{)HUMztt>&V}%)vwa)UWE9+4f5}H! z>@(U6(JGyEUY$dP+Q7HHw>e*rh9seR96|%02BZe@{JVgX+n`8`()CTAUo-v6a|hB= zH=YGF1aa)W%ct)DLq6{=*C}1iHijQ%?%zd8uS4^oC?&Ra5w#hc$u$pvG_q@5&{#HF zeSh_E>z%BNQfJL8+wTE1irqKN;c_~|C+(rWk9*fR%p-VC96vueUOuw7W%>1zQX%sD zUC)R`t=4#|?=3asPU_b%b>h7^K7G5g-37YGLp4`7yB?wnX-FCB=IDN?d16P+4Ihw- zx zE~}>34Z`k%|xV@AUll)o;6&YR9O4aXZB>TQc4ydQ{$_oUAb#6S?R9XPjj4V=A$1g6Xkd zp$$UpdtY^ZV>N>0c9LCIM{H>Ip(5ZxZ*3X^gU;Xk^*Qug!*|b ze=H>Eb?u)uwV#W6lmXv|m&ryi)1*+Un9BLaO@X+R_Ht#{6*!Z{Haji~QKR zWq#ZDm-sD*pm@vP?*t9jSUHu7PpiVYj)t&4(^`{8c}Dw3*7}t0IvOq_KZYTF+h2!v ztp3a&;^KoR9&D^$8>jVAPH61#vsLv3-HO|lB)=>m)j*(!zC?M{0y}*D@NfKfxNnzU z*Z%f2rTzl;Tb`MT|!rE?u(jlP=Cl| zvQnKbuHRzSw`z3h^NDO{RJ?i>vi40|HObLW&y8B9%J%v=yP?bUh|jCuQ=|g!;~AESdsrCe5~4Kk5vRnC}#NYP}H*2Q%a}etwy2;jM5CAU}-&*p9^qkL5x) z^Xpay>x@@0(~JA+#nY)02&?lebKGvE6T`pGc4<&&Tal#)=OXUdFE9d@A5SYgfSI(i z4h6?WBj5g?5nwy-!B2Mh@rXO5af0ThX5!WP#_9`@-!&x!{QEOBV7Iw* zl!O?XB#-gp0Prhq1Mfz!cNHZK`P{}U>mI6wUM5iYUz^ru=~Jxr?sto(D75&kjo!QM zNeV;7k=b$SLNihGM|AdyVw`^S&TFD1?^{k!E{oMa&dh$WFFG{QBhkXiWyrC2vx45y zl!VAGUVEswF!m>>WeeSSB(VNYO@&USnizd`S6pEJ|L)rh8H~j$_e#xp{eG7Ad9{a# z!PK6LiNX*UVsuU!B=FYqigKt1s=+TJWu~~k7vO)JHhFBJPXZVMxN&Tw+?n|b(;oHFW=Q17PLc6uP5>|E4%sk;&3wyN}l@Cf<38VC-}Iwa*(sHc=n6o24b_Bx-3A1yl^h6= z#W!NOj_ri5Ew(s0$s}!w8*%V6ug>_B99QMDo29=w@c^OB0;iyjclL1aD?p_gH2Q?{ zi~3WxOict-W|l8+Er@+;cW^mAtI6^0M+CCA_rT>~n}`R55E;l%2Edi>OHriN+-rv9 zfdh@Z>Z9N%O3P#Rl`7o+Ny<`fETc(HYIPR^M!fzGQo%T!Ck`!B?VE~AoYD+593fj> z@Y#muQ1xKj5+cyk?-m)uw8jx`EVk%Z)I|DQais$ax7RKW(Cf@o*BK2r#&0>rybAqO zaA&5s0#jG10-=BT)|JR*(>GtT2+FdYRFk50-#+VJsjU=lThM}K*=}5?+D((J$al7qJf6#-ihLw5ep{r}}ex~>| zI^yhyb?$d{5*Uv`B3BK5$o91 zWq4W7_(g8wQb7ec14gYzs^3*Uqk5{64qt*mABh_ zH$rt9h_B>V%ihJ5gnlUyQD09ePmbA)fc5UI9&Ayr-4I4>Rrg0^ey#gDqplUq&?f0V(Za^_3@W|lq_K#EkqWY2*#zuQDRE3$D>LmUe% zj>7NWpH||oHdzDd_a~ zu$*7{!=OBtLhF390mnY(hjI2ZNuRhxH^k>o`j=MBLU{0%;>`T14N3)uf>c4lbDT(d zS2yFep9P9ppy?ti6lS&Vv`XY_n1gO|;!VlRjqQftK)#1+Jl??MYv_-JomG>q)i#u! zf2R--m$gez!mNwZFR`u%&5*;(bBcebMW-pCb~2|u#=}*-zkVbtV}gPT+2N*s1Ws(B zFJPn(vw~&GGw0|2X|$fXhy39O>%hT*+A<0B0?vm#RkN7e(o!(e6|j9VTKmFSKQ9}s z=E}T#esyc4-JHDY%OFDop)0{6GoAM+9()a3;w~Ck`kRi;r0^Y&P3nnMMDpflG(hMg zU6US+Iz`%gwH!q+k+9mpfNL3=^CMcJ)j$3oD4cbGLs*5U9r1|NwPq#HwJ7I~eBLtb zi|miGb)LzW5J*zd0D1&)b4U8L84IQJQ{v=_C;c`ElDUD z!Ardmqyvf{l9m4G`0Cupmz5HEaTK^LVz`wxkVd7K(^jDdZ*}bIOJ9P%mHvpJ*f?6N z(I8`AkFow#BiyEZa@uN%)Vy9(UFtgW3K~1Lc3{Vm8dQ_Iop$zJS%>S+)s3&l#NE?0 zEqZfzS8q|b(VBdx?kBOjqUF@dRos`!k>iPXEgAP(E*`t8@(-=MUxFv9aO=<3{LTTe z*#chkQEW))Uc9?MOwHAdI26jnXRSPZip4$!cY8UF5i(g&K8~%OcS^^MN6fOT5j`xwJUkeahUcvCkTIQfTa$gP(4 zNwLcO8rBYCdSq2^WOe-BdQ}fnBQj-> z{7q@?DXZhISnx0s#VeMy3U=#pIz`_!Eh1ks@X98J<1G%OgdMGm7Ii#P1Q~ZvefpbM zCUMMV_rPX>8&e3D_Tj|2D@{$FCer~GHe-f4KE^iEd zFz~Ikj^Oo*OibW|Ty_uJ6n|lH-pK{eGhH9{i)ciEE8x?Pg5clDHu}TqW3ic9r9RzZ zle6Bp2f2=v7qxfk?yPs+Sz1wlJWdcD^DMQt;f!t4R;7YZq(<~bIjf=$K(cf}-1qwi zOp3Nfe=5SZ$KTO45&56;%D%Neg_ZqpMy0m%7J16E{r3#zv z3OM$`_5$pp!9ZILS9*o2h1=yAm8z=lcZ$T>e09SWCXiX?`t_5H0t_wT2H73fb!>!x zJhJl~0Atrr`DNFC#QB*!?34vmTM5x{gzp>pUibUWnc@LFlG)6`@JfSBw?wK8eX{RW z1Hbk z#$XKotsJ>TvnZ?w z6m%L>%~rBHS!osfL~%0L71#}v+wIcpaFYLj=NR5NW?c4E#D)nZGK##A z6!2^}GEknUzptSB;Zkl6M(S~fgf9?fg5zjDmcOA@fS2k}%j6)eB@0u`6uexQN5>PE zM^y6Z0%CeMd-N2C9h@sGLJ)~?@U@vHJgaLP3|e#DAWSVKgFDG<{VfuzBgt5SQ1*L(A%lMcMnSmaN2WQUMqRo*p(prC8IHccIl_&6%b7AMbo20F8 z;(8&D)C^V80St67#GQmLT}*$fNb7bI2UYgkR*cSSXQs0fv3_JZz$gzI8@?7CbX&04 z3|Q{|W(v#GnETN}2+6p4Tn899XQy3Yu3u3OaIMqR=JlZ5bJq1yK~nEZDb4`gur^b!>D5JE1D^NHym)S7GyhiXf4t{%FP{NLciCoV1PnAZ z;y!f&C&j*$bXY_je9#p#Qc!er*A&Ve{C!vEdFaYkpH=F_`JT8Z6-fgpaUoKwRpX-= zP^6WIMJL%KU;`KYQ1>NFNZ15aE-M?}v2@pV;^=k>AC)Kmxu$Qjbwn^tGXkuGgEn8bKI? z%v^1u%0e6v*;En+w+_GJMWot=3;0aeE*f{i4P4xbsmrTO2#(i%xD`(3Wea6_jD2`- zin($p>uUb zYyJJ-RJ;B;_I>xn7yy(wPT9G*=6ExWjv;8l8^9IhxJ73_0-1FJO1c<-w{cZu6QN2R zFji40LTY1yN8k8928maz>JS}}h%n-RtBrjmF1o(A`}#nljKMhWw@kM?A{wwEM$ps) zsQU_jc#bUT)6ZGVEnMrS~;^CZy>Sj`|Km?pz(X0JPws(pEC*AFu2@ z0U++Gn^0WodhRQ9uuD$C;Uc3=e1*Gpv*c=Kx+ylJ{F3bP zW51ncr;Rp#&gg^qfmLRuttw4+Y5Ohnq%i_G@69Ix&AxQK8t{P*b)Ewr^SGp6#Mf`s zOiVr*mEAhU;n2t#7SK;8p0F#u^M&kuT)odmTaiuY_O=l~36LmL^#k_Yby$$kz*V+D`ipA-g#PDTi2`M zpX+0E{I_dda{MkaHgeL0*xa97@lZKXgneN-4}?g)DI4mP8p&iGHx$jCE&j)D;mev^ zPT9Fnmwu>xyq+Zu!w_FoD4$yXVTnC$`BrH%v0$4w8-1Sk;e+qW}P-LJkqr9}e0LW5l zr&s+XO~&oqf|h$9@A#91m|_ zm+(n&J$JcNvzyi9(UH5tclKdL$Jz%Z)kYO~>Hw2lRhf<+R&%YfbTs+joYGz5C0PdZ zM%7sT^rwl4h$)L4@dLOsC~H2*%LZL%?PqTsfPkA{<|K0QD|kNr@Gb8{2$smWr_8RV zAq$HdIF)xQTYPz7;FU%R|tv~IJMxA67$+F-JD$$6>x&ENiL{`}FHJ$TF| zu}@1gxE$a}a4tXX$PtcIE2u{IpxgG=v8u*I9Q+H3OHDyg4ymzs>?wEG2cdusUpUFr}pQ@f^|sQgzQm zRki?o5CH#T|1HdUAP#Bu9~o%s=_D51{Y}l>#wBO^qKWs z@SpiL-j|-(*SIosBkv4p9w+nNovGJsA))zub~l%vqjgL18;XXErRj#(MZNp=_Jd86$`!Uq&ART zX{$k#o%x>#d_H(gZVz~>2pWpLLtDrL{%3?GP<2^jN35)OfM=bhfbplE=?I zElIq$Jale$JWjjv_z#PJ|AJa>2 zR2rbZsl(N`y)a#LANhj@w;t-;oI$eF{%jkjO(D}08(pt$gr3e!J3tJQneNe9BW!+8 z1RnHHMv`X!4mlF>T~ikn8ylJn3KGDn*+b@6ojv4TB03iz#t=3Zo1!HEpl%&!}3JsI5XsJ zfk$WlQ@uLq5DgVoGMwMsxq>zl4gW$x>rXG9n2o@JvDW;Mmv#RWAhdRR?(Igz^6mW0 z@c*xsGYxC%=mKz%r2>Z5f+AZ0X%SpN6w8jyX71OkBof}m9atq?1_ zY+(o4C6Xvqf}reMWUYvVC5aLiB?i7pf#jn98G zcPU7qsMeaLto31-jWulX;2|Hg5&3MKQeac(9v9Y!h&F^C0Xm(P;SgTp_ENB)x}rFH5-Id;o^<7M`Ou55m$6$^X8$WZj==j{;7Mll!*T@)El*0SsZkvq0N`@ znC6v6NKc~zyb1yAqJrY7TnYuYD?I4cr)pBJcXZ`XnAw*VZh7?5M-{`n$aGFWeR15D zH8+qzp|}J(@Rx35kO28N{BYNNFO@cnF`{z@H};fg9$5+Z#E17x+x0TM-k3+6ULRNp z>qj}lWt1?S?JV}9eBR$Cp^wn#mRa>f{H?3QvwtFVXC94n?v+a$R@^gjb4Cp=h0l?a zwMwrYV1!$5mg7(KyPxp2Sdl>7A_L20#n1tAC1OM4u4C)i(0=!t$$x;as&vl~v3FkR z#7_Q-mj+fmvBF!gZTqq*o70=IRP`>&r1>aPO_C+ZJRLjip6hZjjK$Tn$=oIyuRIu) z75TpIB30Inz*P`Bv4JTYou;JjIx=;4=Ne&6>}DUb^`SA26;7@_`?m%#Bl#N7e3wKkbd*P&KR{Q1EjNweioX)mHe4eDw zWEt`i?`iG#TKE~4WoPAMLC&8QFP;JMgrb<%r0Kjl{b`@U?frL*de?oDO9qz0Qwurp zL~`_@Xv^Y9WnxvCGcQUUpQ$1zlKYEh3^Ud`44>;zLqZ6{8*=0auvr4D^eWG3V_c=<6MWT zE8|z3oz`hfw4{q8MbYt6=?zh?tKc-DuY(Dnx{FS>g(jJ-%FHGQ>osWQBtjUcJV_+nVcv;ESE# z4s40K$QzQA8!Gfcj)dnEM4Hm#I8s@Ep%}Syo0MiB?~BrL6(9FLO_u>Bs$Tco&y6Ib z91ml#!kiBQ%&VC>Sc*dGH5Ls@2TTmK@PBz_jVKh2tEy>ji zE31ddVqS5OC#ob7&d9$0RdxU;Vi|XNy@}?ONCtq2gQz4~QqE<&OLVJJLHF6ZUdgI_ zilbA1;cN7^guAi3jSGL2!i92_)H+<1xprTSUWI0^u9VfSIPhlc;POoZGvH14(fl9s z`&=4~iqo5#Dn~7=f#Ga5L3Or*24re48&U9)2l8EytxHvB?DN4-o&>pxAB@xfe0NWJ zvMFQ{I0l?dwO8KtJn7U@=rc>1XAsx&bADNO0;#f^^I)f6KKY0jWE-%5rls7McV`8c zU!fs%L&k$=QhPL$&Dl>I-2JKWCiayG)$69(U2bRP@3#yIC+6#57kQ(i9CRK8lw?jn zJW{HQYh;ehQS^4lmzTzC-NZcByYlM#S&PV}l-aW`r*-ekx3J$g+v5nXaW*ELu>{b(*$mK>Zg-vGW4|V&#>>DhYBkPG4ukIXA z9SW~V?fMxx2HvF=LzzrcZP_uuUeD$C&M)NZn(w{AM;}8UmO5e5W|QW)+s|*T_E2N! zR2Wgpt2Q^0qUtZp$F_#AEz#Jl*_P5YkU8VxrqH2bD_0h9mP(BP+Glz64HPvbp}#M9r|6jd1y2^y3Sf` zO#g+qhLA?qHLPt&5(}J#2E-7%`;Lljc0=~x}^nsT1tl}`zLU4PssH`v=pZwwBG>YBG<6Cm&sAcaUrbk{z zn{>=jNEZ_|1nWc!XfTyuYG$?2@^%m>dtl1P>9Yw4D*B87Cu$$$!8{2vF(7C?2%{h^ z1?n7?qrQ0e%%>zK~vG#(&k+IqrtLGcCy z98Lvln^}x+woBTm?J(Pbr62Cr-*_V@0FDwAHV1qKD3&|KhlpBuSzKvIbs-5l-y@^l z<#J{KMsk1#p2%n>>GC=Kz>%Y50XzrousB1cNvHZ^cF_Ae;J!9 zVqy_cIAm~cB>deU4>G4q`USJ>C0_Zo-iRK|P_vnanmy&=gKRf9rlKwI)9|}{>fxtX z>E^?1xpd*8<0ya3TeIBMtxNSgP0lOtz+Y59S`JA_`ooZwea66Y5#2v0=v8gmuxzBv z7{dK*(7WQej09NENg2PfBYwYDO=YHbp<@A>!yCSyrGX++$cX(i-HfWgzWn?-LoG{u zf(Jx>0~9YEbg9=0!?Zl@dv>c{6;O_@j>(#@wes;3uO|Dg7bMuis9|5KbxJtuB zi=3NX&3+C+ldak*tNtWPo`GQY;@2krtb3j^7bq5&VCo`aB*l6Uhh6(E7w^C+S*5)s zt+_B-Ln)R9f~kGeRXvj}!s9s8EZJE%u84BKv);bG9D$&t=Y-AICe&i3yOrdDo9iO6 zSNxqhhp?){@O?8|%kowpAQ$163LGc28F`@;q`zQD{(FQZ@BaT!z$#GGFkCX2o*6B` zc{LDttXBWRYJKc^WEf-L>JPulm_yb>- zb564pfYP*DZ<75EzNE(|<?^cs+J0)8=;qo3yB`x>kigXj+lJdF8(D8Ilf1^fx4j)Q{sE*H`s%oaHwe?;6v z8eUmdBBodk$nLXFmwyqcx6|&}8_Fmd3sy_f0IqP+&pbxDjjs!`RERb&H75L+ikRB& zPwQCR&ll%J)dwVIANtv5KE^_KcCq6ea|@xqn_4aAAqBr-wbvfRM8vKDZeK)6aEW@y z?i8J$lmc7#1wY%lmo8BM`@5Zf*L|cA$JRaIfg48WYY4R8_$6jW9^ZBRu(Tb1?1`u% zKrccUhLoOzvnDOO1?Qt0WK;`zh;&G#$c-qfejg%^$R&SSDxG3?W5t|JN6-A~3HSSS z@O(p*Zxs$|Y|K~bweM?=0mMV7LnH+{@>C^o^Ly(Z=ouP=z$d;>B5jUW9J?6%KiDA# A9smFU literal 0 HcmV?d00001 diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakTestCardResults_Dark_d19fbf1f_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakTestCardResults_Dark_d19fbf1f_0.png new file mode 100644 index 0000000000000000000000000000000000000000..1e7bb644f4c84a6bef1e71a27b78802dd4f7975c GIT binary patch literal 42948 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 literal 0 HcmV?d00001 diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakTestCardResults_Light_b29dc7a7_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakTestCardResults_Light_b29dc7a7_0.png new file mode 100644 index 0000000000000000000000000000000000000000..25787438fad719c771bd6b1a66fef06802d25503 GIT binary patch literal 42754 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 L!mw2TYUIBGbhk~N literal 0 HcmV?d00001 diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakTestCardRunning_Dark_d19fbf1f_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakTestCardRunning_Dark_d19fbf1f_0.png new file mode 100644 index 0000000000000000000000000000000000000000..278a6fd19881bad46f92f07e9b0447f380f62550 GIT binary patch literal 29695 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%0DUF3Z!Ae0U_FZ%$-@>(#8zMvpHT*yFG`5I z_+aJO^kkdL7jK}#k-%m$SL`vou+wCj4Hw-Qm$A1uD)3s#3rV-4*siaBG|_7G@NOUJ zNtO+JeYl;ksIeLq}0a1?}J4SFx+1Qnb|{d;FF)ObPASMbOK- z*j(%S<$R)nFXWR>*;?$a+F^)j75T`e{rc1HdqLnRK2`w-1i_H_gL$u~4)I%W*+{kl z(sB$WD$NRU_`7|iET`{JAj16RnF~G{a>=E`xt>?;j~Ffh_N>we#9tP-R~poN%lR3` z|1LJlhhb;q?%(6-xrnXApYT#1x+THf_K7)^RN6W>SllRy{+CD2od%&65Gb#_S4N=T z3F;qI?t_FqIVDFmiesc9yYXOgov-pl*1mviO3KdN2u66Kjl^6XG{a&N1R{UGGOb