mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-09-12 21:30:02 -04:00
feat(di): enable Koin compile-time safety (#7127)
This commit is contained in:
1 parent
277d94033c
commit
2ea6c2e2fb
24 files changed
+838
-446
No files matched your search
@@ -106,7 +106,7 @@ jobs:
|
||||
install_jetbrains_jdk: 'true'
|
||||
|
||||
- name: Lint, Analysis & KMP Smoke Compile
|
||||
run: ./gradlew spotlessCheck detekt androidApp:lintFdroidDebug androidApp:lintGoogleDebug core:barcode:lintFdroidDebug core:barcode:lintGoogleDebug kmpSmokeCompile -Pci=true --continue
|
||||
run: ./gradlew spotlessCheck detekt androidApp:lintFdroidDebug androidApp:lintGoogleDebug core:barcode:lintFdroidDebug core:barcode:lintGoogleDebug -Pci=true --continue
|
||||
|
||||
# ── Screenshot Test Validation ──────────────────────────────────────
|
||||
screenshot-check:
|
||||
@@ -197,7 +197,9 @@ jobs:
|
||||
# These lists are hand-maintained; pull-request.yml's check-changes job
|
||||
# guards them against drift (every module in settings.gradle.kts with test
|
||||
# sources must appear here or be explicitly exempted).
|
||||
# shard-core: remaining core:* KMP module tests (allTests)
|
||||
# shard-core: remaining core:* KMP module tests (allTests), plus kmpSmokeCompile,
|
||||
# which is the only iOS compile :core:di, :core:nfc and :core:resources
|
||||
# get. It lived in lint-check until a cold build there blew the 30m budget.
|
||||
# shard-feature: feature:* KMP module tests + :core:service
|
||||
# shard-app: Pure-Android/JVM tests (androidApp, desktopApp,
|
||||
# core:barcode, feature:widget)
|
||||
@@ -230,6 +232,7 @@ jobs:
|
||||
:core:takserver:allTests
|
||||
:core:testing:allTests
|
||||
:core:ui:allTests
|
||||
kmpSmokeCompile
|
||||
kover: >-
|
||||
:core:ble:koverXmlReport
|
||||
:core:common:koverXmlReport
|
||||
|
||||
@@ -12,7 +12,7 @@ This skill covers dependency injection (Koin Annotations 4.2.x) and JetBrains Na
|
||||
4. **Resolution:** Resolve app-layer wrappers via `koinViewModel()` or injected bindings within Compose navigation graphs.
|
||||
|
||||
### Anti-Patterns
|
||||
- **A1 Module Compile Safety:** Do **not** enable `compileSafety`. It is a single boolean that enables A1 per-module checks — there is no separate A3 full-graph mode. Runtime graph verification is handled by `KoinVerificationTest` and `DesktopKoinTest` instead.
|
||||
- **Compile Safety Outside An Entry Point:** Do **not** enable `compileSafety` on a library module. Validation is whole-graph and runs at the `@KoinApplication` entry point, so a library validates against a graph it cannot see and reports `KOIN-D003` for definitions its consumers supply. `KoinConventionPlugin` enables it only for the modules in `KOIN_ENTRY_POINTS`.
|
||||
- **Default Parameters:** Do **not** expect Koin to inject default parameters automatically. The K2 plugin's `skipDefaultValues = true` behavior skips parameters with default Kotlin values.
|
||||
|
||||
### Koin Startup Pattern (K2 Compiler Plugin)
|
||||
@@ -31,7 +31,9 @@ startKoin<AndroidKoinApp> {
|
||||
- `@KoinApplication` goes on a **dedicated bootstrap object**, not on a `@Module` class.
|
||||
- `startKoin<T>()` (from `org.koin.plugin.module.dsl`) is a compiler plugin stub — if the plugin isn't applied, it throws `NotImplementedError`.
|
||||
- `stopKoin()` uses the standard runtime API (`org.koin.core.context.stopKoin`).
|
||||
- `compileSafety` must stay **disabled** — it enables A1 per-module checks that break our inverted-dependency architecture. There is no separate A3 full-graph flag.
|
||||
- `compileSafety` is **on at the entry points only** (`:androidApp`, `:desktopApp`). Plugin 1.1.0 replaced per-module validation with whole-graph validation, so the flag is only meaningful where the graph is assembled. A new app target must be added to `KOIN_ENTRY_POINTS` or it is never validated.
|
||||
- A definition two `@Module(includes = ...)` levels below the entry point is invisible to the index. The flavor modules carry `@Configuration` as well as their `includes` for this reason; dropping the `includes` removes them from the **runtime** graph, which `KoinVerificationTest` catches.
|
||||
- Hand-written DSL `module { }` definitions are not reachable by the assembled graph, which is why `:desktopApp` uses `@Module` classes.
|
||||
|
||||
## Navigation 3
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
*/
|
||||
package org.meshtastic.app.di
|
||||
|
||||
import org.koin.core.annotation.Configuration
|
||||
import org.koin.core.annotation.Module
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.feature.discovery.ai.AlgorithmicSummaryProvider
|
||||
@@ -29,6 +30,7 @@ import org.meshtastic.feature.messaging.translation.NoOpMessageTranslator
|
||||
|
||||
/** Provides keyword-only fallback AI assistant for the F-Droid flavor (no on-device model). */
|
||||
@Module
|
||||
@Configuration
|
||||
class FdroidAiModule {
|
||||
@Single fun aiDocAssistant(fallback: KeywordFallbackAssistant): AIDocAssistant = fallback
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
package org.meshtastic.app.di
|
||||
|
||||
import android.content.Context
|
||||
import org.koin.core.annotation.Configuration
|
||||
import org.koin.core.annotation.Module
|
||||
import org.koin.core.annotation.Named
|
||||
import org.koin.core.annotation.Single
|
||||
@@ -29,6 +30,7 @@ import org.meshtastic.core.repository.AppFunctionsPrefs
|
||||
|
||||
/** Provides AppFunctions integration for the Google flavor. */
|
||||
@Module
|
||||
@Configuration
|
||||
class AppFunctionsModule {
|
||||
@Single
|
||||
fun meshtasticAppFunctions(provider: AiFunctionProvider): MeshtasticAppFunctions = MeshtasticAppFunctions(provider)
|
||||
|
||||
@@ -19,6 +19,7 @@ package org.meshtastic.app.di
|
||||
import android.content.Context
|
||||
import okio.FileSystem
|
||||
import okio.Path.Companion.toOkioPath
|
||||
import org.koin.core.annotation.Configuration
|
||||
import org.koin.core.annotation.Module
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.app.ai.GeminiNanoDocAssistant
|
||||
@@ -41,6 +42,7 @@ import org.meshtastic.feature.messaging.translation.MessageTranslationService
|
||||
|
||||
/** Provides the on-device Gemini Nano AI assistant for the Google flavor. */
|
||||
@Module
|
||||
@Configuration
|
||||
class GoogleAiModule {
|
||||
@Single
|
||||
fun aiDocAssistant(
|
||||
|
||||
@@ -23,11 +23,13 @@ import androidx.datastore.preferences.preferencesDataStoreFile
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import org.koin.core.annotation.ComponentScan
|
||||
import org.koin.core.annotation.Configuration
|
||||
import org.koin.core.annotation.Module
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
|
||||
@Module
|
||||
@Configuration
|
||||
@ComponentScan("org.meshtastic.app.map")
|
||||
class GoogleMapsKoinModule {
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.koin.android.ext.android.get
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.androidx.workmanager.factory.KoinWorkerFactory
|
||||
import org.koin.androidx.workmanager.koin.workManagerFactory
|
||||
import org.koin.plugin.module.dsl.startKoin
|
||||
import org.meshtastic.app.di.AndroidKoinApp
|
||||
@@ -247,6 +248,10 @@ open class MeshUtilApplication :
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Dead unless WorkManager falls back to on-demand init: [workManagerFactory] initializes it eagerly during
|
||||
* [startKoin]. Constructed rather than resolved because nothing declares a [WorkerFactory] in the graph.
|
||||
*/
|
||||
override val workManagerConfiguration: Configuration
|
||||
get() = Configuration.Builder().setWorkerFactory(get()).build()
|
||||
get() = Configuration.Builder().setWorkerFactory(KoinWorkerFactory()).build()
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.di
|
||||
|
||||
import org.koin.plugin.module.dsl.koinApplication
|
||||
import org.meshtastic.feature.docs.translation.DocTranslationService
|
||||
import org.meshtastic.feature.docs.translation.NoOpDocTranslator
|
||||
import org.meshtastic.feature.messaging.translation.MessageTranslationService
|
||||
import org.meshtastic.feature.messaging.translation.NoOpMessageTranslator
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertIs
|
||||
|
||||
class FdroidBindingWinnerTest {
|
||||
|
||||
@Test
|
||||
fun `flavor bindings win over the shared graph`() {
|
||||
// The flavor modules are @Configuration, which loads them before the ones listed in @KoinApplication, and
|
||||
// Koin is last-wins. KoinVerificationTest only checks definitions exist, never which one survives, so a
|
||||
// core-level default added later would silently take these over. Only the Fdroid no-ops are asserted:
|
||||
// the Google flavor's MlKitMessageTranslator builds a RemoteModelManager in a field initializer.
|
||||
val app = koinApplication<AndroidKoinApp>()
|
||||
try {
|
||||
val koin = app.koin
|
||||
assertIs<NoOpMessageTranslator>(koin.get<MessageTranslationService>())
|
||||
assertIs<NoOpDocTranslator>(koin.get<DocTranslationService>())
|
||||
} finally {
|
||||
app.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,13 +29,10 @@ class KoinConventionPlugin : Plugin<Project> {
|
||||
|
||||
// Configure Koin K2 Compiler Plugin (1.1.0+)
|
||||
extensions.configure(KoinGradleExtension::class.java) {
|
||||
// 1.1.0 moved validation to the entry points, which suits this graph's shape, but
|
||||
// its definition index still can't resolve two structural patterns here: modules
|
||||
// reached through FlavorModule's nested `includes` are invisible to it, and DSL
|
||||
// declarations (desktopApp's whole root, workManagerFactory()) are never indexed at
|
||||
// all. Every entry point therefore fails on definitions that exist. Runtime graph
|
||||
// verification is handled by KoinVerificationTest instead.
|
||||
compileSafety.set(false)
|
||||
// Validation is whole-graph and happens at the @KoinApplication entry points, so
|
||||
// it is only enabled there. A library module validates locally, cannot see the
|
||||
// assembled graph, and reports KOIN-D003 on definitions its consumers supply.
|
||||
compileSafety.set(path in KOIN_ENTRY_POINTS)
|
||||
}
|
||||
|
||||
val koinAnnotations = libs.findLibrary("koin-annotations").get()
|
||||
@@ -80,3 +77,6 @@ class KoinConventionPlugin : Plugin<Project> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Modules declaring a `@KoinApplication`. A new app target must be added here or it is never validated. */
|
||||
private val KOIN_ENTRY_POINTS = setOf(":androidApp", ":desktopApp")
|
||||
@@ -39,7 +39,7 @@ import androidx.compose.ui.window.Notification as ComposeNotification
|
||||
* Native sends run on [Dispatchers.IO] within the suspending [dispatch] call, so the returned Boolean reflects the
|
||||
* resolved outcome (native success, or acceptance by the tray fallback) rather than an optimistic guess.
|
||||
*
|
||||
* Registered manually in `desktopPlatformStubsModule` -- do **not** add `@Single` to avoid double-registration with the
|
||||
* Registered manually in `DesktopRuntimeModule` -- do **not** add `@Single` to avoid double-registration with the
|
||||
* `@ComponentScan("org.meshtastic.desktop")` in [DesktopDiModule][org.meshtastic.desktop.di.DesktopDiModule].
|
||||
*/
|
||||
class DesktopNotificationManager(
|
||||
|
||||
@@ -71,8 +71,9 @@ import org.jetbrains.compose.resources.getString
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.koin.compose.koinInject
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.koin.core.context.startKoin
|
||||
import org.koin.core.context.GlobalContext
|
||||
import org.koin.core.context.stopKoin
|
||||
import org.koin.plugin.module.dsl.startKoin
|
||||
import org.maplibre.compose.desktop.ProvideMapPresentationHost
|
||||
import org.maplibre.compose.desktop.rememberAwtComposeMapPresentationHost
|
||||
import org.meshtastic.core.common.BuildConfigProvider
|
||||
@@ -105,11 +106,11 @@ import org.meshtastic.core.ui.util.LocalTracerouteMapProvider
|
||||
import org.meshtastic.core.ui.util.rememberOpenUrl
|
||||
import org.meshtastic.core.ui.viewmodel.UIViewModel
|
||||
import org.meshtastic.desktop.data.DesktopPreferencesDataSource
|
||||
import org.meshtastic.desktop.di.desktopModule
|
||||
import org.meshtastic.desktop.di.desktopPlatformModule
|
||||
import org.meshtastic.desktop.di.DesktopKoinApp
|
||||
import org.meshtastic.desktop.map.DesktopTracerouteMap
|
||||
import org.meshtastic.desktop.map.desktopMapViewProvider
|
||||
import org.meshtastic.desktop.notification.DesktopOS
|
||||
import org.meshtastic.desktop.notification.NativeNotificationSender
|
||||
import org.meshtastic.desktop.ui.DesktopMainScreen
|
||||
import org.meshtastic.feature.map.MapScreen
|
||||
import org.meshtastic.feature.map.SharedMapViewModel
|
||||
@@ -146,6 +147,7 @@ private fun svgPainterResource(path: String, density: Density): Painter = rememb
|
||||
|
||||
@OptIn(ExperimentalCoilApi::class)
|
||||
fun main(args: Array<String>) {
|
||||
installQuitHandler()
|
||||
// exitProcessOnExit = false is what makes the shutdown block below reachable at all: with the default (true),
|
||||
// application() calls System.exit(0) itself as soon as the Compose loop ends, and control never returns here.
|
||||
// Do not "simplify" this back to a bare application {} — that silently disables every teardown that follows.
|
||||
@@ -156,8 +158,9 @@ fun main(args: Array<String>) {
|
||||
// Keep console output and also capture into the in-memory buffer the Debug screen views/exports.
|
||||
Logger.setLogWriters(listOf(platformLogWriter(), InMemoryLogBuffer))
|
||||
Logger.i { "Meshtastic Desktop — Starting" }
|
||||
startKoin { modules(desktopPlatformModule(), desktopModule()) }
|
||||
startKoin<DesktopKoinApp> {}
|
||||
}
|
||||
LaunchedEffect(Unit) { publishExitApplication(::exitApplication) }
|
||||
val systemLocale = remember { Locale.getDefault() }
|
||||
val uiViewModel = remember { koinApp.koin.get<UIViewModel>() }
|
||||
val httpClient = remember { koinApp.koin.get<HttpClient>() }
|
||||
@@ -167,9 +170,11 @@ fun main(args: Array<String>) {
|
||||
ThemeAndLocaleProvider(uiViewModel)
|
||||
}
|
||||
|
||||
// Runs on the main thread with the UI already gone. Closing the container fires the `onClose` callbacks that
|
||||
// release native handles — currently libnotify's process-wide state in LinuxNotificationSender. Guarded because
|
||||
// a teardown failure must not turn a clean quit into a non-zero exit.
|
||||
// Runs on the main thread with the UI already gone. The native sender must be closed before the container goes,
|
||||
// because it owns libnotify's process-wide handle on Linux. Both guarded: a teardown failure must not turn a
|
||||
// clean quit into a non-zero exit.
|
||||
runCatching { (GlobalContext.get().get<NativeNotificationSender>() as? AutoCloseable)?.close() }
|
||||
.onFailure { Logger.w(it) { "Closing the native notification sender failed during shutdown" } }
|
||||
runCatching { stopKoin() }.onFailure { Logger.w(it) { "stopKoin() failed during shutdown" } }
|
||||
Logger.i { "Meshtastic Desktop — Stopped" }
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.desktop
|
||||
|
||||
import java.awt.Desktop
|
||||
import java.awt.desktop.QuitResponse
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
/** Published by the composition so [installQuitHandler] can end the Compose loop from AppKit's quit thread. */
|
||||
private val exitApplicationRef = AtomicReference<(() -> Unit)?>(null)
|
||||
|
||||
/** A quit that arrived before the composition published its callback, held until it can be honoured. */
|
||||
private val pendingQuitRef = AtomicReference<QuitResponse?>(null)
|
||||
|
||||
/** Hands the running composition's `exitApplication` to [installQuitHandler], honouring a quit that beat it here. */
|
||||
internal fun publishExitApplication(exitApplication: () -> Unit) {
|
||||
exitApplicationRef.set(exitApplication)
|
||||
pendingQuitRef.getAndSet(null)?.let { response ->
|
||||
exitApplication()
|
||||
startQuitWatchdog()
|
||||
response.cancelQuit()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes macOS's AppKit quit (Cmd+Q and the app menu) into `exitApplication` so the shutdown in `main` runs at all.
|
||||
* Without it AppKit kills the JVM directly and every teardown is skipped. CMP-6359, still open.
|
||||
*/
|
||||
internal fun installQuitHandler() {
|
||||
if (!Desktop.isDesktopSupported()) return
|
||||
val desktop = Desktop.getDesktop()
|
||||
if (!desktop.isSupported(Desktop.Action.APP_QUIT_HANDLER)) return
|
||||
desktop.setQuitHandler { _, response ->
|
||||
val exitApplication = exitApplicationRef.get()
|
||||
if (exitApplication == null) {
|
||||
// Quitting before the composition published its callback must not skip the shutdown: hold the
|
||||
// response until publishExitApplication can run it.
|
||||
pendingQuitRef.set(response)
|
||||
} else {
|
||||
// Cancels the native quit because the shutdown this unblocks ends in exitProcess(). The watchdog is
|
||||
// what stops a Compose loop that never returns from leaving the app un-quittable.
|
||||
exitApplication()
|
||||
startQuitWatchdog()
|
||||
response.cancelQuit()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Quitting must not depend on the Compose loop returning: force the exit if the shutdown has not run in time. */
|
||||
private fun startQuitWatchdog() = thread(isDaemon = true, name = "quit-watchdog") {
|
||||
Thread.sleep(QUIT_TIMEOUT_MS)
|
||||
Runtime.getRuntime().halt(0)
|
||||
}
|
||||
|
||||
private const val QUIT_TIMEOUT_MS = 5_000L
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.desktop.di
|
||||
|
||||
import org.koin.core.annotation.Module
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.feature.discovery.ai.AlgorithmicSummaryProvider
|
||||
import org.meshtastic.feature.discovery.ai.DiscoverySummaryAiProvider
|
||||
import org.meshtastic.feature.docs.ai.AIDocAssistant
|
||||
import org.meshtastic.feature.docs.ai.KeywordFallbackAssistant
|
||||
import org.meshtastic.feature.docs.translation.DocTranslationService
|
||||
import org.meshtastic.feature.docs.translation.NoOpDocTranslator
|
||||
import org.meshtastic.feature.messaging.translation.MessageTranslationService
|
||||
import org.meshtastic.feature.messaging.translation.NoOpMessageTranslator
|
||||
|
||||
/** Keyword-only fallback AI assistant and no-op translators for Desktop (no on-device model). */
|
||||
@Module
|
||||
class DesktopAiModule {
|
||||
|
||||
@Single fun aiDocAssistant(fallback: KeywordFallbackAssistant): AIDocAssistant = fallback
|
||||
|
||||
@Single fun discoverySummaryAiProvider(fallback: AlgorithmicSummaryProvider): DiscoverySummaryAiProvider = fallback
|
||||
|
||||
@Single fun docTranslationService(): DocTranslationService = NoOpDocTranslator()
|
||||
|
||||
@Single fun messageTranslationService(): MessageTranslationService = NoOpMessageTranslator()
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.desktop.di
|
||||
|
||||
import org.koin.core.annotation.KoinApplication
|
||||
|
||||
/**
|
||||
* Root Koin bootstrap for Desktop. The K2 compiler plugin uses this to discover the full module graph when
|
||||
* [org.koin.plugin.module.dsl.startKoin] is called with this type parameter.
|
||||
*/
|
||||
@KoinApplication(modules = [DesktopKoinModule::class])
|
||||
object DesktopKoinApp
|
||||
@@ -14,258 +14,70 @@
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
@file:Suppress(
|
||||
"ktlint:standard:no-unused-imports",
|
||||
) // Koin K2 compiler plugin generates aliased module extensions referenced in desktopModule()
|
||||
|
||||
package org.meshtastic.desktop.di
|
||||
|
||||
// Generated Koin module extensions from core KMP modules
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.java.Java
|
||||
import io.ktor.client.plugins.DefaultRequest
|
||||
import io.ktor.client.plugins.HttpRequestRetry
|
||||
import io.ktor.client.plugins.HttpTimeout
|
||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.client.plugins.logging.LogLevel
|
||||
import io.ktor.client.plugins.logging.Logging
|
||||
import io.ktor.client.request.header
|
||||
import io.ktor.http.HttpHeaders
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.koin.dsl.module
|
||||
import org.koin.dsl.onClose
|
||||
import org.meshtastic.core.common.di.ServiceScope
|
||||
import org.meshtastic.core.data.datasource.BundledAssetReader
|
||||
import org.meshtastic.core.network.HttpClientDefaults
|
||||
import org.meshtastic.core.network.KermitHttpLogger
|
||||
import org.meshtastic.core.network.configureDefaultRetry
|
||||
import org.meshtastic.core.network.repository.MQTTRepository
|
||||
import org.meshtastic.core.network.service.ApiService
|
||||
import org.meshtastic.core.network.service.ApiServiceImpl
|
||||
import org.meshtastic.core.repository.AdminController
|
||||
import org.meshtastic.core.repository.AppWidgetUpdater
|
||||
import org.meshtastic.core.repository.ConnectionStateProvider
|
||||
import org.meshtastic.core.repository.LocationRepository
|
||||
import org.meshtastic.core.repository.MeshLocationManager
|
||||
import org.meshtastic.core.repository.MeshNotificationManager
|
||||
import org.meshtastic.core.repository.MeshWorkerManager
|
||||
import org.meshtastic.core.repository.MessageQueue
|
||||
import org.meshtastic.core.repository.MessagingController
|
||||
import org.meshtastic.core.repository.NeighborInfoResponseProvider
|
||||
import org.meshtastic.core.repository.NodeController
|
||||
import org.meshtastic.core.repository.NotificationManager
|
||||
import org.meshtastic.core.repository.PlatformAnalytics
|
||||
import org.meshtastic.core.repository.QueryController
|
||||
import org.meshtastic.core.repository.RadioController
|
||||
import org.meshtastic.core.repository.RadioTransportFactory
|
||||
import org.meshtastic.core.repository.ServiceRepository
|
||||
import org.meshtastic.core.repository.ServiceStateWriter
|
||||
import org.meshtastic.core.repository.TracerouteResponseProvider
|
||||
import org.meshtastic.core.service.RadioControllerImpl
|
||||
import org.meshtastic.core.service.ServiceRepositoryImpl
|
||||
import org.meshtastic.desktop.DesktopBuildConfig
|
||||
import org.meshtastic.desktop.DesktopNotificationManager
|
||||
import org.meshtastic.desktop.notification.DesktopMeshNotificationManager
|
||||
import org.meshtastic.desktop.notification.DesktopOS
|
||||
import org.meshtastic.desktop.notification.LinuxNotificationSender
|
||||
import org.meshtastic.desktop.notification.MacOSNotificationSender
|
||||
import org.meshtastic.desktop.notification.NativeNotificationSender
|
||||
import org.meshtastic.desktop.notification.WindowsNotificationSender
|
||||
import org.meshtastic.desktop.radio.DesktopMessageQueue
|
||||
import org.meshtastic.desktop.radio.DesktopRadioTransportFactory
|
||||
import org.meshtastic.desktop.stub.NoopAppWidgetUpdater
|
||||
import org.meshtastic.desktop.stub.NoopCompassHeadingProvider
|
||||
import org.meshtastic.desktop.stub.NoopLocationRepository
|
||||
import org.meshtastic.desktop.stub.NoopMQTTRepository
|
||||
import org.meshtastic.desktop.stub.NoopMagneticFieldProvider
|
||||
import org.meshtastic.desktop.stub.NoopMeshLocationManager
|
||||
import org.meshtastic.desktop.stub.NoopMeshWorkerManager
|
||||
import org.meshtastic.desktop.stub.NoopPhoneLocationProvider
|
||||
import org.meshtastic.desktop.stub.NoopPlatformAnalytics
|
||||
import org.meshtastic.feature.discovery.ai.AlgorithmicSummaryProvider
|
||||
import org.meshtastic.feature.discovery.ai.DiscoverySummaryAiProvider
|
||||
import org.meshtastic.feature.docs.ai.AIDocAssistant
|
||||
import org.meshtastic.feature.docs.ai.KeywordFallbackAssistant
|
||||
import org.meshtastic.feature.docs.translation.DocTranslationService
|
||||
import org.meshtastic.feature.docs.translation.NoOpDocTranslator
|
||||
import org.meshtastic.feature.messaging.translation.MessageTranslationService
|
||||
import org.meshtastic.feature.messaging.translation.NoOpMessageTranslator
|
||||
import org.meshtastic.feature.node.compass.CompassHeadingProvider
|
||||
import org.meshtastic.feature.node.compass.MagneticFieldProvider
|
||||
import org.meshtastic.feature.node.compass.PhoneLocationProvider
|
||||
import org.meshtastic.core.ble.di.module as coreBleModule
|
||||
import org.meshtastic.core.common.di.module as coreCommonModule
|
||||
import org.meshtastic.core.data.di.module as coreDataModule
|
||||
import org.meshtastic.core.database.di.module as coreDatabaseModule
|
||||
import org.meshtastic.core.datastore.di.module as coreDatastoreModule
|
||||
import org.meshtastic.core.di.di.module as coreDiModule
|
||||
import org.meshtastic.core.domain.di.module as coreDomainModule
|
||||
import org.meshtastic.core.network.di.module as coreNetworkModule
|
||||
import org.meshtastic.core.prefs.di.module as corePrefsModule
|
||||
import org.meshtastic.core.repository.di.module as coreRepositoryModule
|
||||
import org.meshtastic.core.service.di.module as coreServiceModule
|
||||
import org.meshtastic.core.takserver.di.module as coreTakServerModule
|
||||
import org.meshtastic.core.ui.di.module as coreUiModule
|
||||
import org.meshtastic.desktop.di.module as desktopDiModule
|
||||
import org.meshtastic.feature.connections.di.module as featureConnectionsModule
|
||||
import org.meshtastic.feature.discovery.di.module as featureDiscoveryModule
|
||||
import org.meshtastic.feature.docs.di.module as featureDocsModule
|
||||
import org.meshtastic.feature.firmware.di.module as featureFirmwareModule
|
||||
import org.meshtastic.feature.intro.di.module as featureIntroModule
|
||||
import org.meshtastic.feature.map.di.module as featureMapModule
|
||||
import org.meshtastic.feature.messaging.di.module as featureMessagingModule
|
||||
import org.meshtastic.feature.node.di.module as featureNodeModule
|
||||
import org.meshtastic.feature.settings.di.module as featureSettingsModule
|
||||
import org.meshtastic.feature.wifiprovision.di.module as featureWifiProvisionModule
|
||||
import org.koin.core.annotation.Module
|
||||
import org.meshtastic.core.ble.di.CoreBleModule
|
||||
import org.meshtastic.core.common.di.CoreCommonModule
|
||||
import org.meshtastic.core.data.di.CoreDataModule
|
||||
import org.meshtastic.core.database.di.CoreDatabaseModule
|
||||
import org.meshtastic.core.datastore.di.CoreDatastoreModule
|
||||
import org.meshtastic.core.network.di.CoreNetworkModule
|
||||
import org.meshtastic.core.prefs.di.CorePrefsModule
|
||||
import org.meshtastic.core.service.di.CoreServiceModule
|
||||
import org.meshtastic.core.takserver.di.CoreTakServerModule
|
||||
import org.meshtastic.core.ui.di.CoreUiModule
|
||||
import org.meshtastic.feature.connections.di.FeatureConnectionsModule
|
||||
import org.meshtastic.feature.discovery.di.FeatureDiscoveryModule
|
||||
import org.meshtastic.feature.docs.di.FeatureDocsModule
|
||||
import org.meshtastic.feature.firmware.di.FeatureFirmwareModule
|
||||
import org.meshtastic.feature.intro.di.FeatureIntroModule
|
||||
import org.meshtastic.feature.map.di.FeatureMapModule
|
||||
import org.meshtastic.feature.messaging.di.FeatureMessagingModule
|
||||
import org.meshtastic.feature.node.di.FeatureNodeModule
|
||||
import org.meshtastic.feature.settings.di.FeatureSettingsModule
|
||||
import org.meshtastic.feature.wifiprovision.di.FeatureWifiProvisionModule
|
||||
|
||||
/**
|
||||
* Koin module for the Desktop target.
|
||||
* Aggregate Koin module for the Desktop target — the single entry [DesktopKoinApp] points at.
|
||||
*
|
||||
* Includes the generated Koin K2 modules from core KMP libraries (which provide real implementations of prefs, data
|
||||
* repositories, managers, datastore data sources, use cases, and ViewModels from `commonMain`).
|
||||
*
|
||||
* Only truly platform-specific interfaces are stubbed here — things that require Android APIs (BLE/USB transport,
|
||||
* notifications, WorkManager, location services, broadcasts, widgets).
|
||||
*
|
||||
* Platform infrastructure (DataStores, Room database, Lifecycle) is provided by [desktopPlatformModule].
|
||||
* Includes the `commonMain` module classes from the core KMP libraries (prefs, data repositories, managers, datastore
|
||||
* data sources, use cases, and ViewModels), then the desktop-specific modules. The desktop modules come last so their
|
||||
* bindings win over any `commonMain` default they deliberately replace.
|
||||
*/
|
||||
fun desktopModule() = module {
|
||||
// Include generated Koin K2 modules from core KMP libraries (commonMain implementations)
|
||||
includes(
|
||||
org.meshtastic.core.di.di.CoreDiModule().coreDiModule(),
|
||||
org.meshtastic.core.common.di.CoreCommonModule().coreCommonModule(),
|
||||
org.meshtastic.core.datastore.di.CoreDatastoreModule().coreDatastoreModule(),
|
||||
org.meshtastic.core.prefs.di.CorePrefsModule().corePrefsModule(),
|
||||
org.meshtastic.core.database.di.CoreDatabaseModule().coreDatabaseModule(),
|
||||
org.meshtastic.core.data.di.CoreDataModule().coreDataModule(),
|
||||
org.meshtastic.core.domain.di.CoreDomainModule().coreDomainModule(),
|
||||
org.meshtastic.core.repository.di.CoreRepositoryModule().coreRepositoryModule(),
|
||||
org.meshtastic.core.network.di.CoreNetworkModule().coreNetworkModule(),
|
||||
org.meshtastic.core.ble.di.CoreBleModule().coreBleModule(),
|
||||
org.meshtastic.core.ui.di.CoreUiModule().coreUiModule(),
|
||||
org.meshtastic.core.service.di.CoreServiceModule().coreServiceModule(),
|
||||
org.meshtastic.core.takserver.di.CoreTakServerModule().coreTakServerModule(),
|
||||
org.meshtastic.feature.settings.di.FeatureSettingsModule().featureSettingsModule(),
|
||||
org.meshtastic.feature.node.di.FeatureNodeModule().featureNodeModule(),
|
||||
org.meshtastic.feature.messaging.di.FeatureMessagingModule().featureMessagingModule(),
|
||||
org.meshtastic.feature.connections.di.FeatureConnectionsModule().featureConnectionsModule(),
|
||||
org.meshtastic.feature.map.di.FeatureMapModule().featureMapModule(),
|
||||
org.meshtastic.feature.discovery.di.FeatureDiscoveryModule().featureDiscoveryModule(),
|
||||
org.meshtastic.feature.firmware.di.FeatureFirmwareModule().featureFirmwareModule(),
|
||||
org.meshtastic.feature.docs.di.FeatureDocsModule().featureDocsModule(),
|
||||
org.meshtastic.feature.intro.di.FeatureIntroModule().featureIntroModule(),
|
||||
org.meshtastic.feature.wifiprovision.di.FeatureWifiProvisionModule().featureWifiProvisionModule(),
|
||||
org.meshtastic.desktop.di.DesktopDiModule().desktopDiModule(),
|
||||
desktopPlatformStubsModule(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stubs for truly platform-specific interfaces that have no `commonMain` implementation. These require Android APIs
|
||||
* (BLE/USB transport, notifications, WorkManager, location, broadcasts, widgets).
|
||||
*/
|
||||
@Suppress("LongMethod")
|
||||
private fun desktopPlatformStubsModule() = module {
|
||||
single<ServiceRepository> { ServiceRepositoryImpl() }
|
||||
single<ConnectionStateProvider> { get<ServiceRepository>() }
|
||||
single<TracerouteResponseProvider> { get<ServiceRepository>() }
|
||||
single<NeighborInfoResponseProvider> { get<ServiceRepository>() }
|
||||
single<ServiceStateWriter> { get<ServiceRepository>() }
|
||||
single<RadioTransportFactory> {
|
||||
DesktopRadioTransportFactory(
|
||||
dispatchers = get(),
|
||||
scanner = get(),
|
||||
bluetoothRepository = get(),
|
||||
connectionFactory = get(),
|
||||
)
|
||||
}
|
||||
single<RadioController> {
|
||||
RadioControllerImpl(
|
||||
serviceRepository = get(),
|
||||
nodeRepository = get(),
|
||||
commandSender = get(),
|
||||
nodeManager = get(),
|
||||
radioInterfaceService = get(),
|
||||
locationManager = get(),
|
||||
packetRepository = lazy { get() },
|
||||
dataHandler = lazy { get() },
|
||||
analytics = get(),
|
||||
meshPrefs = get(),
|
||||
uiPrefs = get(),
|
||||
databaseManager = get(),
|
||||
notificationManager = get(),
|
||||
messageProcessor = lazy { get() },
|
||||
radioConfigRepository = get(),
|
||||
scope = get<ServiceScope>(),
|
||||
)
|
||||
}
|
||||
single<AdminController> { get<RadioController>() }
|
||||
single<MessagingController> { get<RadioController>() }
|
||||
single<NodeController> { get<RadioController>() }
|
||||
single<QueryController> { get<RadioController>() }
|
||||
single<NativeNotificationSender> {
|
||||
when (DesktopOS.current()) {
|
||||
DesktopOS.Linux -> LinuxNotificationSender()
|
||||
DesktopOS.MacOS -> MacOSNotificationSender()
|
||||
DesktopOS.Windows -> WindowsNotificationSender()
|
||||
}
|
||||
}
|
||||
.onClose { sender ->
|
||||
// Only the Linux sender holds a native handle; the others are stateless. `stopKoin()` in Main.kt is what
|
||||
// drives this, after the Compose application loop has returned.
|
||||
(sender as? AutoCloseable)?.close()
|
||||
}
|
||||
single { DesktopNotificationManager(prefs = get(), nativeSender = get()) }
|
||||
single<NotificationManager> { get<DesktopNotificationManager>() }
|
||||
single<MeshNotificationManager> { DesktopMeshNotificationManager(notificationManager = get()) }
|
||||
single<PlatformAnalytics> { NoopPlatformAnalytics() }
|
||||
single<AppWidgetUpdater> { NoopAppWidgetUpdater() }
|
||||
single<MeshWorkerManager> { NoopMeshWorkerManager() }
|
||||
single<MessageQueue> { DesktopMessageQueue(packetRepository = get(), radioController = get(), dispatchers = get()) }
|
||||
single<MeshLocationManager> { NoopMeshLocationManager() }
|
||||
single<LocationRepository> { NoopLocationRepository() }
|
||||
single<MQTTRepository> { NoopMQTTRepository() }
|
||||
single<CompassHeadingProvider> { NoopCompassHeadingProvider() }
|
||||
single<PhoneLocationProvider> { NoopPhoneLocationProvider() }
|
||||
single<MagneticFieldProvider> { NoopMagneticFieldProvider() }
|
||||
|
||||
// AI assistant: keyword-only fallback on desktop (no on-device model)
|
||||
single<AIDocAssistant> { get<KeywordFallbackAssistant>() }
|
||||
single<DiscoverySummaryAiProvider> { get<AlgorithmicSummaryProvider>() }
|
||||
single<DocTranslationService> { NoOpDocTranslator() }
|
||||
single<MessageTranslationService> { NoOpMessageTranslator() }
|
||||
|
||||
// Desktop uses the real ApiService implementation (no flavor stub needed)
|
||||
single<ApiService> { ApiServiceImpl(client = get()) }
|
||||
|
||||
// Ktor HttpClient for JVM/Desktop (equivalent of CoreNetworkAndroidModule on Android)
|
||||
single<HttpClient> {
|
||||
HttpClient(Java) {
|
||||
engine {
|
||||
protocolVersion = java.net.http.HttpClient.Version.HTTP_2
|
||||
config { followRedirects(java.net.http.HttpClient.Redirect.NORMAL) }
|
||||
}
|
||||
install(ContentNegotiation) { json(get<Json>()) }
|
||||
install(DefaultRequest) {
|
||||
url(HttpClientDefaults.API_BASE_URL)
|
||||
header(HttpHeaders.UserAgent, "Meshtastic-Desktop/${DesktopBuildConfig.VERSION_NAME}")
|
||||
}
|
||||
install(HttpTimeout) {
|
||||
requestTimeoutMillis = HttpClientDefaults.REQUEST_TIMEOUT_MS
|
||||
connectTimeoutMillis = HttpClientDefaults.TIMEOUT_MS
|
||||
socketTimeoutMillis = HttpClientDefaults.TIMEOUT_MS
|
||||
}
|
||||
install(HttpRequestRetry) { configureDefaultRetry() }
|
||||
if (DesktopBuildConfig.IS_DEBUG) {
|
||||
install(Logging) {
|
||||
logger = KermitHttpLogger
|
||||
level = LogLevel.INFO
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Desktop has no bundled Android assets; repositories seed from the network instead.
|
||||
single<BundledAssetReader> { BundledAssetReader { null } }
|
||||
}
|
||||
@Module(
|
||||
includes =
|
||||
[
|
||||
org.meshtastic.core.di.di.CoreDiModule::class,
|
||||
CoreCommonModule::class,
|
||||
CoreBleModule::class,
|
||||
CoreDataModule::class,
|
||||
org.meshtastic.core.domain.di.CoreDomainModule::class,
|
||||
CoreDatabaseModule::class,
|
||||
org.meshtastic.core.repository.di.CoreRepositoryModule::class,
|
||||
CoreDatastoreModule::class,
|
||||
CorePrefsModule::class,
|
||||
CoreServiceModule::class,
|
||||
CoreNetworkModule::class,
|
||||
CoreTakServerModule::class,
|
||||
CoreUiModule::class,
|
||||
FeatureNodeModule::class,
|
||||
FeatureMessagingModule::class,
|
||||
FeatureConnectionsModule::class,
|
||||
FeatureMapModule::class,
|
||||
FeatureSettingsModule::class,
|
||||
FeatureDiscoveryModule::class,
|
||||
FeatureDocsModule::class,
|
||||
FeatureFirmwareModule::class,
|
||||
FeatureIntroModule::class,
|
||||
FeatureWifiProvisionModule::class,
|
||||
DesktopDiModule::class,
|
||||
DesktopPlatformModule::class,
|
||||
DesktopPreferencesDataStoreModule::class,
|
||||
DesktopProtoDataStoreModule::class,
|
||||
DesktopRuntimeModule::class,
|
||||
DesktopStubsModule::class,
|
||||
DesktopAiModule::class,
|
||||
],
|
||||
)
|
||||
class DesktopKoinModule
|
||||
@@ -16,89 +16,21 @@
|
||||
*/
|
||||
package org.meshtastic.desktop.di
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.core.DataStoreFactory
|
||||
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
|
||||
import androidx.datastore.core.okio.OkioSerializer
|
||||
import androidx.datastore.core.okio.OkioStorage
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.emptyPreferences
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.LifecycleRegistry
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import okio.FileSystem
|
||||
import okio.Path.Companion.toPath
|
||||
import org.koin.core.qualifier.named
|
||||
import org.koin.dsl.module
|
||||
import org.koin.core.annotation.Module
|
||||
import org.koin.core.annotation.Named
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.common.BuildConfigProvider
|
||||
import org.meshtastic.core.common.di.PROCESS_LIFECYCLE
|
||||
import org.meshtastic.core.database.desktopDataDir
|
||||
import org.meshtastic.core.datastore.di.CoreChannelSetDataStore
|
||||
import org.meshtastic.core.datastore.di.CoreLocalConfigDataStore
|
||||
import org.meshtastic.core.datastore.di.CoreLocalStatsDataStore
|
||||
import org.meshtastic.core.datastore.di.CoreModuleConfigDataStore
|
||||
import org.meshtastic.core.datastore.di.CorePreferencesDataStore
|
||||
import org.meshtastic.core.datastore.di.DataStoreScope
|
||||
import org.meshtastic.core.datastore.di.asCoreChannelSetDataStore
|
||||
import org.meshtastic.core.datastore.di.asCoreLocalConfigDataStore
|
||||
import org.meshtastic.core.datastore.di.asCoreLocalStatsDataStore
|
||||
import org.meshtastic.core.datastore.di.asCoreModuleConfigDataStore
|
||||
import org.meshtastic.core.datastore.di.asCorePreferencesDataStore
|
||||
import org.meshtastic.core.datastore.di.asDataStoreScope
|
||||
import org.meshtastic.core.datastore.serializer.ChannelSetSerializer
|
||||
import org.meshtastic.core.datastore.serializer.LocalConfigSerializer
|
||||
import org.meshtastic.core.datastore.serializer.LocalStatsSerializer
|
||||
import org.meshtastic.core.datastore.serializer.ModuleConfigSerializer
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
import org.meshtastic.core.prefs.di.AnalyticsDataStore
|
||||
import org.meshtastic.core.prefs.di.AppDataStore
|
||||
import org.meshtastic.core.prefs.di.CustomEmojiDataStore
|
||||
import org.meshtastic.core.prefs.di.FilterDataStore
|
||||
import org.meshtastic.core.prefs.di.HomoglyphEncodingDataStore
|
||||
import org.meshtastic.core.prefs.di.MapConsentDataStore
|
||||
import org.meshtastic.core.prefs.di.MapDataStore
|
||||
import org.meshtastic.core.prefs.di.MapTileProviderDataStore
|
||||
import org.meshtastic.core.prefs.di.MeshDataStore
|
||||
import org.meshtastic.core.prefs.di.MeshLogDataStore
|
||||
import org.meshtastic.core.prefs.di.RadioDataStore
|
||||
import org.meshtastic.core.prefs.di.UiDataStore
|
||||
import org.meshtastic.core.prefs.di.asAnalyticsDataStore
|
||||
import org.meshtastic.core.prefs.di.asAppDataStore
|
||||
import org.meshtastic.core.prefs.di.asCustomEmojiDataStore
|
||||
import org.meshtastic.core.prefs.di.asFilterDataStore
|
||||
import org.meshtastic.core.prefs.di.asHomoglyphEncodingDataStore
|
||||
import org.meshtastic.core.prefs.di.asMapConsentDataStore
|
||||
import org.meshtastic.core.prefs.di.asMapDataStore
|
||||
import org.meshtastic.core.prefs.di.asMapTileProviderDataStore
|
||||
import org.meshtastic.core.prefs.di.asMeshDataStore
|
||||
import org.meshtastic.core.prefs.di.asMeshLogDataStore
|
||||
import org.meshtastic.core.prefs.di.asRadioDataStore
|
||||
import org.meshtastic.core.prefs.di.asUiDataStore
|
||||
import org.meshtastic.desktop.DesktopBuildConfig
|
||||
import org.meshtastic.proto.ChannelSet
|
||||
import org.meshtastic.proto.LocalConfig
|
||||
import org.meshtastic.proto.LocalModuleConfig
|
||||
import org.meshtastic.proto.LocalStats
|
||||
|
||||
/** Creates a file-backed [DataStore]<[Preferences]> at the given path under the data directory. */
|
||||
private fun prefsStore(name: String, scope: DataStoreScope): DataStore<Preferences> {
|
||||
val dir = desktopDataDir() + "/datastore"
|
||||
FileSystem.SYSTEM.createDirectories(dir.toPath())
|
||||
return PreferenceDataStoreFactory.createWithPath(
|
||||
corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { emptyPreferences() }),
|
||||
scope = scope,
|
||||
produceFile = { "$dir/$name.preferences_pb".toPath() },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Synthetic [LifecycleOwner] that stays permanently in [Lifecycle.State.RESUMED]. Replaces Android's
|
||||
* `ProcessLifecycleOwner` for desktop.
|
||||
*/
|
||||
private class DesktopProcessLifecycleOwner : LifecycleOwner {
|
||||
internal class DesktopProcessLifecycleOwner : LifecycleOwner {
|
||||
private val registry = LifecycleRegistry(this)
|
||||
|
||||
init {
|
||||
@@ -110,91 +42,30 @@ private class DesktopProcessLifecycleOwner : LifecycleOwner {
|
||||
}
|
||||
|
||||
/**
|
||||
* Desktop platform infrastructure module.
|
||||
* Desktop platform infrastructure module: [BuildConfigProvider] and the process [Lifecycle].
|
||||
*
|
||||
* Provides all platform-specific bindings that the real KMP `commonMain` implementations need:
|
||||
* - Named [DataStore]<[Preferences]> instances (12 preference stores + 1 core preferences store)
|
||||
* - Proto [DataStore] instances (LocalConfig, ModuleConfig, ChannelSet, LocalStats)
|
||||
* - [Lifecycle] (`ProcessLifecycle`)
|
||||
* - [BuildConfigProvider]
|
||||
* The DataStore instances the `commonMain` implementations need live in [DesktopPreferencesDataStoreModule] and
|
||||
* [DesktopProtoDataStoreModule].
|
||||
*/
|
||||
fun desktopPlatformModule() = module {
|
||||
// Application-lifetime scope shared by all DataStore instances. Per the DataStore docs:
|
||||
// "The Job within this context dictates the lifecycle of the DataStore's internal operations.
|
||||
// Ensure it is an application-scoped context that is not canceled by UI lifecycle events."
|
||||
// DataStore has no close() API — the in-memory cache is released only when this Job is cancelled
|
||||
// (at process exit). Using SupervisorJob so a single store's failure doesn't cascade.
|
||||
single<DataStoreScope> { CoroutineScope(get<CoroutineDispatchers>().io + SupervisorJob()).asDataStoreScope() }
|
||||
@Module
|
||||
class DesktopPlatformModule {
|
||||
|
||||
includes(desktopPreferencesDataStoreModule(), desktopProtoDataStoreModule())
|
||||
|
||||
// -- Build config (values generated at build time by generateDesktopBuildConfig) --
|
||||
single<BuildConfigProvider> {
|
||||
object : BuildConfigProvider {
|
||||
override val isDebug: Boolean = DesktopBuildConfig.IS_DEBUG
|
||||
override val applicationId: String = DesktopBuildConfig.APPLICATION_ID
|
||||
override val versionCode: Int = DesktopBuildConfig.VERSION_CODE
|
||||
override val versionName: String = DesktopBuildConfig.VERSION_NAME
|
||||
override val absoluteMinFwVersion: String = DesktopBuildConfig.ABS_MIN_FW_VERSION
|
||||
override val minFwVersion: String = DesktopBuildConfig.MIN_FW_VERSION
|
||||
}
|
||||
/** Values generated at build time by `generateDesktopBuildConfig`. */
|
||||
@Single
|
||||
fun buildConfigProvider(): BuildConfigProvider = object : BuildConfigProvider {
|
||||
override val isDebug: Boolean = DesktopBuildConfig.IS_DEBUG
|
||||
override val applicationId: String = DesktopBuildConfig.APPLICATION_ID
|
||||
override val versionCode: Int = DesktopBuildConfig.VERSION_CODE
|
||||
override val versionName: String = DesktopBuildConfig.VERSION_NAME
|
||||
override val absoluteMinFwVersion: String = DesktopBuildConfig.ABS_MIN_FW_VERSION
|
||||
override val minFwVersion: String = DesktopBuildConfig.MIN_FW_VERSION
|
||||
}
|
||||
|
||||
// -- Process Lifecycle (stays RESUMED forever on desktop) --
|
||||
single(named(PROCESS_LIFECYCLE)) { DesktopProcessLifecycleOwner().lifecycle }
|
||||
}
|
||||
|
||||
/** Typed preference-datastore singletons for each preference domain. */
|
||||
private fun desktopPreferencesDataStoreModule() = module {
|
||||
single<AnalyticsDataStore> { prefsStore("analytics", get()).asAnalyticsDataStore() }
|
||||
single<HomoglyphEncodingDataStore> { prefsStore("homoglyph_encoding", get()).asHomoglyphEncodingDataStore() }
|
||||
single<AppDataStore> { prefsStore("app", get()).asAppDataStore() }
|
||||
single<CustomEmojiDataStore> { prefsStore("custom_emoji", get()).asCustomEmojiDataStore() }
|
||||
single<MapDataStore> { prefsStore("map", get()).asMapDataStore() }
|
||||
single<MapConsentDataStore> { prefsStore("map_consent", get()).asMapConsentDataStore() }
|
||||
single<MapTileProviderDataStore> { prefsStore("map_tile_provider", get()).asMapTileProviderDataStore() }
|
||||
single<MeshDataStore> { prefsStore("mesh", get()).asMeshDataStore() }
|
||||
single<RadioDataStore> { prefsStore("radio", get()).asRadioDataStore() }
|
||||
single<UiDataStore> { prefsStore("ui", get()).asUiDataStore() }
|
||||
single<MeshLogDataStore> { prefsStore("meshlog", get()).asMeshLogDataStore() }
|
||||
single<FilterDataStore> { prefsStore("filter", get()).asFilterDataStore() }
|
||||
single<CorePreferencesDataStore> { prefsStore("core_preferences", get()).asCorePreferencesDataStore() }
|
||||
}
|
||||
|
||||
/** The path is an on-disk identity — changing it orphans existing user data. */
|
||||
private fun <T> protoStore(
|
||||
serializer: OkioSerializer<T>,
|
||||
path: String,
|
||||
produceNewData: () -> T,
|
||||
scope: DataStoreScope,
|
||||
): DataStore<T> = DataStoreFactory.create(
|
||||
storage = OkioStorage(fileSystem = FileSystem.SYSTEM, serializer = serializer, producePath = { path.toPath() }),
|
||||
corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { produceNewData() }),
|
||||
scope = scope,
|
||||
)
|
||||
|
||||
/** Proto [DataStore] instances (OkioStorage-backed). */
|
||||
private fun desktopProtoDataStoreModule() = module {
|
||||
val protoDir = desktopDataDir() + "/datastore"
|
||||
FileSystem.SYSTEM.createDirectories(protoDir.toPath())
|
||||
|
||||
single<CoreLocalConfigDataStore> {
|
||||
protoStore(LocalConfigSerializer, "$protoDir/local_config.pb", { LocalConfig() }, get())
|
||||
.asCoreLocalConfigDataStore()
|
||||
}
|
||||
|
||||
single<CoreModuleConfigDataStore> {
|
||||
protoStore(ModuleConfigSerializer, "$protoDir/module_config.pb", { LocalModuleConfig() }, get())
|
||||
.asCoreModuleConfigDataStore()
|
||||
}
|
||||
|
||||
single<CoreChannelSetDataStore> {
|
||||
protoStore(ChannelSetSerializer, "$protoDir/channel_set.pb", { ChannelSet() }, get())
|
||||
.asCoreChannelSetDataStore()
|
||||
}
|
||||
|
||||
single<CoreLocalStatsDataStore> {
|
||||
protoStore(LocalStatsSerializer, "$protoDir/local_stats.pb", { LocalStats() }, get())
|
||||
.asCoreLocalStatsDataStore()
|
||||
}
|
||||
// LifecycleRegistry holds its owner weakly, so the owner is a definition too:
|
||||
// once it is collected, addObserver silently stops registering.
|
||||
@Single internal fun processLifecycleOwner(): DesktopProcessLifecycleOwner = DesktopProcessLifecycleOwner()
|
||||
|
||||
@Single
|
||||
@Named(PROCESS_LIFECYCLE)
|
||||
internal fun processLifecycle(owner: DesktopProcessLifecycleOwner): Lifecycle = owner.lifecycle
|
||||
}
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.desktop.di
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.emptyPreferences
|
||||
import okio.FileSystem
|
||||
import okio.Path.Companion.toPath
|
||||
import org.koin.core.annotation.Module
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.database.desktopDataDir
|
||||
import org.meshtastic.core.datastore.di.CorePreferencesDataStore
|
||||
import org.meshtastic.core.datastore.di.DataStoreScope
|
||||
import org.meshtastic.core.datastore.di.asCorePreferencesDataStore
|
||||
import org.meshtastic.core.prefs.di.AnalyticsDataStore
|
||||
import org.meshtastic.core.prefs.di.AppDataStore
|
||||
import org.meshtastic.core.prefs.di.CustomEmojiDataStore
|
||||
import org.meshtastic.core.prefs.di.FilterDataStore
|
||||
import org.meshtastic.core.prefs.di.HomoglyphEncodingDataStore
|
||||
import org.meshtastic.core.prefs.di.MapConsentDataStore
|
||||
import org.meshtastic.core.prefs.di.MapDataStore
|
||||
import org.meshtastic.core.prefs.di.MapTileProviderDataStore
|
||||
import org.meshtastic.core.prefs.di.MeshDataStore
|
||||
import org.meshtastic.core.prefs.di.MeshLogDataStore
|
||||
import org.meshtastic.core.prefs.di.RadioDataStore
|
||||
import org.meshtastic.core.prefs.di.UiDataStore
|
||||
import org.meshtastic.core.prefs.di.asAnalyticsDataStore
|
||||
import org.meshtastic.core.prefs.di.asAppDataStore
|
||||
import org.meshtastic.core.prefs.di.asCustomEmojiDataStore
|
||||
import org.meshtastic.core.prefs.di.asFilterDataStore
|
||||
import org.meshtastic.core.prefs.di.asHomoglyphEncodingDataStore
|
||||
import org.meshtastic.core.prefs.di.asMapConsentDataStore
|
||||
import org.meshtastic.core.prefs.di.asMapDataStore
|
||||
import org.meshtastic.core.prefs.di.asMapTileProviderDataStore
|
||||
import org.meshtastic.core.prefs.di.asMeshDataStore
|
||||
import org.meshtastic.core.prefs.di.asMeshLogDataStore
|
||||
import org.meshtastic.core.prefs.di.asRadioDataStore
|
||||
import org.meshtastic.core.prefs.di.asUiDataStore
|
||||
|
||||
/** Typed preference-datastore singletons for each preference domain, one file each under the data directory. */
|
||||
@Suppress("TooManyFunctions")
|
||||
@Module
|
||||
class DesktopPreferencesDataStoreModule {
|
||||
|
||||
@Single
|
||||
fun analyticsDataStore(scope: DataStoreScope): AnalyticsDataStore =
|
||||
prefsStore("analytics", scope).asAnalyticsDataStore()
|
||||
|
||||
@Single
|
||||
fun homoglyphEncodingDataStore(scope: DataStoreScope): HomoglyphEncodingDataStore =
|
||||
prefsStore("homoglyph_encoding", scope).asHomoglyphEncodingDataStore()
|
||||
|
||||
@Single fun appDataStore(scope: DataStoreScope): AppDataStore = prefsStore("app", scope).asAppDataStore()
|
||||
|
||||
@Single
|
||||
fun customEmojiDataStore(scope: DataStoreScope): CustomEmojiDataStore =
|
||||
prefsStore("custom_emoji", scope).asCustomEmojiDataStore()
|
||||
|
||||
@Single fun mapDataStore(scope: DataStoreScope): MapDataStore = prefsStore("map", scope).asMapDataStore()
|
||||
|
||||
@Single
|
||||
fun mapConsentDataStore(scope: DataStoreScope): MapConsentDataStore =
|
||||
prefsStore("map_consent", scope).asMapConsentDataStore()
|
||||
|
||||
@Single
|
||||
fun mapTileProviderDataStore(scope: DataStoreScope): MapTileProviderDataStore =
|
||||
prefsStore("map_tile_provider", scope).asMapTileProviderDataStore()
|
||||
|
||||
@Single fun meshDataStore(scope: DataStoreScope): MeshDataStore = prefsStore("mesh", scope).asMeshDataStore()
|
||||
|
||||
@Single fun radioDataStore(scope: DataStoreScope): RadioDataStore = prefsStore("radio", scope).asRadioDataStore()
|
||||
|
||||
@Single fun uiDataStore(scope: DataStoreScope): UiDataStore = prefsStore("ui", scope).asUiDataStore()
|
||||
|
||||
@Single
|
||||
fun meshLogDataStore(scope: DataStoreScope): MeshLogDataStore = prefsStore("meshlog", scope).asMeshLogDataStore()
|
||||
|
||||
@Single
|
||||
fun filterDataStore(scope: DataStoreScope): FilterDataStore = prefsStore("filter", scope).asFilterDataStore()
|
||||
|
||||
@Single
|
||||
fun corePreferencesDataStore(scope: DataStoreScope): CorePreferencesDataStore =
|
||||
prefsStore("core_preferences", scope).asCorePreferencesDataStore()
|
||||
}
|
||||
|
||||
/** [name] is an on-disk identity — changing it orphans existing user data. */
|
||||
private fun prefsStore(name: String, scope: DataStoreScope): DataStore<Preferences> {
|
||||
val dir = desktopDataDir() + "/datastore"
|
||||
FileSystem.SYSTEM.createDirectories(dir.toPath())
|
||||
return PreferenceDataStoreFactory.createWithPath(
|
||||
corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { emptyPreferences() }),
|
||||
scope = scope,
|
||||
produceFile = { "$dir/$name.preferences_pb".toPath() },
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.desktop.di
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.core.DataStoreFactory
|
||||
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
|
||||
import androidx.datastore.core.okio.OkioSerializer
|
||||
import androidx.datastore.core.okio.OkioStorage
|
||||
import okio.FileSystem
|
||||
import okio.Path.Companion.toPath
|
||||
import org.koin.core.annotation.Module
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.database.desktopDataDir
|
||||
import org.meshtastic.core.datastore.di.CoreChannelSetDataStore
|
||||
import org.meshtastic.core.datastore.di.CoreLocalConfigDataStore
|
||||
import org.meshtastic.core.datastore.di.CoreLocalStatsDataStore
|
||||
import org.meshtastic.core.datastore.di.CoreModuleConfigDataStore
|
||||
import org.meshtastic.core.datastore.di.DataStoreScope
|
||||
import org.meshtastic.core.datastore.di.asCoreChannelSetDataStore
|
||||
import org.meshtastic.core.datastore.di.asCoreLocalConfigDataStore
|
||||
import org.meshtastic.core.datastore.di.asCoreLocalStatsDataStore
|
||||
import org.meshtastic.core.datastore.di.asCoreModuleConfigDataStore
|
||||
import org.meshtastic.core.datastore.serializer.ChannelSetSerializer
|
||||
import org.meshtastic.core.datastore.serializer.LocalConfigSerializer
|
||||
import org.meshtastic.core.datastore.serializer.LocalStatsSerializer
|
||||
import org.meshtastic.core.datastore.serializer.ModuleConfigSerializer
|
||||
import org.meshtastic.proto.ChannelSet
|
||||
import org.meshtastic.proto.LocalConfig
|
||||
import org.meshtastic.proto.LocalModuleConfig
|
||||
import org.meshtastic.proto.LocalStats
|
||||
|
||||
/** Proto [DataStore] instances (OkioStorage-backed). */
|
||||
@Module
|
||||
class DesktopProtoDataStoreModule {
|
||||
|
||||
@Single
|
||||
fun localConfigDataStore(scope: DataStoreScope): CoreLocalConfigDataStore =
|
||||
protoStore(LocalConfigSerializer, "local_config.pb", { LocalConfig() }, scope).asCoreLocalConfigDataStore()
|
||||
|
||||
@Single
|
||||
fun moduleConfigDataStore(scope: DataStoreScope): CoreModuleConfigDataStore =
|
||||
protoStore(ModuleConfigSerializer, "module_config.pb", { LocalModuleConfig() }, scope)
|
||||
.asCoreModuleConfigDataStore()
|
||||
|
||||
@Single
|
||||
fun channelSetDataStore(scope: DataStoreScope): CoreChannelSetDataStore =
|
||||
protoStore(ChannelSetSerializer, "channel_set.pb", { ChannelSet() }, scope).asCoreChannelSetDataStore()
|
||||
|
||||
@Single
|
||||
fun localStatsDataStore(scope: DataStoreScope): CoreLocalStatsDataStore =
|
||||
protoStore(LocalStatsSerializer, "local_stats.pb", { LocalStats() }, scope).asCoreLocalStatsDataStore()
|
||||
}
|
||||
|
||||
/** [fileName] is an on-disk identity — changing it orphans existing user data. */
|
||||
private fun <T> protoStore(
|
||||
serializer: OkioSerializer<T>,
|
||||
fileName: String,
|
||||
produceNewData: () -> T,
|
||||
scope: DataStoreScope,
|
||||
): DataStore<T> {
|
||||
val dir = desktopDataDir() + "/datastore"
|
||||
FileSystem.SYSTEM.createDirectories(dir.toPath())
|
||||
return DataStoreFactory.create(
|
||||
storage =
|
||||
OkioStorage(
|
||||
fileSystem = FileSystem.SYSTEM,
|
||||
serializer = serializer,
|
||||
producePath = { "$dir/$fileName".toPath() },
|
||||
),
|
||||
corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { produceNewData() }),
|
||||
scope = scope,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.desktop.di
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.java.Java
|
||||
import io.ktor.client.plugins.DefaultRequest
|
||||
import io.ktor.client.plugins.HttpRequestRetry
|
||||
import io.ktor.client.plugins.HttpTimeout
|
||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.client.plugins.logging.LogLevel
|
||||
import io.ktor.client.plugins.logging.Logging
|
||||
import io.ktor.client.request.header
|
||||
import io.ktor.http.HttpHeaders
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.koin.core.annotation.Module
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.ble.BleConnectionFactory
|
||||
import org.meshtastic.core.ble.BleScanner
|
||||
import org.meshtastic.core.ble.BluetoothRepository
|
||||
import org.meshtastic.core.common.database.DatabaseManager
|
||||
import org.meshtastic.core.common.di.ServiceScope
|
||||
import org.meshtastic.core.data.datasource.BundledAssetReader
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
import org.meshtastic.core.network.HttpClientDefaults
|
||||
import org.meshtastic.core.network.KermitHttpLogger
|
||||
import org.meshtastic.core.network.configureDefaultRetry
|
||||
import org.meshtastic.core.network.service.ApiService
|
||||
import org.meshtastic.core.network.service.ApiServiceImpl
|
||||
import org.meshtastic.core.repository.AdminController
|
||||
import org.meshtastic.core.repository.CommandSender
|
||||
import org.meshtastic.core.repository.ConnectionStateProvider
|
||||
import org.meshtastic.core.repository.MeshDataHandler
|
||||
import org.meshtastic.core.repository.MeshLocationManager
|
||||
import org.meshtastic.core.repository.MeshMessageProcessor
|
||||
import org.meshtastic.core.repository.MeshNotificationManager
|
||||
import org.meshtastic.core.repository.MeshPrefs
|
||||
import org.meshtastic.core.repository.MessageQueue
|
||||
import org.meshtastic.core.repository.MessagingController
|
||||
import org.meshtastic.core.repository.NeighborInfoResponseProvider
|
||||
import org.meshtastic.core.repository.NodeController
|
||||
import org.meshtastic.core.repository.NodeManager
|
||||
import org.meshtastic.core.repository.NodeRepository
|
||||
import org.meshtastic.core.repository.NotificationManager
|
||||
import org.meshtastic.core.repository.NotificationPrefs
|
||||
import org.meshtastic.core.repository.PacketRepository
|
||||
import org.meshtastic.core.repository.PlatformAnalytics
|
||||
import org.meshtastic.core.repository.QueryController
|
||||
import org.meshtastic.core.repository.RadioConfigRepository
|
||||
import org.meshtastic.core.repository.RadioController
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
import org.meshtastic.core.repository.RadioTransportFactory
|
||||
import org.meshtastic.core.repository.ServiceRepository
|
||||
import org.meshtastic.core.repository.ServiceStateWriter
|
||||
import org.meshtastic.core.repository.TracerouteResponseProvider
|
||||
import org.meshtastic.core.repository.UiPrefs
|
||||
import org.meshtastic.core.service.RadioControllerImpl
|
||||
import org.meshtastic.core.service.ServiceRepositoryImpl
|
||||
import org.meshtastic.desktop.DesktopBuildConfig
|
||||
import org.meshtastic.desktop.DesktopNotificationManager
|
||||
import org.meshtastic.desktop.notification.DesktopMeshNotificationManager
|
||||
import org.meshtastic.desktop.notification.DesktopOS
|
||||
import org.meshtastic.desktop.notification.LinuxNotificationSender
|
||||
import org.meshtastic.desktop.notification.MacOSNotificationSender
|
||||
import org.meshtastic.desktop.notification.NativeNotificationSender
|
||||
import org.meshtastic.desktop.notification.WindowsNotificationSender
|
||||
import org.meshtastic.desktop.radio.DesktopMessageQueue
|
||||
import org.meshtastic.desktop.radio.DesktopRadioTransportFactory
|
||||
|
||||
/**
|
||||
* Desktop runtime wiring: the radio stack, notifications and networking the JVM host owns.
|
||||
*
|
||||
* These replace bindings that exist only in Android source sets, so nothing here duplicates a `commonMain` default.
|
||||
*/
|
||||
@Module
|
||||
class DesktopRuntimeModule {
|
||||
|
||||
@Single(
|
||||
binds =
|
||||
[
|
||||
ServiceRepository::class,
|
||||
ConnectionStateProvider::class,
|
||||
TracerouteResponseProvider::class,
|
||||
NeighborInfoResponseProvider::class,
|
||||
ServiceStateWriter::class,
|
||||
],
|
||||
)
|
||||
fun serviceRepository(): ServiceRepository = ServiceRepositoryImpl()
|
||||
|
||||
@Single
|
||||
fun radioTransportFactory(
|
||||
dispatchers: CoroutineDispatchers,
|
||||
scanner: BleScanner,
|
||||
bluetoothRepository: BluetoothRepository,
|
||||
connectionFactory: BleConnectionFactory,
|
||||
): RadioTransportFactory = DesktopRadioTransportFactory(
|
||||
dispatchers = dispatchers,
|
||||
scanner = scanner,
|
||||
bluetoothRepository = bluetoothRepository,
|
||||
connectionFactory = connectionFactory,
|
||||
)
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
@Single(
|
||||
binds =
|
||||
[
|
||||
RadioController::class,
|
||||
AdminController::class,
|
||||
MessagingController::class,
|
||||
NodeController::class,
|
||||
QueryController::class,
|
||||
],
|
||||
)
|
||||
fun radioController(
|
||||
serviceRepository: ServiceRepository,
|
||||
nodeRepository: NodeRepository,
|
||||
commandSender: CommandSender,
|
||||
nodeManager: NodeManager,
|
||||
radioInterfaceService: RadioInterfaceService,
|
||||
locationManager: MeshLocationManager,
|
||||
packetRepository: Lazy<PacketRepository>,
|
||||
dataHandler: Lazy<MeshDataHandler>,
|
||||
analytics: PlatformAnalytics,
|
||||
meshPrefs: MeshPrefs,
|
||||
uiPrefs: UiPrefs,
|
||||
databaseManager: DatabaseManager,
|
||||
notificationManager: NotificationManager,
|
||||
messageProcessor: Lazy<MeshMessageProcessor>,
|
||||
radioConfigRepository: RadioConfigRepository,
|
||||
scope: ServiceScope,
|
||||
): RadioController = RadioControllerImpl(
|
||||
serviceRepository = serviceRepository,
|
||||
nodeRepository = nodeRepository,
|
||||
commandSender = commandSender,
|
||||
nodeManager = nodeManager,
|
||||
radioInterfaceService = radioInterfaceService,
|
||||
locationManager = locationManager,
|
||||
packetRepository = packetRepository,
|
||||
dataHandler = dataHandler,
|
||||
analytics = analytics,
|
||||
meshPrefs = meshPrefs,
|
||||
uiPrefs = uiPrefs,
|
||||
databaseManager = databaseManager,
|
||||
notificationManager = notificationManager,
|
||||
messageProcessor = messageProcessor,
|
||||
radioConfigRepository = radioConfigRepository,
|
||||
scope = scope,
|
||||
)
|
||||
|
||||
/**
|
||||
* Only the Linux sender holds a native handle; the others are stateless. `Main.kt` closes it explicitly during
|
||||
* shutdown, because annotations have no `onClose` equivalent.
|
||||
*/
|
||||
@Single
|
||||
fun nativeNotificationSender(): NativeNotificationSender = when (DesktopOS.current()) {
|
||||
DesktopOS.Linux -> LinuxNotificationSender()
|
||||
DesktopOS.MacOS -> MacOSNotificationSender()
|
||||
DesktopOS.Windows -> WindowsNotificationSender()
|
||||
}
|
||||
|
||||
@Single(binds = [DesktopNotificationManager::class, NotificationManager::class])
|
||||
fun desktopNotificationManager(
|
||||
prefs: NotificationPrefs,
|
||||
nativeSender: NativeNotificationSender,
|
||||
): DesktopNotificationManager = DesktopNotificationManager(prefs = prefs, nativeSender = nativeSender)
|
||||
|
||||
@Single
|
||||
fun meshNotificationManager(notificationManager: NotificationManager): MeshNotificationManager =
|
||||
DesktopMeshNotificationManager(notificationManager = notificationManager)
|
||||
|
||||
@Single
|
||||
fun messageQueue(
|
||||
packetRepository: PacketRepository,
|
||||
radioController: RadioController,
|
||||
dispatchers: CoroutineDispatchers,
|
||||
): MessageQueue = DesktopMessageQueue(
|
||||
packetRepository = packetRepository,
|
||||
radioController = radioController,
|
||||
dispatchers = dispatchers,
|
||||
)
|
||||
|
||||
/** Desktop uses the real `ApiService` implementation over the JVM `HttpClient` below — no flavor stub needed. */
|
||||
@Single fun apiService(apiServiceImpl: ApiServiceImpl): ApiService = apiServiceImpl
|
||||
|
||||
/** Ktor [HttpClient] for JVM/Desktop — the equivalent of `CoreNetworkAndroidModule`'s OkHttp-backed client. */
|
||||
@Single
|
||||
fun httpClient(json: Json): HttpClient = HttpClient(Java) {
|
||||
engine {
|
||||
protocolVersion = java.net.http.HttpClient.Version.HTTP_2
|
||||
config { followRedirects(java.net.http.HttpClient.Redirect.NORMAL) }
|
||||
}
|
||||
install(ContentNegotiation) { json(json) }
|
||||
install(DefaultRequest) {
|
||||
url(HttpClientDefaults.API_BASE_URL)
|
||||
header(HttpHeaders.UserAgent, "Meshtastic-Desktop/${DesktopBuildConfig.VERSION_NAME}")
|
||||
}
|
||||
install(HttpTimeout) {
|
||||
requestTimeoutMillis = HttpClientDefaults.REQUEST_TIMEOUT_MS
|
||||
connectTimeoutMillis = HttpClientDefaults.TIMEOUT_MS
|
||||
socketTimeoutMillis = HttpClientDefaults.TIMEOUT_MS
|
||||
}
|
||||
install(HttpRequestRetry) { configureDefaultRetry() }
|
||||
if (DesktopBuildConfig.IS_DEBUG) {
|
||||
install(Logging) {
|
||||
logger = KermitHttpLogger
|
||||
level = LogLevel.INFO
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Desktop has no bundled Android assets; repositories seed from the network instead. */
|
||||
@Single fun bundledAssetReader(): BundledAssetReader = BundledAssetReader { null }
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.desktop.di
|
||||
|
||||
import org.koin.core.annotation.Module
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.network.repository.MQTTRepository
|
||||
import org.meshtastic.core.repository.AppWidgetUpdater
|
||||
import org.meshtastic.core.repository.LocationRepository
|
||||
import org.meshtastic.core.repository.MeshLocationManager
|
||||
import org.meshtastic.core.repository.MeshWorkerManager
|
||||
import org.meshtastic.core.repository.PlatformAnalytics
|
||||
import org.meshtastic.desktop.stub.NoopAppWidgetUpdater
|
||||
import org.meshtastic.desktop.stub.NoopCompassHeadingProvider
|
||||
import org.meshtastic.desktop.stub.NoopLocationRepository
|
||||
import org.meshtastic.desktop.stub.NoopMQTTRepository
|
||||
import org.meshtastic.desktop.stub.NoopMagneticFieldProvider
|
||||
import org.meshtastic.desktop.stub.NoopMeshLocationManager
|
||||
import org.meshtastic.desktop.stub.NoopMeshWorkerManager
|
||||
import org.meshtastic.desktop.stub.NoopPhoneLocationProvider
|
||||
import org.meshtastic.desktop.stub.NoopPlatformAnalytics
|
||||
import org.meshtastic.feature.node.compass.CompassHeadingProvider
|
||||
import org.meshtastic.feature.node.compass.MagneticFieldProvider
|
||||
import org.meshtastic.feature.node.compass.PhoneLocationProvider
|
||||
|
||||
/**
|
||||
* Stubs for interfaces whose only real implementation needs Android APIs — WorkManager, widgets, location, sensors and
|
||||
* analytics. [MQTTRepository] is the exception: it has a working `commonMain` implementation, and this binding
|
||||
* deliberately shadows it because desktop does not run the MQTT bridge.
|
||||
*/
|
||||
@Module
|
||||
class DesktopStubsModule {
|
||||
|
||||
@Single fun platformAnalytics(): PlatformAnalytics = NoopPlatformAnalytics()
|
||||
|
||||
@Single fun appWidgetUpdater(): AppWidgetUpdater = NoopAppWidgetUpdater()
|
||||
|
||||
@Single fun meshWorkerManager(): MeshWorkerManager = NoopMeshWorkerManager()
|
||||
|
||||
@Single fun meshLocationManager(): MeshLocationManager = NoopMeshLocationManager()
|
||||
|
||||
@Single fun locationRepository(): LocationRepository = NoopLocationRepository()
|
||||
|
||||
@Single fun mqttRepository(): MQTTRepository = NoopMQTTRepository()
|
||||
|
||||
@Single fun compassHeadingProvider(): CompassHeadingProvider = NoopCompassHeadingProvider()
|
||||
|
||||
@Single fun phoneLocationProvider(): PhoneLocationProvider = NoopPhoneLocationProvider()
|
||||
|
||||
@Single fun magneticFieldProvider(): MagneticFieldProvider = NoopMagneticFieldProvider()
|
||||
}
|
||||
+1
-1
@@ -42,7 +42,7 @@ import org.meshtastic.proto.Telemetry
|
||||
*
|
||||
* Android-only concepts (notification channels, foreground-service state updates) are intentionally no-ops.
|
||||
*
|
||||
* Registered manually in `desktopPlatformStubsModule` -- do **not** add `@Single` to avoid double-registration with the
|
||||
* Registered manually in `DesktopRuntimeModule` -- do **not** add `@Single` to avoid double-registration with the
|
||||
* `@ComponentScan("org.meshtastic.desktop")` in [DesktopDiModule][org.meshtastic.desktop.di.DesktopDiModule].
|
||||
*/
|
||||
@Suppress("TooManyFunctions")
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ import org.meshtastic.core.repository.RadioTransportFactory
|
||||
* Desktop implementation of [RadioTransportFactory] delegating multiplatform transports (BLE, TCP) and providing
|
||||
* platform-specific transports (USB/Serial) via jSerialComm.
|
||||
*
|
||||
* Registered manually in [desktopPlatformStubsModule] — do NOT add @Single to avoid double-registration with
|
||||
* Registered manually in [DesktopRuntimeModule] — do NOT add @Single to avoid double-registration with
|
||||
* the @ComponentScan("org.meshtastic.desktop") in DesktopDiModule.
|
||||
*/
|
||||
class DesktopRadioTransportFactory(
|
||||
|
||||
@@ -57,7 +57,7 @@ import org.meshtastic.proto.Position as ProtoPosition
|
||||
* real `commonMain` implementations wired through the generated Koin K2 modules.
|
||||
*
|
||||
* As real desktop implementations become available (e.g., serial transport, TCP transport), they replace individual
|
||||
* stubs in [desktopModule].
|
||||
* stubs in [org.meshtastic.desktop.di.DesktopStubsModule].
|
||||
*/
|
||||
private const val TAG = "NoopStub"
|
||||
|
||||
|
||||
@@ -22,24 +22,28 @@ import io.ktor.client.engine.HttpClientEngine
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import org.koin.core.annotation.KoinExperimentalAPI
|
||||
import org.koin.dsl.koinApplication
|
||||
import org.koin.dsl.module
|
||||
import org.koin.dsl.onClose
|
||||
import org.koin.plugin.module.dsl.koinApplication
|
||||
import org.koin.test.verify.verify
|
||||
import org.meshtastic.core.ble.BleLogFormat
|
||||
import org.meshtastic.core.ble.BleLogLevel
|
||||
import org.meshtastic.core.network.repository.MQTTRepository
|
||||
import org.meshtastic.desktop.stub.NoopMQTTRepository
|
||||
import org.meshtastic.feature.docs.translation.DocTranslationService
|
||||
import org.meshtastic.feature.docs.translation.NoOpDocTranslator
|
||||
import org.meshtastic.feature.messaging.translation.MessageTranslationService
|
||||
import org.meshtastic.feature.messaging.translation.NoOpMessageTranslator
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.test.assertIs
|
||||
|
||||
@OptIn(KoinExperimentalAPI::class)
|
||||
class DesktopKoinTest {
|
||||
|
||||
@OptIn(KoinExperimentalAPI::class)
|
||||
@Test
|
||||
fun `verify desktop koin modules`() {
|
||||
// This test validates the full Koin DI graph for the Desktop target.
|
||||
// It includes the main desktopModule (repositories, use cases, ViewModels, stubs)
|
||||
// and the desktopPlatformModule (DataStores, Room database, lifecycle).
|
||||
module { includes(desktopModule(), desktopPlatformModule()) }
|
||||
// Validates the full Koin DI graph for the Desktop target: the core KMP modules (repositories, use cases,
|
||||
// ViewModels) plus the desktop-specific platform, datastore, runtime, stub and AI modules.
|
||||
DesktopKoinModule()
|
||||
.module()
|
||||
.verify(
|
||||
extraTypes =
|
||||
listOf(
|
||||
@@ -61,18 +65,26 @@ class DesktopKoinTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `closing a koin container fires onClose for instantiated singles`() {
|
||||
// Pins the mechanism desktopModule() relies on: LinuxNotificationSender's native teardown
|
||||
// (notify_uninit) runs only because Koin invokes onClose when the container closes, which
|
||||
// Main.kt triggers via stopKoin() once the Compose application loop returns. A Koin upgrade
|
||||
// that changed this would silently reinstate the leak, so it is asserted rather than assumed.
|
||||
var closed = false
|
||||
val app = koinApplication {
|
||||
modules(module { single<AutoCloseable> { AutoCloseable { closed = true } }.onClose { it?.close() } })
|
||||
fun `desktop bindings win over the shared graph`() {
|
||||
// @Configuration modules load before the ones listed in @KoinApplication, and Koin is last-wins, so which
|
||||
// binding survives is ordering-dependent. MQTTRepository is the live case: core:network commonMain declares
|
||||
// MQTTRepositoryImpl, and desktop must shadow it. verify() only checks definitions exist, never who won.
|
||||
val app = koinApplication<DesktopKoinApp>()
|
||||
try {
|
||||
val koin = app.koin
|
||||
assertIs<NoopMQTTRepository>(koin.get<MQTTRepository>())
|
||||
assertIs<NoOpMessageTranslator>(koin.get<MessageTranslationService>())
|
||||
assertIs<NoOpDocTranslator>(koin.get<DocTranslationService>())
|
||||
} finally {
|
||||
app.close()
|
||||
}
|
||||
app.koin.get<AutoCloseable>() // onClose only fires for singles that were actually instantiated
|
||||
app.close()
|
||||
}
|
||||
|
||||
assertTrue(closed, "Expected Koin to invoke onClose when the container is closed")
|
||||
@Test
|
||||
fun `typed bootstrap loads the module graph`() {
|
||||
// koinApplication<T>() is a K2 compiler plugin stub. If the plugin fails to transform it, the stub throws
|
||||
// NotImplementedError at runtime. This is the production bootstrap path Main.kt takes via startKoin<T>.
|
||||
val app = koinApplication<DesktopKoinApp>()
|
||||
app.close()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user