mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
refactor(transport): complete transport architecture overhaul — extract callback, wire BleReconnectPolicy, fix safety issues (#5080)
This commit is contained in:
parent
962c619c4c
commit
e85300531e
64 changed files with 1184 additions and 1018 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue