refactor(transport): complete transport architecture overhaul — extract callback, wire BleReconnectPolicy, fix safety issues (#5080)

This commit is contained in:
James Rich 2026-04-11 23:22:18 -05:00 committed by GitHub
parent 962c619c4c
commit e85300531e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
64 changed files with 1184 additions and 1018 deletions

View file

@ -17,15 +17,22 @@
package org.meshtastic.core.service
import android.content.Context
import android.content.Intent
import co.touchlab.kermit.Logger
import kotlinx.coroutines.flow.StateFlow
import org.koin.core.annotation.Single
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.model.service.ServiceAction
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.proto.Channel
import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.Config
import org.meshtastic.proto.ModuleConfig
import org.meshtastic.proto.SharedContact
import org.meshtastic.proto.User
/**
* Android [RadioController] implementation that delegates to the bound [MeshService] via AIDL.
@ -69,41 +76,37 @@ class AndroidRadioControllerImpl(
override suspend fun sendSharedContact(nodeNum: Int): Boolean {
val nodeDef = nodeRepository.getNode(DataPacket.nodeNumToDefaultId(nodeNum))
val contact =
org.meshtastic.proto.SharedContact(
node_num = nodeDef.num,
user = nodeDef.user,
manually_verified = nodeDef.manuallyVerified,
)
SharedContact(node_num = nodeDef.num, user = nodeDef.user, manually_verified = nodeDef.manuallyVerified)
val action = ServiceAction.SendContact(contact)
serviceRepository.onServiceAction(action)
return action.result.await()
}
override suspend fun setLocalConfig(config: org.meshtastic.proto.Config) {
override suspend fun setLocalConfig(config: Config) {
serviceRepository.meshService?.setConfig(config.encode())
}
override suspend fun setLocalChannel(channel: org.meshtastic.proto.Channel) {
override suspend fun setLocalChannel(channel: Channel) {
serviceRepository.meshService?.setChannel(channel.encode())
}
override suspend fun setOwner(destNum: Int, user: org.meshtastic.proto.User, packetId: Int) {
override suspend fun setOwner(destNum: Int, user: User, packetId: Int) {
serviceRepository.meshService?.setRemoteOwner(packetId, destNum, user.encode())
}
override suspend fun setConfig(destNum: Int, config: org.meshtastic.proto.Config, packetId: Int) {
override suspend fun setConfig(destNum: Int, config: Config, packetId: Int) {
serviceRepository.meshService?.setRemoteConfig(packetId, destNum, config.encode())
}
override suspend fun setModuleConfig(destNum: Int, config: org.meshtastic.proto.ModuleConfig, packetId: Int) {
override suspend fun setModuleConfig(destNum: Int, config: ModuleConfig, packetId: Int) {
serviceRepository.meshService?.setModuleConfig(packetId, destNum, config.encode())
}
override suspend fun setRemoteChannel(destNum: Int, channel: org.meshtastic.proto.Channel, packetId: Int) {
override suspend fun setRemoteChannel(destNum: Int, channel: Channel, packetId: Int) {
serviceRepository.meshService?.setRemoteChannel(packetId, destNum, channel.encode())
}
override suspend fun setFixedPosition(destNum: Int, position: org.meshtastic.core.model.Position) {
override suspend fun setFixedPosition(destNum: Int, position: Position) {
serviceRepository.meshService?.setFixedPosition(destNum, position)
}
@ -171,7 +174,7 @@ class AndroidRadioControllerImpl(
serviceRepository.meshService?.removeByNodenum(packetId, nodeNum)
}
override suspend fun requestPosition(destNum: Int, currentPosition: org.meshtastic.core.model.Position) {
override suspend fun requestPosition(destNum: Int, currentPosition: Position) {
serviceRepository.meshService?.requestPosition(destNum, currentPosition)
}
@ -214,10 +217,7 @@ class AndroidRadioControllerImpl(
@Suppress("DEPRECATION") // Internal use: routes address change through AIDL binder
serviceRepository.meshService?.setDeviceAddress(address)
// Ensure service is running/restarted to handle the new address
val intent =
android.content.Intent().apply {
setClassName("com.geeksville.mesh", "org.meshtastic.core.service.MeshService")
}
val intent = Intent().apply { setClassName("com.geeksville.mesh", "org.meshtastic.core.service.MeshService") }
context.startForegroundService(intent)
}
}

View file

@ -50,6 +50,12 @@ import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.proto.PortNum
/**
* Android foreground service that hosts the Meshtastic mesh radio connection.
*
* Acts as the lifecycle anchor for the [MeshServiceOrchestrator], which manages all manager initialization and
* connection state. Exposes an AIDL binder for external client integration via [core:api].
*/
// IMeshService is deprecated but still required for AIDL binding
@Suppress("TooManyFunctions", "LargeClass", "DEPRECATION")
class MeshService : Service() {

View file

@ -41,6 +41,7 @@ import org.jetbrains.compose.resources.StringResource
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.NumberFormatter
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Message
import org.meshtastic.core.model.Node
@ -303,17 +304,14 @@ class MeshServiceNotificationsImpl(
// region Public Notification Methods
@Suppress("CyclomaticComplexMethod", "NestedBlockDepth")
override fun updateServiceStateNotification(
state: org.meshtastic.core.model.ConnectionState,
telemetry: Telemetry?,
) {
override fun updateServiceStateNotification(state: ConnectionState, telemetry: Telemetry?) {
val summaryString =
when (state) {
is org.meshtastic.core.model.ConnectionState.Connected ->
is ConnectionState.Connected ->
getString(Res.string.meshtastic_app_name) + ": " + getString(Res.string.connected)
is org.meshtastic.core.model.ConnectionState.Disconnected -> getString(Res.string.disconnected)
is org.meshtastic.core.model.ConnectionState.DeviceSleep -> getString(Res.string.device_sleeping)
is org.meshtastic.core.model.ConnectionState.Connecting -> getString(Res.string.connecting)
is ConnectionState.Disconnected -> getString(Res.string.disconnected)
is ConnectionState.DeviceSleep -> getString(Res.string.device_sleeping)
is ConnectionState.Connecting -> getString(Res.string.connecting)
}
// Update caches if telemetry is provided

View file

@ -19,9 +19,12 @@ package org.meshtastic.core.service
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.coroutineScope
import co.touchlab.kermit.Logger
import kotlinx.atomicfu.atomic
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
@ -96,10 +99,7 @@ class SharedRadioInterfaceService(
override val receivedData: SharedFlow<ByteArray> = _receivedData
private val _meshActivity =
MutableSharedFlow<MeshActivity>(
extraBufferCapacity = 64,
onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST,
)
MutableSharedFlow<MeshActivity>(extraBufferCapacity = 64, onBufferOverflow = BufferOverflow.DROP_OLDEST)
override val meshActivity: SharedFlow<MeshActivity> = _meshActivity.asSharedFlow()
private val _connectionError = MutableSharedFlow<String>(extraBufferCapacity = 64)
@ -109,12 +109,12 @@ class SharedRadioInterfaceService(
get() = _serviceScope
private var _serviceScope = CoroutineScope(dispatchers.io + SupervisorJob())
private var radioIf: RadioTransport? = null
private var runningInterfaceId: InterfaceId? = null
private var radioTransport: RadioTransport? = null
private var runningTransportId: InterfaceId? = null
private var isStarted = false
private val listenersInitialized = kotlinx.atomicfu.atomic(false)
private var heartbeatJob: kotlinx.coroutines.Job? = null
private val listenersInitialized = atomic(false)
private var heartbeatJob: Job? = null
private var lastHeartbeatMillis = 0L
@Volatile private var lastDataReceivedMillis = 0L
@ -130,7 +130,7 @@ class SharedRadioInterfaceService(
}
private val initLock = Mutex()
private val interfaceMutex = Mutex()
private val transportMutex = Mutex()
private fun initStateListeners() {
if (listenersInitialized.value) return
@ -141,10 +141,10 @@ class SharedRadioInterfaceService(
radioPrefs.devAddr
.onEach { addr ->
interfaceMutex.withLock {
transportMutex.withLock {
if (_currentDeviceAddressFlow.value != addr) {
_currentDeviceAddressFlow.value = addr
startInterfaceLocked()
startTransportLocked()
}
}
}
@ -152,11 +152,11 @@ class SharedRadioInterfaceService(
bluetoothRepository.state
.onEach { state ->
interfaceMutex.withLock {
transportMutex.withLock {
if (state.enabled) {
startInterfaceLocked()
} else if (runningInterfaceId == InterfaceId.BLUETOOTH) {
stopInterfaceLocked()
startTransportLocked()
} else if (runningTransportId == InterfaceId.BLUETOOTH) {
stopTransportLocked()
}
}
}
@ -165,11 +165,11 @@ class SharedRadioInterfaceService(
networkRepository.networkAvailable
.onEach { state ->
interfaceMutex.withLock {
transportMutex.withLock {
if (state) {
startInterfaceLocked()
} else if (runningInterfaceId == InterfaceId.TCP) {
stopInterfaceLocked()
startTransportLocked()
} else if (runningTransportId == InterfaceId.TCP) {
stopTransportLocked()
}
}
}
@ -180,11 +180,11 @@ class SharedRadioInterfaceService(
}
override fun connect() {
processLifecycle.coroutineScope.launch { interfaceMutex.withLock { startInterfaceLocked() } }
processLifecycle.coroutineScope.launch { transportMutex.withLock { startTransportLocked() } }
initStateListeners()
}
override fun isMockInterface(): Boolean = transportFactory.isMockInterface()
override fun isMockTransport(): Boolean = transportFactory.isMockTransport()
override fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String =
transportFactory.toInterfaceAddress(interfaceId, rest)
@ -215,17 +215,17 @@ class SharedRadioInterfaceService(
_currentDeviceAddressFlow.value = sanitized
processLifecycle.coroutineScope.launch {
interfaceMutex.withLock {
ignoreException { stopInterfaceLocked() }
startInterfaceLocked()
transportMutex.withLock {
ignoreException { stopTransportLocked() }
startTransportLocked()
}
}
return true
}
/** Must be called under [interfaceMutex]. */
private fun startInterfaceLocked() {
if (radioIf != null) return
/** Must be called under [transportMutex]. */
private fun startTransportLocked() {
if (radioTransport != null) return
// Never autoconnect to the simulated node. The mock transport may be offered in the
// device-picker UI on debug builds, but it must only connect when the user explicitly
@ -237,26 +237,26 @@ class SharedRadioInterfaceService(
return
}
Logger.i { "Starting radio interface for ${address.anonymize}" }
Logger.i { "Starting radio transport for ${address.anonymize}" }
isStarted = true
runningInterfaceId = address.firstOrNull()?.let { InterfaceId.forIdChar(it) }
radioIf = transportFactory.createTransport(address, this)
runningTransportId = address.firstOrNull()?.let { InterfaceId.forIdChar(it) }
radioTransport = transportFactory.createTransport(address, this)
startHeartbeat()
}
/** Must be called under [interfaceMutex]. */
private fun stopInterfaceLocked() {
val currentIf = radioIf
Logger.i { "Stopping interface $currentIf" }
/** Must be called under [transportMutex]. */
private fun stopTransportLocked() {
val currentTransport = radioTransport
Logger.i { "Stopping transport $currentTransport" }
isStarted = false
radioIf = null
runningInterfaceId = null
currentIf?.close()
radioTransport = null
runningTransportId = null
currentTransport?.close()
_serviceScope.cancel("stopping interface")
_serviceScope.cancel("stopping transport")
_serviceScope = CoroutineScope(dispatchers.io + SupervisorJob())
if (currentIf != null) {
if (currentTransport != null) {
onDisconnect(isPermanent = true)
}
}
@ -295,23 +295,25 @@ class SharedRadioInterfaceService(
fun keepAlive(now: Long = nowMillis) {
if (now - lastHeartbeatMillis > HEARTBEAT_INTERVAL_MILLIS) {
radioIf?.keepAlive()
radioTransport?.keepAlive()
lastHeartbeatMillis = now
}
}
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
// Snapshot the transport to avoid calling handleSendToRadio on a null reference.
// There is still a benign race: stopTransportLocked() may cancel _serviceScope
// between the null-check and the launch, causing the coroutine to be silently
// dropped. This is acceptable — if the transport is shutting down, dropping the
// send is the correct behavior.
val currentTransport =
radioTransport
?: run {
Logger.w { "sendToRadio: no active radio interface, dropping ${bytes.size} bytes" }
Logger.w { "sendToRadio: no active radio transport, dropping ${bytes.size} bytes" }
return
}
_serviceScope.handledLaunch {
currentIf.handleSendToRadio(bytes)
currentTransport.handleSendToRadio(bytes)
_meshActivity.tryEmit(MeshActivity.Send)
}
}