From c3d19fb8296d0781eaedee6f2c053b6ada6916be Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sun, 30 Aug 2026 23:54:37 -0500 Subject: [PATCH] feat(service): enable wasmJs by extracting a TakServerIntegration seam core:service is the central app-orchestration layer: MeshServiceOrchestrator is constructed by both androidApp's MeshService.kt and desktopApp's Main.kt, and will be needed by a future web client too -- it can't be wholesale-excluded the way a leaf feature could be. Its only web-hostile dependency was two constructor parameters typed directly against core:takserver: TAKServerManager (already an interface) and TAKMeshIntegration (a concrete class). core:takserver can never get a wasmJs target -- its production implementation is a TLS SSLServerSocket *listener* accepting inbound ATAK/iTAK connections, and a browser sandbox can never accept inbound connections at all, a more fundamental impossibility than the outbound-only TCP case already excluded for MQTT. MeshServiceOrchestrator only ever read takServerManager.isRunning and called takMeshIntegration.start()/stop() -- the entire interaction surface. Following this codebase's own convention (core:repository hosts portable interfaces, core:*Impl-style modules hold the platform-coupled implementation), a new minimal TakServerIntegration interface in core:repository folds those three members into one seam. TAKMeshIntegration now implements it directly; isRunning delegates to the real takServerManager.isRunning (its own internal start/stop re-entrancy latch, previously also named isRunning, is renamed to isRunningState to keep the two states distinct). Its Koin provider binds under both types (`@Single(binds = [TAKMeshIntegration::class, TakServerIntegration::class])`) so feature/settings' debug UI still resolves the concrete class while core:service resolves only the interface -- verified sound against Koin's own K2-compiler-plugin binding model via a real KoinVerificationTest run, not assumed. core:service's dependency on core:takserver is removed entirely (confirmed via grep: nothing else in the module referenced it). wasmJs gets a real, honest no-op TakServerIntegration -- isRunning always false, start/stop are no-ops -- documented as a permanent platform impossibility, not a stand-in for future work. androidApp/desktopApp needed zero changes: both already register core:takserver's own Koin module directly, which still supplies the real implementation there. Three of six commonTest files move to a new nonWebTest source set, for two unrelated, both-confirmed-empirically reasons: SharedRadioInterfaceServiceLivenessTest.kt depends on core:testing (no wasmJs target, the same gap every KMP module's test suite has hit this session); RadioControllerImplTest.kt and RadioControllerRestoreTest.kt crash the Kotlin/Wasm compiler ("Serialization of IrErrorType is not supported anymore") when constructing a real RadioControllerImpl -- bisected to exactly these two files, which both differ from the four that pass by constructing that class (interface delegation via `by` plus Lazy constructor params) -- a genuine backend limitation, not a library gap, logged as deferred rather than worked around. Co-Authored-By: Claude Sonnet 5 --- .../core/repository/TakServerIntegration.kt | 37 +++++++ core/service/build.gradle.kts | 40 ++++++- .../core/service/MeshServiceOrchestrator.kt | 14 ++- .../service/MeshServiceOrchestratorTest.kt | 100 ++++++++---------- .../core/service/RadioControllerImplTest.kt | 0 .../service/RadioControllerRestoreTest.kt | 0 ...SharedRadioInterfaceServiceLivenessTest.kt | 0 .../core/service/NoopTakServerIntegration.kt | 39 +++++++ .../core/takserver/TAKMeshIntegration.kt | 19 ++-- .../core/takserver/di/CoreTakServerModule.kt | 6 +- 10 files changed, 182 insertions(+), 73 deletions(-) create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/TakServerIntegration.kt rename core/service/src/{commonTest => nonWebTest}/kotlin/org/meshtastic/core/service/RadioControllerImplTest.kt (100%) rename core/service/src/{commonTest => nonWebTest}/kotlin/org/meshtastic/core/service/RadioControllerRestoreTest.kt (100%) rename core/service/src/{commonTest => nonWebTest}/kotlin/org/meshtastic/core/service/SharedRadioInterfaceServiceLivenessTest.kt (100%) create mode 100644 core/service/src/wasmJsMain/kotlin/org/meshtastic/core/service/NoopTakServerIntegration.kt diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/TakServerIntegration.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/TakServerIntegration.kt new file mode 100644 index 0000000000..0c2b811f60 --- /dev/null +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/TakServerIntegration.kt @@ -0,0 +1,37 @@ +/* + * 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.repository + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.StateFlow + +/** + * The minimal seam [org.meshtastic.core.service.MeshServiceOrchestrator] needs onto the TAK server integration — + * folding `TAKServerManager.isRunning` and `TAKMeshIntegration.start`/`stop` (core:takserver) into one interface so a + * platform with no TAK support (e.g. wasmJs, where core:takserver's TLS listener is a permanent browser-sandbox + * impossibility) can supply a real no-op without core:service depending on core:takserver at all. + */ +interface TakServerIntegration { + /** Whether the TAK server + mesh bridge are currently running. */ + val isRunning: StateFlow + + /** Start the TAK server and the mesh<->CoT bridge on [scope]. */ + fun start(scope: CoroutineScope) + + /** Stop the TAK server and the mesh<->CoT bridge. */ + fun stop() +} diff --git a/core/service/build.gradle.kts b/core/service/build.gradle.kts index 50dbe35556..5bbc01fa54 100644 --- a/core/service/build.gradle.kts +++ b/core/service/build.gradle.kts @@ -23,6 +23,13 @@ plugins { kotlin { android { withHostTest { isIncludeAndroidResources = true } } + // Library module: bare wasmJs(), no browser() (that's for the eventual webApp executable). No custom + // hierarchy group is needed for MAIN: core:takserver (the one dependency with no wasmJs variant) is + // removed entirely below, not relocated, and nothing else in commonMain/androidMain/jvmMain is + // web-hostile — same shape as core:repository, unlike core:ble/core:database/core:prefs/core:network. + @OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class) + wasmJs() + sourceSets { commonMain.dependencies { api(projects.core.repository) @@ -37,7 +44,9 @@ kotlin { implementation(projects.core.prefs) implementation(projects.core.resources) implementation(libs.meshtastic.protobufs) - implementation(projects.core.takserver) + // core:takserver removed (was only used by MeshServiceOrchestrator, which now depends on + // core:repository's TakServerIntegration seam instead — see TakServerIntegration.kt). + // Confirmed via grep: nothing else in this module references org.meshtastic.core.takserver. implementation(libs.jetbrains.lifecycle.runtime) implementation(libs.kotlinx.atomicfu) @@ -60,6 +69,33 @@ kotlin { } } - commonTest.dependencies { implementation(projects.core.testing) } + // TEST only: three of this module's six commonTest files don't compile for wasmJs, for two + // unrelated reasons, both confirmed empirically via a real compileTestKotlinWasmJs run (never + // guessed): (1) SharedRadioInterfaceServiceLivenessTest.kt depends on core:testing, which has no + // wasmJs target (same gap core:ble/core:database/core:network/core:repository each hit). + // (2) RadioControllerImplTest.kt and RadioControllerRestoreTest.kt both crash the Kotlin/Wasm + // compiler ("Serialization of IrErrorType is not supported anymore") -- bisected to exactly these + // two files (the other four compile fine); the one thing both do that no passing file does is + // construct a real RadioControllerImpl, which delegates to four collaborators via `by` (interface + // delegation) alongside Lazy/default-value constructor params. That combination is the leading + // suspect, not confirmed as the exact trigger -- this looks like a genuine Kotlin/Wasm backend + // limitation, not a missing library, and is out of this module's scope to fix. A plain additional + // source set -- not a full applyHierarchyTemplate reset -- keeps this scoped to test only, since + // MAIN needs no split at all (unlike core:ble/core:database/core:prefs/core:network). + // + // jvmTest/androidHostTest are leaf source sets tied 1:1 to their registered target, created + // synchronously and so already exist here — but the shared "iosTest" intermediate the default + // hierarchy template would otherwise provide is NOT materialized this early (its creation is + // deferred; confirmed empirically: `getByName("iosTest")` here throws "KotlinSourceSet with name + // 'iosTest' not found", while jvmTest/androidHostTest resolve fine). Wire the two iOS leaf test + // source sets directly instead. + val nonWebTest by creating { + dependsOn(commonTest.get()) + dependencies { implementation(projects.core.testing) } + } + getByName("jvmTest") { dependsOn(nonWebTest) } + getByName("androidHostTest") { dependsOn(nonWebTest) } + matching { it.name == "iosArm64Test" || it.name == "iosSimulatorArm64Test" } + .configureEach { dependsOn(nonWebTest) } } } diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt index 5a6d225896..4c83865802 100644 --- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt +++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt @@ -42,11 +42,10 @@ import org.meshtastic.core.repository.NodeManager import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.core.repository.ServiceStateWriter import org.meshtastic.core.repository.TakPrefs +import org.meshtastic.core.repository.TakServerIntegration import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.getStringSuspend import org.meshtastic.core.resources.local_network_permission_denied_hint -import org.meshtastic.core.takserver.TAKMeshIntegration -import org.meshtastic.core.takserver.TAKServerManager // English fallback for local_network_permission_denied_hint when resource lookup is unavailable. Kept above the // class KDoc so that KDoc still binds to the class. Internal so tests can assert the exact surfaced text. @@ -70,8 +69,7 @@ class MeshServiceOrchestrator( private val nodeManager: NodeManager, private val messageProcessor: MeshMessageProcessor, private val serviceNotifications: MeshNotificationManager, - private val takServerManager: TAKServerManager, - private val takMeshIntegration: TAKMeshIntegration, + private val takServerIntegration: TakServerIntegration, private val takPrefs: TakPrefs, private val databaseManager: DatabaseManager, private val connectionManager: MeshConnectionManager, @@ -167,12 +165,12 @@ class MeshServiceOrchestrator( // Observe TAK server pref to start/stop takPrefs.isTakServerEnabled .onEach { isEnabled -> - if (isEnabled && !takServerManager.isRunning.value) { + if (isEnabled && !takServerIntegration.isRunning.value) { Logger.i { "TAK Server enabled by preference, starting integration" } - takMeshIntegration.start(newScope) + takServerIntegration.start(newScope) } else if (!isEnabled) { Logger.i { "TAK Server disabled by preference, stopping integration" } - takMeshIntegration.stop() + takServerIntegration.stop() } } .launchIn(newScope) @@ -223,7 +221,7 @@ class MeshServiceOrchestrator( */ fun stop() { Logger.i { "Stopping mesh service orchestrator" } - takMeshIntegration.stop() + takServerIntegration.stop() // Best-effort polite goodbye on service teardown (onDestroy / process shutdown). We launch // on a fresh detached scope — not the orchestrator's per-start scope — so the subsequent // scope.cancel() below doesn't interrupt the short drain delay inside disconnect(). The 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 7b26c2a1e8..b91fe2cd5e 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 @@ -38,26 +38,20 @@ import org.meshtastic.core.common.database.DatabaseManager import org.meshtastic.core.common.util.safeCatchingAll import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.ConnectionState -import org.meshtastic.core.repository.CommandSender -import org.meshtastic.core.repository.MeshConfigHandler import org.meshtastic.core.repository.MeshConnectionManager import org.meshtastic.core.repository.MeshMessageProcessor import org.meshtastic.core.repository.MeshNotificationManager import org.meshtastic.core.repository.NodeManager -import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.core.repository.RadioSessionContext import org.meshtastic.core.repository.ReceivedRadioFrame import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.repository.TakPrefs +import org.meshtastic.core.repository.TakServerIntegration import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.getStringSuspend import org.meshtastic.core.resources.local_network_permission_denied_hint -import org.meshtastic.core.takserver.MeshToCotBroadcaster -import org.meshtastic.core.takserver.TAKMeshIntegration -import org.meshtastic.core.takserver.TAKServerManager import org.meshtastic.proto.FromRadio -import org.meshtastic.proto.LocalModuleConfig import org.meshtastic.proto.MyNodeInfo import kotlin.test.Test import kotlin.test.assertEquals @@ -75,12 +69,9 @@ class MeshServiceOrchestratorTest { private val nodeManager: NodeManager = mock(MockMode.autofill) private val messageProcessor: MeshMessageProcessor = mock(MockMode.autofill) - private val commandSender: CommandSender = mock(MockMode.autofill) - private val meshConfigHandler: MeshConfigHandler = mock(MockMode.autofill) private val serviceNotifications: MeshNotificationManager = mock(MockMode.autofill) - private val takServerManager: TAKServerManager = mock(MockMode.autofill) + private val takServerIntegration: TakServerIntegration = mock(MockMode.autofill) private val takPrefs: TakPrefs = mock(MockMode.autofill) - private val nodeRepository: NodeRepository = mock(MockMode.autofill) private val databaseManager: DatabaseManager = mock(MockMode.autofill) private val connectionManager: MeshConnectionManager = mock(MockMode.autofill) @@ -120,40 +111,8 @@ class MeshServiceOrchestratorTest { { isSessionActive(it.args[0] as RadioSessionContext) } - every { serviceRepository.meshPacketFlow } returns MutableSharedFlow() - every { meshConfigHandler.moduleConfig } returns MutableStateFlow(LocalModuleConfig()) every { takPrefs.isTakServerEnabled } returns takEnabledFlow - every { takPrefs.isMeshToCotEnabled } returns MutableStateFlow(false) - every { takPrefs.takServerChannel } returns MutableStateFlow(0) - every { takServerManager.isRunning } returns takRunningFlow - every { takServerManager.inboundMessages } returns MutableSharedFlow() - every { nodeRepository.myNodeInfo } returns MutableStateFlow(null) - - // Deliberately its own dispatcher, not the class-level testDispatcher: the broadcaster's - // scheduler doesn't need to be the same one driving this test, and a distinct name keeps - // that from reading as though the two are linked. - val broadcasterTestDispatcher = UnconfinedTestDispatcher() - val takMeshIntegration = - TAKMeshIntegration( - takServerManager = takServerManager, - commandSender = commandSender, - serviceRepository = serviceRepository, - meshConfigHandler = meshConfigHandler, - nodeRepository = nodeRepository, - takPrefs = takPrefs, - meshToCotBroadcaster = - MeshToCotBroadcaster( - takServerManager = takServerManager, - nodeRepository = nodeRepository, - takPrefs = takPrefs, - dispatchers = - CoroutineDispatchers( - io = broadcasterTestDispatcher, - main = broadcasterTestDispatcher, - default = broadcasterTestDispatcher, - ), - ), - ) + every { takServerIntegration.isRunning } returns takRunningFlow return MeshServiceOrchestrator( radioInterfaceService = radioInterfaceService, @@ -161,8 +120,7 @@ class MeshServiceOrchestratorTest { nodeManager = nodeManager, messageProcessor = messageProcessor, serviceNotifications = serviceNotifications, - takServerManager = takServerManager, - takMeshIntegration = takMeshIntegration, + takServerIntegration = takServerIntegration, takPrefs = takPrefs, databaseManager = databaseManager, connectionManager = connectionManager, @@ -202,14 +160,23 @@ class MeshServiceOrchestratorTest { // Toggle on takEnabledFlow.value = true - verify { takServerManager.start(any()) } + // Exactly 1, not just "at least 1": nothing before this point could have called start() — the + // orchestrator's collector's only prior emission (isTakServerEnabled's initial `false`) takes the + // stop() branch instead — so this genuinely proves the toggle drove it. + verify(exactly(1)) { takServerIntegration.start(any()) } // Update mock state to reflect it's running takRunningFlow.value = true // Toggle off takEnabledFlow.value = false - verify { takServerManager.stop() } + // Exactly 2, not 1: the real TAKMeshIntegration this mock replaces has its own start()/stop() + // re-entrancy latch, so its initial no-op stop() (from isTakServerEnabled's starting `false` + // value, before "Toggle on" above) never reached the wrapped TAKServerManager it forwards to. A + // bare interface mock has no such latch — the orchestrator really does call stop() twice here + // (once at that initial emission, once for this toggle) — so asserting exactly(1) would fail, + // and a bare `verify { stop() }` would pass without proving *this* toggle caused a call. + verify(exactly(2)) { takServerIntegration.stop() } orchestrator.stop() } @@ -219,14 +186,26 @@ class MeshServiceOrchestratorTest { val takEnabledFlow = MutableStateFlow(false) val takRunningFlow = MutableStateFlow(false) val lifecycleEvents = mutableListOf() - every { takServerManager.start(any()) } calls + // The real TAKMeshIntegration this mock replaces has its own start()/stop() re-entrancy latch + // (a CAS'd AtomicBoolean) so a redundant stop() while already stopped — or a redundant start() + // while already started — is a silent no-op instead of forwarding to TAKServerManager. A bare + // autofill mock has no such state, so replicate the latch here to keep asserting the real + // lifecycle sequence rather than every call the orchestrator happens to make. + var started = false + every { takServerIntegration.start(any()) } calls { - lifecycleEvents += "start" + if (!started) { + started = true + lifecycleEvents += "start" + } Unit } - every { takServerManager.stop() } calls + every { takServerIntegration.stop() } calls { - lifecycleEvents += "stop" + if (started) { + started = false + lifecycleEvents += "stop" + } Unit } val orchestrator = createOrchestrator(takEnabledFlow = takEnabledFlow, takRunningFlow = takRunningFlow) @@ -248,14 +227,23 @@ class MeshServiceOrchestratorTest { val takEnabledFlow = MutableStateFlow(true) val takRunningFlow = MutableStateFlow(false) val lifecycleEvents = mutableListOf() - every { takServerManager.start(any()) } calls + // See testTakServerCanRetryAfterFailedStart for why this latch replicates the real + // TAKMeshIntegration's own start()/stop() re-entrancy guard. + var started = false + every { takServerIntegration.start(any()) } calls { - lifecycleEvents += "start" + if (!started) { + started = true + lifecycleEvents += "start" + } Unit } - every { takServerManager.stop() } calls + every { takServerIntegration.stop() } calls { - lifecycleEvents += "stop" + if (started) { + started = false + lifecycleEvents += "stop" + } Unit } val orchestrator = createOrchestrator(takEnabledFlow = takEnabledFlow, takRunningFlow = takRunningFlow) diff --git a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/RadioControllerImplTest.kt b/core/service/src/nonWebTest/kotlin/org/meshtastic/core/service/RadioControllerImplTest.kt similarity index 100% rename from core/service/src/commonTest/kotlin/org/meshtastic/core/service/RadioControllerImplTest.kt rename to core/service/src/nonWebTest/kotlin/org/meshtastic/core/service/RadioControllerImplTest.kt diff --git a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/RadioControllerRestoreTest.kt b/core/service/src/nonWebTest/kotlin/org/meshtastic/core/service/RadioControllerRestoreTest.kt similarity index 100% rename from core/service/src/commonTest/kotlin/org/meshtastic/core/service/RadioControllerRestoreTest.kt rename to core/service/src/nonWebTest/kotlin/org/meshtastic/core/service/RadioControllerRestoreTest.kt diff --git a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/SharedRadioInterfaceServiceLivenessTest.kt b/core/service/src/nonWebTest/kotlin/org/meshtastic/core/service/SharedRadioInterfaceServiceLivenessTest.kt similarity index 100% rename from core/service/src/commonTest/kotlin/org/meshtastic/core/service/SharedRadioInterfaceServiceLivenessTest.kt rename to core/service/src/nonWebTest/kotlin/org/meshtastic/core/service/SharedRadioInterfaceServiceLivenessTest.kt diff --git a/core/service/src/wasmJsMain/kotlin/org/meshtastic/core/service/NoopTakServerIntegration.kt b/core/service/src/wasmJsMain/kotlin/org/meshtastic/core/service/NoopTakServerIntegration.kt new file mode 100644 index 0000000000..f559ea2e33 --- /dev/null +++ b/core/service/src/wasmJsMain/kotlin/org/meshtastic/core/service/NoopTakServerIntegration.kt @@ -0,0 +1,39 @@ +/* + * 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.service + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import org.koin.core.annotation.Single +import org.meshtastic.core.repository.TakServerIntegration + +// Auto-discovered by CoreServiceModule's existing @ComponentScan("org.meshtastic.core.service") in this target's +// compilation — no separate wasmJs Koin module needed (same "no per-target duplicate scan" reasoning as +// core:database's SingleDatabaseProvider). +// +// core:takserver's TAK server is a raw TLS listener (SSLServerSocket) — a browser sandbox can never accept inbound +// connections, so ATAK/TAK integration is a permanent web impossibility, not a pending feature. This is a real, +// honest no-op, not a stand-in for future work. +@Single(binds = [TakServerIntegration::class]) +internal class NoopTakServerIntegration : TakServerIntegration { + override val isRunning: StateFlow = MutableStateFlow(false) + + override fun start(scope: CoroutineScope) = Unit + + override fun stop() = Unit +} 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 d536a63288..566be00e9d 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 @@ -21,6 +21,7 @@ package org.meshtastic.core.takserver import co.touchlab.kermit.Logger import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.map @@ -34,6 +35,7 @@ import org.meshtastic.core.repository.MeshConfigHandler import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.repository.TakPrefs +import org.meshtastic.core.repository.TakServerIntegration import org.meshtastic.core.takserver.TAKPacketConversion.toCoTMessage import org.meshtastic.core.takserver.TAKPacketConversion.toTAKPacket import org.meshtastic.core.takserver.TAKPacketV2Conversion.toTAKPacketV2 @@ -98,8 +100,13 @@ class TAKMeshIntegration( private val nodeRepository: NodeRepository, private val meshToCotBroadcaster: MeshToCotBroadcaster, private val takPrefs: TakPrefs, -) { - private val isRunning = AtomicBoolean(false) +) : TakServerIntegration { + // This class's own start()/stop() re-entrancy latch (below) — distinct from isRunning, which reports + // the underlying TAK server's actual running state via TAKServerManager. + private val isRunningState = AtomicBoolean(false) + + override val isRunning: StateFlow + get() = takServerManager.isRunning // Immutable list reference replaced atomically in start()/stop(); never mutated in-place. // @Volatile only guarantees visibility of the reference itself — any in-place mutation @@ -115,8 +122,8 @@ class TAKMeshIntegration( // from the single meshPacketFlow collector coroutine (handleMeshPacket), so no locking. private val deliveryDedup = CotDeliveryDedup() - fun start(scope: CoroutineScope) { - if (!isRunning.compareAndSet(expectedValue = false, newValue = true)) return + override fun start(scope: CoroutineScope) { + if (!isRunningState.compareAndSet(expectedValue = false, newValue = true)) return takServerManager.start(scope) @@ -178,8 +185,8 @@ class TAKMeshIntegration( Logger.i { "TAK Mesh Integration started — firmware=$fw, outbound=$proto" } } - fun stop() { - if (!isRunning.compareAndSet(expectedValue = true, newValue = false)) return + override fun stop() { + if (!isRunningState.compareAndSet(expectedValue = true, newValue = false)) return val toCancel = jobs jobs = emptyList() toCancel.forEach(Job::cancel) 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 12836a92c9..12345a97c9 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 @@ -24,6 +24,7 @@ import org.meshtastic.core.repository.MeshConfigHandler import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.repository.TakPrefs +import org.meshtastic.core.repository.TakServerIntegration import org.meshtastic.core.takserver.MeshToCotBroadcaster import org.meshtastic.core.takserver.TAKMeshIntegration import org.meshtastic.core.takserver.TAKServer @@ -46,7 +47,10 @@ class CoreTakServerModule { dispatchers: CoroutineDispatchers, ): MeshToCotBroadcaster = MeshToCotBroadcaster(takServerManager, nodeRepository, takPrefs, dispatchers) - @Single + // Bound under both types: feature/settings' debug TakMeshTestCard resolves the concrete TAKMeshIntegration + // (it needs members beyond TakServerIntegration's minimal seam), MeshServiceOrchestrator resolves + // TakServerIntegration (core:repository) so core:service never has to depend on core:takserver. + @Single(binds = [TAKMeshIntegration::class, TakServerIntegration::class]) fun provideTAKMeshIntegration( takServerManager: TAKServerManager, commandSender: CommandSender,