fix: harden reliability, clean up KMP compliance, and improve code quality (#5023)

This commit is contained in:
James Rich 2026-04-09 13:21:46 -05:00 committed by GitHub
parent 537029a71c
commit 14b381c1eb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
53 changed files with 370 additions and 409 deletions

View file

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

View file

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

View file

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

View file

@ -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<BleDevice?>(replay = 1)
override val deviceFlow: SharedFlow<BleDevice?> = _deviceFlow.asSharedFlow()
@ -93,6 +108,7 @@ class KableBleConnection(private val scope: CoroutineScope) : BleConnection {
)
override val connectionState: SharedFlow<BleConnectionState> = _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()

View file

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

View file

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

View file

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

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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())

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.common.util
/** A deferred execution object (with various possible implementations) */
interface Continuation<in T> {
fun resume(res: Result<T>)
/** 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<in T>(private val cb: (Result<T>) -> Unit) : Continuation<T> {
override fun resume(res: Result<T>) = cb(res)
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<T> : Continuation<T> {
private val lock = ReentrantLock()
private val condition = lock.newCondition()
private var result: Result<T>? = null
override fun resume(res: Result<T>) {
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 <T> suspend(timeoutMsecs: Long = -1, initfn: (SyncContinuation<T>) -> Unit): T {
val cont = SyncContinuation<T>()
initfn(cont)
return cont.await(timeoutMsecs)
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -32,7 +32,5 @@ kotlin {
implementation(libs.jetbrains.navigation3.ui)
implementation(libs.kermit)
}
commonTest.dependencies { implementation(kotlin("test")) }
}
}

View file

@ -26,6 +26,7 @@ import org.koin.core.annotation.Single
class CoreNetworkModule {
@Single
fun provideJson(): Json = Json {
isLenient = true
ignoreUnknownKeys = true
coerceInputValues = true
}

View file

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

View file

@ -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. */

View file

@ -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<Subscription>()
channelSet.subscribeList.forEach { globalId ->
subscriptions.add(

View file

@ -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<NetworkDeviceHardware>
/** 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<NetworkDeviceHardware> =
client.get("https://api.meshtastic.org/resource/deviceHardware").body()

View file

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

View file

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

View file

@ -34,7 +34,5 @@ kotlin {
implementation(libs.androidx.activity.compose)
implementation(libs.compose.multiplatform.ui)
}
commonTest.dependencies { implementation(kotlin("test")) }
}
}

View file

@ -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<Preferences>` 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).

View file

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

View file

@ -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<Preferences> = PreferenceDataStoreFactory.create(
migrations = listOf(SharedPreferencesMigration(context, "analytics-prefs")),
scope = scope,
produceFile = { context.preferencesDataStoreFile("analytics_ds") },
)
fun provideAnalyticsDataStore(context: Context, dispatchers: CoroutineDispatchers): DataStore<Preferences> =
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<Preferences> = PreferenceDataStoreFactory.create(
migrations = listOf(SharedPreferencesMigration(context, "homoglyph-encoding-prefs")),
scope = scope,
produceFile = { context.preferencesDataStoreFile("homoglyph_encoding_ds") },
)
fun provideHomoglyphEncodingDataStore(context: Context, dispatchers: CoroutineDispatchers): DataStore<Preferences> =
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<Preferences> = PreferenceDataStoreFactory.create(
migrations = listOf(SharedPreferencesMigration(context, "prefs")),
scope = scope,
produceFile = { context.preferencesDataStoreFile("app_ds") },
)
fun provideAppDataStore(context: Context, dispatchers: CoroutineDispatchers): DataStore<Preferences> =
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<Preferences> = 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<Preferences> =
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<Preferences> = PreferenceDataStoreFactory.create(
migrations = listOf(SharedPreferencesMigration(context, "map_prefs")),
scope = scope,
produceFile = { context.preferencesDataStoreFile("map_ds") },
)
fun provideMapDataStore(context: Context, dispatchers: CoroutineDispatchers): DataStore<Preferences> =
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<Preferences> = PreferenceDataStoreFactory.create(
migrations = listOf(SharedPreferencesMigration(context, "map_consent_preferences")),
scope = scope,
produceFile = { context.preferencesDataStoreFile("map_consent_ds") },
)
fun provideMapConsentDataStore(context: Context, dispatchers: CoroutineDispatchers): DataStore<Preferences> =
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<Preferences> = 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<Preferences> =
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<Preferences> = PreferenceDataStoreFactory.create(
migrations = listOf(SharedPreferencesMigration(context, "mesh-prefs")),
scope = scope,
produceFile = { context.preferencesDataStoreFile("mesh_ds") },
)
fun provideMeshDataStore(context: Context, dispatchers: CoroutineDispatchers): DataStore<Preferences> =
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<Preferences> = PreferenceDataStoreFactory.create(
migrations = listOf(SharedPreferencesMigration(context, "radio-prefs")),
scope = scope,
produceFile = { context.preferencesDataStoreFile("radio_ds") },
)
fun provideRadioDataStore(context: Context, dispatchers: CoroutineDispatchers): DataStore<Preferences> =
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<Preferences> = PreferenceDataStoreFactory.create(
migrations = listOf(SharedPreferencesMigration(context, "ui-prefs")),
scope = scope,
produceFile = { context.preferencesDataStoreFile("ui_ds") },
)
fun provideUiDataStore(context: Context, dispatchers: CoroutineDispatchers): DataStore<Preferences> =
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<Preferences> = PreferenceDataStoreFactory.create(
migrations = listOf(SharedPreferencesMigration(context, "meshlog-prefs")),
scope = scope,
produceFile = { context.preferencesDataStoreFile("meshlog_ds") },
)
fun provideMeshLogDataStore(context: Context, dispatchers: CoroutineDispatchers): DataStore<Preferences> =
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<Preferences> = PreferenceDataStoreFactory.create(
migrations = listOf(SharedPreferencesMigration(context, "filter-prefs")),
scope = scope,
produceFile = { context.preferencesDataStoreFile("filter_ds") },
)
fun provideFilterDataStore(context: Context, dispatchers: CoroutineDispatchers): DataStore<Preferences> =
PreferenceDataStoreFactory.create(
migrations = listOf(SharedPreferencesMigration(context, "filter-prefs")),
scope = CoroutineScope(dispatchers.io + SupervisorJob()),
produceFile = { context.preferencesDataStoreFile("filter_ds") },
)
}

View file

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

View file

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

View file

@ -746,6 +746,8 @@
<string name="serial_enabled">Serial enabled</string>
<string name="echo_enabled">Echo enabled</string>
<string name="serial_baud_rate">Serial baud rate</string>
<string name="serial_rx_pin">RX</string>
<string name="serial_tx_pin">TX</string>
<string name="timeout">Timeout</string>
<string name="serial_mode">Serial mode</string>
<string name="override_console_serial_port">Override console serial port</string>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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()
}
}
}

View file

@ -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<String, String>()
// 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<String, String>()
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)

View file

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

View file

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

View file

@ -14,8 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:Suppress("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(

View file

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

View file

@ -162,6 +162,11 @@ private fun desktopPlatformStubsModule() = module {
single<org.meshtastic.feature.node.compass.PhoneLocationProvider> { NoopPhoneLocationProvider() }
single<org.meshtastic.feature.node.compass.MagneticFieldProvider> { NoopMagneticFieldProvider() }
// Desktop uses the real ApiService implementation (no flavor stub needed)
single<org.meshtastic.core.network.service.ApiService> {
org.meshtastic.core.network.service.ApiServiceImpl(client = get())
}
// Ktor HttpClient for JVM/Desktop (equivalent of CoreNetworkAndroidModule on Android)
single<HttpClient> { HttpClient(Java) { install(ContentNegotiation) { json(get<Json>()) } } }

View file

@ -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() {

View file

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

View file

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

View file

@ -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) {}

View file

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

View file

@ -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<Boolean> = 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<Boolean> = 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<Boolean> = 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<LastHeardFilter> = 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<LastHeardFilter> = lastHeardTrackFilterValue.asStateFlow()
fun setLastHeardTrackFilter(filter: LastHeardFilter) {
lastHeardTrackFilterValue.value = filter

View file

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

View file

@ -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() }),

View file

@ -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() }),

View file

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