mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
fix: harden reliability, clean up KMP compliance, and improve code quality (#5023)
This commit is contained in:
parent
537029a71c
commit
14b381c1eb
53 changed files with 370 additions and 409 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,7 +32,5 @@ kotlin {
|
|||
implementation(libs.jetbrains.navigation3.ui)
|
||||
implementation(libs.kermit)
|
||||
}
|
||||
|
||||
commonTest.dependencies { implementation(kotlin("test")) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import org.koin.core.annotation.Single
|
|||
class CoreNetworkModule {
|
||||
@Single
|
||||
fun provideJson(): Json = Json {
|
||||
isLenient = true
|
||||
ignoreUnknownKeys = true
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ---
|
||||
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -34,7 +34,5 @@ kotlin {
|
|||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.compose.multiplatform.ui)
|
||||
}
|
||||
|
||||
commonTest.dependencies { implementation(kotlin("test")) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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") },
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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>()) } } }
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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) {}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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() }),
|
||||
|
|
|
|||
|
|
@ -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() }),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue