diff --git a/app/src/main/java/com/geeksville/mesh/model/UIState.kt b/app/src/main/java/com/geeksville/mesh/model/UIState.kt index 4770bb512..1a0a40eb0 100644 --- a/app/src/main/java/com/geeksville/mesh/model/UIState.kt +++ b/app/src/main/java/com/geeksville/mesh/model/UIState.kt @@ -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 = + radioInterfaceService.meshActivity.shareIn(viewModelScope, SharingStarted.Eagerly, 0) + data class AlertData( val title: String, val message: String? = null, diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt index c93978051..4c75379d6 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt @@ -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( + 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.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() } diff --git a/app/src/main/java/com/geeksville/mesh/ui/Main.kt b/app/src/main/java/com/geeksville/mesh/ui/Main.kt index 7bd0edc1b..e6d5f208d 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/Main.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/Main.kt @@ -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)) - } -}