From 14b381c1eb01d7917dc19830ab25041b7beb5006 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Thu, 9 Apr 2026 13:21:46 -0500 Subject: [PATCH] fix: harden reliability, clean up KMP compliance, and improve code quality (#5023) --- .../org/meshtastic/app/di/NetworkModule.kt | 6 - core/ble/build.gradle.kts | 5 +- .../core/ble/ActiveBleConnection.kt | 8 +- .../meshtastic/core/ble/KableBleConnection.kt | 56 ++++++- .../core/ble/KableMeshtasticRadioProfile.kt | 21 ++- .../meshtastic/core/ble/KablePlatformSetup.kt | 7 +- .../meshtastic/core/common/ContextServices.kt | 17 --- .../core/common/util/TimeExtensions.kt | 34 ----- .../core/common/util/SyncContinuation.kt | 33 ---- .../util/SyncContinuation.jvmAndroid.kt | 83 ----------- .../common/util/TimeExtensions.jvmAndroid.kt} | 0 core/data/build.gradle.kts | 3 - .../core/data/manager/MeshDataHandlerImpl.kt | 8 +- .../core/data/manager/NodeManagerImpl.kt | 26 ++-- core/database/build.gradle.kts | 2 - core/domain/build.gradle.kts | 6 +- core/navigation/build.gradle.kts | 2 - .../core/network/di/CoreNetworkModule.kt | 1 + .../core/network/radio/BleRadioInterface.kt | 2 +- .../core/network/radio/StreamInterface.kt | 4 +- .../network/repository/MQTTRepositoryImpl.kt | 57 +++++-- .../core/network/service/ApiService.kt | 11 +- .../core/network/transport/TcpTransport.kt | 9 +- .../core/network/SerialTransport.kt | 14 +- core/nfc/build.gradle.kts | 2 - core/prefs/README.md | 6 +- core/prefs/build.gradle.kts | 5 +- .../core/prefs/di/CorePrefsAndroidModule.kt | 141 ++++++++++-------- core/repository/build.gradle.kts | 3 - core/resources/build.gradle.kts | 5 +- .../composeResources/values/strings.xml | 2 + core/service/build.gradle.kts | 6 +- .../service/AndroidRadioControllerImpl.kt | 18 ++- .../core/service/BootCompleteReceiver.kt | 16 +- .../org/meshtastic/core/service/Constants.kt | 16 +- .../service/MeshServiceNotificationsImpl.kt | 7 +- .../core/service/ReactionReceiver.kt | 9 ++ .../core/service/ServiceBroadcasts.kt | 8 +- .../service/SharedRadioInterfaceService.kt | 11 +- .../core/ui/util/ContextExtensions.kt | 9 -- .../core/ui/component/PreferenceFooter.kt | 24 --- .../desktop/DesktopNotificationManager.kt | 6 +- .../desktop/di/DesktopKoinModule.kt | 5 + .../DesktopMeshServiceNotifications.kt | 6 +- .../desktop/radio/DesktopMessageQueue.kt | 3 +- .../radio/DesktopRadioTransportFactory.kt | 6 +- .../org/meshtastic/desktop/stub/NoopStubs.kt | 9 +- .../firmware/FirmwareUpdateViewModel.kt | 4 +- .../feature/map/BaseMapViewModel.kt | 17 ++- .../settings/radio/RadioConfigViewModel.kt | 2 +- .../channel/component/EditChannelDialog.kt | 3 +- .../radio/component/SerialConfigItemList.kt | 6 +- .../wifiprovision/WifiProvisionViewModel.kt | 9 +- 53 files changed, 370 insertions(+), 409 deletions(-) delete mode 100644 core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/TimeExtensions.kt delete mode 100644 core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/SyncContinuation.kt delete mode 100644 core/common/src/jvmAndroidMain/kotlin/org/meshtastic/core/common/util/SyncContinuation.jvmAndroid.kt rename core/common/src/{jvmMain/kotlin/org/meshtastic/core/common/util/TimeExtensions.kt => jvmAndroidMain/kotlin/org/meshtastic/core/common/util/TimeExtensions.jvmAndroid.kt} (100%) diff --git a/app/src/main/kotlin/org/meshtastic/app/di/NetworkModule.kt b/app/src/main/kotlin/org/meshtastic/app/di/NetworkModule.kt index fe9989f68..7f6fb0215 100644 --- a/app/src/main/kotlin/org/meshtastic/app/di/NetworkModule.kt +++ b/app/src/main/kotlin/org/meshtastic/app/di/NetworkModule.kt @@ -79,12 +79,6 @@ class NetworkModule { .crossfade(enable = true) .build() - @Single - fun provideJson(): Json = Json { - isLenient = true - ignoreUnknownKeys = true - } - @Single fun provideHttpClient(json: Json, buildConfigProvider: BuildConfigProvider): HttpClient = HttpClient(engineFactory = Android) { diff --git a/core/ble/build.gradle.kts b/core/ble/build.gradle.kts index bdf449f49..b61fad0e7 100644 --- a/core/ble/build.gradle.kts +++ b/core/ble/build.gradle.kts @@ -46,10 +46,7 @@ kotlin { implementation(libs.jetbrains.lifecycle.runtime) } - commonTest.dependencies { - implementation(kotlin("test")) - implementation(libs.kotlinx.coroutines.test) - } + commonTest.dependencies { implementation(libs.kotlinx.coroutines.test) } val androidHostTest by getting { dependencies { diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/ActiveBleConnection.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/ActiveBleConnection.kt index 004beec06..1bfaff648 100644 --- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/ActiveBleConnection.kt +++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/ActiveBleConnection.kt @@ -17,12 +17,16 @@ package org.meshtastic.core.ble import com.juul.kable.Peripheral +import kotlin.concurrent.Volatile /** * A simple global tracker for the currently active BLE connection. This resolves instance mismatch issues between * dynamically created UI devices (scanned vs bonded) and the actual connection. + * + * Fields are volatile to ensure visibility across AIDL binder threads and coroutine dispatchers. */ internal object ActiveBleConnection { - var activePeripheral: Peripheral? = null - var activeAddress: String? = null + @Volatile var activePeripheral: Peripheral? = null + + @Volatile var activeAddress: String? = null } diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnection.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnection.kt index 31563aa80..5265127c1 100644 --- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnection.kt +++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnection.kt @@ -35,11 +35,13 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.job import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import kotlin.time.Duration import kotlin.uuid.Uuid +/** [BleService] implementation backed by a Kable [Peripheral] for a specific GATT service. */ class KableBleService(private val peripheral: Peripheral, private val serviceUuid: Uuid) : BleService { override fun hasCharacteristic(characteristic: BleCharacteristic): Boolean = peripheral.services.value?.any { svc -> svc.serviceUuid == serviceUuid && svc.characteristics.any { it.characteristicUuid == characteristic.uuid } @@ -73,12 +75,25 @@ class KableBleService(private val peripheral: Peripheral, private val serviceUui } } +/** + * [BleConnection] implementation using Kable for cross-platform BLE communication. + * + * Manages peripheral lifecycle (connect with exponential backoff, disconnect, reconnect), connection state tracking, + * and GATT service profile access. + */ class KableBleConnection(private val scope: CoroutineScope) : BleConnection { private var peripheral: Peripheral? = null private var stateJob: Job? = null private var connectionScope: CoroutineScope? = null + companion object { + private const val INITIAL_RETRY_DELAY_MS = 1000L + private const val MAX_RETRY_DELAY_MS = 30_000L + private const val MAX_CONNECT_RETRIES = 15 + private const val BACKOFF_MULTIPLIER = 2 + } + private val _deviceFlow = MutableSharedFlow(replay = 1) override val deviceFlow: SharedFlow = _deviceFlow.asSharedFlow() @@ -93,6 +108,7 @@ class KableBleConnection(private val scope: CoroutineScope) : BleConnection { ) override val connectionState: SharedFlow = _connectionState.asSharedFlow() + @Suppress("LongMethod", "CyclomaticComplexMethod") override suspend fun connect(device: BleDevice) { val autoConnect = MutableStateFlow(device is DirectBleDevice) @@ -115,8 +131,20 @@ class KableBleConnection(private val scope: CoroutineScope) : BleConnection { else -> error("Unsupported BleDevice type: ${device::class}") } - peripheral?.disconnect() - peripheral?.close() + // Clean up previous peripheral under NonCancellable to prevent GATT resource leaks + // if the calling coroutine is cancelled during teardown. + withContext(NonCancellable) { + try { + peripheral?.disconnect() + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + Logger.w(e) { "[${device.address}] Failed to disconnect previous peripheral" } + } + try { + peripheral?.close() + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + Logger.w(e) { "[${device.address}] Failed to close previous peripheral" } + } + } peripheral = p ActiveBleConnection.activePeripheral = p @@ -143,17 +171,30 @@ class KableBleConnection(private val scope: CoroutineScope) : BleConnection { } .launchIn(scope) + var retryCount = 0 + var retryDelayMs = INITIAL_RETRY_DELAY_MS while (p.state.value !is State.Connected) { autoConnect.value = try { + // Cancel any previous connectionScope to avoid leaking the old coroutine scope. + connectionScope?.let { oldScope -> + Logger.d { "[${device.address}] Cancelling previous connectionScope before reconnect" } + oldScope.coroutineContext.job.cancel() + } connectionScope = p.connect() false } catch (e: CancellationException) { throw e } catch (@Suppress("TooGenericExceptionCaught", "SwallowedException") _: Exception) { - @Suppress("MagicNumber") - val retryDelayMs = 1000L + retryCount++ + if (retryCount > MAX_CONNECT_RETRIES) { + Logger.w { "[${device.address}] Max connect retries ($MAX_CONNECT_RETRIES) exceeded" } + _connectionState.emit(BleConnectionState.Disconnected) + return + } + Logger.d { "[${device.address}] Connect retry $retryCount, backoff ${retryDelayMs}ms" } delay(retryDelayMs) + retryDelayMs = (retryDelayMs * BACKOFF_MULTIPLIER).coerceAtMost(MAX_RETRY_DELAY_MS) true } } @@ -176,6 +217,11 @@ class KableBleConnection(private val scope: CoroutineScope) : BleConnection { } override suspend fun disconnect() = withContext(NonCancellable) { + // Emit Disconnected before cancelling stateJob so downstream collectors see the + // state transition. If we cancel stateJob first, the peripheral's state flow + // emission of Disconnected is never forwarded to _connectionState. + _connectionState.emit(BleConnectionState.Disconnected) + stateJob?.cancel() stateJob = null peripheral?.disconnect() @@ -197,7 +243,7 @@ class KableBleConnection(private val scope: CoroutineScope) : BleConnection { val p = peripheral ?: error("Not connected") val cScope = connectionScope ?: error("No active connection scope") val service = KableBleService(p, serviceUuid) - return cScope.setup(service) + return withTimeout(timeout) { cScope.setup(service) } } override fun maximumWriteValueLength(writeType: BleWriteType): Int? = peripheral?.negotiatedMaxWriteLength() diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableMeshtasticRadioProfile.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableMeshtasticRadioProfile.kt index ed4df97d0..46ace854f 100644 --- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableMeshtasticRadioProfile.kt +++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableMeshtasticRadioProfile.kt @@ -16,8 +16,10 @@ */ package org.meshtastic.core.ble +import co.touchlab.kermit.Logger import kotlinx.coroutines.CancellationException import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.channelFlow @@ -28,6 +30,12 @@ import org.meshtastic.core.ble.MeshtasticBleConstants.FROMRADIO_CHARACTERISTIC import org.meshtastic.core.ble.MeshtasticBleConstants.LOGRADIO_CHARACTERISTIC import org.meshtastic.core.ble.MeshtasticBleConstants.TORADIO_CHARACTERISTIC +/** + * [MeshtasticRadioProfile] implementation using Kable BLE characteristics. + * + * Supports both the modern `FROMRADIOSYNC` characteristic (single observe stream) and the legacy `FROMNUM` + + * `FROMRADIO` polling fallback for older firmware versions. + */ class KableMeshtasticRadioProfile(private val service: BleService) : MeshtasticRadioProfile { private val toRadio = service.characteristic(TORADIO_CHARACTERISTIC) @@ -36,6 +44,10 @@ class KableMeshtasticRadioProfile(private val service: BleService) : MeshtasticR private val fromNum = service.characteristic(FROMNUM_CHARACTERISTIC) private val logRadioChar = service.characteristic(LOGRADIO_CHARACTERISTIC) + companion object { + private const val TRANSIENT_RETRY_DELAY_MS = 500L + } + // replay = 1: a seed emission placed here before the collector starts is replayed to the // collector immediately on subscription. This is what drives the initial FROMRADIO poll // during the config-handshake phase, where the firmware suppresses FROMNUM notifications @@ -81,8 +93,13 @@ class KableMeshtasticRadioProfile(private val service: BleService) : MeshtasticR } val packet = service.read(fromRadioChar) if (packet.isEmpty()) keepReading = false else send(packet) - } catch (_: Exception) { + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Logger.w(e) { "FROMRADIO read error, pausing before next drain trigger" } keepReading = false + // Don't permanently stop — the next triggerDrain emission will retry. + delay(TRANSIENT_RETRY_DELAY_MS) } } } @@ -96,6 +113,8 @@ class KableMeshtasticRadioProfile(private val service: BleService) : MeshtasticR if (service.hasCharacteristic(logRadioChar)) { service.observe(logRadioChar).collect { send(it) } } + } catch (e: CancellationException) { + throw e } catch (_: Exception) { // logRadio is optional, ignore if not found } diff --git a/core/ble/src/jvmMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt b/core/ble/src/jvmMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt index 33da61ff1..99ff6885c 100644 --- a/core/ble/src/jvmMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt +++ b/core/ble/src/jvmMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt @@ -27,5 +27,8 @@ internal actual fun PeripheralBuilder.platformConfig(device: BleDevice, autoConn internal actual fun createPeripheral(address: String, builderAction: PeripheralBuilder.() -> Unit): Peripheral = com.juul.kable.Peripheral(address.toIdentifier(), builderAction) -// JVM/desktop Kable does not expose an MTU StateFlow; fall back to null so callers use their default. -internal actual fun Peripheral.negotiatedMaxWriteLength(): Int? = null +// JVM/desktop Kable does not expose an MTU StateFlow; return a reasonable default (512) +// so callers can size their writes without falling back to an overly conservative minimum. +internal actual fun Peripheral.negotiatedMaxWriteLength(): Int? = DEFAULT_JVM_MTU + +private const val DEFAULT_JVM_MTU = 512 diff --git a/core/common/src/androidMain/kotlin/org/meshtastic/core/common/ContextServices.kt b/core/common/src/androidMain/kotlin/org/meshtastic/core/common/ContextServices.kt index ad4629fba..92463c191 100644 --- a/core/common/src/androidMain/kotlin/org/meshtastic/core/common/ContextServices.kt +++ b/core/common/src/androidMain/kotlin/org/meshtastic/core/common/ContextServices.kt @@ -18,9 +18,7 @@ package org.meshtastic.core.common import android.Manifest import android.app.Application -import android.content.BroadcastReceiver import android.content.Context -import android.content.IntentFilter import android.content.pm.PackageManager import android.location.LocationManager import android.os.Build @@ -80,18 +78,3 @@ fun Context.hasLocationPermission(): Boolean { val perms = listOf(Manifest.permission.ACCESS_FINE_LOCATION) return perms.all { ContextCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_GRANTED } } - -/** - * Extension for Context to register a BroadcastReceiver in a compatible way across Android versions. - * - * @param receiver The receiver to register. - * @param filter The intent filter. - * @param flag The export flag (defaults to [ContextCompat.RECEIVER_EXPORTED]). - */ -fun Context.registerReceiverCompat( - receiver: BroadcastReceiver, - filter: IntentFilter, - flag: Int = ContextCompat.RECEIVER_EXPORTED, -) { - ContextCompat.registerReceiver(this, receiver, filter, flag) -} diff --git a/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/TimeExtensions.kt b/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/TimeExtensions.kt deleted file mode 100644 index 2003092f4..000000000 --- a/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/TimeExtensions.kt +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright (c) 2025-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 . - */ -package org.meshtastic.core.common.util - -import java.util.Date -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit -import kotlin.time.Duration -import kotlin.time.Instant - -/** - * Awaits the latch for the given [Duration]. - * - * @param timeout The maximum time to wait. - * @return `true` if the count reached zero and `false` if the waiting time elapsed before the count reached zero. - */ -fun CountDownLatch.await(timeout: Duration): Boolean = this.await(timeout.inWholeMilliseconds, TimeUnit.MILLISECONDS) - -/** Converts this [Instant] to a legacy [Date]. */ -fun Instant.toDate(): Date = Date(this.toEpochMilliseconds()) diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/SyncContinuation.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/SyncContinuation.kt deleted file mode 100644 index 80251e801..000000000 --- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/SyncContinuation.kt +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright (c) 2025-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 . - */ -package org.meshtastic.core.common.util - -/** A deferred execution object (with various possible implementations) */ -interface Continuation { - fun resume(res: Result) - - /** Syntactic sugar for resuming with success. */ - fun resumeSuccess(res: T) = resume(Result.success(res)) - - /** Syntactic sugar for resuming with failure. */ - fun resumeWithException(ex: Throwable) = resume(Result.failure(ex)) -} - -/** An async continuation that calls a callback when the result is available. */ -class CallbackContinuation(private val cb: (Result) -> Unit) : Continuation { - override fun resume(res: Result) = cb(res) -} diff --git a/core/common/src/jvmAndroidMain/kotlin/org/meshtastic/core/common/util/SyncContinuation.jvmAndroid.kt b/core/common/src/jvmAndroidMain/kotlin/org/meshtastic/core/common/util/SyncContinuation.jvmAndroid.kt deleted file mode 100644 index 8e9a0ec68..000000000 --- a/core/common/src/jvmAndroidMain/kotlin/org/meshtastic/core/common/util/SyncContinuation.jvmAndroid.kt +++ /dev/null @@ -1,83 +0,0 @@ -/* - * Copyright (c) 2025-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 . - */ -package org.meshtastic.core.common.util - -import java.util.concurrent.TimeUnit -import java.util.concurrent.locks.ReentrantLock - -/** - * A blocking version of coroutine Continuation using traditional threading primitives. - * - * This is useful in contexts where coroutine suspension is not desirable or when bridging with legacy threaded code. - */ -class SyncContinuation : Continuation { - private val lock = ReentrantLock() - private val condition = lock.newCondition() - private var result: Result? = null - - override fun resume(res: Result) { - lock.lock() - try { - result = res - condition.signal() - } finally { - lock.unlock() - } - } - - /** - * Blocks the current thread until the result is available or the timeout expires. - * - * @param timeoutMsecs Maximum time to wait in milliseconds. If 0, waits indefinitely. - * @return The result of the operation. - * @throws IllegalStateException if a timeout occurs or if an internal error happens. - */ - @Suppress("NestedBlockDepth") - fun await(timeoutMsecs: Long = 0): T { - lock.lock() - try { - val startT = nowMillis - while (result == null) { - if (timeoutMsecs > 0) { - val remaining = timeoutMsecs - (nowMillis - startT) - check(remaining > 0) { "SyncContinuation timeout" } - condition.await(remaining, TimeUnit.MILLISECONDS) - } else { - condition.await() - } - } - - val r = result - checkNotNull(r) { "Unexpected null result in SyncContinuation" } - return r.getOrThrow() - } finally { - lock.unlock() - } - } -} - -/** - * Calls an [initfn] that is responsible for starting an operation and saving the [SyncContinuation]. Then blocks the - * current thread until the operation completes or times out. - * - * Essentially a blocking version of [kotlinx.coroutines.suspendCancellableCoroutine]. - */ -fun suspend(timeoutMsecs: Long = -1, initfn: (SyncContinuation) -> Unit): T { - val cont = SyncContinuation() - initfn(cont) - return cont.await(timeoutMsecs) -} diff --git a/core/common/src/jvmMain/kotlin/org/meshtastic/core/common/util/TimeExtensions.kt b/core/common/src/jvmAndroidMain/kotlin/org/meshtastic/core/common/util/TimeExtensions.jvmAndroid.kt similarity index 100% rename from core/common/src/jvmMain/kotlin/org/meshtastic/core/common/util/TimeExtensions.kt rename to core/common/src/jvmAndroidMain/kotlin/org/meshtastic/core/common/util/TimeExtensions.jvmAndroid.kt diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts index c6e8600cd..552bde88a 100644 --- a/core/data/build.gradle.kts +++ b/core/data/build.gradle.kts @@ -69,10 +69,7 @@ kotlin { commonTest.dependencies { implementation(projects.core.testing) - implementation(kotlin("test")) implementation(libs.kotlinx.coroutines.test) - implementation(libs.kotest.assertions) - implementation(libs.kotest.property) } } } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt index 0a3f03004..07521b21c 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt @@ -253,7 +253,13 @@ class MeshDataHandlerImpl( val u = User.ADAPTER.decode(payload) .let { if (it.is_licensed == true) it.copy(public_key = okio.ByteString.EMPTY) else it } - .let { if (packet.via_mqtt == true) it.copy(long_name = "${it.long_name} (MQTT)") else it } + .let { + if (packet.via_mqtt == true && !it.long_name.endsWith(" (MQTT)")) { + it.copy(long_name = "${it.long_name} (MQTT)") + } else { + it + } + } nodeManager.handleReceivedUser(packet.from, u, packet.channel) } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt index 85e858882..9ce4ba05d 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt @@ -170,19 +170,27 @@ class NodeManagerImpl( } override fun updateNode(nodeNum: Int, withBroadcast: Boolean, channel: Int, transform: (Node) -> Node) { - val next = transform(_nodeDBbyNodeNum.value[nodeNum] ?: getOrCreateNode(nodeNum, channel)) - - _nodeDBbyNodeNum.update { it.put(nodeNum, next) } - if (next.user.id.isNotEmpty()) { - _nodeDBbyID.update { it.put(next.user.id, next) } + // Perform read + transform inside update{} to ensure atomicity. + // Without this, concurrent calls for the same nodeNum could read the same snapshot + // and the last writer would silently overwrite the other's changes. + var next: Node? = null + _nodeDBbyNodeNum.update { map -> + val current = map[nodeNum] ?: getOrCreateNode(nodeNum, channel) + val transformed = transform(current) + next = transformed + map.put(nodeNum, transformed) + } + val result = next ?: return + if (result.user.id.isNotEmpty()) { + _nodeDBbyID.update { it.put(result.user.id, result) } } - if (next.user.id.isNotEmpty() && isNodeDbReady.value) { - scope.handledLaunch { nodeRepository.upsert(next) } + if (result.user.id.isNotEmpty() && isNodeDbReady.value) { + scope.handledLaunch { nodeRepository.upsert(result) } } if (withBroadcast) { - serviceBroadcasts.broadcastNodeChange(next) + serviceBroadcasts.broadcastNodeChange(result) } } @@ -282,7 +290,7 @@ class NodeManagerImpl( } else { var newUser = user.let { if (it.is_licensed == true) it.copy(public_key = ByteString.EMPTY) else it } - if (info.via_mqtt) { + if (info.via_mqtt && !newUser.long_name.endsWith(" (MQTT)")) { newUser = newUser.copy(long_name = "${newUser.long_name} (MQTT)") } next = next.copy(user = newUser, publicKey = newUser.public_key) diff --git a/core/database/build.gradle.kts b/core/database/build.gradle.kts index 4622f1be8..6f5ae71ed 100644 --- a/core/database/build.gradle.kts +++ b/core/database/build.gradle.kts @@ -49,10 +49,8 @@ kotlin { } commonTest.dependencies { implementation(projects.core.testing) - implementation(kotlin("test")) implementation(libs.kotlinx.coroutines.test) implementation(libs.androidx.room.testing) - implementation(libs.turbine) } val androidHostTest by getting { diff --git a/core/domain/build.gradle.kts b/core/domain/build.gradle.kts index 9407a5de3..918570a6d 100644 --- a/core/domain/build.gradle.kts +++ b/core/domain/build.gradle.kts @@ -43,10 +43,6 @@ kotlin { implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.serialization.json) } - commonTest.dependencies { - implementation(projects.core.testing) - implementation(kotlin("test")) - } - val androidHostTest by getting { dependencies { implementation(kotlin("test")) } } + commonTest.dependencies { implementation(projects.core.testing) } } } diff --git a/core/navigation/build.gradle.kts b/core/navigation/build.gradle.kts index 9b0977a2e..99a0802ae 100644 --- a/core/navigation/build.gradle.kts +++ b/core/navigation/build.gradle.kts @@ -32,7 +32,5 @@ kotlin { implementation(libs.jetbrains.navigation3.ui) implementation(libs.kermit) } - - commonTest.dependencies { implementation(kotlin("test")) } } } diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/di/CoreNetworkModule.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/di/CoreNetworkModule.kt index 37d5726b9..9b9d49828 100644 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/di/CoreNetworkModule.kt +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/di/CoreNetworkModule.kt @@ -26,6 +26,7 @@ import org.koin.core.annotation.Single class CoreNetworkModule { @Single fun provideJson(): Json = Json { + isLenient = true ignoreUnknownKeys = true coerceInputValues = true } diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioInterface.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioInterface.kt index 78e16edba..9942eec87 100644 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioInterface.kt +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioInterface.kt @@ -433,7 +433,7 @@ class BleRadioInterface( } } - private var radioService: org.meshtastic.core.ble.MeshtasticRadioProfile? = null + @Volatile private var radioService: org.meshtastic.core.ble.MeshtasticRadioProfile? = null // --- RadioTransport Implementation --- diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/StreamInterface.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/StreamInterface.kt index 7414def38..ea985c020 100644 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/StreamInterface.kt +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/StreamInterface.kt @@ -17,7 +17,7 @@ package org.meshtastic.core.network.radio import co.touchlab.kermit.Logger -import kotlinx.coroutines.launch +import org.meshtastic.core.common.util.handledLaunch import org.meshtastic.core.network.transport.StreamFrameCodec import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.core.repository.RadioTransport @@ -64,7 +64,7 @@ abstract class StreamInterface(protected val service: RadioInterfaceService) : R override fun handleSendToRadio(p: ByteArray) { // This method is called from a continuation and it might show up late, so check for uart being null - service.serviceScope.launch { codec.frameAndSend(p, ::sendBytes, ::flushBytes) } + service.serviceScope.handledLaunch { codec.frameAndSend(p, ::sendBytes, ::flushBytes) } } /** Process a single incoming byte through the stream framing state machine. */ diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/MQTTRepositoryImpl.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/MQTTRepositoryImpl.kt index a429b90ae..4b95f0191 100644 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/MQTTRepositoryImpl.kt +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/MQTTRepositoryImpl.kt @@ -21,12 +21,14 @@ import io.github.davidepianca98.MQTTClient import io.github.davidepianca98.mqtt.MQTTVersion import io.github.davidepianca98.mqtt.Subscription import io.github.davidepianca98.mqtt.packets.Qos +import io.github.davidepianca98.mqtt.packets.mqttv5.ReasonCode import io.github.davidepianca98.mqtt.packets.mqttv5.SubscriptionOptions import io.github.davidepianca98.socket.tls.TLSClientSettings import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.first @@ -54,6 +56,9 @@ class MQTTRepositoryImpl( private const val DEFAULT_TOPIC_LEVEL = "/2/e/" private const val JSON_TOPIC_LEVEL = "/2/json/" private const val DEFAULT_SERVER_ADDRESS = "mqtt.meshtastic.org" + private const val INITIAL_RECONNECT_DELAY_MS = 1000L + private const val MAX_RECONNECT_DELAY_MS = 30_000L + private const val RECONNECT_BACKOFF_MULTIPLIER = 2 } private var client: MQTTClient? = null @@ -62,8 +67,14 @@ class MQTTRepositoryImpl( private var clientJob: Job? = null private val publishSemaphore = Semaphore(20) + @Suppress("TooGenericExceptionCaught") override fun disconnect() { Logger.i { "MQTT Disconnecting" } + try { + client?.disconnect(ReasonCode.SUCCESS) + } catch (e: Exception) { + Logger.w(e) { "MQTT clean disconnect failed" } + } clientJob?.cancel() clientJob = null client = null @@ -123,23 +134,39 @@ class MQTTRepositoryImpl( client = newClient - clientJob = scope.launch { - try { - Logger.i { "MQTT Starting client loop for $host:$port" } - newClient.runSuspend() - } catch (e: io.github.davidepianca98.mqtt.MQTTException) { - Logger.e(e) { "MQTT Client loop error (MQTT)" } - close(e) - } catch (e: io.github.davidepianca98.socket.IOException) { - Logger.e(e) { "MQTT Client loop error (IO)" } - close(e) - } catch (e: kotlinx.coroutines.CancellationException) { - Logger.i { "MQTT Client loop cancelled" } - throw e + clientJob = + scope.launch { + var reconnectDelay = INITIAL_RECONNECT_DELAY_MS + while (true) { + try { + Logger.i { "MQTT Starting client loop for $host:$port" } + // Reset backoff on each successful connection establishment. If the broker + // disconnects cleanly after hours of operation, the next reconnect should + // start with the minimum delay rather than whatever was accumulated. + reconnectDelay = INITIAL_RECONNECT_DELAY_MS + newClient.runSuspend() + // runSuspend returned normally — broker closed connection. Retry. + Logger.w { "MQTT client loop ended normally, reconnecting in ${reconnectDelay}ms" } + } catch (e: io.github.davidepianca98.mqtt.MQTTException) { + Logger.e(e) { "MQTT Client loop error (MQTT), reconnecting in ${reconnectDelay}ms" } + } catch (e: io.github.davidepianca98.socket.IOException) { + Logger.e(e) { "MQTT Client loop error (IO), reconnecting in ${reconnectDelay}ms" } + } catch (e: kotlinx.coroutines.CancellationException) { + Logger.i { "MQTT Client loop cancelled" } + throw e + } + delay(reconnectDelay) + reconnectDelay = + (reconnectDelay * RECONNECT_BACKOFF_MULTIPLIER).coerceAtMost(MAX_RECONNECT_DELAY_MS) + } } - } - // Subscriptions + // Subscriptions: placed after runSuspend is launched and has had time to establish + // the TCP connection. KMQTT's subscribe() queues internally, but subscribing before + // the connection is ready may silently drop subscriptions depending on the version. + // A brief yield gives runSuspend() time to connect before we subscribe. + kotlinx.coroutines.yield() + val subscriptions = mutableListOf() channelSet.subscribeList.forEach { globalId -> subscriptions.add( diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/service/ApiService.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/service/ApiService.kt index 1e12344b4..ed7461058 100644 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/service/ApiService.kt +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/service/ApiService.kt @@ -23,13 +23,22 @@ import org.koin.core.annotation.Single import org.meshtastic.core.model.NetworkDeviceHardware import org.meshtastic.core.model.NetworkFirmwareReleases +/** Client for the Meshtastic public API (device hardware catalog and firmware releases). */ interface ApiService { + /** Fetches the device hardware catalog from the Meshtastic API. */ suspend fun getDeviceHardware(): List + /** Fetches the list of available firmware releases from the Meshtastic API. */ suspend fun getFirmwareReleases(): NetworkFirmwareReleases } -@Single +/** + * Ktor-based [ApiService] implementation. + * + * Registered with `binds = []` to prevent Koin from auto-binding to [ApiService]; host modules (`app`, `desktop`) + * provide their own explicit `ApiService` binding to allow platform-specific `HttpClient` engines. + */ +@Single(binds = []) class ApiServiceImpl(private val client: HttpClient) : ApiService { override suspend fun getDeviceHardware(): List = client.get("https://api.meshtastic.org/resource/deviceHardware").body() diff --git a/core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/transport/TcpTransport.kt b/core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/transport/TcpTransport.kt index 553d9a49a..dcc0a402f 100644 --- a/core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/transport/TcpTransport.kt +++ b/core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/transport/TcpTransport.kt @@ -99,7 +99,10 @@ class TcpTransport( /** Whether the transport is currently connected. */ val isConnected: Boolean - get() = socket?.isConnected == true && !socket!!.isClosed + get() { + val s = socket ?: return false + return s.isConnected && !s.isClosed + } /** * Start a TCP connection to the given address with automatic reconnect. @@ -127,6 +130,8 @@ class TcpTransport( */ suspend fun sendPacket(payload: ByteArray) { codec.frameAndSend(payload = payload, sendBytes = ::sendBytesRaw, flush = ::flushBytes) + packetsSent++ + bytesSent += payload.size } /** Send a heartbeat packet to keep the connection alive. */ @@ -283,8 +288,6 @@ class TcpTransport( Logger.w { "$logTag: [$currentAddress] Cannot send ${p.size} bytes: not connected" } return } - packetsSent++ - bytesSent += p.size try { stream.write(p) } catch (ex: IOException) { diff --git a/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/SerialTransport.kt b/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/SerialTransport.kt index 00b00bac2..a77331267 100644 --- a/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/SerialTransport.kt +++ b/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/SerialTransport.kt @@ -19,10 +19,10 @@ package org.meshtastic.core.network import co.touchlab.kermit.Logger import com.fazecast.jSerialComm.SerialPort import com.fazecast.jSerialComm.SerialPortTimeoutException -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.network.radio.StreamInterface import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.proto.Heartbeat @@ -41,6 +41,7 @@ private constructor( private val portName: String, private val baudRate: Int = DEFAULT_BAUD_RATE, service: RadioInterfaceService, + private val dispatchers: CoroutineDispatchers, ) : StreamInterface(service) { private var serialPort: SerialPort? = null private var readJob: Job? = null @@ -73,7 +74,7 @@ private constructor( private fun startReadLoop(port: SerialPort) { Logger.d { "[$portName] Starting serial read loop" } readJob = - service.serviceScope.launch(Dispatchers.IO) { + service.serviceScope.launch(dispatchers.io) { val input = port.inputStream val buffer = ByteArray(READ_BUFFER_SIZE) try { @@ -169,8 +170,13 @@ private constructor( * Creates and opens a [SerialTransport]. If the port cannot be opened, the transport signals a permanent * disconnect to the [service] and returns the (non-connected) instance. */ - fun open(portName: String, baudRate: Int = DEFAULT_BAUD_RATE, service: RadioInterfaceService): SerialTransport { - val transport = SerialTransport(portName, baudRate, service) + fun open( + portName: String, + baudRate: Int = DEFAULT_BAUD_RATE, + service: RadioInterfaceService, + dispatchers: CoroutineDispatchers, + ): SerialTransport { + val transport = SerialTransport(portName, baudRate, service, dispatchers) if (!transport.startConnection()) { val errorMessage = diagnoseOpenFailure(portName) Logger.w { "[$portName] Serial port could not be opened; signalling disconnect. $errorMessage" } diff --git a/core/nfc/build.gradle.kts b/core/nfc/build.gradle.kts index 801bbf8f2..c5b89c004 100644 --- a/core/nfc/build.gradle.kts +++ b/core/nfc/build.gradle.kts @@ -34,7 +34,5 @@ kotlin { implementation(libs.androidx.activity.compose) implementation(libs.compose.multiplatform.ui) } - - commonTest.dependencies { implementation(kotlin("test")) } } } diff --git a/core/prefs/README.md b/core/prefs/README.md index ecaf0feb6..ac01afd66 100644 --- a/core/prefs/README.md +++ b/core/prefs/README.md @@ -1,12 +1,12 @@ # `:core:prefs` ## Overview -The `:core:prefs` module provides a type-safe wrapper around `SharedPreferences` for managing application and radio configuration preferences. +The `:core:prefs` module provides a type-safe preferences layer backed by DataStore (multiplatform). On Android, legacy `SharedPreferences` are automatically migrated to DataStore on first access via `SharedPreferencesMigration`. ## Key Components -### 1. `PrefDelegate.kt` -Uses Kotlin property delegates to simplify reading and writing preferences. +### 1. DataStore Providers (`CorePrefsAndroidModule`) +Provides named `DataStore` singletons for each preference domain (analytics, app, map, mesh, radio, UI, etc.). Each DataStore uses an injected `CoroutineDispatchers.io` scope and includes a `SharedPreferencesMigration` for seamless migration from the legacy preference files. ### 2. Specialized Prefs - **`RadioPrefs`**: Manages radio-specific settings (e.g., the last connected device address). diff --git a/core/prefs/build.gradle.kts b/core/prefs/build.gradle.kts index 97f728e81..eba3604d7 100644 --- a/core/prefs/build.gradle.kts +++ b/core/prefs/build.gradle.kts @@ -39,9 +39,6 @@ kotlin { implementation(libs.kotlinx.coroutines.core) } - commonTest.dependencies { - implementation(kotlin("test")) - implementation(libs.kotlinx.coroutines.test) - } + commonTest.dependencies { implementation(libs.kotlinx.coroutines.test) } } } diff --git a/core/prefs/src/androidMain/kotlin/org/meshtastic/core/prefs/di/CorePrefsAndroidModule.kt b/core/prefs/src/androidMain/kotlin/org/meshtastic/core/prefs/di/CorePrefsAndroidModule.kt index dfd9d048c..578c0c685 100644 --- a/core/prefs/src/androidMain/kotlin/org/meshtastic/core/prefs/di/CorePrefsAndroidModule.kt +++ b/core/prefs/src/androidMain/kotlin/org/meshtastic/core/prefs/di/CorePrefsAndroidModule.kt @@ -23,110 +23,127 @@ 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.Module import org.koin.core.annotation.Named import org.koin.core.annotation.Single +import org.meshtastic.core.di.CoroutineDispatchers +/** + * Koin module providing Android [DataStore] instances for each preference domain. + * + * Each DataStore is a singleton backed by its own [CoroutineScope] using the injected [CoroutineDispatchers.io] + * dispatcher, and includes a [SharedPreferencesMigration] to migrate legacy SharedPreferences data on first access. + */ @Suppress("TooManyFunctions") @Module class CorePrefsAndroidModule { - private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) @Single @Named("AnalyticsDataStore") - fun provideAnalyticsDataStore(context: Context): DataStore = PreferenceDataStoreFactory.create( - migrations = listOf(SharedPreferencesMigration(context, "analytics-prefs")), - scope = scope, - produceFile = { context.preferencesDataStoreFile("analytics_ds") }, - ) + fun provideAnalyticsDataStore(context: Context, dispatchers: CoroutineDispatchers): DataStore = + PreferenceDataStoreFactory.create( + migrations = listOf(SharedPreferencesMigration(context, "analytics-prefs")), + scope = CoroutineScope(dispatchers.io + SupervisorJob()), + produceFile = { context.preferencesDataStoreFile("analytics_ds") }, + ) @Single @Named("HomoglyphEncodingDataStore") - fun provideHomoglyphEncodingDataStore(context: Context): DataStore = PreferenceDataStoreFactory.create( - migrations = listOf(SharedPreferencesMigration(context, "homoglyph-encoding-prefs")), - scope = scope, - produceFile = { context.preferencesDataStoreFile("homoglyph_encoding_ds") }, - ) + fun provideHomoglyphEncodingDataStore(context: Context, dispatchers: CoroutineDispatchers): DataStore = + PreferenceDataStoreFactory.create( + migrations = listOf(SharedPreferencesMigration(context, "homoglyph-encoding-prefs")), + scope = CoroutineScope(dispatchers.io + SupervisorJob()), + produceFile = { context.preferencesDataStoreFile("homoglyph_encoding_ds") }, + ) @Single @Named("AppDataStore") - fun provideAppDataStore(context: Context): DataStore = PreferenceDataStoreFactory.create( - migrations = listOf(SharedPreferencesMigration(context, "prefs")), - scope = scope, - produceFile = { context.preferencesDataStoreFile("app_ds") }, - ) + fun provideAppDataStore(context: Context, dispatchers: CoroutineDispatchers): DataStore = + PreferenceDataStoreFactory.create( + migrations = listOf(SharedPreferencesMigration(context, "prefs")), + scope = CoroutineScope(dispatchers.io + SupervisorJob()), + produceFile = { context.preferencesDataStoreFile("app_ds") }, + ) @Single @Named("CustomEmojiDataStore") - fun provideCustomEmojiDataStore(context: Context): DataStore = PreferenceDataStoreFactory.create( - migrations = listOf(SharedPreferencesMigration(context, "org.geeksville.emoji.prefs")), - scope = scope, - produceFile = { context.preferencesDataStoreFile("custom_emoji_ds") }, - ) + fun provideCustomEmojiDataStore(context: Context, dispatchers: CoroutineDispatchers): DataStore = + PreferenceDataStoreFactory.create( + migrations = listOf(SharedPreferencesMigration(context, "org.geeksville.emoji.prefs")), + scope = CoroutineScope(dispatchers.io + SupervisorJob()), + produceFile = { context.preferencesDataStoreFile("custom_emoji_ds") }, + ) @Single @Named("MapDataStore") - fun provideMapDataStore(context: Context): DataStore = PreferenceDataStoreFactory.create( - migrations = listOf(SharedPreferencesMigration(context, "map_prefs")), - scope = scope, - produceFile = { context.preferencesDataStoreFile("map_ds") }, - ) + fun provideMapDataStore(context: Context, dispatchers: CoroutineDispatchers): DataStore = + PreferenceDataStoreFactory.create( + migrations = listOf(SharedPreferencesMigration(context, "map_prefs")), + scope = CoroutineScope(dispatchers.io + SupervisorJob()), + produceFile = { context.preferencesDataStoreFile("map_ds") }, + ) @Single @Named("MapConsentDataStore") - fun provideMapConsentDataStore(context: Context): DataStore = PreferenceDataStoreFactory.create( - migrations = listOf(SharedPreferencesMigration(context, "map_consent_preferences")), - scope = scope, - produceFile = { context.preferencesDataStoreFile("map_consent_ds") }, - ) + fun provideMapConsentDataStore(context: Context, dispatchers: CoroutineDispatchers): DataStore = + PreferenceDataStoreFactory.create( + migrations = listOf(SharedPreferencesMigration(context, "map_consent_preferences")), + scope = CoroutineScope(dispatchers.io + SupervisorJob()), + produceFile = { context.preferencesDataStoreFile("map_consent_ds") }, + ) @Single @Named("MapTileProviderDataStore") - fun provideMapTileProviderDataStore(context: Context): DataStore = PreferenceDataStoreFactory.create( - migrations = listOf(SharedPreferencesMigration(context, "map_tile_provider_prefs")), - scope = scope, - produceFile = { context.preferencesDataStoreFile("map_tile_provider_ds") }, - ) + fun provideMapTileProviderDataStore(context: Context, dispatchers: CoroutineDispatchers): DataStore = + PreferenceDataStoreFactory.create( + migrations = listOf(SharedPreferencesMigration(context, "map_tile_provider_prefs")), + scope = CoroutineScope(dispatchers.io + SupervisorJob()), + produceFile = { context.preferencesDataStoreFile("map_tile_provider_ds") }, + ) @Single @Named("MeshDataStore") - fun provideMeshDataStore(context: Context): DataStore = PreferenceDataStoreFactory.create( - migrations = listOf(SharedPreferencesMigration(context, "mesh-prefs")), - scope = scope, - produceFile = { context.preferencesDataStoreFile("mesh_ds") }, - ) + fun provideMeshDataStore(context: Context, dispatchers: CoroutineDispatchers): DataStore = + PreferenceDataStoreFactory.create( + migrations = listOf(SharedPreferencesMigration(context, "mesh-prefs")), + scope = CoroutineScope(dispatchers.io + SupervisorJob()), + produceFile = { context.preferencesDataStoreFile("mesh_ds") }, + ) @Single @Named("RadioDataStore") - fun provideRadioDataStore(context: Context): DataStore = PreferenceDataStoreFactory.create( - migrations = listOf(SharedPreferencesMigration(context, "radio-prefs")), - scope = scope, - produceFile = { context.preferencesDataStoreFile("radio_ds") }, - ) + fun provideRadioDataStore(context: Context, dispatchers: CoroutineDispatchers): DataStore = + PreferenceDataStoreFactory.create( + migrations = listOf(SharedPreferencesMigration(context, "radio-prefs")), + scope = CoroutineScope(dispatchers.io + SupervisorJob()), + produceFile = { context.preferencesDataStoreFile("radio_ds") }, + ) @Single @Named("UiDataStore") - fun provideUiDataStore(context: Context): DataStore = PreferenceDataStoreFactory.create( - migrations = listOf(SharedPreferencesMigration(context, "ui-prefs")), - scope = scope, - produceFile = { context.preferencesDataStoreFile("ui_ds") }, - ) + fun provideUiDataStore(context: Context, dispatchers: CoroutineDispatchers): DataStore = + PreferenceDataStoreFactory.create( + migrations = listOf(SharedPreferencesMigration(context, "ui-prefs")), + scope = CoroutineScope(dispatchers.io + SupervisorJob()), + produceFile = { context.preferencesDataStoreFile("ui_ds") }, + ) @Single @Named("MeshLogDataStore") - fun provideMeshLogDataStore(context: Context): DataStore = PreferenceDataStoreFactory.create( - migrations = listOf(SharedPreferencesMigration(context, "meshlog-prefs")), - scope = scope, - produceFile = { context.preferencesDataStoreFile("meshlog_ds") }, - ) + fun provideMeshLogDataStore(context: Context, dispatchers: CoroutineDispatchers): DataStore = + PreferenceDataStoreFactory.create( + migrations = listOf(SharedPreferencesMigration(context, "meshlog-prefs")), + scope = CoroutineScope(dispatchers.io + SupervisorJob()), + produceFile = { context.preferencesDataStoreFile("meshlog_ds") }, + ) @Single @Named("FilterDataStore") - fun provideFilterDataStore(context: Context): DataStore = PreferenceDataStoreFactory.create( - migrations = listOf(SharedPreferencesMigration(context, "filter-prefs")), - scope = scope, - produceFile = { context.preferencesDataStoreFile("filter_ds") }, - ) + fun provideFilterDataStore(context: Context, dispatchers: CoroutineDispatchers): DataStore = + PreferenceDataStoreFactory.create( + migrations = listOf(SharedPreferencesMigration(context, "filter-prefs")), + scope = CoroutineScope(dispatchers.io + SupervisorJob()), + produceFile = { context.preferencesDataStoreFile("filter_ds") }, + ) } diff --git a/core/repository/build.gradle.kts b/core/repository/build.gradle.kts index 1f9cdc585..9eb277575 100644 --- a/core/repository/build.gradle.kts +++ b/core/repository/build.gradle.kts @@ -37,10 +37,7 @@ kotlin { } commonTest.dependencies { implementation(projects.core.testing) - implementation(kotlin("test")) implementation(libs.kotlinx.coroutines.test) - implementation(libs.turbine) - implementation(libs.kotest.assertions) } } } diff --git a/core/resources/build.gradle.kts b/core/resources/build.gradle.kts index 47d8c12e0..a1ba8fd63 100644 --- a/core/resources/build.gradle.kts +++ b/core/resources/build.gradle.kts @@ -29,10 +29,7 @@ kotlin { withHostTest { isIncludeAndroidResources = true } } - sourceSets { - commonMain.dependencies { implementation(projects.core.common) } - commonTest.dependencies { implementation(kotlin("test")) } - } + sourceSets { commonMain.dependencies { implementation(projects.core.common) } } } compose.resources { diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 461b52178..b746ce3e5 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -746,6 +746,8 @@ Serial enabled Echo enabled Serial baud rate + RX + TX Timeout Serial mode Override console serial port diff --git a/core/service/build.gradle.kts b/core/service/build.gradle.kts index ff97a05ec..2e0b6965d 100644 --- a/core/service/build.gradle.kts +++ b/core/service/build.gradle.kts @@ -67,10 +67,6 @@ kotlin { } } - commonTest.dependencies { - implementation(kotlin("test")) - implementation(libs.kotlinx.coroutines.test) - implementation(libs.turbine) - } + commonTest.dependencies { implementation(libs.kotlinx.coroutines.test) } } } diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidRadioControllerImpl.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidRadioControllerImpl.kt index ea1884ab1..c7ef0ed10 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidRadioControllerImpl.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidRadioControllerImpl.kt @@ -17,6 +17,7 @@ package org.meshtastic.core.service import android.content.Context +import co.touchlab.kermit.Logger import kotlinx.coroutines.flow.StateFlow import org.koin.core.annotation.Single import org.meshtastic.core.model.ConnectionState @@ -26,6 +27,12 @@ import org.meshtastic.core.model.service.ServiceAction import org.meshtastic.core.repository.NodeRepository import org.meshtastic.proto.ClientNotification +/** + * Android [RadioController] implementation that delegates to the bound [MeshService] via AIDL. + * + * All radio commands are forwarded through [AndroidServiceRepository.meshService]. If the service is not yet bound, + * commands are silently dropped with a warning log. + */ @Single @Suppress("TooManyFunctions") class AndroidRadioControllerImpl( @@ -41,8 +48,12 @@ class AndroidRadioControllerImpl( get() = serviceRepository.clientNotification override suspend fun sendMessage(packet: DataPacket) { - // Bridging to the existing flow via IMeshService - serviceRepository.meshService?.send(packet) + val svc = serviceRepository.meshService + if (svc == null) { + Logger.w { "sendMessage: meshService is null, dropping packet" } + return + } + svc.send(packet) } override fun clearClientNotification() { @@ -187,7 +198,8 @@ class AndroidRadioControllerImpl( serviceRepository.meshService?.commitEditSettings(destNum) } - override fun getPacketId(): Int = serviceRepository.meshService?.getPacketId() ?: 0 + override fun getPacketId(): Int = + serviceRepository.meshService?.getPacketId() ?: error("Cannot generate packet ID: meshService is not bound") override fun startProvideLocation() { serviceRepository.meshService?.startProvideLocation() diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/BootCompleteReceiver.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/BootCompleteReceiver.kt index b01475b6d..4e9194f42 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/BootCompleteReceiver.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/BootCompleteReceiver.kt @@ -19,19 +19,29 @@ package org.meshtastic.core.service import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import co.touchlab.kermit.Logger +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import org.meshtastic.core.repository.MeshPrefs /** This receiver starts the MeshService on boot if a device was previously connected. */ -class BootCompleteReceiver : BroadcastReceiver() { +class BootCompleteReceiver : + BroadcastReceiver(), + KoinComponent { + + private val meshPrefs: MeshPrefs by inject() override fun onReceive(context: Context, intent: Intent) { if (Intent.ACTION_BOOT_COMPLETED != intent.action) { return } - val prefs = context.getSharedPreferences("mesh-prefs", Context.MODE_PRIVATE) - if (!prefs.contains("device_address")) { + val address = meshPrefs.deviceAddress.value + if (address.isNullOrBlank() || address.equals("n", ignoreCase = true)) { + Logger.d { "BootCompleteReceiver: no device previously connected, skipping service start" } return } + Logger.i { "BootCompleteReceiver: starting MeshService for device $address" } MeshService.startService(context) } } diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/Constants.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/Constants.kt index 2007bbcaa..8b57c8c6c 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/Constants.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/Constants.kt @@ -28,24 +28,10 @@ const val ACTION_MESH_DISCONNECTED = MeshtasticIntent.ACTION_MESH_DISCONNECTED const val ACTION_CONNECTION_CHANGED = MeshtasticIntent.ACTION_CONNECTION_CHANGED const val ACTION_MESSAGE_STATUS = MeshtasticIntent.ACTION_MESSAGE_STATUS -const val ACTION_RECEIVED_TEXT_MESSAGE_APP = MeshtasticIntent.ACTION_RECEIVED_TEXT_MESSAGE_APP -const val ACTION_RECEIVED_POSITION_APP = MeshtasticIntent.ACTION_RECEIVED_POSITION_APP -const val ACTION_RECEIVED_NODEINFO_APP = MeshtasticIntent.ACTION_RECEIVED_NODEINFO_APP -const val ACTION_RECEIVED_TELEMETRY_APP = MeshtasticIntent.ACTION_RECEIVED_TELEMETRY_APP -const val ACTION_RECEIVED_ATAK_PLUGIN = MeshtasticIntent.ACTION_RECEIVED_ATAK_PLUGIN -const val ACTION_RECEIVED_ATAK_FORWARDER = MeshtasticIntent.ACTION_RECEIVED_ATAK_FORWARDER -const val ACTION_RECEIVED_DETECTION_SENSOR_APP = MeshtasticIntent.ACTION_RECEIVED_DETECTION_SENSOR_APP -const val ACTION_RECEIVED_PRIVATE_APP = MeshtasticIntent.ACTION_RECEIVED_PRIVATE_APP - fun actionReceived(portNum: String) = "$PREFIX.RECEIVED.$portNum" -// -// standard EXTRA bundle definitions -// - +// Standard EXTRA bundle definitions const val EXTRA_CONNECTED = MeshtasticIntent.EXTRA_CONNECTED -const val EXTRA_PROGRESS = "$PREFIX.Progress" -const val EXTRA_PERMANENT = "$PREFIX.Permanent" const val EXTRA_PAYLOAD = MeshtasticIntent.EXTRA_PAYLOAD const val EXTRA_NODEINFO = MeshtasticIntent.EXTRA_NODEINFO diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImpl.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImpl.kt index 095010440..05fe1d3b4 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImpl.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImpl.kt @@ -37,7 +37,6 @@ import androidx.core.graphics.createBitmap import androidx.core.graphics.drawable.IconCompat import androidx.core.net.toUri import kotlinx.coroutines.flow.first -import kotlinx.coroutines.runBlocking import org.jetbrains.compose.resources.StringResource import org.koin.core.annotation.Single import org.meshtastic.core.common.util.NumberFormatter @@ -319,9 +318,9 @@ class MeshServiceNotificationsImpl( val repo = nodeRepository.value val myNodeNum = repo.myNodeInfo.value?.myNodeNum if (myNodeNum != null) { - // We use runBlocking here because this is called from MeshConnectionManager's synchronous methods, - // and we only do this once if the cache is empty. - val nodes = runBlocking { repo.nodeDBbyNum.first() } + // Use .value instead of runBlocking { .first() } to avoid potential deadlock + // if called on the same dispatcher the Flow's upstream coroutine needs. + val nodes = repo.nodeDBbyNum.value nodes[myNodeNum]?.let { node -> if (cachedDeviceMetrics == null) { cachedDeviceMetrics = node.deviceMetrics diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReactionReceiver.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReactionReceiver.kt index 7a3e026a7..5965b9ddd 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReactionReceiver.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReactionReceiver.kt @@ -29,6 +29,12 @@ import org.koin.core.component.inject import org.meshtastic.core.model.service.ServiceAction import org.meshtastic.core.repository.ServiceRepository +/** + * Handles inline emoji reaction actions from message notifications. + * + * Uses [goAsync] to keep the process alive while the coroutine dispatches the reaction through [ServiceRepository], + * matching the pattern used by [ReplyReceiver] and [MarkAsReadReceiver]. + */ class ReactionReceiver : BroadcastReceiver(), KoinComponent { @@ -45,11 +51,14 @@ class ReactionReceiver : val reaction = intent.getStringExtra(EXTRA_EMOJI) ?: intent.getStringExtra(EXTRA_REACTION) ?: return val replyId = intent.getIntExtra(EXTRA_REPLY_ID, intent.getIntExtra(EXTRA_PACKET_ID, 0)) + val pendingResult = goAsync() scope.launch { try { serviceRepository.onServiceAction(ServiceAction.Reaction(reaction, replyId, contactKey)) } catch (e: Exception) { Logger.e(e) { "Error sending reaction" } + } finally { + pendingResult.finish() } } } diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ServiceBroadcasts.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ServiceBroadcasts.kt index 436d2dec7..57408cff1 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ServiceBroadcasts.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ServiceBroadcasts.kt @@ -34,8 +34,10 @@ import org.meshtastic.core.repository.ServiceBroadcasts as SharedServiceBroadcas @Single class ServiceBroadcasts(private val context: Context, private val serviceRepository: ServiceRepository) : SharedServiceBroadcasts { - // A mapping of receiver class name to package name - used for explicit broadcasts - private val clientPackages = mutableMapOf() + // A mapping of receiver class name to package name - used for explicit broadcasts. + // ConcurrentHashMap because subscribeReceiver() is called from AIDL binder threads + // while explicitBroadcast() iterates from coroutine contexts. + private val clientPackages = java.util.concurrent.ConcurrentHashMap() override fun subscribeReceiver(receiverName: String, packageName: String) { clientPackages[receiverName] = packageName @@ -153,7 +155,7 @@ class ServiceBroadcasts(private val context: Context, private val serviceReposit private fun explicitBroadcast(intent: Intent) { context.sendBroadcast( intent, - ) // We also do a regular (not explicit broadcast) so any context-registered rceivers will work + ) // We also do a regular (not explicit broadcast) so any context-registered receivers will work clientPackages.forEach { intent.setClassName(it.value, it.key) context.sendBroadcast(intent) diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt index 32f7c5dce..0785624f5 100644 --- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt +++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt @@ -292,8 +292,17 @@ class SharedRadioInterfaceService( } override fun sendToRadio(bytes: ByteArray) { + // Capture radioIf reference atomically to avoid racing with stopInterfaceLocked() + // which sets radioIf = null and cancels _serviceScope. Without this snapshot, + // we could read a non-null radioIf but launch into an already-cancelled scope. + val currentIf = + radioIf + ?: run { + Logger.w { "sendToRadio: no active radio interface, dropping ${bytes.size} bytes" } + return + } _serviceScope.handledLaunch { - radioIf?.handleSendToRadio(bytes) + currentIf.handleSendToRadio(bytes) _meshActivity.tryEmit(MeshActivity.Send) } } diff --git a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/ContextExtensions.kt b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/ContextExtensions.kt index babb05fb3..dda2f2219 100644 --- a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/ContextExtensions.kt +++ b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/ContextExtensions.kt @@ -16,9 +16,7 @@ */ package org.meshtastic.core.ui.util -import android.app.Activity import android.content.Context -import android.content.ContextWrapper import android.content.Intent import android.provider.Settings import android.widget.Toast @@ -33,13 +31,6 @@ suspend fun Context.showToast(text: String) { Toast.makeText(this, text, Toast.LENGTH_SHORT).show() } -/** Finds the [Activity] from a [Context]. */ -fun Context.findActivity(): Activity? = when (this) { - is Activity -> this - is ContextWrapper -> baseContext.findActivity() - else -> null -} - fun Context.openNfcSettings() { val intent = Intent(Settings.ACTION_NFC_SETTINGS) startActivity(intent) diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/PreferenceFooter.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/PreferenceFooter.kt index 8a2caf5e3..37e354d32 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/PreferenceFooter.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/PreferenceFooter.kt @@ -14,8 +14,6 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -@file:Suppress("detekt:ALL") - package org.meshtastic.core.ui.component import androidx.compose.foundation.layout.Arrangement @@ -30,28 +28,6 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import org.jetbrains.compose.resources.StringResource -import org.jetbrains.compose.resources.stringResource - -@Deprecated(message = "Use overload that accepts Strings for button text.") -@Composable -fun PreferenceFooter( - enabled: Boolean, - negativeText: StringResource, - onNegativeClicked: () -> Unit, - positiveText: StringResource, - onPositiveClicked: () -> Unit, - modifier: Modifier = Modifier, -) { - PreferenceFooter( - modifier = modifier, - enabled = enabled, - negativeText = stringResource(negativeText), - onNegativeClicked = onNegativeClicked, - positiveText = stringResource(positiveText), - onPositiveClicked = onPositiveClicked, - ) -} @Composable fun PreferenceFooter( diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/DesktopNotificationManager.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/DesktopNotificationManager.kt index 86b1fb4db..26fa16f6e 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/DesktopNotificationManager.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/DesktopNotificationManager.kt @@ -19,13 +19,15 @@ package org.meshtastic.desktop import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow -import org.koin.core.annotation.Single import org.meshtastic.core.repository.Notification import org.meshtastic.core.repository.NotificationManager import org.meshtastic.core.repository.NotificationPrefs import androidx.compose.ui.window.Notification as ComposeNotification -@Single +/** + * Desktop notification manager. Registered manually in [desktopPlatformStubsModule] — do NOT add @Single to avoid + * double-registration with the @ComponentScan("org.meshtastic.desktop") in DesktopDiModule. + */ class DesktopNotificationManager(private val prefs: NotificationPrefs) : NotificationManager { init { co.touchlab.kermit.Logger.i { "DesktopNotificationManager initialized" } diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt index b4b47736e..b93c16a75 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt @@ -162,6 +162,11 @@ private fun desktopPlatformStubsModule() = module { single { NoopPhoneLocationProvider() } single { NoopMagneticFieldProvider() } + // Desktop uses the real ApiService implementation (no flavor stub needed) + single { + org.meshtastic.core.network.service.ApiServiceImpl(client = get()) + } + // Ktor HttpClient for JVM/Desktop (equivalent of CoreNetworkAndroidModule on Android) single { HttpClient(Java) { install(ContentNegotiation) { json(get()) } } } diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshServiceNotifications.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshServiceNotifications.kt index 36648d54d..061da246d 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshServiceNotifications.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshServiceNotifications.kt @@ -16,7 +16,6 @@ */ package org.meshtastic.desktop.notification -import org.koin.core.annotation.Single import org.meshtastic.core.model.Node import org.meshtastic.core.repository.MeshServiceNotifications import org.meshtastic.core.repository.Notification @@ -29,7 +28,10 @@ import org.meshtastic.core.resources.new_node_seen import org.meshtastic.proto.ClientNotification import org.meshtastic.proto.Telemetry -@Single +/** + * Desktop notifications implementation. Registered manually in [desktopPlatformStubsModule] — do NOT add @Single to + * avoid double-registration with the @ComponentScan("org.meshtastic.desktop") in DesktopDiModule. + */ @Suppress("TooManyFunctions") class DesktopMeshServiceNotifications(private val notificationManager: NotificationManager) : MeshServiceNotifications { override fun clearNotifications() { diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopMessageQueue.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopMessageQueue.kt index f69d103cc..c272e7bd9 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopMessageQueue.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopMessageQueue.kt @@ -19,6 +19,7 @@ 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.model.ConnectionState import org.meshtastic.core.model.MessageStatus @@ -36,7 +37,7 @@ class DesktopMessageQueue( private val packetRepository: PacketRepository, private val radioController: RadioController, ) : MessageQueue { - private val scope = CoroutineScope(Dispatchers.IO) + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) override suspend fun enqueue(packetId: Int) { scope.launch { diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopRadioTransportFactory.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopRadioTransportFactory.kt index 484e2294e..0518620c0 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopRadioTransportFactory.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopRadioTransportFactory.kt @@ -16,7 +16,6 @@ */ package org.meshtastic.desktop.radio -import org.koin.core.annotation.Single import org.meshtastic.core.ble.BleConnectionFactory import org.meshtastic.core.ble.BleScanner import org.meshtastic.core.ble.BluetoothRepository @@ -33,8 +32,10 @@ import org.meshtastic.core.repository.RadioTransportFactory /** * Desktop implementation of [RadioTransportFactory] delegating multiplatform transports (BLE, TCP) and providing * platform-specific transports (USB/Serial) via jSerialComm. + * + * Registered manually in [desktopPlatformStubsModule] — do NOT add @Single to avoid double-registration with + * the @ComponentScan("org.meshtastic.desktop") in DesktopDiModule. */ -@Single(binds = [RadioTransportFactory::class]) class DesktopRadioTransportFactory( scanner: BleScanner, bluetoothRepository: BluetoothRepository, @@ -54,6 +55,7 @@ class DesktopRadioTransportFactory( SerialTransport.open( portName = address.removePrefix(InterfaceId.SERIAL.id.toString()), service = service, + dispatchers = dispatchers, ) } else -> error("Unsupported transport for address: $address") diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt index adaea22f0..563571ef6 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt @@ -20,6 +20,7 @@ package org.meshtastic.desktop.stub import co.touchlab.kermit.Logger import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -38,7 +39,6 @@ import org.meshtastic.core.repository.LocationRepository import org.meshtastic.core.repository.MeshLocationManager import org.meshtastic.core.repository.MeshServiceNotifications import org.meshtastic.core.repository.MeshWorkerManager -import org.meshtastic.core.repository.MessageQueue import org.meshtastic.core.repository.PlatformAnalytics import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.core.repository.ServiceBroadcasts @@ -98,8 +98,7 @@ class NoopRadioInterfaceService : RadioInterfaceService { override fun handleFromRadio(bytes: ByteArray) {} @Suppress("InjectDispatcher") - override val serviceScope: CoroutineScope - get() = CoroutineScope(kotlinx.coroutines.Dispatchers.Default) + override val serviceScope: CoroutineScope = CoroutineScope(SupervisorJob() + kotlinx.coroutines.Dispatchers.Default) } // endregion @@ -190,10 +189,6 @@ class NoopMeshWorkerManager : MeshWorkerManager { override fun enqueueSendMessage(packetId: Int) {} } -class NoopMessageQueue : MessageQueue { - override suspend fun enqueue(packetId: Int) {} -} - class NoopMeshLocationManager : MeshLocationManager { override fun start(scope: CoroutineScope, sendPositionFn: (ProtoPosition) -> Unit) {} diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt index 90e171e8e..b82e26432 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt @@ -70,6 +70,7 @@ private const val DEVICE_DETACH_TIMEOUT = 30_000L private const val VERIFY_TIMEOUT = 60_000L private const val VERIFY_DELAY = 2000L private const val MIN_BATTERY_LEVEL = 10 +private const val LOCAL_RELEASE_ID = "local" private val BLUETOOTH_ADDRESS_REGEX = Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}") @@ -299,8 +300,7 @@ class FirmwareUpdateViewModel( val updateArtifact = firmwareUpdateManager.startUpdate( - release = - FirmwareRelease(id = "local", title = "Local File", zipUrl = "", releaseNotes = ""), + release = FirmwareRelease(id = LOCAL_RELEASE_ID, zipUrl = "", releaseNotes = ""), hardware = currentState.deviceHardware, address = currentState.address, updateState = { _state.value = it }, diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt index e6f6645d0..e637b0d76 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt @@ -20,6 +20,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest @@ -44,6 +45,12 @@ import org.meshtastic.feature.map.model.TracerouteOverlay import org.meshtastic.proto.Position import org.meshtastic.proto.Waypoint +/** + * Shared base ViewModel for the map feature, providing node data, waypoints, map filter preferences, and traceroute + * overlay state. + * + * Platform-specific map ViewModels (fdroid/google) extend this to add flavor-specific map provider logic. + */ @Suppress("TooManyFunctions") open class BaseMapViewModel( protected val mapPrefs: MapPrefs, @@ -92,7 +99,7 @@ open class BaseMapViewModel( .stateInWhileSubscribed(initialValue = emptyMap()) private val showOnlyFavorites = MutableStateFlow(mapPrefs.showOnlyFavorites.value) - val showOnlyFavoritesOnMap = showOnlyFavorites + val showOnlyFavoritesOnMap: StateFlow = showOnlyFavorites.asStateFlow() fun toggleOnlyFavorites() { val newValue = !showOnlyFavorites.value @@ -101,7 +108,7 @@ open class BaseMapViewModel( } private val showWaypoints = MutableStateFlow(mapPrefs.showWaypointsOnMap.value) - val showWaypointsOnMap = showWaypoints + val showWaypointsOnMap: StateFlow = showWaypoints.asStateFlow() fun toggleShowWaypointsOnMap() { val newValue = !showWaypoints.value @@ -110,7 +117,7 @@ open class BaseMapViewModel( } private val showPrecisionCircle = MutableStateFlow(mapPrefs.showPrecisionCircleOnMap.value) - val showPrecisionCircleOnMap = showPrecisionCircle + val showPrecisionCircleOnMap: StateFlow = showPrecisionCircle.asStateFlow() fun toggleShowPrecisionCircleOnMap() { val newValue = !showPrecisionCircle.value @@ -119,7 +126,7 @@ open class BaseMapViewModel( } private val lastHeardFilterValue = MutableStateFlow(LastHeardFilter.fromSeconds(mapPrefs.lastHeardFilter.value)) - val lastHeardFilter = lastHeardFilterValue + val lastHeardFilter: StateFlow = lastHeardFilterValue.asStateFlow() fun setLastHeardFilter(filter: LastHeardFilter) { lastHeardFilterValue.value = filter @@ -128,7 +135,7 @@ open class BaseMapViewModel( private val lastHeardTrackFilterValue = MutableStateFlow(LastHeardFilter.fromSeconds(mapPrefs.lastHeardTrackFilter.value)) - val lastHeardTrackFilter = lastHeardTrackFilterValue + val lastHeardTrackFilter: StateFlow = lastHeardTrackFilterValue.asStateFlow() fun setLastHeardTrackFilter(filter: LastHeardFilter) { lastHeardTrackFilterValue.value = filter diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt index d5632a88a..592c15d3a 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt @@ -126,7 +126,7 @@ open class RadioConfigViewModel( private val locationService: LocationService, private val fileService: FileService, ) : ViewModel() { - var analyticsAllowedFlow = analyticsPrefs.analyticsAllowed + val analyticsAllowedFlow = analyticsPrefs.analyticsAllowed fun toggleAnalyticsAllowed() { toggleAnalyticsUseCase() diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/component/EditChannelDialog.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/component/EditChannelDialog.kt index 202cacd22..fed34368d 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/component/EditChannelDialog.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/component/EditChannelDialog.kt @@ -38,6 +38,7 @@ import org.meshtastic.core.resources.cancel import org.meshtastic.core.resources.channel_name import org.meshtastic.core.resources.default_ import org.meshtastic.core.resources.downlink_enabled +import org.meshtastic.core.resources.psk import org.meshtastic.core.resources.save import org.meshtastic.core.resources.uplink_enabled import org.meshtastic.core.ui.component.EditBase64Preference @@ -99,7 +100,7 @@ fun EditChannelDialog( ) EditBase64Preference( - title = "PSK", + title = stringResource(Res.string.psk), value = channelInput.psk, enabled = true, keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SerialConfigItemList.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SerialConfigItemList.kt index 29f29e7eb..e5b527944 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SerialConfigItemList.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SerialConfigItemList.kt @@ -32,6 +32,8 @@ import org.meshtastic.core.resources.serial_baud_rate import org.meshtastic.core.resources.serial_config import org.meshtastic.core.resources.serial_enabled import org.meshtastic.core.resources.serial_mode +import org.meshtastic.core.resources.serial_rx_pin +import org.meshtastic.core.resources.serial_tx_pin import org.meshtastic.core.resources.timeout import org.meshtastic.core.ui.component.DropDownPreference import org.meshtastic.core.ui.component.EditTextPreference @@ -78,7 +80,7 @@ fun SerialConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { ) HorizontalDivider() EditTextPreference( - title = "RX", + title = stringResource(Res.string.serial_rx_pin), value = formState.value.rxd, enabled = state.connected, keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), @@ -86,7 +88,7 @@ fun SerialConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { ) HorizontalDivider() EditTextPreference( - title = "TX", + title = stringResource(Res.string.serial_tx_pin), value = formState.value.txd, enabled = state.connected, keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), diff --git a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/WifiProvisionViewModel.kt b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/WifiProvisionViewModel.kt index af0541d43..9e1177be8 100644 --- a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/WifiProvisionViewModel.kt +++ b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/WifiProvisionViewModel.kt @@ -24,7 +24,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import org.koin.core.annotation.Factory +import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.ble.BleConnectionFactory import org.meshtastic.core.ble.BleScanner import org.meshtastic.feature.wifiprovision.domain.NymeaWifiService @@ -98,10 +98,11 @@ sealed interface WifiProvisionError { /** * ViewModel for the WiFi provisioning flow. * - * Uses [Factory] scope so a fresh [NymeaWifiService] (and its own [BleConnectionFactory]-backed - * [org.meshtastic.core.ble.BleConnection]) is created for each provisioning session. + * Uses [KoinViewModel] so the instance is scoped to the navigation entry's [ViewModelStoreOwner]. A fresh + * [NymeaWifiService] (and its own [BleConnectionFactory]-backed [org.meshtastic.core.ble.BleConnection]) is created + * lazily for each provisioning session and cleaned up via [onCleared]. */ -@Factory +@KoinViewModel class WifiProvisionViewModel( private val bleScanner: BleScanner, private val bleConnectionFactory: BleConnectionFactory,