mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
refactor: modern APIs — Koin 4.2, CMP 1.11, Ktor resilience, Room @Upsert, injected dispatchers (#5119)
This commit is contained in:
parent
99378c9291
commit
9acdf5309f
32 changed files with 453 additions and 278 deletions
|
|
@ -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.
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue