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

@ -266,7 +266,6 @@ dependencies {
implementation(libs.usb.serial.android)
implementation(libs.androidx.work.runtime.ktx)
implementation(libs.koin.android)
implementation(libs.koin.androidx.compose)
implementation(libs.koin.compose.viewmodel)
implementation(libs.koin.androidx.workmanager)
implementation(libs.koin.annotations)

View file

@ -28,7 +28,11 @@ import com.google.android.gms.maps.model.TileProvider
import com.google.android.gms.maps.model.UrlTileProvider
import com.google.maps.android.compose.CameraPositionState
import com.google.maps.android.compose.MapType
import kotlinx.coroutines.Dispatchers
import io.ktor.client.HttpClient
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsChannel
import io.ktor.http.isSuccess
import io.ktor.utils.io.jvm.javaio.toInputStream
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
@ -45,6 +49,7 @@ import org.koin.core.annotation.KoinViewModel
import org.meshtastic.app.map.model.CustomTileProviderConfig
import org.meshtastic.app.map.prefs.map.GoogleMapsPrefs
import org.meshtastic.app.map.repository.CustomTileProviderRepository
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.core.repository.NodeRepository
@ -77,6 +82,8 @@ data class MapCameraPosition(
@KoinViewModel
class MapViewModel(
private val application: Application,
private val dispatchers: CoroutineDispatchers,
private val httpClient: HttpClient,
mapPrefs: MapPrefs,
private val googleMapsPrefs: GoogleMapsPrefs,
nodeRepository: NodeRepository,
@ -404,7 +411,7 @@ class MapViewModel(
}
private fun loadPersistedLayers() {
viewModelScope.launch(Dispatchers.IO) {
viewModelScope.launch(dispatchers.io) {
try {
val layersDir = File(application.filesDir, "map_layers")
if (layersDir.exists() && layersDir.isDirectory) {
@ -412,32 +419,33 @@ class MapViewModel(
if (persistedLayerFiles != null) {
val hiddenLayerUrls = googleMapsPrefs.hiddenLayerUrls.value
val loadedItems = persistedLayerFiles.mapNotNull { file ->
if (file.isFile) {
val layerType =
when (file.extension.lowercase()) {
"kml",
"kmz",
-> LayerType.KML
"geojson",
"json",
-> LayerType.GEOJSON
else -> null
}
val loadedItems =
persistedLayerFiles.mapNotNull { file ->
if (file.isFile) {
val layerType =
when (file.extension.lowercase()) {
"kml",
"kmz",
-> LayerType.KML
"geojson",
"json",
-> LayerType.GEOJSON
else -> null
}
layerType?.let {
val uri = Uri.fromFile(file)
MapLayerItem(
name = file.nameWithoutExtension,
uri = uri,
isVisible = !hiddenLayerUrls.contains(uri.toString()),
layerType = it,
)
layerType?.let {
val uri = Uri.fromFile(file)
MapLayerItem(
name = file.nameWithoutExtension,
uri = uri,
isVisible = !hiddenLayerUrls.contains(uri.toString()),
layerType = it,
)
}
} else {
null
}
} else {
null
}
}
val networkItems =
googleMapsPrefs.networkMapLayers.value.mapNotNull { networkString ->
@ -550,7 +558,7 @@ class MapViewModel(
}
}
private suspend fun copyFileToInternalStorage(uri: Uri, fileName: String): Uri? = withContext(Dispatchers.IO) {
private suspend fun copyFileToInternalStorage(uri: Uri, fileName: String): Uri? = withContext(dispatchers.io) {
try {
val inputStream = application.contentResolver.openInputStream(uri)
val directory = File(application.filesDir, "map_layers")
@ -621,7 +629,7 @@ class MapViewModel(
}
private suspend fun deleteFileToInternalStorage(uri: Uri) {
withContext(Dispatchers.IO) {
withContext(dispatchers.io) {
try {
val file = uri.toFile()
if (file.exists()) {
@ -636,11 +644,15 @@ class MapViewModel(
@Suppress("Recycle")
suspend fun getInputStreamFromUri(layerItem: MapLayerItem): InputStream? {
val uriToLoad = layerItem.uri ?: return null
return withContext(Dispatchers.IO) {
return withContext(dispatchers.io) {
try {
if (layerItem.isNetwork && (uriToLoad.scheme == "http" || uriToLoad.scheme == "https")) {
val url = java.net.URL(uriToLoad.toString())
java.io.BufferedInputStream(url.openStream())
val response = httpClient.get(uriToLoad.toString())
if (!response.status.isSuccess()) {
Logger.withTag("MapViewModel").e { "HTTP ${response.status} fetching layer: $uriToLoad" }
return@withContext null
}
response.bodyAsChannel().toInputStream()
} else {
application.contentResolver.openInputStream(uriToLoad)
}

View file

@ -23,12 +23,12 @@ import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStoreFile
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import org.koin.core.annotation.ComponentScan
import org.koin.core.annotation.Module
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.di.CoroutineDispatchers
@Module
@ComponentScan("org.meshtastic.app.map")
@ -36,9 +36,10 @@ class GoogleMapsKoinModule {
@Single
@Named("GoogleMapsDataStore")
fun provideGoogleMapsDataStore(context: Context): DataStore<Preferences> = PreferenceDataStoreFactory.create(
migrations = listOf(SharedPreferencesMigration(context, "google_maps_prefs")),
scope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
produceFile = { context.preferencesDataStoreFile("google_maps_ds") },
)
fun provideGoogleMapsDataStore(context: Context, dispatchers: CoroutineDispatchers): DataStore<Preferences> =
PreferenceDataStoreFactory.create(
migrations = listOf(SharedPreferencesMigration(context, "google_maps_prefs")),
scope = CoroutineScope(dispatchers.io + SupervisorJob()),
produceFile = { context.preferencesDataStoreFile("google_maps_ds") },
)
}

View file

@ -48,8 +48,8 @@ import coil3.compose.setSingletonImageLoaderFactory
import kotlinx.coroutines.launch
import org.koin.android.ext.android.get
import org.koin.android.ext.android.inject
import org.koin.androidx.compose.koinViewModel
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
import org.meshtastic.app.intro.AnalyticsIntro
import org.meshtastic.app.map.getMapViewProvider

View file

@ -31,6 +31,8 @@ import coil3.util.DebugLogger
import coil3.util.Logger
import io.ktor.client.HttpClient
import io.ktor.client.engine.android.Android
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
@ -40,6 +42,7 @@ import okio.Path.Companion.toOkioPath
import org.koin.core.annotation.Module
import org.koin.core.annotation.Single
import org.meshtastic.core.common.BuildConfigProvider
import org.meshtastic.core.network.HttpClientDefaults
import org.meshtastic.core.network.KermitHttpLogger
private const val DISK_CACHE_PERCENT = 0.02
@ -84,6 +87,15 @@ class NetworkModule {
fun provideHttpClient(json: Json, buildConfigProvider: BuildConfigProvider): HttpClient =
HttpClient(engineFactory = Android) {
install(plugin = ContentNegotiation) { json(json) }
install(plugin = HttpTimeout) {
requestTimeoutMillis = HttpClientDefaults.TIMEOUT_MS
connectTimeoutMillis = HttpClientDefaults.TIMEOUT_MS
socketTimeoutMillis = HttpClientDefaults.TIMEOUT_MS
}
install(plugin = HttpRequestRetry) {
retryOnServerErrors(maxRetries = HttpClientDefaults.MAX_RETRIES)
exponentialDelay()
}
if (buildConfigProvider.isDebug) {
install(plugin = Logging) {
logger = KermitHttpLogger

View file

@ -17,18 +17,15 @@
package org.meshtastic.core.database.dao
import androidx.room3.Dao
import androidx.room3.Insert
import androidx.room3.OnConflictStrategy
import androidx.room3.Query
import androidx.room3.Upsert
import org.meshtastic.core.database.entity.DeviceHardwareEntity
@Dao
interface DeviceHardwareDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(deviceHardware: DeviceHardwareEntity)
@Upsert suspend fun insert(deviceHardware: DeviceHardwareEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(deviceHardware: List<DeviceHardwareEntity>)
@Upsert suspend fun insertAll(deviceHardware: List<DeviceHardwareEntity>)
@Query("SELECT * FROM device_hardware WHERE hwModel = :hwModel")
suspend fun getByHwModel(hwModel: Int): List<DeviceHardwareEntity>

View file

@ -17,16 +17,14 @@
package org.meshtastic.core.database.dao
import androidx.room3.Dao
import androidx.room3.Insert
import androidx.room3.OnConflictStrategy
import androidx.room3.Query
import androidx.room3.Upsert
import org.meshtastic.core.database.entity.FirmwareReleaseEntity
import org.meshtastic.core.database.entity.FirmwareReleaseType
@Dao
interface FirmwareReleaseDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(firmwareReleaseEntity: FirmwareReleaseEntity)
@Upsert suspend fun insert(firmwareReleaseEntity: FirmwareReleaseEntity)
@Query("DELETE FROM firmware_release")
suspend fun deleteAll()

View file

@ -17,9 +17,7 @@
package org.meshtastic.core.database.dao
import androidx.room3.Dao
import androidx.room3.Insert
import androidx.room3.MapColumn
import androidx.room3.OnConflictStrategy
import androidx.room3.Query
import androidx.room3.Transaction
import androidx.room3.Upsert
@ -168,8 +166,7 @@ interface NodeInfoDao {
@Query("SELECT * FROM my_node")
fun getMyNodeInfo(): Flow<MyNodeEntity?>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun setMyNodeInfo(myInfo: MyNodeEntity)
@Upsert suspend fun setMyNodeInfo(myInfo: MyNodeEntity)
@Query("DELETE FROM my_node")
suspend fun clearMyNodeInfo()
@ -295,8 +292,7 @@ interface NodeInfoDao {
doUpsert(verifiedNode)
}
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun putAll(nodes: List<NodeEntity>)
@Upsert suspend fun putAll(nodes: List<NodeEntity>)
@Query("UPDATE nodes SET notes = :notes WHERE num = :num")
suspend fun setNodeNotes(num: Int, notes: String)

View file

@ -17,9 +17,8 @@
package org.meshtastic.core.database.dao
import androidx.room3.Dao
import androidx.room3.Insert
import androidx.room3.OnConflictStrategy
import androidx.room3.Query
import androidx.room3.Upsert
import kotlinx.coroutines.flow.Flow
import org.meshtastic.core.database.entity.TracerouteNodePositionEntity
@ -32,6 +31,5 @@ interface TracerouteNodePositionDao {
@Query("DELETE FROM traceroute_node_position WHERE log_uuid = :logUuid")
suspend fun deleteByLogUuid(logUuid: String)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(entities: List<TracerouteNodePositionEntity>)
@Upsert suspend fun insertAll(entities: List<TracerouteNodePositionEntity>)
}

View file

@ -50,7 +50,7 @@ class PreferencesDataStoreModule {
@Named("CorePreferencesDataStore")
fun providePreferencesDataStore(
context: Context,
@Named("DataStoreScope") scope: CoroutineScope,
@Named(DATASTORE_SCOPE) scope: CoroutineScope,
): DataStore<Preferences> = PreferenceDataStoreFactory.create(
corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { emptyPreferences() }),
migrations =
@ -66,7 +66,7 @@ class LocalConfigDataStoreModule {
@Named("CoreLocalConfigDataStore")
fun provideLocalConfigDataStore(
context: Context,
@Named("DataStoreScope") scope: CoroutineScope,
@Named(DATASTORE_SCOPE) scope: CoroutineScope,
): DataStore<LocalConfig> = DataStoreFactory.create(
storage =
OkioStorage(
@ -85,7 +85,7 @@ class ModuleConfigDataStoreModule {
@Named("CoreModuleConfigDataStore")
fun provideModuleConfigDataStore(
context: Context,
@Named("DataStoreScope") scope: CoroutineScope,
@Named(DATASTORE_SCOPE) scope: CoroutineScope,
): DataStore<LocalModuleConfig> = DataStoreFactory.create(
storage =
OkioStorage(
@ -104,7 +104,7 @@ class ChannelSetDataStoreModule {
@Named("CoreChannelSetDataStore")
fun provideChannelSetDataStore(
context: Context,
@Named("DataStoreScope") scope: CoroutineScope,
@Named(DATASTORE_SCOPE) scope: CoroutineScope,
): DataStore<ChannelSet> = DataStoreFactory.create(
storage =
OkioStorage(
@ -123,7 +123,7 @@ class LocalStatsDataStoreModule {
@Named("CoreLocalStatsDataStore")
fun provideLocalStatsDataStore(
context: Context,
@Named("DataStoreScope") scope: CoroutineScope,
@Named(DATASTORE_SCOPE) scope: CoroutineScope,
): DataStore<LocalStats> = DataStoreFactory.create(
storage =
OkioStorage(

View file

@ -24,10 +24,17 @@ import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.ioDispatcher
/**
* Koin qualifier for the application-scoped [CoroutineScope] shared by all [DataStore] instances.
*
* Used with `@Named(DATASTORE_SCOPE)` in Koin annotations and `named(DATASTORE_SCOPE)` in manual DSL modules.
*/
const val DATASTORE_SCOPE = "DataStoreScope"
@Module
@ComponentScan("org.meshtastic.core.datastore")
class CoreDatastoreModule {
@Single
@Named("DataStoreScope")
@Named(DATASTORE_SCOPE)
fun provideDataStoreScope(): CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
}

View file

@ -0,0 +1,31 @@
/*
* 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.core.network
/**
* Shared HTTP client configuration constants used by both Android and Desktop Ktor `HttpClient` setups.
*
* These values are consumed by the platform-specific Koin modules (`NetworkModule` on Android, `DesktopKoinModule` on
* Desktop) when installing [io.ktor.client.plugins.HttpTimeout] and [io.ktor.client.plugins.HttpRequestRetry].
*/
object HttpClientDefaults {
/** Timeout in milliseconds for connect, request, and socket operations. */
const val TIMEOUT_MS = 30_000L
/** Maximum number of automatic retries on server errors (5xx). */
const val MAX_RETRIES = 3
}

View file

@ -20,12 +20,12 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.PacketRepository
@ -38,7 +38,9 @@ class MarkAsReadReceiver :
private val serviceNotifications: MeshServiceNotifications by inject()
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val dispatchers: CoroutineDispatchers by inject()
private val scope by lazy { CoroutineScope(dispatchers.io + SupervisorJob()) }
companion object {
const val MARK_AS_READ_ACTION = "com.geeksville.mesh.MARK_AS_READ"

View file

@ -25,11 +25,11 @@ import android.os.IBinder
import androidx.core.app.ServiceCompat
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import org.koin.android.ext.android.inject
import org.meshtastic.core.common.hasLocationPermission
import org.meshtastic.core.common.util.toRemoteExceptions
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.DeviceVersion
import org.meshtastic.core.model.MeshUser
@ -84,8 +84,10 @@ class MeshService : Service() {
private val router: MeshRouter by inject()
private val dispatchers: CoroutineDispatchers by inject()
private val serviceJob = Job()
private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob)
private val serviceScope by lazy { CoroutineScope(dispatchers.io + serviceJob) }
private var isServiceInitialized = false

View file

@ -21,11 +21,11 @@ import android.content.Context
import android.content.Intent
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.service.ServiceAction
import org.meshtastic.core.repository.ServiceRepository
@ -41,7 +41,9 @@ class ReactionReceiver :
private val serviceRepository: ServiceRepository by inject()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val dispatchers: CoroutineDispatchers by inject()
private val scope by lazy { CoroutineScope(SupervisorJob() + dispatchers.io) }
@Suppress("TooGenericExceptionCaught", "ReturnCount")
override fun onReceive(context: Context, intent: Intent) {

View file

@ -21,11 +21,11 @@ import android.content.Context
import android.content.Intent
import androidx.core.app.RemoteInput
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.repository.MeshServiceNotifications
@ -44,7 +44,9 @@ class ReplyReceiver :
private val meshServiceNotifications: MeshServiceNotifications by inject()
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val dispatchers: CoroutineDispatchers by inject()
private val scope by lazy { CoroutineScope(dispatchers.io + SupervisorJob()) }
companion object {
const val REPLY_ACTION = "org.meshtastic.app.REPLY_ACTION"

View file

@ -55,7 +55,6 @@ kotlin {
implementation(libs.jetbrains.compose.material3.adaptive.layout)
implementation(libs.jetbrains.compose.material3.adaptive.navigation)
implementation(libs.jetbrains.compose.material3.adaptive.navigation.suite)
implementation(libs.jetbrains.navigationevent.compose)
implementation(libs.jetbrains.navigation3.ui)
implementation(libs.jetbrains.compose.material3.adaptive.navigation3)
implementation(libs.jetbrains.lifecycle.viewmodel.navigation3)

View file

@ -27,17 +27,18 @@ import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import org.meshtastic.core.common.util.nowMillis
@Composable
actual fun rememberTimeTickWithLifecycle(): Long {
val context = LocalContext.current
var value by remember { mutableLongStateOf(System.currentTimeMillis()) }
var value by remember { mutableLongStateOf(nowMillis) }
DisposableEffect(context) {
val receiver =
object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
value = System.currentTimeMillis()
value = nowMillis
}
}

View file

@ -17,6 +17,7 @@
package org.meshtastic.core.ui.component
import androidx.compose.runtime.Composable
import org.meshtastic.core.common.util.nowMillis
/** JVM implementation — returns System.currentTimeMillis() (no lifecycle-based updates on Desktop). */
@Composable actual fun rememberTimeTickWithLifecycle(): Long = System.currentTimeMillis()
/** JVM implementation — returns the current epoch millis (no lifecycle-based updates on Desktop). */
@Composable actual fun rememberTimeTickWithLifecycle(): Long = nowMillis

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

View file

@ -42,7 +42,6 @@ kotlin {
implementation(projects.core.ui)
implementation(libs.jetbrains.navigation3.ui)
implementation(libs.jetbrains.navigationevent.compose)
implementation(libs.androidx.paging.common)
implementation(libs.androidx.paging.compose)

View file

@ -12,7 +12,6 @@ glance = "1.2.0-rc01"
lifecycle = "2.10.0"
jetbrains-lifecycle = "2.11.0-alpha03"
navigation3 = "1.1.0-rc01"
navigationevent = "1.1.0-alpha01"
paging = "3.4.2"
room = "3.0.0-alpha03"
koin = "4.2.1"
@ -112,7 +111,6 @@ jetbrains-lifecycle-runtime-compose = { module = "org.jetbrains.androidx.lifecyc
jetbrains-lifecycle-viewmodel-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "jetbrains-lifecycle" }
jetbrains-lifecycle-viewmodel-navigation3 = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "jetbrains-lifecycle" }
jetbrains-navigation3-ui = { module = "org.jetbrains.androidx.navigation3:navigation3-ui", version.ref = "navigation3" }
jetbrains-navigationevent-compose = { module = "org.jetbrains.androidx.navigationevent:navigationevent-compose", version.ref = "navigationevent" }
androidx-paging-common = { module = "androidx.paging:paging-common", version.ref = "paging" }
androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "paging" }
androidx-room-compiler = { module = "androidx.room3:room3-compiler", version.ref = "room" }