Skip to content

Commit 39620d0

Browse files
authored
fix(nav): restore broken traceroute map navigation (#5104)
1 parent 35bf1fd commit 39620d0

6 files changed

Lines changed: 71 additions & 6 deletions

File tree

core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/MultiBackstackTest.kt

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,4 +111,35 @@ class MultiBackstackTest {
111111
assertEquals(2, multiBackstack.activeBackStack.size)
112112
assertEquals(SettingsRoute.About, multiBackstack.activeBackStack.last())
113113
}
114+
115+
@Test
116+
fun `handleDeepLink from different tab switches tab and sets stack`() {
117+
// Start on Connections tab
118+
val startTab = TopLevelDestination.Connections.route
119+
val multiBackstack = MultiBackstack(startTab)
120+
121+
val connectionsStack = NavBackStack<NavKey>().apply { addAll(listOf(TopLevelDestination.Connections.route)) }
122+
val nodesStack = NavBackStack<NavKey>().apply { addAll(listOf(TopLevelDestination.Nodes.route)) }
123+
124+
multiBackstack.backStacks =
125+
mapOf(
126+
TopLevelDestination.Connections.route to connectionsStack,
127+
TopLevelDestination.Nodes.route to nodesStack,
128+
)
129+
130+
// Verify we start on Connections
131+
assertEquals(TopLevelDestination.Connections.route, multiBackstack.currentTabRoute)
132+
133+
// Deep-link to a TracerouteMap on the Nodes tab (this is the exact pattern
134+
// MeshtasticAppShell uses for traceroute alert "View on Map")
135+
val tracerouteMap = NodeDetailRoute.TracerouteMap(destNum = 100, requestId = 42, logUuid = "abc")
136+
multiBackstack.handleDeepLink(listOf(NodesRoute.NodesGraph, tracerouteMap))
137+
138+
// Should have switched to the Nodes tab
139+
assertEquals(TopLevelDestination.Nodes.route, multiBackstack.currentTabRoute)
140+
// Stack should contain the graph root + the traceroute map route
141+
assertEquals(2, multiBackstack.activeBackStack.size)
142+
assertEquals(NodesRoute.NodesGraph, multiBackstack.activeBackStack.first())
143+
assertEquals(tracerouteMap, multiBackstack.activeBackStack.last())
144+
}
114145
}

core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticAppShell.kt

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import androidx.compose.runtime.LaunchedEffect
2121
import androidx.compose.ui.Modifier
2222
import org.meshtastic.core.navigation.MultiBackstack
2323
import org.meshtastic.core.navigation.NodeDetailRoute
24+
import org.meshtastic.core.navigation.NodesRoute
2425
import org.meshtastic.core.ui.viewmodel.UIViewModel
2526

2627
/**
@@ -43,8 +44,11 @@ fun MeshtasticAppShell(
4344
MeshtasticCommonAppSetup(
4445
uiViewModel = uiViewModel,
4546
onNavigateToTracerouteMap = { destNum, requestId, logUuid ->
46-
multiBackstack.activeBackStack.add(
47-
NodeDetailRoute.TracerouteMap(destNum = destNum, requestId = requestId, logUuid = logUuid),
47+
multiBackstack.handleDeepLink(
48+
listOf(
49+
NodesRoute.NodesGraph,
50+
NodeDetailRoute.TracerouteMap(destNum = destNum, requestId = requestId, logUuid = logUuid),
51+
),
4852
)
4953
},
5054
)

core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/TracerouteAlertHandler.kt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,11 @@ import androidx.compose.runtime.LaunchedEffect
2626
import androidx.compose.runtime.getValue
2727
import androidx.compose.runtime.mutableStateOf
2828
import androidx.compose.runtime.remember
29+
import androidx.compose.runtime.rememberCoroutineScope
2930
import androidx.compose.runtime.setValue
3031
import androidx.compose.ui.Modifier
3132
import androidx.lifecycle.compose.collectAsStateWithLifecycle
33+
import kotlinx.coroutines.launch
3234
import org.meshtastic.core.resources.Res
3335
import org.meshtastic.core.resources.okay
3436
import org.meshtastic.core.resources.traceroute
@@ -52,6 +54,7 @@ fun TracerouteAlertHandler(
5254
val traceRouteResponse by uiViewModel.tracerouteResponse.collectAsStateWithLifecycle(null)
5355
var dismissedTracerouteRequestId by remember { mutableStateOf<Int?>(null) }
5456
val colorScheme = MaterialTheme.colorScheme
57+
val scope = rememberCoroutineScope()
5558

5659
LaunchedEffect(traceRouteResponse, dismissedTracerouteRequestId) {
5760
val response = traceRouteResponse
@@ -83,8 +86,10 @@ fun TracerouteAlertHandler(
8386
dismissedTracerouteRequestId = response.requestId
8487
onNavigateToMap(response.destinationNodeNum, response.requestId, response.logUuid)
8588
} else {
86-
uiViewModel.showAlert(titleRes = Res.string.traceroute, messageRes = errorRes)
8789
uiViewModel.clearTracerouteResponse()
90+
// Post the error alert after the current alert is dismissed to avoid
91+
// the wrapping dismissAlert() in AlertManager immediately clearing it.
92+
scope.launch { uiViewModel.showAlert(titleRes = Res.string.traceroute, messageRes = errorRes) }
8893
}
8994
},
9095
dismissTextRes = Res.string.okay,

core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/AlertManagerTest.kt

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,4 +68,27 @@ class AlertManagerTest {
6868
assertEquals(true, dismissClicked)
6969
assertNull(alertManager.currentAlert.value)
7070
}
71+
72+
@Test
73+
fun showAlert_inside_onConfirm_is_dismissed_by_wrapping_dismissAlert() {
74+
// Documents the known race condition: AlertManager wraps onConfirm to call
75+
// dismissAlert() AFTER the user callback, so a showAlert() inside onConfirm
76+
// gets immediately cleared. Callers must defer via launch {} to work around this.
77+
alertManager.showAlert(
78+
title = "First",
79+
onConfirm = {
80+
// This simulates an error path where onConfirm shows a follow-up alert
81+
alertManager.showAlert(title = "Second", message = "Error details")
82+
},
83+
)
84+
85+
// Trigger the wrapped onConfirm (user callback + dismissAlert)
86+
alertManager.currentAlert.value?.onConfirm?.invoke()
87+
88+
// The second alert is wiped by dismissAlert() — currentAlert is null
89+
assertNull(
90+
alertManager.currentAlert.value,
91+
"showAlert inside onConfirm is cleared by the wrapping dismissAlert; callers must defer via launch {}",
92+
)
93+
}
7194
}

feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -277,7 +277,6 @@ open class MetricsViewModel(
277277
responseLogUuid: String,
278278
overlay: TracerouteOverlay?,
279279
onViewOnMap: (Int, String) -> Unit,
280-
onShowError: (StringResource) -> Unit,
281280
) {
282281
viewModelScope.launch {
283282
val snapshotPositions = tracerouteSnapshotRepository.getSnapshotPositions(responseLogUuid).first()
@@ -300,7 +299,11 @@ open class MetricsViewModel(
300299
)
301300
val errorRes = availability.toMessageRes()
302301
if (errorRes != null) {
303-
onShowError(errorRes)
302+
// Post the error alert after the current alert is dismissed to avoid
303+
// the wrapping dismissAlert() in AlertManager immediately clearing it.
304+
viewModelScope.launch {
305+
alertManager.showAlert(titleRes = Res.string.traceroute, messageRes = errorRes)
306+
}
304307
} else {
305308
onViewOnMap(requestId, responseLogUuid)
306309
}

feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -361,7 +361,6 @@ private fun showTracerouteDetail(
361361
responseLogUuid = result.uuid,
362362
overlay = overlay,
363363
onViewOnMap = onViewOnMap,
364-
onShowError = {},
365364
)
366365
}
367366

0 commit comments

Comments
 (0)