refactor: modern APIs — Koin 4.2, CMP 1.11, Ktor resilience, Room @Upsert, injected dispatchers (#5119)

This commit is contained in:
James Rich 2026-04-14 06:41:01 -05:00 committed by GitHub
parent 99378c9291
commit 9acdf5309f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 453 additions and 278 deletions

View file

@ -16,6 +16,7 @@
*/
package org.meshtastic.desktop
import co.touchlab.kermit.Logger
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
@ -25,15 +26,22 @@ import org.meshtastic.core.repository.NotificationPrefs
import androidx.compose.ui.window.Notification as ComposeNotification
/**
* Desktop notification manager. Registered manually in [desktopPlatformStubsModule] do NOT add @Single to avoid
* double-registration with the @ComponentScan("org.meshtastic.desktop") in DesktopDiModule.
* Desktop notification manager that bridges domain [Notification] objects to Compose Desktop tray notifications.
*
* Notifications are emitted via [notifications] and collected by the tray composable in [Main.kt]. Respects user
* preferences for message, node-event, and low-battery categories.
*
* Registered manually in `desktopPlatformStubsModule` -- do **not** add `@Single` to avoid double-registration with the
* `@ComponentScan("org.meshtastic.desktop")` in [DesktopDiModule][org.meshtastic.desktop.di.DesktopDiModule].
*/
class DesktopNotificationManager(private val prefs: NotificationPrefs) : NotificationManager {
init {
co.touchlab.kermit.Logger.i { "DesktopNotificationManager initialized" }
Logger.i { "DesktopNotificationManager initialized" }
}
private val _notifications = MutableSharedFlow<ComposeNotification>(extraBufferCapacity = 10)
/** Flow of Compose [ComposeNotification] objects to be forwarded to [TrayState.sendNotification]. */
val notifications: SharedFlow<ComposeNotification> = _notifications.asSharedFlow()
override fun dispatch(notification: Notification) {
@ -46,9 +54,7 @@ class DesktopNotificationManager(private val prefs: NotificationPrefs) : Notific
Notification.Category.Service -> true
}
co.touchlab.kermit.Logger.d {
"DesktopNotificationManager dispatch: category=${notification.category}, enabled=$enabled"
}
Logger.d { "DesktopNotificationManager dispatch: category=${notification.category}, enabled=$enabled" }
if (!enabled) return
@ -61,14 +67,14 @@ class DesktopNotificationManager(private val prefs: NotificationPrefs) : Notific
}
val success = _notifications.tryEmit(ComposeNotification(notification.title, notification.message, composeType))
co.touchlab.kermit.Logger.d { "DesktopNotificationManager emit: success=$success, title=${notification.title}" }
Logger.d { "DesktopNotificationManager emit: success=$success, title=${notification.title}" }
}
override fun cancel(id: Int) {
// Desktop Tray notifications cannot be cancelled once sent via TrayState
// Desktop tray notifications cannot be cancelled once sent via TrayState.
}
override fun cancelAll() {
// Desktop Tray notifications cannot be cleared once sent via TrayState
// Desktop tray notifications cannot be cleared once sent via TrayState.
}
}

View file

@ -18,7 +18,6 @@ package org.meshtastic.desktop
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@ -27,22 +26,22 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.toComposeImageBitmap
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.isMetaPressed
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.ApplicationScope
import androidx.compose.ui.window.Tray
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.WindowPosition
import androidx.compose.ui.window.WindowState
import androidx.compose.ui.window.application
import androidx.compose.ui.window.rememberTrayState
import androidx.compose.ui.window.rememberWindowState
@ -55,13 +54,19 @@ import coil3.memory.MemoryCache
import coil3.network.ktor3.KtorNetworkFetcherFactory
import coil3.request.crossfade
import coil3.svg.SvgDecoder
import coil3.util.DebugLogger
import io.ktor.client.HttpClient
import kotlinx.coroutines.flow.first
import okio.Path.Companion.toPath
import org.jetbrains.skia.Image
import org.jetbrains.compose.resources.decodeToSvgPainter
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.context.startKoin
import org.koin.core.context.stopKoin
import org.meshtastic.core.common.BuildConfigProvider
import org.meshtastic.core.common.util.MeshtasticUri
import org.meshtastic.core.database.desktopDataDir
import org.meshtastic.core.navigation.MultiBackstack
import org.meshtastic.core.navigation.SettingsRoute
import org.meshtastic.core.navigation.TopLevelDestination
import org.meshtastic.core.navigation.rememberMultiBackstack
@ -75,33 +80,50 @@ import org.meshtastic.desktop.di.desktopPlatformModule
import org.meshtastic.desktop.ui.DesktopMainScreen
import java.awt.Desktop
import java.util.Locale
import coil3.util.Logger as CoilLogger
/** Meshtastic Desktop — the first non-Android target for the shared KMP module graph. */
private val LocalAppLocale = staticCompositionLocalOf { "" }
private const val MEMORY_CACHE_MAX_BYTES = 64L * 1024L * 1024L // 64 MiB
private const val DISK_CACHE_MAX_BYTES = 32L * 1024L * 1024L // 32 MiB
/**
* Loads an SVG from JVM classpath resources and returns a [Painter].
*
* Uses the CMP 1.11 `decodeToSvgPainter` extension which replaces the deprecated `useResource`/`loadSvgPainter` pair.
* The SVG bytes are read from the classpath because CMP `composeResources/` only supports XML vector drawables and
* raster images not raw SVGs. Since the desktop module is a JVM-only host shell, classpath resource access is safe.
*/
@Composable
private fun classpathPainterResource(path: String): Painter {
val bitmap: ImageBitmap =
remember(path) {
val bytes = Thread.currentThread().contextClassLoader!!.getResourceAsStream(path)!!.readAllBytes()
Image.makeFromEncoded(bytes).toComposeImageBitmap()
private fun svgPainterResource(path: String, density: Density): Painter = remember(path, density) {
val classLoader =
requireNotNull(Thread.currentThread().contextClassLoader) {
"Missing context class loader while loading resource: $path"
}
return remember(bitmap) { BitmapPainter(bitmap) }
val bytes =
requireNotNull(classLoader.getResourceAsStream(path)) { "Missing classpath resource: $path" }
.use { it.readAllBytes() }
bytes.decodeToSvgPainter(density)
}
@Suppress("LongMethod", "CyclomaticComplexMethod")
@OptIn(ExperimentalCoilApi::class)
fun main(args: Array<String>) = application(exitProcessOnExit = false) {
Logger.i { "Meshtastic Desktop — Starting" }
val koinApp = remember { startKoin { modules(desktopPlatformModule(), desktopModule()) } }
val systemLocale = remember { Locale.getDefault() }
val uiViewModel = remember { koinApp.koin.get<UIViewModel>() }
val httpClient = remember { koinApp.koin.get<HttpClient>() }
remember { startKoin { modules(desktopPlatformModule(), desktopModule()) } }
DisposableEffect(Unit) { onDispose { stopKoin() } }
val uiViewModel = koinViewModel<UIViewModel>()
DeepLinkHandler(args, uiViewModel)
MeshServiceLifecycle()
ThemeAndLocaleProvider(uiViewModel)
}
// ----- Deep link handling -----
/** Processes deep-link URIs from CLI arguments and OS-level URI handlers. */
@Composable
private fun ApplicationScope.DeepLinkHandler(args: Array<String>, uiViewModel: UIViewModel) {
LaunchedEffect(args) {
args.forEach { arg ->
if (
@ -124,14 +146,28 @@ fun main(args: Array<String>) = application(exitProcessOnExit = false) {
}
}
}
}
val meshServiceController = remember { koinApp.koin.get<MeshServiceOrchestrator>() }
// ----- Mesh service lifecycle -----
/** Starts [MeshServiceOrchestrator] on composition and stops it on disposal. */
@Composable
private fun MeshServiceLifecycle() {
val meshServiceController = koinInject<MeshServiceOrchestrator>()
DisposableEffect(Unit) {
meshServiceController.start()
onDispose { meshServiceController.stop() }
}
}
val uiPrefs = remember { koinApp.koin.get<UiPrefs>() }
// ----- Theme, locale, and application shell -----
/** Resolves the user's theme/locale preferences and renders the full application UI. */
@Composable
@OptIn(ExperimentalCoilApi::class)
private fun ApplicationScope.ThemeAndLocaleProvider(uiViewModel: UIViewModel) {
val systemLocale = remember { Locale.getDefault() }
val uiPrefs = koinInject<UiPrefs>()
val themePref by uiPrefs.theme.collectAsState(initial = -1)
val localePref by uiPrefs.locale.collectAsState(initial = "")
@ -144,25 +180,59 @@ fun main(args: Array<String>) = application(exitProcessOnExit = false) {
else -> isSystemInDarkTheme()
}
MeshtasticDesktopApp(uiViewModel, isDarkTheme)
}
// ----- Application chrome (tray, window, navigation) -----
/** Composes the system tray, window, and Coil image loader. */
@Composable
@OptIn(ExperimentalCoilApi::class)
private fun ApplicationScope.MeshtasticDesktopApp(uiViewModel: UIViewModel, isDarkTheme: Boolean) {
var isAppVisible by remember { mutableStateOf(true) }
var isWindowReady by remember { mutableStateOf(false) }
val trayState = rememberTrayState()
val appIcon = classpathPainterResource("icon.png")
val density = LocalDensity.current
val appIcon = svgPainterResource("tray_icon_black.svg", density)
@Suppress("DEPRECATION")
val trayIcon =
androidx.compose.ui.res.painterResource(
if (isSystemInDarkTheme()) "tray_icon_white.svg" else "tray_icon_black.svg",
)
svgPainterResource(if (isSystemInDarkTheme()) "tray_icon_white.svg" else "tray_icon_black.svg", density)
val notificationManager = remember { koinApp.koin.get<DesktopNotificationManager>() }
val desktopPrefs = remember { koinApp.koin.get<DesktopPreferencesDataSource>() }
val notificationManager = koinInject<DesktopNotificationManager>()
val desktopPrefs = koinInject<DesktopPreferencesDataSource>()
val windowState = rememberWindowState()
LaunchedEffect(Unit) {
notificationManager.notifications.collect { notification -> trayState.sendNotification(notification) }
}
WindowBoundsManager(desktopPrefs, windowState) { isWindowReady = true }
Tray(
state = trayState,
icon = trayIcon,
tooltip = "Meshtastic Desktop",
onAction = { isAppVisible = true },
menu = {
Item("Show Meshtastic", onClick = { isAppVisible = true })
Item("Quit", onClick = ::exitApplication)
},
)
if (isWindowReady && isAppVisible) {
MeshtasticWindow(uiViewModel, isDarkTheme, appIcon, windowState) { isAppVisible = false }
}
}
// ----- Window bounds persistence -----
/** Restores window geometry from preferences and persists changes via [snapshotFlow]. */
@Composable
private fun WindowBoundsManager(
desktopPrefs: DesktopPreferencesDataSource,
windowState: WindowState,
onReady: () -> Unit,
) {
LaunchedEffect(Unit) {
val initialWidth = desktopPrefs.windowWidth.first()
val initialHeight = desktopPrefs.windowHeight.first()
@ -177,7 +247,7 @@ fun main(args: Array<String>) = application(exitProcessOnExit = false) {
WindowPosition(Alignment.Center)
}
isWindowReady = true
onReady()
snapshotFlow {
val x = if (windowState.position.isSpecified) windowState.position.x.value else Float.NaN
@ -188,86 +258,99 @@ fun main(args: Array<String>) = application(exitProcessOnExit = false) {
desktopPrefs.setWindowBounds(width = bounds[0], height = bounds[1], x = bounds[2], y = bounds[3])
}
}
}
Tray(
state = trayState,
icon = trayIcon,
tooltip = "Meshtastic Desktop",
onAction = { isAppVisible = true },
menu = {
Item("Show Meshtastic", onClick = { isAppVisible = true })
Item("Quit", onClick = ::exitApplication)
},
)
// ----- Main window with keyboard shortcuts and Coil -----
if (isWindowReady && isAppVisible) {
val multiBackstack = rememberMultiBackstack(TopLevelDestination.Connections.route)
val backStack = multiBackstack.activeBackStack
/** Renders the main application window with keyboard shortcuts, Coil image loading, and the Compose UI tree. */
@Composable
@OptIn(ExperimentalCoilApi::class)
private fun ApplicationScope.MeshtasticWindow(
uiViewModel: UIViewModel,
isDarkTheme: Boolean,
appIcon: Painter,
windowState: WindowState,
onCloseRequest: () -> Unit,
) {
val multiBackstack = rememberMultiBackstack(TopLevelDestination.Connections.route)
Window(
onCloseRequest = { isAppVisible = false },
title = "Meshtastic Desktop",
icon = appIcon,
state = windowState,
onPreviewKeyEvent = { event ->
if (event.type != KeyEventType.KeyDown || !event.isMetaPressed) return@Window false
when {
event.key == Key.Q -> {
exitApplication()
true
}
event.key == Key.Comma -> {
if (
TopLevelDestination.Settings != TopLevelDestination.fromNavKey(backStack.lastOrNull())
) {
multiBackstack.navigateTopLevel(TopLevelDestination.Settings.route)
}
true
}
event.key == Key.One -> {
multiBackstack.navigateTopLevel(TopLevelDestination.Conversations.route)
true
}
event.key == Key.Two -> {
multiBackstack.navigateTopLevel(TopLevelDestination.Nodes.route)
true
}
event.key == Key.Three -> {
multiBackstack.navigateTopLevel(TopLevelDestination.Map.route)
true
}
event.key == Key.Four -> {
multiBackstack.navigateTopLevel(TopLevelDestination.Connections.route)
true
}
event.key == Key.Slash -> {
backStack.add(SettingsRoute.About)
true
}
else -> false
}
},
) {
setSingletonImageLoaderFactory { context ->
val cacheDir = desktopDataDir() + "/image_cache_v3"
ImageLoader.Builder(context)
.components {
add(KtorNetworkFetcherFactory(httpClient = httpClient))
// Render SVGs to a bitmap on Desktop to avoid Skiko vector rendering artifacts
// that show up as solid/black hardware images.
add(SvgDecoder.Factory(renderToBitmap = true))
}
.memoryCache { MemoryCache.Builder().maxSizeBytes(MEMORY_CACHE_MAX_BYTES).build() }
.diskCache {
DiskCache.Builder().directory(cacheDir.toPath()).maxSizeBytes(DISK_CACHE_MAX_BYTES).build()
}
.crossfade(true)
.build()
}
CompositionLocalProvider(LocalAppLocale provides localePref) {
AppTheme(darkTheme = isDarkTheme) { DesktopMainScreen(uiViewModel, multiBackstack) }
}
}
Window(
onCloseRequest = onCloseRequest,
title = "Meshtastic Desktop",
icon = appIcon,
state = windowState,
onPreviewKeyEvent = { event -> handleKeyboardShortcut(event, multiBackstack, ::exitApplication) },
) {
CoilImageLoaderSetup()
AppTheme(darkTheme = isDarkTheme) { DesktopMainScreen(uiViewModel, multiBackstack) }
}
}
/** Configures the Coil singleton [ImageLoader] with Ktor networking, SVG decoding, and caching. */
@Composable
@OptIn(ExperimentalCoilApi::class)
private fun CoilImageLoaderSetup() {
val httpClient = koinInject<HttpClient>()
val buildConfigProvider = koinInject<BuildConfigProvider>()
setSingletonImageLoaderFactory { context ->
val cacheDir = desktopDataDir() + "/image_cache_v3"
ImageLoader.Builder(context)
.components {
add(KtorNetworkFetcherFactory(httpClient = httpClient))
// Render SVGs to a bitmap on Desktop to avoid Skiko vector rendering artifacts
// that show up as solid/black hardware images.
add(SvgDecoder.Factory(renderToBitmap = true))
}
.memoryCache { MemoryCache.Builder().maxSizeBytes(MEMORY_CACHE_MAX_BYTES).build() }
.diskCache { DiskCache.Builder().directory(cacheDir.toPath()).maxSizeBytes(DISK_CACHE_MAX_BYTES).build() }
.logger(if (buildConfigProvider.isDebug) DebugLogger(minLevel = CoilLogger.Level.Verbose) else null)
.crossfade(true)
.build()
}
}
// ----- Keyboard shortcuts -----
/** Handles Cmd-key shortcuts. Returns `true` if the event was consumed. */
private fun handleKeyboardShortcut(
event: androidx.compose.ui.input.key.KeyEvent,
multiBackstack: MultiBackstack,
exitApplication: () -> Unit,
): Boolean {
if (event.type != KeyEventType.KeyDown || !event.isMetaPressed) return false
val backStack = multiBackstack.activeBackStack
return when (event.key) {
Key.Q -> {
exitApplication()
true
}
Key.Comma -> {
if (TopLevelDestination.Settings != TopLevelDestination.fromNavKey(backStack.lastOrNull())) {
multiBackstack.navigateTopLevel(TopLevelDestination.Settings.route)
}
true
}
Key.One -> {
multiBackstack.navigateTopLevel(TopLevelDestination.Conversations.route)
true
}
Key.Two -> {
multiBackstack.navigateTopLevel(TopLevelDestination.Nodes.route)
true
}
Key.Three -> {
multiBackstack.navigateTopLevel(TopLevelDestination.Map.route)
true
}
Key.Four -> {
multiBackstack.navigateTopLevel(TopLevelDestination.Connections.route)
true
}
Key.Slash -> {
backStack.add(SettingsRoute.About)
true
}
else -> false
}
}

View file

@ -21,7 +21,6 @@ import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.floatPreferencesKey
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@ -30,16 +29,21 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.di.CoroutineDispatchers
const val KEY_WINDOW_WIDTH = "window_width"
const val KEY_WINDOW_HEIGHT = "window_height"
const val KEY_WINDOW_X = "window_x"
const val KEY_WINDOW_Y = "window_y"
/**
* Persists and restores desktop window geometry (position and size) across application restarts.
*
* Backed by the `CorePreferencesDataStore` [DataStore] instance. Window bounds are written atomically via
* [setWindowBounds] and exposed as [StateFlow] properties for composable consumption.
*/
@Single
class DesktopPreferencesDataSource(@Named("CorePreferencesDataStore") private val dataStore: DataStore<Preferences>) {
class DesktopPreferencesDataSource(
@Named("CorePreferencesDataStore") private val dataStore: DataStore<Preferences>,
dispatchers: CoroutineDispatchers,
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val scope = CoroutineScope(SupervisorJob() + dispatchers.io)
val windowWidth: StateFlow<Float> = dataStore.prefStateFlow(key = WINDOW_WIDTH, default = 1024f)
val windowHeight: StateFlow<Float> = dataStore.prefStateFlow(key = WINDOW_HEIGHT, default = 768f)
@ -64,9 +68,9 @@ class DesktopPreferencesDataSource(@Named("CorePreferencesDataStore") private va
): StateFlow<T> = data.map { it[key] ?: default }.stateIn(scope = scope, started = started, initialValue = default)
companion object {
val WINDOW_WIDTH = floatPreferencesKey(KEY_WINDOW_WIDTH)
val WINDOW_HEIGHT = floatPreferencesKey(KEY_WINDOW_HEIGHT)
val WINDOW_X = floatPreferencesKey(KEY_WINDOW_X)
val WINDOW_Y = floatPreferencesKey(KEY_WINDOW_Y)
val WINDOW_WIDTH = floatPreferencesKey("window_width")
val WINDOW_HEIGHT = floatPreferencesKey("window_height")
val WINDOW_X = floatPreferencesKey("window_x")
val WINDOW_Y = floatPreferencesKey("window_y")
}
}

View file

@ -19,6 +19,10 @@ package org.meshtastic.desktop.di
import org.koin.core.annotation.ComponentScan
import org.koin.core.annotation.Module
/**
* Koin module that component-scans the `org.meshtastic.desktop` package for annotated bindings (`@Single`, `@Factory`,
* `@KoinViewModel`).
*/
@Module
@ComponentScan("org.meshtastic.desktop")
class DesktopDiModule

View file

@ -14,11 +14,15 @@
* 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 KSP-generated extension functions require aliased imports
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.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
@ -32,6 +36,7 @@ import org.meshtastic.core.model.BootloaderOtaQuirk
import org.meshtastic.core.model.NetworkDeviceHardware
import org.meshtastic.core.model.NetworkFirmwareReleases
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.network.HttpClientDefaults
import org.meshtastic.core.network.KermitHttpLogger
import org.meshtastic.core.network.repository.MQTTRepository
import org.meshtastic.core.network.service.ApiService
@ -163,7 +168,7 @@ private fun desktopPlatformStubsModule() = module {
single<ServiceBroadcasts> { NoopServiceBroadcasts() }
single<AppWidgetUpdater> { NoopAppWidgetUpdater() }
single<MeshWorkerManager> { NoopMeshWorkerManager() }
single<MessageQueue> { DesktopMessageQueue(packetRepository = get(), radioController = get()) }
single<MessageQueue> { DesktopMessageQueue(packetRepository = get(), radioController = get(), dispatchers = get()) }
single<MeshLocationManager> { NoopMeshLocationManager() }
single<LocationRepository> { NoopLocationRepository() }
single<MQTTRepository> { NoopMQTTRepository() }
@ -178,6 +183,15 @@ private fun desktopPlatformStubsModule() = module {
single<HttpClient> {
HttpClient(Java) {
install(ContentNegotiation) { json(get<Json>()) }
install(HttpTimeout) {
requestTimeoutMillis = HttpClientDefaults.TIMEOUT_MS
connectTimeoutMillis = HttpClientDefaults.TIMEOUT_MS
socketTimeoutMillis = HttpClientDefaults.TIMEOUT_MS
}
install(HttpRequestRetry) {
retryOnServerErrors(maxRetries = HttpClientDefaults.MAX_RETRIES)
exponentialDelay()
}
if (DesktopBuildConfig.IS_DEBUG) {
install(Logging) {
logger = KermitHttpLogger

View file

@ -27,7 +27,6 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import okio.FileSystem
import okio.Path.Companion.toPath
@ -35,10 +34,12 @@ import org.koin.core.qualifier.named
import org.koin.dsl.module
import org.meshtastic.core.common.BuildConfigProvider
import org.meshtastic.core.database.desktopDataDir
import org.meshtastic.core.datastore.di.DATASTORE_SCOPE
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.desktop.DesktopBuildConfig
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.LocalConfig
@ -49,10 +50,10 @@ import org.meshtastic.proto.LocalStats
private fun createPreferencesDataStore(name: String, scope: CoroutineScope): DataStore<Preferences> {
val dir = desktopDataDir() + "/datastore"
FileSystem.SYSTEM.createDirectories(dir.toPath())
return PreferenceDataStoreFactory.create(
return PreferenceDataStoreFactory.createWithPath(
corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { emptyPreferences() }),
scope = scope,
produceFile = { (dir + "/$name.preferences_pb").toPath().toNioPath().toFile() },
produceFile = { "$dir/$name.preferences_pb".toPath() },
)
}
@ -80,16 +81,15 @@ private class DesktopProcessLifecycleOwner : LifecycleOwner {
* - [Lifecycle] (`ProcessLifecycle`)
* - [BuildConfigProvider]
*/
@Suppress("InjectDispatcher")
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.
val dataStoreScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
single<CoroutineScope>(named(DATASTORE_SCOPE)) { CoroutineScope(get<CoroutineDispatchers>().io + SupervisorJob()) }
includes(desktopPreferencesDataStoreModule(dataStoreScope), desktopProtoDataStoreModule(dataStoreScope))
includes(desktopPreferencesDataStoreModule(), desktopProtoDataStoreModule())
// -- Build config (values generated at build time by generateDesktopBuildConfig) --
single<BuildConfigProvider> {
@ -108,30 +108,50 @@ fun desktopPlatformModule() = module {
}
/** Named [DataStore]<[Preferences]> instances for all preference domains. */
private fun desktopPreferencesDataStoreModule(scope: CoroutineScope) = module {
single<DataStore<Preferences>>(named("AnalyticsDataStore")) { createPreferencesDataStore("analytics", scope) }
private fun desktopPreferencesDataStoreModule() = module {
single<DataStore<Preferences>>(named("AnalyticsDataStore")) {
createPreferencesDataStore("analytics", get(named(DATASTORE_SCOPE)))
}
single<DataStore<Preferences>>(named("HomoglyphEncodingDataStore")) {
createPreferencesDataStore("homoglyph_encoding", scope)
createPreferencesDataStore("homoglyph_encoding", get(named(DATASTORE_SCOPE)))
}
single<DataStore<Preferences>>(named("AppDataStore")) {
createPreferencesDataStore("app", get(named(DATASTORE_SCOPE)))
}
single<DataStore<Preferences>>(named("CustomEmojiDataStore")) {
createPreferencesDataStore("custom_emoji", get(named(DATASTORE_SCOPE)))
}
single<DataStore<Preferences>>(named("MapDataStore")) {
createPreferencesDataStore("map", get(named(DATASTORE_SCOPE)))
}
single<DataStore<Preferences>>(named("MapConsentDataStore")) {
createPreferencesDataStore("map_consent", get(named(DATASTORE_SCOPE)))
}
single<DataStore<Preferences>>(named("AppDataStore")) { createPreferencesDataStore("app", scope) }
single<DataStore<Preferences>>(named("CustomEmojiDataStore")) { createPreferencesDataStore("custom_emoji", scope) }
single<DataStore<Preferences>>(named("MapDataStore")) { createPreferencesDataStore("map", scope) }
single<DataStore<Preferences>>(named("MapConsentDataStore")) { createPreferencesDataStore("map_consent", scope) }
single<DataStore<Preferences>>(named("MapTileProviderDataStore")) {
createPreferencesDataStore("map_tile_provider", scope)
createPreferencesDataStore("map_tile_provider", get(named(DATASTORE_SCOPE)))
}
single<DataStore<Preferences>>(named("MeshDataStore")) {
createPreferencesDataStore("mesh", get(named(DATASTORE_SCOPE)))
}
single<DataStore<Preferences>>(named("RadioDataStore")) {
createPreferencesDataStore("radio", get(named(DATASTORE_SCOPE)))
}
single<DataStore<Preferences>>(named("UiDataStore")) {
createPreferencesDataStore("ui", get(named(DATASTORE_SCOPE)))
}
single<DataStore<Preferences>>(named("MeshLogDataStore")) {
createPreferencesDataStore("meshlog", get(named(DATASTORE_SCOPE)))
}
single<DataStore<Preferences>>(named("FilterDataStore")) {
createPreferencesDataStore("filter", get(named(DATASTORE_SCOPE)))
}
single<DataStore<Preferences>>(named("MeshDataStore")) { createPreferencesDataStore("mesh", scope) }
single<DataStore<Preferences>>(named("RadioDataStore")) { createPreferencesDataStore("radio", scope) }
single<DataStore<Preferences>>(named("UiDataStore")) { createPreferencesDataStore("ui", scope) }
single<DataStore<Preferences>>(named("MeshLogDataStore")) { createPreferencesDataStore("meshlog", scope) }
single<DataStore<Preferences>>(named("FilterDataStore")) { createPreferencesDataStore("filter", scope) }
single<DataStore<Preferences>>(named("CorePreferencesDataStore")) {
createPreferencesDataStore("core_preferences", scope)
createPreferencesDataStore("core_preferences", get(named(DATASTORE_SCOPE)))
}
}
/** Proto [DataStore] instances (OkioStorage-backed). */
private fun desktopProtoDataStoreModule(scope: CoroutineScope) = module {
private fun desktopProtoDataStoreModule() = module {
val protoDir = desktopDataDir() + "/datastore"
single<DataStore<LocalConfig>>(named("CoreLocalConfigDataStore")) {
@ -143,7 +163,7 @@ private fun desktopProtoDataStoreModule(scope: CoroutineScope) = module {
producePath = { "$protoDir/local_config.pb".toPath() },
),
corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { LocalConfig() }),
scope = scope,
scope = get(named(DATASTORE_SCOPE)),
)
}
@ -156,7 +176,7 @@ private fun desktopProtoDataStoreModule(scope: CoroutineScope) = module {
producePath = { "$protoDir/module_config.pb".toPath() },
),
corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { LocalModuleConfig() }),
scope = scope,
scope = get(named(DATASTORE_SCOPE)),
)
}
@ -169,7 +189,7 @@ private fun desktopProtoDataStoreModule(scope: CoroutineScope) = module {
producePath = { "$protoDir/channel_set.pb".toPath() },
),
corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { ChannelSet() }),
scope = scope,
scope = get(named(DATASTORE_SCOPE)),
)
}
@ -182,7 +202,7 @@ private fun desktopProtoDataStoreModule(scope: CoroutineScope) = module {
producePath = { "$protoDir/local_stats.pb".toPath() },
),
corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { LocalStats() }),
scope = scope,
scope = get(named(DATASTORE_SCOPE)),
)
}
}

View file

@ -19,6 +19,7 @@ package org.meshtastic.desktop.navigation
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import org.meshtastic.core.ui.viewmodel.UIViewModel
import org.meshtastic.feature.connections.navigation.connectionsGraph
import org.meshtastic.feature.firmware.navigation.firmwareGraph
import org.meshtastic.feature.map.navigation.mapGraph
@ -29,42 +30,22 @@ import org.meshtastic.feature.settings.radio.channel.channelsGraph
import org.meshtastic.feature.wifiprovision.navigation.wifiProvisionGraph
/**
* Registers entry providers for all top-level desktop destinations.
* Registers [NavKey] entry providers for every desktop destination.
*
* Nodes uses real composables from `feature:node` via [nodesGraph]. Conversations uses real composables from
* `feature:messaging` via [desktopMessagingGraph]. Settings uses real composables from `feature:settings` via
* [settingsGraph]. Connections uses the shared [ConnectionsScreen]. Other features use placeholder screens until their
* shared composables are wired.
* Each call delegates to the shared navigation graph extension exported by the corresponding feature module, keeping
* the desktop shell free of screen-level composable knowledge.
*/
fun EntryProviderScope<NavKey>.desktopNavGraph(
backStack: NavBackStack<NavKey>,
uiViewModel: org.meshtastic.core.ui.viewmodel.UIViewModel,
) {
// Nodes — real composables from feature:node
fun EntryProviderScope<NavKey>.desktopNavGraph(backStack: NavBackStack<NavKey>, uiViewModel: UIViewModel) {
nodesGraph(
backStack = backStack,
scrollToTopEvents = uiViewModel.scrollToTopEventFlow,
onHandleDeepLink = uiViewModel::handleDeepLink,
)
// Conversations — real composables from feature:messaging
contactsGraph(backStack, uiViewModel.scrollToTopEventFlow)
// Map — placeholder for now, will be replaced with feature:map real implementation
mapGraph(backStack)
// Firmware — in-flow destination (for example from Settings), not a top-level rail tab
firmwareGraph(backStack)
// Settings — real composables from feature:settings
settingsGraph(backStack)
// Channels
channelsGraph(backStack)
// Connections — shared screen
connectionsGraph(backStack)
// WiFi Provisioning — nymea-networkmanager BLE protocol
wifiProvisionGraph(backStack)
}

View file

@ -16,6 +16,7 @@
*/
package org.meshtastic.desktop.notification
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.Node
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.Notification
@ -29,8 +30,15 @@ import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.Telemetry
/**
* Desktop notifications implementation. Registered manually in [desktopPlatformStubsModule] do NOT add @Single to
* avoid double-registration with the @ComponentScan("org.meshtastic.desktop") in DesktopDiModule.
* Desktop implementation of [MeshServiceNotifications].
*
* Converts mesh-layer notification events into domain [Notification] objects and dispatches them through
* [NotificationManager], which ultimately surfaces them as Compose Desktop tray notifications.
*
* 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
* `@ComponentScan("org.meshtastic.desktop")` in [DesktopDiModule][org.meshtastic.desktop.di.DesktopDiModule].
*/
@Suppress("TooManyFunctions")
class DesktopMeshServiceNotifications(private val notificationManager: NotificationManager) : MeshServiceNotifications {
@ -39,14 +47,11 @@ class DesktopMeshServiceNotifications(private val notificationManager: Notificat
}
override fun initChannels() {
// no-op for desktop
// No-op: desktop has no Android notification channels.
}
override fun updateServiceStateNotification(
state: org.meshtastic.core.model.ConnectionState,
telemetry: Telemetry?,
) {
// We don't have a foreground service on desktop
override fun updateServiceStateNotification(state: ConnectionState, telemetry: Telemetry?) {
// No-op: desktop has no foreground service notification.
}
override suspend fun updateMessageNotification(
@ -106,16 +111,10 @@ class DesktopMeshServiceNotifications(private val notificationManager: Notificat
)
}
@Suppress("ktlint:standard:max-line-length")
override fun showAlertNotification(contactKey: String, name: String, alert: String) {
notificationManager.dispatch(
Notification(
title = name,
message = alert,
category = Notification.Category.Alert,
contactKey = contactKey,
),
)
val notification =
Notification(title = name, message = alert, category = Notification.Category.Alert, contactKey = contactKey)
notificationManager.dispatch(notification)
}
override fun showNewNodeSeenNotification(node: Node) {

View file

@ -18,9 +18,9 @@ package org.meshtastic.desktop.radio
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.RadioController
@ -36,8 +36,9 @@ import org.meshtastic.core.repository.PacketRepository
class DesktopMessageQueue(
private val packetRepository: PacketRepository,
private val radioController: RadioController,
dispatchers: CoroutineDispatchers,
) : MessageQueue {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val scope = CoroutineScope(SupervisorJob() + dispatchers.io)
override suspend fun enqueue(packetId: Int) {
scope.launch {

View file

@ -24,15 +24,18 @@ import org.meshtastic.feature.node.compass.MagneticFieldProvider
import org.meshtastic.feature.node.compass.PhoneLocationProvider
import org.meshtastic.feature.node.compass.PhoneLocationState
/** No-op [CompassHeadingProvider] — desktop has no compass sensor. */
class NoopCompassHeadingProvider : CompassHeadingProvider {
override fun headingUpdates(): Flow<HeadingState> = flowOf(HeadingState(hasSensor = false))
}
/** No-op [PhoneLocationProvider] — desktop has no GPS provider. */
class NoopPhoneLocationProvider : PhoneLocationProvider {
override fun locationUpdates(): Flow<PhoneLocationState> =
flowOf(PhoneLocationState(permissionGranted = false, providerEnabled = false))
}
/** No-op [MagneticFieldProvider] — always returns zero declination. */
class NoopMagneticFieldProvider : MagneticFieldProvider {
override fun getDeclination(latitude: Double, longitude: Double, altitude: Double, timeMillis: Long): Float = 0f
}

View file

@ -31,7 +31,10 @@ import org.meshtastic.core.ui.component.MeshtasticNavigationSuite
import org.meshtastic.core.ui.viewmodel.UIViewModel
import org.meshtastic.desktop.navigation.desktopNavGraph
/** Desktop main screen — uses shared navigation components. */
/**
* Desktop main screen assembles the shared [MeshtasticAppShell], [MeshtasticNavigationSuite], and
* [MeshtasticNavDisplay] with the desktop-specific [desktopNavGraph] entry provider.
*/
@Composable
fun DesktopMainScreen(uiViewModel: UIViewModel, multiBackstack: MultiBackstack) {
val backStack = multiBackstack.activeBackStack