Ping β Implementation Deep Dive
How the Ping screen works: native subprocess, streaming output, and status computation
Overview
The Ping screen executes the native ping binary as a subprocess via Runtime.exec() and streams its stdout line-by-line into a terminal-like output card. Unlike NTP (UDP query) and DIG (DNS query), Ping is a continuous, streaming operation β it runs until manually stopped or the user-configurable timeout expires.
The feature reports:
- Live output β each line from the
pingprocess as it arrives (ICMP echo requests/replies, statistics) - Status summary β derived from the collected output:
- ALL_SUCCESS β
β every sent packet got a reply (
received == sent > 0) - PARTIAL π€· β some packets lost (
0 < received < sent) - ALL_FAILED β β no reply received (
received == 0)
- ALL_SUCCESS β
β every sent packet got a reply (
- History β last 5 distinct host pings with status
The feature is implemented across three files (no separate Repository β the subprocess logic lives directly in the ViewModel):
| File | Role |
|---|---|
PingViewModel.kt | State management + subprocess orchestration β StateFlow<PingUiState>, Runtime.exec() |
PingHistoryStore.kt | Persistence β DataStore-backed history (last 5 entries) |
PingScreen.kt | UI β Jetpack Compose screen with terminal output card and custom scrollbar |
PingViewModel β Subprocess Orchestration
File: app/src/main/java/.../PingViewModel.kt
class PingViewModel(
private val historyStore: PingHistoryStore,
private val settingsRepository: SettingsRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow(PingUiState())
val uiState: StateFlow<PingUiState> = _uiState.asStateFlow()
private var pingJob: Job? = null
private var pingProcess: Process? = null
// Regex to extract the icmp_seq number from a reply or timeout line
private val icmpSeqRegex = Regex("""icmp_seq=(\d+)""")UI State
data class PingUiState(
/** Text field content. */
val host: String = "",
/** Whether a ping is currently running. */
val isRunning: Boolean = false,
/** Accumulated output lines from the ping process. */
val outputLines: List<String> = emptyList(),
/** Up to 5 most recent distinct host pings, newest first. */
val history: List<PingHistoryEntry> = emptyList(),
)Unlike NTP and DIG, Ping's UI state includes outputLines: List<String> β a growing list of all lines from the subprocess. This is the only screen in the app that accumulates streaming output.
The startPing() lifecycle
fun startPing() {
val host = _uiState.value.host.trim()
if (host.isBlank() || _uiState.value.isRunning) return
_uiState.value = _uiState.value.copy(
isRunning = true,
outputLines = emptyList(),
)
pingJob = viewModelScope.launch {
val timeoutMs = settingsRepository.timeoutSecondsFlow.first() * 1000L
try {
withTimeout(timeoutMs) {
val process = withContext(Dispatchers.IO) {
Runtime.getRuntime().exec(arrayOf("ping", "-c", "100", host))
}
pingProcess = process
val reader = BufferedReader(InputStreamReader(process.inputStream))
withContext(Dispatchers.IO) {
var line: String? = null
while (isActive && reader.readLine().also { line = it } != null) {
val trimmed = line!!
withContext(Dispatchers.Main) {
_uiState.value = _uiState.value.copy(
outputLines = _uiState.value.outputLines + trimmed,
)
}
}
}
process.waitFor()
}
} catch (_: TimeoutCancellationException) {
// Append a visible timeout marker before the finally block cleans up.
_uiState.value = _uiState.value.copy(
outputLines = _uiState.value.outputLines +
"--- Timed out after ${timeoutMs / 1000}s ---",
)
} catch (_: Exception) {
// Interrupted or cancelled β treat as stopped
} finally {
pingProcess?.destroy()
pingProcess = null
val wasRunning = _uiState.value.isRunning
val status = computeStatus(_uiState.value.outputLines)
_uiState.value = _uiState.value.copy(isRunning = false)
if (wasRunning) {
saveHistory(host, status)
}
}
}
}Key implementation details
-
No-op if already running β
if (host.isBlank() || _uiState.value.isRunning) returnprevents duplicate pings. -
Subprocess command β
Runtime.getRuntime().exec(arrayOf("ping", "-c", "100", host))runs the systempingwith-c 100(max 100 packets). The-c 100cap is a safety net β the real stop condition is the user-configurable timeout or manual stop. -
Dual
withContext(Dispatchers.IO)blocks β the first opens the subprocess, the second reads the stream. This ensures both operations run on the IO dispatcher (thread pool) rather than the default coroutine context. -
Line-by-line streaming β
reader.readLine()in awhile (isActive && ...)loop, withwithContext(Dispatchers.Main)for each UI update. This is necessary because Compose state updates must happen on the main thread. -
isActivecheck βwhile (isActive && ...)checks the coroutine's active state on each iteration. WhenpingJob?.cancel()orpingProcess?.destroy()is called, the coroutine becomes inactive and the loop exits cleanly. -
Timeout handling β
withTimeout(timeoutMs)wraps the entire ping operation. If the timeout expires, a visible marker line"--- Timed out after ${timeoutMs / 1000}s ---"is appended to the output before thefinallyblock cleans up. -
finallyblock β always destroys the subprocess, clears thepingProcessreference, computes status, and saves history (if the ping was actually running). -
onCleared()override β when the ViewModel is destroyed (e.g., configuration change),pingProcess?.destroy()ensures no orphaned subprocess survives.
The stopPing() method
fun stopPing() {
pingProcess?.destroy()
pingJob?.cancel()
pingProcess = null
val host = _uiState.value.host.trim()
val status = computeStatus(_uiState.value.outputLines)
_uiState.value = _uiState.value.copy(isRunning = false)
viewModelScope.launch { saveHistory(host, status) }
}Stops the process, cancels the job, computes status from collected output, and saves history asynchronously.
Status computation
private fun computeStatus(lines: List<String>): PingStatus {
val received = lines.count { it.contains("bytes from") }
// Highest icmp_seq seen β present in both reply ("bytes from") and
// "Request timeout" lines, giving a good estimate of sent count.
val maxSeq = lines.mapNotNull { line ->
icmpSeqRegex.find(line)?.groupValues?.get(1)?.toIntOrNull()
}.maxOrNull() ?: 0
// If no icmp_seq seen at all, fall back to received count as "sent"
val sent = if (maxSeq > 0) maxSeq else received
return when {
received == 0 -> PingStatus.ALL_FAILED
received >= sent -> PingStatus.ALL_SUCCESS
else -> PingStatus.PARTIAL
}
}| Status | Condition | Emoji |
|---|---|---|
ALL_SUCCESS | received >= sent > 0 | β |
PARTIAL | 0 < received < sent | π€·ββοΈ |
ALL_FAILED | received == 0 | β |
How it works:
receivedβ count of lines containing"bytes from"(each successful ICMP reply)sentβ derived from the highesticmp_seq=number seen across all lines (both replies and timeout lines). This is a proxy for sent count becausepingincrementsicmp_seqfor each packet sent, whether or not it gets a reply.- Fallback β if no
icmp_seqis found (e.g., the output format differs on some platforms),sentfalls back toreceivedcount, which means any output with replies is treated asALL_SUCCESS.
Command handlers
| Function | Behavior |
|---|---|
onHostChange(value) | Updates host (no result clearing needed β there's no result field) |
startPing() | Validates, launches subprocess, streams output, saves history on completion |
stopPing() | Destroys subprocess, cancels job, computes status, saves history |
selectHistoryEntry(entry) | Stops current ping (if running), populates host, immediately starts new ping |
Factory delegate
companion object {
fun factory(context: Context): ViewModelProvider.Factory =
object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T =
PingViewModel(
historyStore = PingHistoryStore(context.applicationContext),
settingsRepository = SettingsRepository(context.applicationContext),
) as T
}
}Note: Ping has no Repository β the Runtime.exec() call lives directly in the ViewModel. This is one of two screens without a Repository file (the other is Traceroute).
PingHistoryStore β Persistence
File: app/src/main/java/.../PingHistoryStore.kt
DataStore setup
private val Context.pingHistoryDataStore: DataStore<Preferences>
by preferencesDataStore(name = "ping_history")Each tool has its own named DataStore file. Ping uses "ping_history".
Serialization format
A single String preference key ("ping_history") contains all entries, one per line, fields separated by |:
2026/05/10 14:30:00|google.com|ALL_SUCCESS
2026/05/09 09:15:22|192.168.1.1|PARTIAL
2026/05/08 18:00:00|nonexistent.host|ALL_FAILED| Field | Example | Notes |
|---|---|---|
timestamp | 2026/05/10 14:30:00 | yyyy/MM/dd HH:mm:ss |
host | google.com | Hostname or IP |
status | ALL_SUCCESS | Enum serialized as .name |
Deserialization with backward compatibility
private fun deserialise(raw: String): List<PingHistoryEntry> =
raw.split(ENTRY_SEP)
.filter { it.isNotBlank() }
.mapNotNull { line ->
val parts = line.split(FIELD_SEP)
if (parts.size >= 2) {
// Support old boolean format ("true"/"false") and new enum names
val status = when (parts.getOrNull(2)) {
"ALL_SUCCESS", "true" -> PingStatus.ALL_SUCCESS
"PARTIAL" -> PingStatus.PARTIAL
else -> PingStatus.ALL_FAILED
}
PingHistoryEntry(timestamp = parts[0], host = parts[1], status = status)
} else null
}
.take(MAX_ENTRIES)- Requires at least 2 fields (timestamp, host); 3rd field (
status) defaults toALL_FAILEDif missing - Backward compatibility β supports the old boolean format (
"true"βALL_SUCCESS,"false"βALL_FAILED) alongside the new enum names - Empty/blank lines are filtered out
take(MAX_ENTRIES)acts as a safety net even though the ViewModel caps at 5 before saving
Reactive history flow
val historyFlow: Flow<List<PingHistoryEntry>> = context.pingHistoryDataStore.data.map { prefs ->
prefs[KEY]?.let { raw -> deserialise(raw) } ?: emptyList()
}The ViewModel's init block reads the first emission:
init {
viewModelScope.launch {
val saved = historyStore.historyFlow.first()
_uiState.value = _uiState.value.copy(history = saved)
}
}UI β PingScreen
File: app/src/main/java/.../PingScreen.kt
The Ping screen has a different layout strategy from NTP and DIG. Instead of a single scrollable column, it uses a pinned input area with a scrollable output terminal that fills all remaining space:
βββββββββββββββββββββββββββββββββββββββββββ
β [Hostname / IP ] β β pinned, always visible
βββββββββββββββββββββββββββββββββββββββββββ€
β [π² Terminal Ping ] β β pinned, always visible
βββββββββββββββββββββββββββββββββββββββββββ€
β ββ Terminal output (scrollable) ββββββ β
β β PING google.com (142.250.188.46): β β
β β 64 bytes from 142.250.188.46: ... β β
β β 64 bytes from 142.250.188.46: ... β β
β β ... (fills remaining space) β β
β β β β β custom scrollbar
β ββββββββββββββββββββββββββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββ€
β ββ Recent Pings ββββββββββββββββββββ β
β β 2026/05/10 14:30:00 google.com β
β β
β ββββββββββββββββββββββββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββScreen structure
@Composable
fun PingScreen() {
val context = LocalContext.current
val vm: PingViewModel = viewModel(factory = PingViewModel.factory(context))
val uiState by vm.uiState.collectAsState()
val focusManager = LocalFocusManager.current
// Separate scroll state just for the output area
val outputScrollState = rememberScrollState()
LaunchedEffect(uiState.outputLines.size) {
outputScrollState.animateScrollTo(outputScrollState.maxValue)
}
// ββ Outer column: no scroll β input+button pinned, output gets remaining space
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp, vertical = 20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// ββ Hostname field (always visible)
OutlinedTextField(/* host, KeyboardType.Uri, ImeAction.Go */)
// ββ Ping / Stop button (always visible)
Button(/* ... */)
// ββ Output terminal card (weight(1f) = fills all remaining space)
AnimatedVisibility(
visible = uiState.outputLines.isNotEmpty(),
modifier = Modifier.weight(1f),
enter = fadeIn(tween(300)),
exit = fadeOut(tween(200)),
) {
Card(/* ... */) {
Box {
Column(
modifier = Modifier
.fillMaxSize()
.padding(end = 10.dp) // room for scrollbar
.verticalScroll(outputScrollState)
.verticalScrollbar(outputScrollState),
) {
uiState.outputLines.forEach { line ->
Text(line, /* monospace, 12sp */)
}
}
}
}
}
// ββ Ping History (below the output)
AnimatedVisibility(visible = uiState.history.isNotEmpty()) {
PingHistorySection(entries = uiState.history, /* ... */)
}
}
}Key UI differences from NTP/DIG
| Aspect | NTP / DIG | Ping |
|---|---|---|
| Scroll strategy | Single scrollable column | Pinned input + scrollable output terminal |
| Output area | Fixed-size result card | Fills remaining space (weight(1f)) |
| Auto-scroll | Not needed | LaunchedEffect + animateScrollTo(maxValue) on every new line |
| Scrollbar | None | Custom verticalScrollbar modifier (draws a rounded rect on the right edge) |
| Button behavior | Single action | Toggle (Ping β Stop) |
| Button color | Primary (always) | Primary when idle, Error (red) when running |
Custom scrollbar
private fun Modifier.verticalScrollbar(
scrollState: androidx.compose.foundation.ScrollState,
width: Dp = 4.dp,
color: Color = Color.Gray.copy(alpha = 0.5f),
): Modifier = this.drawWithContent {
drawContent()
val contentHeight = scrollState.maxValue.toFloat() + size.height
if (contentHeight <= size.height) return@drawWithContent // nothing to scroll
val thumbHeightPx = (size.height / contentHeight) * size.height
val thumbTopPx = (scrollState.value.toFloat() / contentHeight) * size.height
val barWidth = width.toPx()
drawRoundRect(
color = color,
topLeft = Offset(size.width - barWidth, thumbTopPx),
size = Size(barWidth, thumbHeightPx),
cornerRadius = CornerRadius(barWidth / 2),
)
}A custom Modifier extension that draws a thin rounded rectangle on the right edge, positioned proportionally to the scroll offset β mimicking a terminal scrollbar.
Button toggle behavior
Button(
onClick = {
focusManager.clearFocus()
if (uiState.isRunning) vm.stopPing() else vm.startPing()
},
enabled = uiState.isRunning || uiState.host.isNotBlank(),
colors = if (uiState.isRunning)
ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)
else
ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary),
) {
if (uiState.isRunning) {
// Stop icon + "Stop" text
Icon(Icons.Filled.Stop, /* ... */)
Text("Stop", fontWeight = FontWeight.Medium)
} else {
// Terminal icon + "Ping" text
Icon(Icons.Filled.Terminal, /* ... */)
Text("Ping", fontWeight = FontWeight.Medium)
}
}The button toggles between "Ping" (terminal icon) and "Stop" (stop icon, red background). It's enabled when either running (always) or host is non-blank (ready to start).
IME action behavior
keyboardActions = KeyboardActions(
onGo = {
focusManager.clearFocus()
if (uiState.isRunning) vm.stopPing() else vm.startPing()
},
),The "Go" key on the keyboard toggles ping start/stop β same behavior as the button. This is consistent with the button's toggle behavior.
Terminal output styling
uiState.outputLines.forEach { line ->
Text(
text = line,
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
lineHeight = 18.sp,
),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}Each output line is rendered in monospace font at 12sp with 18sp line height β compact enough to fit many lines in the terminal card while remaining readable.
Test Coverage
There is no dedicated PingViewModelTest in the test suite. The Ping ViewModel's subprocess-based nature makes it harder to unit test (requires mocking Runtime.getRuntime().exec()). The feature relies on:
- Manual testing β the primary test method for the Ping screen
PingHistoryStoretests β history serialization/deserialization, backward compatibility, edge cases- Integration testing β via the test automation scripts (BULKACTIONS-ADB-SCRIPT)
Data Flow Summary
User enters host β taps "Ping" / presses "Go"
β
ββ Validates (non-blank, not already running)
ββ _uiState.value = isRunning = true, outputLines = []
β
ββ viewModelScope.launch {
withTimeout(userSetting * 1000) {
Runtime.exec(["ping", "-c", "100", host])
β
ββ BufferedReader.readLine() loop
ββ withContext(Dispatchers.Main) β append line
ββ LaunchedEffect β auto-scroll to bottom
ββ process.waitFor() (when stopped or timeout)
}
β
finally {
pingProcess?.destroy()
computeStatus(outputLines)
β
ββ received = count("bytes from")
ββ sent = max(icmp_seq) or fallback to received
ββ ALL_SUCCESS / PARTIAL / ALL_FAILED
}
saveHistory(host, status)
}
}