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

@ -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