diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt index c335ec1162..5d4df7aac1 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt @@ -16,8 +16,12 @@ */ package org.meshtastic.feature.node.metrics +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Text +import androidx.compose.ui.Modifier import androidx.compose.ui.text.AnnotatedString import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -294,7 +298,11 @@ open class MetricsViewModel( fun showLogDetail(titleRes: StringResource, annotatedMessage: AnnotatedString) { alertManager.showAlert( titleRes = titleRes, - composableMessage = { SelectionContainer { Text(text = annotatedMessage) } }, + composableMessage = { + Column(modifier = Modifier.verticalScroll(rememberScrollState())) { + SelectionContainer { Text(text = annotatedMessage) } + } + }, ) } @@ -309,7 +317,11 @@ open class MetricsViewModel( val snapshotPositions = tracerouteSnapshotRepository.getSnapshotPositions(responseLogUuid).first() alertManager.showAlert( titleRes = Res.string.traceroute, - composableMessage = { SelectionContainer { Text(text = annotatedMessage) } }, + composableMessage = { + Column(modifier = Modifier.verticalScroll(rememberScrollState())) { + SelectionContainer { Text(text = annotatedMessage) } + } + }, confirmTextRes = Res.string.view_on_map, onConfirm = { val positionedNodeNums = diff --git a/feature/node/src/jvmTest/kotlin/org/meshtastic/feature/node/metrics/DetailDialogScrollTest.kt b/feature/node/src/jvmTest/kotlin/org/meshtastic/feature/node/metrics/DetailDialogScrollTest.kt new file mode 100644 index 0000000000..eee289fe47 --- /dev/null +++ b/feature/node/src/jvmTest/kotlin/org/meshtastic/feature/node/metrics/DetailDialogScrollTest.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.node.metrics + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Text +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.v2.runComposeUiTest +import androidx.compose.ui.text.buildAnnotatedString +import org.meshtastic.core.ui.component.MeshtasticDialog +import org.meshtastic.core.ui.theme.AppTheme +import kotlin.test.Test + +private const val HOP_COUNT = 80 +private const val LAST_HOP_MARKER = "LAST_HOP_MARKER" + +/** + * #6701: a traceroute with many hops scrolled fine live, but was cut off with no way to scroll when reopened from + * history. Root cause: [MetricsViewModel.showTracerouteDetail] and [MetricsViewModel.showLogDetail] passed + * [MeshtasticDialog] a bare `SelectionContainer { Text(...) }` as `composableMessage` - [MeshtasticDialog] only adds + * `verticalScroll` to its own wrapping column when the dialog has `choices` (a button list), so a plain text dialog's + * scrolling is entirely the caller's responsibility. + * + * A screenshot can't catch this regression: a single frame of "scrolled to top, more content below" looks identical + * whether or not scrolling actually works. [performScrollTo] is the right tool instead - it throws when the target node + * has no scrollable ancestor, so it fails exactly when the `verticalScroll` wrapper is missing. + */ +@OptIn(ExperimentalTestApi::class) +class DetailDialogScrollTest { + + @Test + fun longDetailContentScrollsToRevealTrailingText() = runComposeUiTest { + val longRoute = buildAnnotatedString { + repeat(HOP_COUNT) { append("Hop $it -> ") } + append(LAST_HOP_MARKER) + } + + setContent { + AppTheme { + MeshtasticDialog( + title = "Traceroute", + // Matches the fixed composableMessage shape in showTracerouteDetail/showLogDetail exactly. + text = { + Column(modifier = Modifier.verticalScroll(rememberScrollState())) { + SelectionContainer { Text(text = longRoute) } + } + }, + onDismiss = {}, + ) + } + } + + onNodeWithText(LAST_HOP_MARKER, substring = true).performScrollTo().assertIsDisplayed() + } +}