From 1e86135a52ad60012aa58d02bef0645e349e151d Mon Sep 17 00:00:00 2001 From: Nina Cox Date: Fri, 31 Jul 2026 16:28:54 -0700 Subject: [PATCH 1/2] feat: wire ping results into dashboard UI - Add PingConstants with placeholder server address, ping count and timeout - Add _pingPacketLoss and _pingRttResult StateFlows to MainActivityViewModel - Call Ping.start() after MLab test and populate flows with real results - Reset ping state in resetMLabTestResult() - Use real packet loss value when saving ConnectivityReportModel - HomeScreen: collect ping flows and pass to ConnectivityCard - ConnectivityCard: accept packetLoss and pingRtt params; replace hardcoded '0 % loss' TODO: Replace PingConstants.PING_SERVER_ADDRESS with real server endpoint Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../MainActivityViewModel.kt | 36 ++++++++++++++++--- .../constants/PingConstants.kt | 8 +++++ .../lcl/lclmeasurementtool/ui/HomeScreen.kt | 9 ++++- 3 files changed, 47 insertions(+), 6 deletions(-) create mode 100644 app/src/main/java/com/lcl/lclmeasurementtool/constants/PingConstants.kt diff --git a/app/src/main/java/com/lcl/lclmeasurementtool/MainActivityViewModel.kt b/app/src/main/java/com/lcl/lclmeasurementtool/MainActivityViewModel.kt index e89f33e..212e110 100644 --- a/app/src/main/java/com/lcl/lclmeasurementtool/MainActivityViewModel.kt +++ b/app/src/main/java/com/lcl/lclmeasurementtool/MainActivityViewModel.kt @@ -33,6 +33,9 @@ import net.measurementlab.ndt7.android.NDTTest import okhttp3.ResponseBody import retrofit2.HttpException import retrofit2.Response +import com.lcl.lclmeasurementtool.constants.PingConstants +import com.lcl.lclmeasurementtool.features.ping.Ping +import com.lcl.lclmeasurementtool.features.ping.PingErrorCase import com.lcl.lclmeasurementtool.sync.UploadWorker import java.io.ByteArrayOutputStream import java.security.SecureRandom @@ -91,6 +94,11 @@ class MainActivityViewModel @Inject constructor( private var _mLabUploadResult = MutableStateFlow(ConnectivityTestResult()) private var _mLabDownloadResult = MutableStateFlow(ConnectivityTestResult()) + private val _pingPacketLoss = MutableStateFlow("-- %") + private val _pingRttResult = MutableStateFlow("-- ms") + val pingPacketLoss = _pingPacketLoss.asStateFlow() + val pingRttResult = _pingRttResult.asStateFlow() + var mlabRttResult = _mlabRttResult.asStateFlow() var mlabUploadResult = _mLabUploadResult.asStateFlow() var mlabDownloadResult = _mLabDownloadResult.asStateFlow() @@ -288,7 +296,7 @@ class MainActivityViewModel @Inject constructor( .onCompletion { if (it != null) { Log.e(TAG, "Error in MLab test: ${it.message}", it) - _isMLabTestActive.value = false + _isMLabTestActive.value = false } else { Log.d(TAG, "MLab test completed normally") } @@ -315,7 +323,7 @@ class MainActivityViewModel @Inject constructor( } else if (it.status == MLabTestStatus.ERROR) { Log.e(TAG, "Upload test error with null speed: ${it.errorMsg}") _mLabUploadResult.value = ConnectivityTestResult.Error(it.errorMsg ?: "Unknown error") - _isMLabTestActive.value = false + _isMLabTestActive.value = false } } NDTTest.TestType.DOWNLOAD -> { @@ -344,7 +352,7 @@ class MainActivityViewModel @Inject constructor( } else if (it.status == MLabTestStatus.ERROR) { Log.e(TAG, "Download test error with null speed: ${it.errorMsg}") _mLabDownloadResult.value = ConnectivityTestResult.Error(it.errorMsg ?: "Unknown error") - _isMLabTestActive.value = false + _isMLabTestActive.value = false } } else -> { @@ -354,7 +362,7 @@ class MainActivityViewModel @Inject constructor( } } catch (e: Exception) { Log.e(TAG, "Exception during MLab test", e) - _isMLabTestActive.value = false + _isMLabTestActive.value = false } } @@ -382,6 +390,22 @@ class MainActivityViewModel @Inject constructor( ensureActive() + // Run ping alongside MLab to get packet loss + val pingResult = try { + Ping.start( + address = PingConstants.PING_SERVER_ADDRESS, + times = PingConstants.PING_TIMES, + timeout = PingConstants.PING_TIMEOUT_MS, + ) + } catch (e: Exception) { null } + if (pingResult != null && pingResult.error.code == PingErrorCase.OK) { + _pingRttResult.value = "${pingResult.avg ?: "--"} ms" + _pingPacketLoss.value = "${pingResult.numLoss ?: "--"} %" + } else { + _pingRttResult.value = "-- ms" + _pingPacketLoss.value = "-- %" + } + _isMLabTestActive.value = false Log.d(TAG, "upload, download are finished. isMLabTestActive.value=${isMLabTestActive.value}") val curTime = TimeUtil.getCurrentTime() @@ -418,7 +442,7 @@ class MainActivityViewModel @Inject constructor( (_mLabUploadResult.value as ConnectivityTestResult.Result).result.toDouble(), (_mLabDownloadResult.value as ConnectivityTestResult.Result).result.toDouble(), (_mlabRttResult.value as ConnectivityTestResult.Result).result.toDouble(), - 0.0, // No packet loss information available from ndt7, defaulting to 0 + _pingPacketLoss.value.replace(" %", "").toDoubleOrNull() ?: 0.0, ) saveToDB(signalStrengthReportModel, connectivityReportModel) @@ -495,6 +519,8 @@ class MainActivityViewModel @Inject constructor( _mlabRttResult.value = ConnectivityTestResult.Result("0.0", Color.LightGray) _mLabUploadResult.value = ConnectivityTestResult.Result("0.0", Color.LightGray) _mLabDownloadResult.value = ConnectivityTestResult.Result("0.0", Color.LightGray) + _pingPacketLoss.value = "-- %" + _pingRttResult.value = "-- ms" _isMLabTestActive.value = false } } diff --git a/app/src/main/java/com/lcl/lclmeasurementtool/constants/PingConstants.kt b/app/src/main/java/com/lcl/lclmeasurementtool/constants/PingConstants.kt new file mode 100644 index 0000000..c6bedcf --- /dev/null +++ b/app/src/main/java/com/lcl/lclmeasurementtool/constants/PingConstants.kt @@ -0,0 +1,8 @@ +package com.lcl.lclmeasurementtool.constants + +object PingConstants { + // TODO: Replace with the real ping server endpoint once available + const val PING_SERVER_ADDRESS = "ping.example.lcl.test:31337" + const val PING_TIMES = 5 + const val PING_TIMEOUT_MS = 3000L +} diff --git a/app/src/main/java/com/lcl/lclmeasurementtool/ui/HomeScreen.kt b/app/src/main/java/com/lcl/lclmeasurementtool/ui/HomeScreen.kt index d17a8a0..aebd346 100644 --- a/app/src/main/java/com/lcl/lclmeasurementtool/ui/HomeScreen.kt +++ b/app/src/main/java/com/lcl/lclmeasurementtool/ui/HomeScreen.kt @@ -48,6 +48,8 @@ fun HomeScreen(modifier: Modifier = Modifier, isOffline: Boolean, mainActivityVi val mlabRttResult = mainActivityViewModel.mlabRttResult.collectAsStateWithLifecycle() val mlabUploadResult = mainActivityViewModel.mlabUploadResult.collectAsStateWithLifecycle() val mlabDownloadResult = mainActivityViewModel.mlabDownloadResult.collectAsStateWithLifecycle() + val pingPacketLoss = mainActivityViewModel.pingPacketLoss.collectAsStateWithLifecycle() + val pingRttResult = mainActivityViewModel.pingRttResult.collectAsStateWithLifecycle() val signalStrength = mainActivityViewModel.signalStrengthResult.collectAsStateWithLifecycle() val context = LocalContext.current val coroutineScope = rememberCoroutineScope() @@ -59,6 +61,8 @@ fun HomeScreen(modifier: Modifier = Modifier, isOffline: Boolean, mainActivityVi ) { SignalStrengthCard(modifier = modifier, signalStrengthResult = signalStrength.value) ConnectivityCard( + packetLoss = pingPacketLoss.value, + pingRtt = pingRttResult.value, label = "MLab", modifier = modifier, rttValue = mlabRttResult.value, @@ -150,6 +154,8 @@ private fun ConnectivityCard( rttValue: ConnectivityTestResult, uploadResult: ConnectivityTestResult, downloadResult: ConnectivityTestResult, + packetLoss: String = "-- %", + pingRtt: String = "-- ms", ) { Card(colors = CardDefaults.cardColors(MaterialTheme.colorScheme.surfaceVariant), modifier = Modifier @@ -196,7 +202,8 @@ private fun ConnectivityCard( } DataEntry(icon = Rounded.NetworkPing, text = "$formattedRtt ms") - DataEntry(icon = Rounded.Cancel, text = "0 % loss") + DataEntry(icon = Rounded.Cancel, text = packetLoss) + DataEntry(icon = Rounded.NetworkPing, text = pingRtt) } } From 45b378f3b0d4d1cff402dca5d49fb67eeb662ab3 Mon Sep 17 00:00:00 2001 From: Nina Cox Date: Sun, 9 Aug 2026 11:59:29 -0700 Subject: [PATCH 2/2] Refine connectivity dashboard styling - restyle Home connectivity card to dashboard tile layout - add live/pending status pill and stronger metric hierarchy - set default ping endpoint constant to google.com:443 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../constants/PingConstants.kt | 4 +- .../lcl/lclmeasurementtool/ui/HomeScreen.kt | 210 +++++++++++++----- 2 files changed, 161 insertions(+), 53 deletions(-) diff --git a/app/src/main/java/com/lcl/lclmeasurementtool/constants/PingConstants.kt b/app/src/main/java/com/lcl/lclmeasurementtool/constants/PingConstants.kt index c6bedcf..8965047 100644 --- a/app/src/main/java/com/lcl/lclmeasurementtool/constants/PingConstants.kt +++ b/app/src/main/java/com/lcl/lclmeasurementtool/constants/PingConstants.kt @@ -1,8 +1,8 @@ package com.lcl.lclmeasurementtool.constants object PingConstants { - // TODO: Replace with the real ping server endpoint once available - const val PING_SERVER_ADDRESS = "ping.example.lcl.test:31337" + // Keep google.com as default host while preserving explicit host:port parsing. + const val PING_SERVER_ADDRESS = "google.com:443" const val PING_TIMES = 5 const val PING_TIMEOUT_MS = 3000L } diff --git a/app/src/main/java/com/lcl/lclmeasurementtool/ui/HomeScreen.kt b/app/src/main/java/com/lcl/lclmeasurementtool/ui/HomeScreen.kt index aebd346..d35f472 100644 --- a/app/src/main/java/com/lcl/lclmeasurementtool/ui/HomeScreen.kt +++ b/app/src/main/java/com/lcl/lclmeasurementtool/ui/HomeScreen.kt @@ -4,6 +4,7 @@ import android.util.Log import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons.Filled import androidx.compose.material.icons.Icons.Rounded import androidx.compose.material.icons.filled.NetworkCheck @@ -21,7 +22,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip 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.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -33,6 +33,14 @@ import com.lcl.lclmeasurementtool.MainActivityViewModel import com.lcl.lclmeasurementtool.SignalStrengthResult import kotlinx.coroutines.cancel +private val DashboardSurface = Color(0xFFF5F7FB) +private val DashboardTile = Color(0xFFFFFFFF) +private val DashboardBorder = Color(0xFFE3E8F3) +private val DashboardTextMuted = Color(0xFF677085) +private val DashboardTextStrong = Color(0xFF1C2434) +private val DashboardLive = Color(0xFF1FA56A) +private val DashboardPending = Color(0xFF7A8090) + @Composable fun HomeRoute(isOffline: Boolean, mainActivityViewModel: MainActivityViewModel) { HomeScreen(isOffline = isOffline, mainActivityViewModel = mainActivityViewModel) @@ -51,7 +59,6 @@ fun HomeScreen(modifier: Modifier = Modifier, isOffline: Boolean, mainActivityVi val pingPacketLoss = mainActivityViewModel.pingPacketLoss.collectAsStateWithLifecycle() val pingRttResult = mainActivityViewModel.pingRttResult.collectAsStateWithLifecycle() val signalStrength = mainActivityViewModel.signalStrengthResult.collectAsStateWithLifecycle() - val context = LocalContext.current val coroutineScope = rememberCoroutineScope() Box(modifier = Modifier.fillMaxSize()) { @@ -117,25 +124,28 @@ private fun SignalStrengthCard( val (dbm, level) = signalStrengthResult - Card(colors = CardDefaults.cardColors(MaterialTheme.colorScheme.surfaceVariant), + Card( + colors = CardDefaults.cardColors(DashboardSurface), + border = androidx.compose.foundation.BorderStroke(1.dp, DashboardBorder), + shape = RoundedCornerShape(16.dp), modifier = Modifier .padding(horizontal = 10.dp, vertical = 10.dp) .fillMaxWidth() ) { Row( verticalAlignment = Alignment.CenterVertically, - modifier = modifier.padding(20.dp) + modifier = modifier.padding(horizontal = 16.dp, vertical = 18.dp) ) { Icon(modifier = modifier, imageVector = Filled.SignalCellularAlt, contentDescription = null) Spacer(modifier = Modifier.width(12.dp)) - Text(text = "Signal Strength:", fontSize = fontSize) + Text(text = "Signal Strength:", fontSize = fontSize, color = DashboardTextStrong) Spacer(modifier = Modifier.width(4.dp)) - Text(text = "$dbm", fontWeight = FontWeight.Bold, fontSize = fontSize) + Text(text = "$dbm", fontWeight = FontWeight.Bold, fontSize = fontSize, color = DashboardTextStrong) Spacer(modifier = Modifier.width(4.dp)) - Text(text = "dBm", fontSize = fontSize) + Text(text = "dBm", fontSize = fontSize, color = DashboardTextMuted) Spacer(modifier = Modifier.width(20.dp)) Box(modifier = Modifier .size(10.dp) @@ -157,61 +167,159 @@ private fun ConnectivityCard( packetLoss: String = "-- %", pingRtt: String = "-- ms", ) { - Card(colors = CardDefaults.cardColors(MaterialTheme.colorScheme.surfaceVariant), + val uploadText = when (uploadResult) { + is ConnectivityTestResult.Result -> "${uploadResult.result} Mbps" + else -> "--" + } + val downloadText = when (downloadResult) { + is ConnectivityTestResult.Result -> "${downloadResult.result} Mbps" + else -> "--" + } + val mlabRttText = when (rttValue) { + is ConnectivityTestResult.Result -> { + val numeric = rttValue.result.toDoubleOrNull() ?: 0.0 + if (numeric > 0) "${String.format("%.1f", numeric)} ms" else "--" + } + else -> "--" + } + val hasLivePing = pingRtt != "-- ms" && packetLoss != "-- %" + + Card( + colors = CardDefaults.cardColors(DashboardSurface), + border = androidx.compose.foundation.BorderStroke(1.dp, DashboardBorder), + shape = RoundedCornerShape(16.dp), modifier = Modifier .padding(horizontal = 10.dp, vertical = 10.dp) .fillMaxWidth() ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = modifier.padding(start = 12.dp, end = 12.dp, top = 12.dp) + Column( + modifier = modifier.padding(14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Filled.NetworkCheck, + contentDescription = null, + modifier = Modifier.padding(end = 8.dp) + ) + Text( + text = "Mobile Connectivity", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = DashboardTextStrong + ) + } + Surface( + shape = RoundedCornerShape(999.dp), + color = if (hasLivePing) DashboardLive.copy(alpha = 0.16f) else DashboardPending.copy(alpha = 0.16f) + ) { + Text( + text = if (hasLivePing) "Live" else "Pending", + style = MaterialTheme.typography.labelMedium, + color = if (hasLivePing) DashboardLive else DashboardPending, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 3.dp) + ) + } + } - Icon(modifier = modifier.padding(end = 12.dp), - imageVector = Filled.NetworkCheck, - contentDescription = null) - Column { - Row(horizontalArrangement = Arrangement.spacedBy(20.dp)) { - when (uploadResult) { - is ConnectivityTestResult.Result -> { - DataEntry(icon = Rounded.CloudUpload, text = "${uploadResult.result} Mbps") - } - else -> DataEntry(icon = Rounded.CloudUpload, text = "0.0 Mbps") - } - + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + DashboardMetricTile( + icon = Rounded.CloudDownload, + label = "Download", + value = downloadText, + modifier = Modifier.weight(1f) + ) + DashboardMetricTile( + icon = Rounded.CloudUpload, + label = "Upload", + value = uploadText, + modifier = Modifier.weight(1f) + ) + } - when (downloadResult) { - is ConnectivityTestResult.Result -> { - DataEntry(icon = Rounded.CloudDownload, text = "${downloadResult.result} Mbps") - } - else -> { - DataEntry(icon = Rounded.CloudDownload, text = "0.0 Mbps") - } - } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + DashboardMetricTile( + icon = Rounded.NetworkPing, + label = "MLab Latency", + value = mlabRttText, + modifier = Modifier.weight(1f) + ) + DashboardMetricTile( + icon = Rounded.Cancel, + label = "Packet Loss", + value = packetLoss, + modifier = Modifier.weight(1f) + ) + } - } - Row(horizontalArrangement = Arrangement.spacedBy(20.dp)) { - val formattedRtt = when (rttValue) { - is ConnectivityTestResult.Result -> { - val numeric = rttValue.result.toDoubleOrNull() ?: 0.0 - if (numeric > 0) String.format("%.1f", numeric) else "0.0" - } - else -> { - "0.0" // or rttValue.error if you want to display the error message - } - } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + DashboardMetricTile( + icon = Rounded.NetworkPing, + label = "Custom Ping", + value = pingRtt, + modifier = Modifier.fillMaxWidth(0.49f), + ) + } - DataEntry(icon = Rounded.NetworkPing, text = "$formattedRtt ms") - DataEntry(icon = Rounded.Cancel, text = packetLoss) - DataEntry(icon = Rounded.NetworkPing, text = pingRtt) - } + Text( + text = "Powered by $label", + fontWeight = FontWeight.Thin, + fontSize = 10.sp, + color = DashboardTextMuted, + modifier = Modifier + .align(Alignment.End) + .padding(end = 10.dp, bottom = 4.dp) + ) + } + } +} +@Composable +private fun DashboardMetricTile( + icon: ImageVector, + label: String, + value: String, + modifier: Modifier = Modifier, +) { + Card( + colors = CardDefaults.cardColors(DashboardTile), + border = androidx.compose.foundation.BorderStroke(1.dp, DashboardBorder), + shape = RoundedCornerShape(12.dp), + modifier = modifier + ) { + Column( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(imageVector = icon, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = DashboardTextMuted + ) } - } - Box(modifier = Modifier.fillMaxWidth()) { - Text(text = "Powered by $label", fontWeight = FontWeight.Thin, fontSize = 10.sp, modifier = Modifier - .align(Alignment.BottomEnd) - .padding(end = 10.dp, bottom = 4.dp)) + Text( + text = value, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + color = DashboardTextStrong + ) } } }