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 60aac0452..0d340c9cb 100644 --- a/app/src/main/java/com/geeksville/mesh/model/UIState.kt +++ b/app/src/main/java/com/geeksville/mesh/model/UIState.kt @@ -266,6 +266,13 @@ constructor( serviceRepository.clearTracerouteResponse() } + val neighborInfoResponse: LiveData + get() = serviceRepository.neighborInfoResponse.asLiveData() + + fun clearNeighborInfoResponse() { + serviceRepository.clearNeighborInfoResponse() + } + val appIntroCompleted: StateFlow = uiPreferencesDataSource.appIntroCompleted fun onAppIntroCompleted() { diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt index 2df148a0d..b81c83811 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt @@ -179,6 +179,9 @@ class MeshService : Service() { @Inject lateinit var analytics: PlatformAnalytics private val tracerouteStartTimes = ConcurrentHashMap() + private val neighborInfoStartTimes = ConcurrentHashMap() + + @Volatile private var lastNeighborInfo: MeshProtos.NeighborInfo? = null private val logUuidByPacketId = ConcurrentHashMap() private val logInsertJobByPacketId = ConcurrentHashMap() @@ -241,6 +244,8 @@ class MeshService : Service() { private const val DEFAULT_HISTORY_RETURN_MAX_MESSAGES = 100 private const val MAX_EARLY_PACKET_BUFFER = 128 + private const val NEIGHBOR_RQ_COOLDOWN = 3 * 60 * 1000L // ms + @VisibleForTesting internal fun buildStoreForwardHistoryRequest( lastRequest: Int, @@ -305,6 +310,8 @@ class MeshService : Service() { private val batteryPercentCooldownSeconds = 1500 private val batteryPercentCooldowns: HashMap = HashMap() + private val oneHour = 3600 + private fun getSenderName(packet: DataPacket?): String { val name = nodeDBbyID[packet?.from]?.user?.longName return name ?: getString(Res.string.unknown_username) @@ -995,6 +1002,138 @@ class MeshService : Service() { } } + Portnums.PortNum.NEIGHBORINFO_APP_VALUE -> { + val requestId = packet.decoded.requestId + Timber.d("Processing NEIGHBORINFO_APP packet with requestId: $requestId") + val start = neighborInfoStartTimes.remove(requestId) + Timber.d("Found start time for requestId $requestId: $start") + + val info = + runCatching { MeshProtos.NeighborInfo.parseFrom(data.payload.toByteArray()) }.getOrNull() + + // Store the last neighbor info from our connected radio + if (info != null && packet.from == myInfo.myNodeNum) { + lastNeighborInfo = info + Timber.d("Stored last neighbor info from connected radio") + } + + // Only show response if packet is addressed to us and we sent a request in the last 3 minutes + val isAddressedToUs = packet.to == myInfo.myNodeNum + val isRecentRequest = + start != null && (System.currentTimeMillis() - start) < NEIGHBOR_RQ_COOLDOWN + + if (isAddressedToUs && isRecentRequest) { + val formatted = + if (info != null) { + val fmtNode: (Int) -> String = { nodeNum -> + val user = nodeRepository.nodeDBbyNum.value[nodeNum]?.user + val shortName = user?.shortName?.takeIf { it.isNotEmpty() } ?: "" + val nodeId = "!%08x".format(nodeNum) + if (shortName.isNotEmpty()) "$nodeId ($shortName)" else nodeId + } + buildString { + appendLine("NeighborInfo:") + appendLine("node_id: ${fmtNode(info.nodeId)}") + appendLine("last_sent_by_id: ${fmtNode(info.lastSentById)}") + appendLine("node_broadcast_interval_secs: ${info.nodeBroadcastIntervalSecs}") + if (info.neighborsCount > 0) { + appendLine("neighbors:") + info.neighborsList.forEach { n -> + appendLine(" - node_id: ${fmtNode(n.nodeId)} snr: ${n.snr}") + } + } + } + } else { + // Fallback to raw string if parsing fails + String(data.payload.toByteArray()) + } + + val response = + if (start != null) { + val elapsedMs = System.currentTimeMillis() - start + val seconds = elapsedMs / 1000.0 + Timber.i("Neighbor info $requestId complete in $seconds s") + "$formatted\n\nDuration: ${"%.1f".format(seconds)} s" + } else { + Timber.w("No start time found for neighbor info requestId: $requestId") + formatted + } + serviceRepository.setNeighborInfoResponse(response) + } else { + Timber.d( + "Neighbor info response filtered: ToUs=%s, isRecentRequest=%s", + isAddressedToUs, + isRecentRequest, + ) + } + } + + Portnums.PortNum.NEIGHBORINFO_APP_VALUE -> { + val requestId = packet.decoded.requestId + Timber.d("Processing NEIGHBORINFO_APP packet with requestId: $requestId") + val start = neighborInfoStartTimes.remove(requestId) + Timber.d("Found start time for requestId $requestId: $start") + + val info = + runCatching { MeshProtos.NeighborInfo.parseFrom(data.payload.toByteArray()) }.getOrNull() + + // Store the last neighbor info from our connected radio + if (info != null && packet.from == myInfo.myNodeNum) { + lastNeighborInfo = info + Timber.d("Stored last neighbor info from connected radio") + } + + // Only show response if packet is addressed to us and we sent a request in the last 3 minutes + val isAddressedToUs = packet.to == myInfo.myNodeNum + val isRecentRequest = + start != null && (System.currentTimeMillis() - start) < NEIGHBOR_RQ_COOLDOWN + + if (isAddressedToUs && isRecentRequest) { + val formatted = + if (info != null) { + val fmtNode: (Int) -> String = { nodeNum -> + val user = nodeRepository.nodeDBbyNum.value[nodeNum]?.user + val shortName = user?.shortName?.takeIf { it.isNotEmpty() } ?: "" + val nodeId = "!%08x".format(nodeNum) + if (shortName.isNotEmpty()) "$nodeId ($shortName)" else nodeId + } + buildString { + appendLine("NeighborInfo:") + appendLine("node_id: ${fmtNode(info.nodeId)}") + appendLine("last_sent_by_id: ${fmtNode(info.lastSentById)}") + appendLine("node_broadcast_interval_secs: ${info.nodeBroadcastIntervalSecs}") + if (info.neighborsCount > 0) { + appendLine("neighbors:") + info.neighborsList.forEach { n -> + appendLine(" - node_id: ${fmtNode(n.nodeId)} snr: ${n.snr}") + } + } + } + } else { + // Fallback to raw string if parsing fails + String(data.payload.toByteArray()) + } + + val response = + if (start != null) { + val elapsedMs = System.currentTimeMillis() - start + val seconds = elapsedMs / 1000.0 + Timber.i("Neighbor info $requestId complete in $seconds s") + "$formatted\n\nDuration: ${"%.1f".format(seconds)} s" + } else { + Timber.w("No start time found for neighbor info requestId: $requestId") + formatted + } + serviceRepository.setNeighborInfoResponse(response) + } else { + Timber.d( + "Neighbor info response filtered: isToUs=%s, isRecent=%s", + isAddressedToUs, + isRecentRequest, + ) + } + } + else -> Timber.d("No custom processing needed for ${data.portnumValue} from $fromId") } @@ -2649,6 +2788,45 @@ class MeshService : Service() { } } + override fun requestNeighborInfo(requestId: Int, destNum: Int) = toRemoteExceptions { + if (destNum != myNodeNum) { + neighborInfoStartTimes[requestId] = System.currentTimeMillis() + // Always send the neighbor info from our connected radio (myNodeNum), not request from destNum + val neighborInfoToSend = + lastNeighborInfo + ?: run { + // If we don't have it, send dummy/interceptable data + Timber.d("No stored neighbor info from connected radio, sending dummy data") + MeshProtos.NeighborInfo.newBuilder() + .setNodeId(myNodeNum) + .setLastSentById(myNodeNum) + .setNodeBroadcastIntervalSecs(oneHour) + .addNeighbors( + MeshProtos.Neighbor.newBuilder() + .setNodeId(0) // Dummy node ID that can be intercepted + .setSnr(0f) + .setLastRxTime(currentSecond()) + .setNodeBroadcastIntervalSecs(oneHour) + .build(), + ) + .build() + } + + // Send the neighbor info from our connected radio to the destination + packetHandler.sendToRadio( + newMeshPacketTo(destNum).buildMeshPacket( + wantAck = true, + id = requestId, + channel = nodeDBbyNodeNum[destNum]?.channel ?: 0, + ) { + portnumValue = Portnums.PortNum.NEIGHBORINFO_APP_VALUE + payload = neighborInfoToSend.toByteString() + wantResponse = true + }, + ) + } + } + override fun requestPosition(destNum: Int, position: Position) = toRemoteExceptions { if (destNum != myNodeNum) { val provideLocation = meshPrefs.shouldProvideNodeLocation(myNodeNum) 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 989923fcf..1ea9d75a7 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/Main.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/Main.kt @@ -33,8 +33,10 @@ import androidx.compose.animation.scaleOut import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.recalculateWindowInsets import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.layout.width @@ -47,6 +49,7 @@ import androidx.compose.material3.BadgedBox import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.PlainTooltip import androidx.compose.material3.Text @@ -74,6 +77,7 @@ 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 +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -132,6 +136,7 @@ import org.meshtastic.core.strings.firmware_old import org.meshtastic.core.strings.firmware_too_old import org.meshtastic.core.strings.map import org.meshtastic.core.strings.must_update +import org.meshtastic.core.strings.neighbor_info import org.meshtastic.core.strings.nodes import org.meshtastic.core.strings.okay import org.meshtastic.core.strings.should_update @@ -293,6 +298,98 @@ fun MainScreen(uIViewModel: UIViewModel = hiltViewModel(), scanModel: BTScanMode onDismiss = { tracerouteMapError = null }, ) } + + val neighborInfoResponse by uIViewModel.neighborInfoResponse.observeAsState() + neighborInfoResponse?.let { response -> + SimpleAlertDialog( + title = Res.string.neighbor_info, + text = { + Column(modifier = Modifier.fillMaxWidth()) { + fun tryParseNeighborInfo(input: String): MeshProtos.NeighborInfo? { + // First, try parsing directly from raw bytes of the string + var neighborInfo: MeshProtos.NeighborInfo? = + runCatching { MeshProtos.NeighborInfo.parseFrom(input.toByteArray()) }.getOrNull() + + if (neighborInfo == null) { + // Next, try to decode a hex dump embedded as text (e.g., "AA BB CC ...") + val hexPairs = Regex("""\b[0-9A-Fa-f]{2}\b""").findAll(input).map { it.value }.toList() + @Suppress("detekt:MagicNumber") // byte offsets + if (hexPairs.size >= 4) { + val bytes = hexPairs.map { it.toInt(16).toByte() }.toByteArray() + neighborInfo = runCatching { MeshProtos.NeighborInfo.parseFrom(bytes) }.getOrNull() + } + } + + return neighborInfo + } + + val parsed = tryParseNeighborInfo(response) + if (parsed != null) { + fun fmtNode(nodeNum: Int): String = "!%08x".format(nodeNum) + Text(text = "NeighborInfo:", style = MaterialTheme.typography.bodyMedium) + Text( + text = "node_id: ${fmtNode(parsed.nodeId)}", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(top = 8.dp), + ) + Text( + text = "last_sent_by_id: ${fmtNode(parsed.lastSentById)}", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(top = 2.dp), + ) + Text( + text = "node_broadcast_interval_secs: ${parsed.nodeBroadcastIntervalSecs}", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(top = 2.dp), + ) + if (parsed.neighborsCount > 0) { + Text( + text = "neighbors:", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(top = 4.dp), + ) + parsed.neighborsList.forEach { n -> + Text( + text = " - node_id: ${fmtNode(n.nodeId)} snr: ${n.snr}", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(start = 8.dp), + ) + } + } + } else { + val rawBytes = response.toByteArray() + + @Suppress("detekt:MagicNumber") // byte offsets + val isBinary = response.any { it.code < 32 && it != '\n' && it != '\r' && it != '\t' } + if (isBinary) { + val hexString = rawBytes.joinToString(" ") { "%02X".format(it) } + Text( + text = "Binary data (hex view):", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(bottom = 4.dp), + ) + Text( + text = hexString, + style = + MaterialTheme.typography.bodyMedium.copy( + fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, + ), + modifier = Modifier.padding(bottom = 8.dp).fillMaxWidth(), + ) + } else { + Text( + text = response, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(bottom = 8.dp).fillMaxWidth(), + ) + } + } + } + }, + dismissText = stringResource(Res.string.okay), + onDismiss = { uIViewModel.clearNeighborInfoResponse() }, + ) + } val navSuiteType = NavigationSuiteScaffoldDefaults.navigationSuiteType(currentWindowAdaptiveInfo()) val currentDestination = navController.currentBackStackEntryAsState().value?.destination val topLevelDestination = TopLevelDestination.fromNavDestination(currentDestination) diff --git a/core/service/src/main/aidl/org/meshtastic/core/service/IMeshService.aidl b/core/service/src/main/aidl/org/meshtastic/core/service/IMeshService.aidl index 19317633f..69116184c 100644 --- a/core/service/src/main/aidl/org/meshtastic/core/service/IMeshService.aidl +++ b/core/service/src/main/aidl/org/meshtastic/core/service/IMeshService.aidl @@ -126,6 +126,9 @@ interface IMeshService { /// Send traceroute packet with wantResponse to nodeNum void requestTraceroute(in int requestId, in int destNum); + /// Send neighbor info packet with wantResponse to nodeNum + void requestNeighborInfo(in int requestId, in int destNum); + /// Send Shutdown admin packet to nodeNum void requestShutdown(in int requestId, in int destNum); diff --git a/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceRepository.kt b/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceRepository.kt index 38e8845b6..f6c2eb274 100644 --- a/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceRepository.kt +++ b/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceRepository.kt @@ -118,6 +118,18 @@ class ServiceRepository @Inject constructor() { setTracerouteResponse(null) } + private val _neighborInfoResponse = MutableStateFlow(null) + val neighborInfoResponse: StateFlow + get() = _neighborInfoResponse + + fun setNeighborInfoResponse(value: String?) { + _neighborInfoResponse.value = value + } + + fun clearNeighborInfoResponse() { + setNeighborInfoResponse(null) + } + private val _serviceAction = Channel() val serviceAction = _serviceAction.receiveAsFlow() diff --git a/core/strings/src/commonMain/composeResources/values/strings.xml b/core/strings/src/commonMain/composeResources/values/strings.xml index 22ec6b345..640cc4779 100644 --- a/core/strings/src/commonMain/composeResources/values/strings.xml +++ b/core/strings/src/commonMain/composeResources/values/strings.xml @@ -742,6 +742,7 @@ Warning: This contact is known, importing will overwrite the previous contact information. Public Key Changed Import + Request NeighborInfo (2.7.15+) Request Metadata Actions Firmware diff --git a/feature/map/src/fdroid/java/org/meshtastic/feature/map/cluster/RadiusMarkerClusterer.java b/feature/map/src/fdroid/java/org/meshtastic/feature/map/cluster/RadiusMarkerClusterer.java index 48ceeebeb..655e9d7b9 100644 --- a/feature/map/src/fdroid/java/org/meshtastic/feature/map/cluster/RadiusMarkerClusterer.java +++ b/feature/map/src/fdroid/java/org/meshtastic/feature/map/cluster/RadiusMarkerClusterer.java @@ -125,10 +125,10 @@ public class RadiusMarkerClusterer extends MarkerClusterer { Iterator it = mClonedMarkers.iterator(); while (it.hasNext()) { - MarkerWithLabel neighbour = it.next(); - double distance = clusterPosition.distanceToAsDouble(neighbour.getPosition()); + MarkerWithLabel neighbor = it.next(); + double distance = clusterPosition.distanceToAsDouble(neighbor.getPosition()); if (distance <= mRadiusInMeters) { - cluster.add(neighbour); + cluster.add(neighbor); it.remove(); } } diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/TracerouteButton.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/CooldownButton.kt similarity index 68% rename from feature/node/src/main/kotlin/org/meshtastic/feature/node/component/TracerouteButton.kt rename to feature/node/src/main/kotlin/org/meshtastic/feature/node/component/CooldownButton.kt index 284283726..5fec486b9 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/TracerouteButton.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/CooldownButton.kt @@ -22,6 +22,7 @@ import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Route +import androidx.compose.material.icons.twotone.Mediation import androidx.compose.material3.CircularWavyProgressIndicator import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.runtime.Composable @@ -30,16 +31,19 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.strings.Res +import org.meshtastic.core.strings.request_neighbor_info import org.meshtastic.core.strings.traceroute import org.meshtastic.core.ui.component.BasicListItem import org.meshtastic.core.ui.theme.AppTheme private const val COOL_DOWN_TIME_MS = 30000L +private const val REQUEST_NEIGHBORS_COOL_DOWN_TIME_MS = 180000L // 3 minutes @Composable fun TracerouteButton( @@ -61,20 +65,42 @@ fun TracerouteButton( } } - TracerouteButton(text = text, progress = progress.value, onClick = onClick) + CooldownButton(text = text, leadingIcon = Icons.Default.Route, progress = progress.value, onClick = onClick) +} + +@Composable +fun RequestNeighborsButton( + text: String = stringResource(Res.string.request_neighbor_info), + lastRequestNeighborsTime: Long?, + onClick: () -> Unit, +) { + val progress = remember { Animatable(0f) } + + LaunchedEffect(lastRequestNeighborsTime) { + val timeSinceLast = System.currentTimeMillis() - (lastRequestNeighborsTime ?: 0) + if (timeSinceLast < REQUEST_NEIGHBORS_COOL_DOWN_TIME_MS) { + val remainingTime = REQUEST_NEIGHBORS_COOL_DOWN_TIME_MS - timeSinceLast + progress.snapTo(remainingTime / REQUEST_NEIGHBORS_COOL_DOWN_TIME_MS.toFloat()) + progress.animateTo( + targetValue = 0f, + animationSpec = tween(durationMillis = remainingTime.toInt(), easing = { it }), + ) + } + } + + CooldownButton(text = text, leadingIcon = Icons.TwoTone.Mediation, progress = progress.value, onClick = onClick) } @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -private fun TracerouteButton(text: String, progress: Float, onClick: () -> Unit) { +private fun CooldownButton(text: String, leadingIcon: ImageVector, progress: Float, onClick: () -> Unit) { val isCoolingDown = progress > 0f - val stroke = Stroke(width = with(LocalDensity.current) { 2.dp.toPx() }, cap = StrokeCap.Round) BasicListItem( text = text, enabled = !isCoolingDown, - leadingIcon = Icons.Default.Route, + leadingIcon = leadingIcon, trailingContent = { if (isCoolingDown) { CircularWavyProgressIndicator( @@ -97,5 +123,5 @@ private fun TracerouteButton(text: String, progress: Float, onClick: () -> Unit) @Preview(showBackground = true) @Composable private fun TracerouteButtonPreview() { - AppTheme { TracerouteButton(text = "Traceroute", progress = .6f, onClick = {}) } + AppTheme { CooldownButton(text = "Traceroute", leadingIcon = Icons.Default.Route, progress = .6f, onClick = {}) } } diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/DeviceActions.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/DeviceActions.kt index f7ad9ee89..6293f3bb0 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/DeviceActions.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/DeviceActions.kt @@ -50,6 +50,7 @@ import org.meshtastic.feature.node.model.NodeDetailAction fun DeviceActions( node: Node, lastTracerouteTime: Long?, + lastRequestNeighborsTime: Long?, onAction: (NodeDetailAction) -> Unit, modifier: Modifier = Modifier, isLocal: Boolean = false, @@ -81,7 +82,12 @@ fun DeviceActions( ) if (!isLocal) { InsetDivider() - RemoteDeviceActions(node = node, lastTracerouteTime = lastTracerouteTime, onAction = onAction) + RemoteDeviceActions( + node = node, + lastTracerouteTime = lastTracerouteTime, + lastRequestNeighborsTime = lastRequestNeighborsTime, + onAction = onAction, + ) } InsetDivider() diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeMenu.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeMenu.kt index ee85f3dd6..ebdae2f34 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeMenu.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeMenu.kt @@ -96,6 +96,8 @@ sealed class NodeMenuAction { data class RequestUserInfo(val node: Node) : NodeMenuAction() + data class RequestNeighborInfo(val node: Node) : NodeMenuAction() + data class RequestPosition(val node: Node) : NodeMenuAction() data class TraceRoute(val node: Node) : NodeMenuAction() diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/RemoteDeviceActions.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/RemoteDeviceActions.kt index dff008af1..f43240a60 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/RemoteDeviceActions.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/RemoteDeviceActions.kt @@ -32,7 +32,12 @@ import org.meshtastic.feature.node.model.NodeDetailAction import org.meshtastic.feature.node.model.isEffectivelyUnmessageable @Composable -internal fun RemoteDeviceActions(node: Node, lastTracerouteTime: Long?, onAction: (NodeDetailAction) -> Unit) { +internal fun RemoteDeviceActions( + node: Node, + lastTracerouteTime: Long?, + lastRequestNeighborsTime: Long?, + onAction: (NodeDetailAction) -> Unit, +) { if (!node.isEffectivelyUnmessageable) { ListItem( text = stringResource(Res.string.direct_message), @@ -57,4 +62,8 @@ internal fun RemoteDeviceActions(node: Node, lastTracerouteTime: Long?, onAction lastTracerouteTime = lastTracerouteTime, onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.TraceRoute(node))) }, ) + RequestNeighborsButton( + lastRequestNeighborsTime = lastRequestNeighborsTime, + onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.RequestNeighborInfo(node))) }, + ) } diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailList.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailList.kt index 60ea2d37a..c88bc233c 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailList.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailList.kt @@ -74,6 +74,7 @@ fun NodeDetailContent( ourNode: Node?, metricsState: MetricsState, lastTracerouteTime: Long?, + lastRequestNeighborsTime: Long?, availableLogs: Set, onAction: (NodeDetailAction) -> Unit, onSaveNotes: (nodeNum: Int, notes: String) -> Unit, @@ -87,6 +88,7 @@ fun NodeDetailContent( NodeDetailList( node = node, lastTracerouteTime = lastTracerouteTime, + lastRequestNeighborsTime = lastRequestNeighborsTime, ourNode = ourNode, metricsState = metricsState, onAction = { action -> @@ -108,6 +110,7 @@ fun NodeDetailContent( fun NodeDetailList( node: Node, lastTracerouteTime: Long?, + lastRequestNeighborsTime: Long?, ourNode: Node?, metricsState: MetricsState, onAction: (NodeDetailAction) -> Unit, @@ -165,6 +168,7 @@ fun NodeDetailList( DeviceActions( isLocal = metricsState.isLocal, lastTracerouteTime = lastTracerouteTime, + lastRequestNeighborsTime = lastRequestNeighborsTime, node = node, onAction = onAction, ) @@ -263,6 +267,7 @@ private fun NodeDetailsPreview(@PreviewParameter(NodePreviewParameterProvider::c node = node, ourNode = node, lastTracerouteTime = null, + lastRequestNeighborsTime = null, metricsState = MetricsState.Companion.Empty, availableLogs = emptySet(), onAction = {}, diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreen.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreen.kt index 3c593d7b6..ca3c66b1c 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreen.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreen.kt @@ -56,6 +56,7 @@ fun NodeDetailScreen( val state by viewModel.state.collectAsStateWithLifecycle() val environmentState by viewModel.environmentState.collectAsStateWithLifecycle() val lastTracerouteTime by nodeDetailViewModel.lastTraceRouteTime.collectAsStateWithLifecycle() + val lastRequestNeighborsTime by nodeDetailViewModel.lastRequestNeighborsTime.collectAsStateWithLifecycle() val ourNode by nodeDetailViewModel.ourNodeInfo.collectAsStateWithLifecycle() val availableLogs by @@ -100,6 +101,7 @@ fun NodeDetailScreen( ourNode = ourNode, metricsState = state, lastTracerouteTime = lastTracerouteTime, + lastRequestNeighborsTime = lastRequestNeighborsTime, availableLogs = availableLogs, onAction = { action -> handleNodeAction( diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt index 6a4b5fada..80806ed1b 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt @@ -48,12 +48,19 @@ constructor( private val _lastTraceRouteTime = MutableStateFlow(null) val lastTraceRouteTime: StateFlow = _lastTraceRouteTime.asStateFlow() + private val _lastRequestNeighborsTime = MutableStateFlow(null) + val lastRequestNeighborsTime: StateFlow = _lastRequestNeighborsTime.asStateFlow() + fun handleNodeMenuAction(action: NodeMenuAction) { when (action) { is NodeMenuAction.Remove -> removeNode(action.node.num) is NodeMenuAction.Ignore -> ignoreNode(action.node) is NodeMenuAction.Favorite -> favoriteNode(action.node) is NodeMenuAction.RequestUserInfo -> requestUserInfo(action.node.num) + is NodeMenuAction.RequestNeighborInfo -> { + requestNeighborInfo(action.node.num) + _lastRequestNeighborsTime.value = System.currentTimeMillis() + } is NodeMenuAction.RequestPosition -> requestPosition(action.node.num) is NodeMenuAction.TraceRoute -> { requestTraceroute(action.node.num) @@ -110,6 +117,16 @@ constructor( } } + private fun requestNeighborInfo(destNum: Int) { + Timber.i("Requesting NeighborInfo for '$destNum'") + try { + val packetId = serviceRepository.meshService?.packetId ?: return + serviceRepository.meshService?.requestNeighborInfo(packetId, destNum) + } catch (ex: RemoteException) { + Timber.e("Request NeighborInfo error: ${ex.message}") + } + } + private fun requestPosition(destNum: Int, position: Position = Position(0.0, 0.0, 0)) { Timber.i("Requesting position for '$destNum'") try {