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
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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") },
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue