refactor(service): unify dual connectionState flows into single source of truth (#5077)

This commit is contained in:
James Rich 2026-04-11 19:50:52 -05:00 committed by GitHub
parent 5e44cbd3a9
commit 9468bc6ebe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 103 additions and 8 deletions

View file

@ -98,6 +98,9 @@ class MeshConnectionManagerImpl(
private var connectionRestored = false
init {
// Bridge transport-level state into the canonical app-level state.
// This is the ONLY consumer of RadioInterfaceService.connectionState — it applies
// light-sleep policy and handshake awareness before writing to ServiceRepository.
radioInterfaceService.connectionState.onEach(::onRadioConnectionState).launchIn(scope)
// Ensure notification title and content stay in sync with state changes
@ -131,6 +134,13 @@ class MeshConnectionManagerImpl(
.launchIn(scope)
}
/**
* Bridges a transport-level [ConnectionState] into the canonical app-level state.
*
* Applies light-sleep policy (power-saving / router role) to decide whether a [ConnectionState.DeviceSleep] event
* should be surfaced as sleep or as a full disconnect, then delegates to [onConnectionChanged] for the actual state
* transition.
*/
private suspend fun onRadioConnectionState(newState: ConnectionState) {
val localConfig = radioConfigRepository.localConfigFlow.first()
val isRouter = localConfig.device?.role == Config.DeviceConfig.Role.ROUTER

View file

@ -28,7 +28,16 @@ import org.meshtastic.proto.ClientNotification
*/
@Suppress("TooManyFunctions")
interface RadioController {
/** Reactive connection state of the radio. */
/**
* Canonical app-level connection state, delegated from [ServiceRepository][connectionState].
*
* This exposes the same single source of truth as `ServiceRepository.connectionState`, surfaced through the
* controller interface for convenience in feature modules and ViewModels that depend on [RadioController] rather
* than [ServiceRepository] directly.
*
* This is **not** the transport-level state it reflects the fully reconciled app-level state including handshake
* progress and device sleep policy.
*/
val connectionState: StateFlow<ConnectionState>
/**

View file

@ -24,12 +24,42 @@ import org.meshtastic.core.model.DeviceType
import org.meshtastic.core.model.InterfaceId
import org.meshtastic.core.model.MeshActivity
/** Interface for the low-level radio interface that handles raw byte communication. */
/**
* Interface for the low-level radio interface that handles raw byte communication.
*
* This is the **transport layer** it manages the raw hardware connection (BLE, TCP, Serial, USB) to a Meshtastic
* radio. Its [connectionState] reflects whether the physical link is up or down, **before** any handshake or
* config-loading logic is applied.
*
* **Important:** UI and feature modules should **never** observe [connectionState] directly. Instead, they should use
* [ServiceRepository.connectionState], which is the canonical app-level connection state that accounts for handshake
* progress, light-sleep policy, and other higher-level concerns. The only legitimate consumer of this transport-level
* flow is [MeshConnectionManager], which bridges transport state changes into the app-level
* [ServiceRepository.connectionState].
*
* @see ServiceRepository.connectionState
*/
interface RadioInterfaceService {
/** The device types supported by this platform's radio interface. */
val supportedDeviceTypes: List<DeviceType>
/** Reactive connection state of the radio. */
/**
* Transport-level connection state of the radio hardware.
*
* This flow reflects the raw state of the physical link (BLE, TCP, Serial, USB):
* - [ConnectionState.Connected] the transport link is established
* - [ConnectionState.Disconnected] the transport link is down (permanent)
* - [ConnectionState.DeviceSleep] the transport link is down (transient, device sleeping)
*
* **This is NOT the canonical app-level connection state.** The transport may report [ConnectionState.Connected]
* while the app is still performing the mesh handshake (config + node-info exchange), during which the app-level
* state remains [ConnectionState.Connecting].
*
* Only [MeshConnectionManager] should observe this flow. All other consumers (ViewModels, feature modules, UI) must
* use [ServiceRepository.connectionState].
*
* @see ServiceRepository.connectionState
*/
val connectionState: StateFlow<ConnectionState>
/** Flow of the current device address. */

View file

@ -31,14 +31,39 @@ import org.meshtastic.proto.MeshPacket
*
* This repository acts as the primary data bridge between the long-running mesh service and the UI/Feature layers. It
* maintains reactive flows for connection status, error messages, and incoming mesh traffic.
*
* **Connection state contract:** [connectionState] is the **canonical, app-level** connection state that all UI,
* feature modules, and ViewModels should observe. It incorporates handshake progress, light-sleep policy, and transport
* reconciliation unlike [RadioInterfaceService.connectionState], which only reflects the raw hardware link status.
* The [MeshConnectionManager] is the sole writer of this state; it bridges [RadioInterfaceService.connectionState]
* changes into app-level transitions via [setConnectionState].
*
* @see RadioInterfaceService.connectionState
*/
@Suppress("TooManyFunctions")
interface ServiceRepository {
/** Reactive flow of the current connection state. */
/**
* Canonical app-level connection state.
*
* This is the **single source of truth** for connection status across the entire application. All UI components,
* feature modules, and ViewModels should observe this flow never [RadioInterfaceService.connectionState].
*
* State transitions are managed exclusively by [MeshConnectionManager], which reconciles transport-level events
* with handshake progress and device sleep policy:
* - [ConnectionState.Disconnected] no active connection to a radio
* - [ConnectionState.Connecting] transport is up, mesh handshake (config + node-info) in progress
* - [ConnectionState.Connected] handshake complete, radio fully operational
* - [ConnectionState.DeviceSleep] radio entered light-sleep (transient disconnect)
*
* @see RadioInterfaceService.connectionState
*/
val connectionState: StateFlow<ConnectionState>
/**
* Updates the current connection state.
* Updates the canonical app-level connection state.
*
* **This should only be called by [MeshConnectionManager].** Direct mutation from other components would bypass the
* transport-to-app reconciliation logic and create state inconsistencies.
*
* @param connectionState The new [ConnectionState].
*/

View file

@ -41,6 +41,7 @@ class AndroidRadioControllerImpl(
private val nodeRepository: NodeRepository,
) : RadioController {
/** Delegates to [ServiceRepository.connectionState] — the canonical app-level source of truth. */
override val connectionState: StateFlow<ConnectionState>
get() = serviceRepository.connectionState

View file

@ -63,6 +63,7 @@ class DirectRadioControllerImpl(
private val myNodeNum: Int
get() = nodeManager.myNodeNum.value ?: 0
/** Delegates to [ServiceRepository.connectionState] — the canonical app-level source of truth. */
override val connectionState: StateFlow<ConnectionState>
get() = serviceRepository.connectionState

View file

@ -42,7 +42,7 @@ import org.meshtastic.proto.MeshPacket
@Suppress("TooManyFunctions")
open class ServiceRepositoryImpl : ServiceRepository {
// Connection state to our radio device
// Canonical app-level connection state — written exclusively by MeshConnectionManager.
private val _connectionState: MutableStateFlow<ConnectionState> = MutableStateFlow(ConnectionState.Disconnected)
override val connectionState: StateFlow<ConnectionState>
get() = _connectionState

View file

@ -77,6 +77,15 @@ class SharedRadioInterfaceService(
override val supportedDeviceTypes: List<DeviceType>
get() = transportFactory.supportedDeviceTypes
/**
* Transport-level connection state reflecting the raw hardware link status.
*
* Updated directly by [onConnect] and [onDisconnect] when the physical transport (BLE, TCP, Serial) connects or
* disconnects. This is consumed exclusively by
* [MeshConnectionManager][org.meshtastic.core.repository.MeshConnectionManager], which reconciles it into the
* canonical app-level
* [ServiceRepository.connectionState][org.meshtastic.core.repository.ServiceRepository.connectionState].
*/
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
override val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()

View file

@ -30,6 +30,7 @@ class FakeRadioController :
BaseFake(),
RadioController {
/** Canonical app-level connection state, mirroring [ServiceRepository][connectionState] semantics. */
private val _connectionState = mutableStateFlow<ConnectionState>(ConnectionState.Connected)
override val connectionState: StateFlow<ConnectionState> = _connectionState

View file

@ -28,12 +28,20 @@ import org.meshtastic.core.model.InterfaceId
import org.meshtastic.core.model.MeshActivity
import org.meshtastic.core.repository.RadioInterfaceService
/** A test double for [RadioInterfaceService] that provides an in-memory implementation. */
/**
* A test double for [RadioInterfaceService] that provides an in-memory implementation.
*
* The [connectionState] here mirrors the transport-level semantics of the real implementation. In production, only
* [MeshConnectionManager][org.meshtastic.core.repository.MeshConnectionManager] observes this flow; tests should verify
* that bridging behavior rather than consuming it directly from UI/feature test code (use
* [FakeServiceRepository.connectionState] instead).
*/
@Suppress("TooManyFunctions")
class FakeRadioInterfaceService(override val serviceScope: CoroutineScope = MainScope()) : RadioInterfaceService {
override val supportedDeviceTypes: List<DeviceType> = emptyList()
/** Transport-level connection state (raw hardware link status). */
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
override val connectionState: StateFlow<ConnectionState> = _connectionState

View file

@ -31,6 +31,7 @@ import org.meshtastic.proto.MeshPacket
@Suppress("TooManyFunctions")
class FakeServiceRepository : ServiceRepository {
/** Canonical app-level connection state — the single source of truth for UI/feature tests. */
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
override val connectionState: StateFlow<ConnectionState> = _connectionState

View file

@ -241,7 +241,7 @@ class UIViewModel(
_sharedContactRequested.value = null
}
// Connection state to our radio device
/** Canonical app-level connection state, sourced from [ServiceRepository.connectionState]. */
val connectionState
get() = serviceRepository.connectionState