feat(ui): add mesh activity modem lights (#2656)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2025-08-07 14:17:01 -05:00 committed by GitHub
parent da42d67486
commit ad59edd8d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 206 additions and 84 deletions

View file

@ -60,6 +60,7 @@ import com.geeksville.mesh.repository.api.DeviceHardwareRepository
import com.geeksville.mesh.repository.api.FirmwareReleaseRepository
import com.geeksville.mesh.repository.datastore.RadioConfigRepository
import com.geeksville.mesh.repository.location.LocationRepository
import com.geeksville.mesh.repository.radio.MeshActivity
import com.geeksville.mesh.repository.radio.RadioInterfaceService
import com.geeksville.mesh.service.MeshService
import com.geeksville.mesh.service.MeshServiceNotifications
@ -72,6 +73,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@ -84,6 +86,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@ -239,6 +242,13 @@ constructor(
meshServiceNotifications.clearClientNotification(notification)
}
/**
* Emits events for mesh network send/receive activity. This is a SharedFlow to ensure all events are delivered,
* even if they are the same.
*/
val meshActivity: SharedFlow<MeshActivity> =
radioInterfaceService.meshActivity.shareIn(viewModelScope, SharingStarted.Eagerly, 0)
data class AlertData(
val title: String,
val message: String? = null,

View file

@ -39,10 +39,12 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@ -51,16 +53,18 @@ import javax.inject.Inject
import javax.inject.Singleton
/**
* Handles the bluetooth link with a mesh radio device. Does not cache any device state,
* just does bluetooth comms etc...
* Handles the bluetooth link with a mesh radio device. Does not cache any device state, just does bluetooth comms
* etc...
*
* This service is not exposed outside of this process.
*
* Note - this class intentionally dumb. It doesn't understand protobuf framing etc...
* It is designed to be simple so it can be stubbed out with a simulated version as needed.
* Note - this class intentionally dumb. It doesn't understand protobuf framing etc... It is designed to be simple so it
* can be stubbed out with a simulated version as needed.
*/
@Singleton
class RadioInterfaceService @Inject constructor(
class RadioInterfaceService
@Inject
constructor(
private val context: Application,
private val dispatchers: CoroutineDispatchers,
private val bluetoothRepository: BluetoothRepository,
@ -85,18 +89,15 @@ class RadioInterfaceService @Inject constructor(
private lateinit var sentPacketsLog: BinaryLogFile // inited in onCreate
private lateinit var receivedPacketsLog: BinaryLogFile
val mockInterfaceAddress: String by lazy {
toInterfaceAddress(InterfaceId.MOCK, "")
}
val mockInterfaceAddress: String by lazy { toInterfaceAddress(InterfaceId.MOCK, "") }
/**
* We recreate this scope each time we stop an interface
*/
/** We recreate this scope each time we stop an interface */
var serviceScope = CoroutineScope(Dispatchers.IO + Job())
private var radioIf: IRadioInterface = NopInterface("")
/** true if we have started our interface
/**
* true if we have started our interface
*
* Note: an interface may be started without necessarily yet having a connection
*/
@ -106,15 +107,25 @@ class RadioInterfaceService @Inject constructor(
private var isConnected = false
private fun initStateListeners() {
bluetoothRepository.state.onEach { state ->
if (state.enabled) startInterface()
else if (radioIf is BluetoothInterface) stopInterface()
}.launchIn(processLifecycle.coroutineScope)
bluetoothRepository.state
.onEach { state ->
if (state.enabled) {
startInterface()
} else if (radioIf is BluetoothInterface) {
stopInterface()
}
}
.launchIn(processLifecycle.coroutineScope)
networkRepository.networkAvailable.onEach { state ->
if (state) startInterface()
else if (radioIf is TCPInterface) stopInterface()
}.launchIn(processLifecycle.coroutineScope)
networkRepository.networkAvailable
.onEach { state ->
if (state) {
startInterface()
} else if (radioIf is TCPInterface) {
stopInterface()
}
}
.launchIn(processLifecycle.coroutineScope)
}
companion object {
@ -123,48 +134,43 @@ class RadioInterfaceService @Inject constructor(
}
private var lastHeartbeatMillis = 0L
private fun keepAlive(now: Long) {
if (now - lastHeartbeatMillis > HEARTBEAT_INTERVAL_MILLIS) {
info("Sending ToRadio heartbeat")
val heartbeat = MeshProtos.ToRadio.newBuilder()
.setHeartbeat(MeshProtos.Heartbeat.getDefaultInstance()).build()
val heartbeat =
MeshProtos.ToRadio.newBuilder().setHeartbeat(MeshProtos.Heartbeat.getDefaultInstance()).build()
handleSendToRadio(heartbeat.toByteArray())
lastHeartbeatMillis = now
}
}
/**
* Constructs a full radio address for the specific interface type.
*/
fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String {
return interfaceFactory.toInterfaceAddress(interfaceId, rest)
}
/** Constructs a full radio address for the specific interface type. */
fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String =
interfaceFactory.toInterfaceAddress(interfaceId, rest)
fun isMockInterface(): Boolean {
return BuildConfig.DEBUG || (context as GeeksvilleApplication).isInTestLab
}
fun isMockInterface(): Boolean = BuildConfig.DEBUG || (context as GeeksvilleApplication).isInTestLab
/**
* Determines whether to default to mock interface for device address.
* This keeps the decision logic separate and easy to extend.
* Determines whether to default to mock interface for device address. This keeps the decision logic separate and
* easy to extend.
*/
private fun shouldDefaultToMockInterface(): Boolean {
return BuildUtils.isEmulator
}
private fun shouldDefaultToMockInterface(): Boolean = BuildUtils.isEmulator
/** Return the device we are configured to use, or null for none
* device address strings are of the form:
/**
* Return the device we are configured to use, or null for none device address strings are of the form:
*
* at
*
* where a is either x for bluetooth or s for serial
* and t is an interface specific address (macaddr or a device path)
* where a is either x for bluetooth or s for serial and t is an interface specific address (macaddr or a device
* path)
*/
fun getDeviceAddress(): String? {
// If the user has unpaired our device, treat things as if we don't have one
var address = prefs.getString(DEVADDR_KEY, null)
// If we are running on the emulator we default to the mock interface, so we can have some data to show to the user
// If we are running on the emulator we default to the mock interface, so we can have some data to show to the
// user
if (address == null && shouldDefaultToMockInterface()) {
address = mockInterfaceAddress
}
@ -172,12 +178,13 @@ class RadioInterfaceService @Inject constructor(
return address
}
/** Like getDeviceAddress, but filtered to return only devices we are currently bonded with
/**
* Like getDeviceAddress, but filtered to return only devices we are currently bonded with
*
* at
*
* where a is either x for bluetooth or s for serial
* and t is an interface specific address (macaddr or a device path)
* where a is either x for bluetooth or s for serial and t is an interface specific address (macaddr or a device
* path)
*/
fun getBondedDeviceAddress(): String? {
// If the user has unpaired our device, treat things as if we don't have one
@ -193,15 +200,14 @@ class RadioInterfaceService @Inject constructor(
debug("Broadcasting connection=$isConnected")
processLifecycle.coroutineScope.launch(dispatchers.default) {
_connectionState.emit(
RadioServiceConnectionState(isConnected, isPermanent)
)
_connectionState.emit(RadioServiceConnectionState(isConnected, isPermanent))
}
}
// Send a packet/command out the radio link, this routine can block if it needs to
private fun handleSendToRadio(p: ByteArray) {
radioIf.handleSendToRadio(p)
emitSendActivity()
}
// Handle an incoming packet from the radio, broadcasts it as an android intent
@ -217,9 +223,8 @@ class RadioInterfaceService @Inject constructor(
// ignoreException { debug("FromRadio: ${MeshProtos.FromRadio.parseFrom(p)}") }
processLifecycle.coroutineScope.launch(dispatchers.io) {
_receivedData.emit(p)
}
processLifecycle.coroutineScope.launch(dispatchers.io) { _receivedData.emit(p) }
emitReceiveActivity()
}
fun onConnect() {
@ -289,20 +294,16 @@ class RadioInterfaceService @Inject constructor(
*
* @return true if the device changed, false if no change
*/
private fun setBondedDeviceAddress(address: String?): Boolean {
return if (getBondedDeviceAddress() == address && isStarted) {
private fun setBondedDeviceAddress(address: String?): Boolean =
if (getBondedDeviceAddress() == address && isStarted) {
warn("Ignoring setBondedDevice ${address.anonymize}, because we are already using that device")
false
} else {
// Record that this use has configured a new radio
GeeksvilleApplication.analytics.track(
"mesh_bond"
)
GeeksvilleApplication.analytics.track("mesh_bond")
// Ignore any errors that happen while closing old device
ignoreException {
stopInterface()
}
ignoreException { stopInterface() }
// The device address "n" can be used to mean none
@ -321,14 +322,13 @@ class RadioInterfaceService @Inject constructor(
startInterface()
true
}
}
fun setDeviceAddress(deviceAddr: String?): Boolean = toRemoteExceptions {
setBondedDeviceAddress(deviceAddr)
}
fun setDeviceAddress(deviceAddr: String?): Boolean = toRemoteExceptions { setBondedDeviceAddress(deviceAddr) }
/** If the service is not currently connected to the radio, try to connect now. At boot the radio interface service will
* not connect to a radio until this call is received. */
/**
* If the service is not currently connected to the radio, try to connect now. At boot the radio interface service
* will not connect to a radio until this call is received.
*/
fun connect() = toRemoteExceptions {
// We don't start actually talking to our device until MeshService binds to us - this prevents
// broadcasting connection events before MeshService is ready to receive them
@ -340,4 +340,33 @@ class RadioInterfaceService @Inject constructor(
// Do this in the IO thread because it might take a while (and we don't care about the result code)
serviceScope.handledLaunch { handleSendToRadio(a) }
}
private val _meshActivity =
MutableSharedFlow<MeshActivity>(
replay = 0, // No replay needed for event-like emissions
extraBufferCapacity = 1, // Buffer one event to avoid loss on rapid emissions
onBufferOverflow = BufferOverflow.DROP_OLDEST, // Drop oldest if buffer overflows
)
val meshActivity: SharedFlow<MeshActivity> = _meshActivity.asSharedFlow()
private fun emitSendActivity() {
// Use tryEmit for SharedFlow as it's non-blocking
val emitted = _meshActivity.tryEmit(MeshActivity.Send)
if (!emitted) {
debug("MeshActivity.Send event was not emitted due to buffer overflow or no collectors")
}
}
private fun emitReceiveActivity() {
val emitted = _meshActivity.tryEmit(MeshActivity.Receive)
if (!emitted) {
debug("MeshActivity.Receive event was not emitted due to buffer overflow or no collectors")
}
}
}
sealed class MeshActivity {
data object Send : MeshActivity()
data object Receive : MeshActivity()
}

View file

@ -21,7 +21,11 @@ import android.Manifest
import android.os.Build
import androidx.annotation.StringRes
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
@ -43,6 +47,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MaterialTheme.colorScheme
import androidx.compose.material3.PlainTooltip
@ -57,13 +62,16 @@ import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType
import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
@ -98,12 +106,14 @@ import com.geeksville.mesh.navigation.NodesRoutes
import com.geeksville.mesh.navigation.RadioConfigRoutes
import com.geeksville.mesh.navigation.Route
import com.geeksville.mesh.navigation.showLongNameTitle
import com.geeksville.mesh.repository.radio.MeshActivity
import com.geeksville.mesh.service.MeshService
import com.geeksville.mesh.ui.TopLevelDestination.Companion.isTopLevel
import com.geeksville.mesh.ui.common.components.MultipleChoiceAlertDialog
import com.geeksville.mesh.ui.common.components.ScannedQrCodeDialog
import com.geeksville.mesh.ui.common.components.SimpleAlertDialog
import com.geeksville.mesh.ui.common.theme.StatusColors.StatusGreen
import com.geeksville.mesh.ui.common.theme.StatusColors.StatusOrange
import com.geeksville.mesh.ui.common.theme.StatusColors.StatusRed
import com.geeksville.mesh.ui.common.theme.StatusColors.StatusYellow
import com.geeksville.mesh.ui.debug.DebugMenuActions
@ -114,6 +124,8 @@ import com.geeksville.mesh.ui.sharing.SharedContactDialog
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
enum class TopLevelDestination(@StringRes val label: Int, val icon: ImageVector, val route: Route) {
Contacts(R.string.contacts, Icons.AutoMirrored.TwoTone.Chat, ContactsRoutes.ContactsGraph),
@ -222,6 +234,39 @@ fun MainScreen(
val navSuiteType = NavigationSuiteScaffoldDefaults.navigationSuiteType(currentWindowAdaptiveInfo())
val currentDestination = navController.currentBackStackEntryAsState().value?.destination
val topLevelDestination = TopLevelDestination.fromNavDestination(currentDestination)
// State for managing the glow animation around the Connections icon
var currentGlowColor by remember { mutableStateOf(Color.Transparent) }
val animatedGlowAlpha = remember { Animatable(0f) }
val coroutineScope = rememberCoroutineScope()
val capturedColorScheme = colorScheme // Capture current colorScheme instance for LaunchedEffect
val sendColor = capturedColorScheme.StatusOrange
val receiveColor = capturedColorScheme.StatusYellow
LaunchedEffect(uIViewModel.meshActivity, capturedColorScheme) {
uIViewModel.meshActivity.collectLatest { activity ->
debug("MeshActivity Event: $activity, Current Alpha: ${animatedGlowAlpha.value}")
val newTargetColor =
when (activity) {
is MeshActivity.Send -> sendColor
is MeshActivity.Receive -> receiveColor
}
currentGlowColor = newTargetColor
// Stop any existing animation and launch a new one.
// Launching in a new coroutine ensures the collect block is not suspended.
coroutineScope.launch {
animatedGlowAlpha.stop() // Stop before snapping/animating
animatedGlowAlpha.snapTo(1.0f) // Show glow instantly
animatedGlowAlpha.animateTo(
targetValue = 0.0f, // Fade out
animationSpec = tween(durationMillis = 1000, easing = LinearEasing),
)
}
}
}
NavigationSuiteScaffold(
modifier = Modifier.fillMaxSize(),
navigationSuiteItems = {
@ -245,7 +290,39 @@ fun MainScreen(
},
state = rememberTooltipState(),
) {
TopLevelNavIcon(destination, connectionState)
val iconModifier =
if (isConnectionsRoute) {
Modifier.drawWithCache {
onDrawWithContent {
drawContent()
if (animatedGlowAlpha.value > 0f) {
val glowRadius = size.minDimension
drawCircle(
brush =
Brush.radialGradient(
colors =
listOf(
currentGlowColor.copy(
alpha = 0.8f * animatedGlowAlpha.value,
),
currentGlowColor.copy(
alpha = 0.4f * animatedGlowAlpha.value,
),
Color.Transparent,
),
center = center,
radius = glowRadius,
),
radius = glowRadius,
blendMode = BlendMode.Screen,
)
}
}
}
} else {
Modifier
}
Box(modifier = iconModifier) { TopLevelNavIcon(destination, connectionState) }
}
},
selected = isSelected,
@ -309,6 +386,25 @@ fun MainScreen(
}
}
@Composable
private fun TopLevelNavIcon(destination: TopLevelDestination, connectionState: MeshService.ConnectionState) {
val iconTint =
when {
destination == TopLevelDestination.Connections -> connectionState.getConnectionColor()
else -> LocalContentColor.current
}
Icon(
imageVector =
if (destination == TopLevelDestination.Connections) {
connectionState.getConnectionIcon()
} else {
destination.icon
},
contentDescription = stringResource(id = destination.label),
tint = iconTint,
)
}
@Composable
@Suppress("LongMethod", "CyclomaticComplexMethod")
private fun VersionChecks(viewModel: UIViewModel) {
@ -322,7 +418,8 @@ private fun VersionChecks(viewModel: UIViewModel) {
val currentDeviceHardware by viewModel.deviceHardware.collectAsStateWithLifecycle(null)
val latestStableFirmwareRelease by viewModel.latestStableFirmwareRelease.collectAsState(DeviceVersion("2.6.4"))
val latestStableFirmwareRelease by
viewModel.latestStableFirmwareRelease.collectAsStateWithLifecycle(DeviceVersion("2.6.4"))
LaunchedEffect(connectionState, firmwareEdition) {
if (connectionState == MeshService.ConnectionState.CONNECTED) {
firmwareEdition?.let { edition ->
@ -419,7 +516,7 @@ private fun MainAppBar(
val totalNodeCount by viewModel.totalNodeCount.collectAsStateWithLifecycle(0)
TopAppBar(
title = {
val title =
val titleText =
when {
currentDestination == null || currentDestination.isTopLevel() ->
stringResource(id = R.string.app_name)
@ -435,7 +532,7 @@ private fun MainAppBar(
else -> stringResource(id = R.string.app_name)
}
Text(
text = title,
text = titleText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.titleLarge,
@ -514,7 +611,7 @@ private fun MainMenuActions(isManaged: Boolean, onAction: (MainMenuAction) -> Un
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false },
modifier = Modifier.background(MaterialTheme.colorScheme.background.copy(alpha = 1f)),
modifier = Modifier.background(colorScheme.background.copy(alpha = 1f)),
) {
MainMenuAction.entries.forEach { action ->
DropdownMenuItem(
@ -552,17 +649,3 @@ private fun MeshService.ConnectionState.getTooltipString(): String = when (this)
MeshService.ConnectionState.DEVICE_SLEEP -> stringResource(R.string.device_sleeping)
MeshService.ConnectionState.DISCONNECTED -> stringResource(R.string.disconnected)
}
@Composable
private fun TopLevelNavIcon(dest: TopLevelDestination, connectionState: MeshService.ConnectionState) {
when (dest) {
TopLevelDestination.Connections ->
Icon(
imageVector = connectionState.getConnectionIcon(),
contentDescription = stringResource(id = dest.label),
tint = connectionState.getConnectionColor(),
)
else -> Icon(imageVector = dest.icon, contentDescription = stringResource(id = dest.label))
}
}