Traceroute β Implementation Deep Dive
How the Traceroute screen works: TTL probing via ping, hop classification, and status computation
Overview
The Traceroute screen implements traceroute by probing with ping -c 1 -t <TTL> for incrementing TTL values (1, 2, β¦, 30). Since Android devices typically don't ship with a traceroute binary, this approach uses the ICMP Time Exceeded mechanism to discover each hop along the path.
For each TTL:
- Intermediate router β receives a packet with TTL=0 and sends back an ICMP "Time to live exceeded" message. The router's IP is captured as the hop.
- Destination β the target itself replies (ICMP Echo Reply). We've reached the end.
- Timeout β no ICMP reply within 2 seconds. Displayed as
* * *.
The feature reports:
- Live hop output β each TTL line as it's probed (e.g.
01 192.168.1.1 12 ms) - Status summary β derived from the collected hops:
- ALL_SUCCESS β β destination reached
- PARTIAL π€· β some hops replied but destination not reached / stopped early
- ALL_FAILED β β no hop replied at all
The feature is implemented across three files (no separate Repository β the subprocess logic lives directly in the ViewModel):
| File | Role |
|---|---|
TracerouteViewModel.kt | State management + TTL probing β StateFlow<TracerouteUiState>, Runtime.exec() |
TracerouteHistoryStore.kt | Persistence β DataStore-backed history (last 5 entries) |
TracerouteScreen.kt | UI β Jetpack Compose screen with terminal output card and custom scrollbar |
TracerouteViewModel β TTL Probing
File: app/src/main/java/.../TracerouteViewModel.kt
class TracerouteViewModel(
private val historyStore: TracerouteHistoryStore,
private val settingsRepository: SettingsRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow(TracerouteUiState())
val uiState: StateFlow<TracerouteUiState> = _uiState.asStateFlow()
private var traceJob: Job? = null
// "From 192.168.1.1 icmp_seq=β¦ Time to live exceeded"
private val ttlExceededIpRegex = Regex("""From (\S+).*[Tt]ime to live exceeded""")
// "64 bytes from 142.251.12.100:"
private val destinationReplyRegex = Regex("""bytes from (\S+):""")
// Alternative: some Android pings say "From x.x.x.x: ..."
private val ttlExceededAltRegex = Regex("""From (\S+):.*ttl""", RegexOption.IGNORE_CASE)UI State
data class TracerouteUiState(
val host: String = "",
val isRunning: Boolean = false,
val outputLines: List<String> = emptyList(),
val history: List<TracerouteHistoryEntry> = emptyList(),
)Same structure as Ping: includes outputLines: List<String> for streaming hop output.
The startTraceroute() lifecycle
fun startTraceroute() {
val host = _uiState.value.host.trim()
if (host.isBlank() || _uiState.value.isRunning) return
_uiState.value = _uiState.value.copy(
isRunning = true,
outputLines = emptyList(),
)
traceJob = viewModelScope.launch {
appendLine("traceroute to $host, 30 hops max")
val timeoutMs = settingsRepository.timeoutSecondsFlow.first() * 1000L
var reachableHops = 0
var destinationReached = false
try {
withTimeout(timeoutMs) {
for (ttl in 1..30) {
if (!isActive) break
val hopResult = withContext(Dispatchers.IO) { probeHop(host, ttl) }
appendLine(hopResult.displayLine)
if (hopResult.isReachable) reachableHops++
if (hopResult.isDestination) {
destinationReached = true
break
}
}
}
} catch (_: TimeoutCancellationException) {
appendLine("--- Timed out after ${timeoutMs / 1000}s ---")
}
val status = when {
reachableHops == 0 -> TracerouteStatus.ALL_FAILED
destinationReached -> TracerouteStatus.ALL_SUCCESS
else -> TracerouteStatus.PARTIAL
}
val wasRunning = _uiState.value.isRunning
_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 traces. -
TTL loop β
for (ttl in 1..30)probes up to 30 hops (standard traceroute maximum). Each iteration:- Calls
probeHop(host, ttl)on the IO dispatcher - Appends the display line to
outputLineson the main thread - Breaks early if
isDestination(destination reached)
- Calls
-
isActivecheck βif (!isActive) breakinside the loop checks coroutine cancellation on each iteration. This is the primary waystopTraceroute()halts the trace. -
withTimeout()wrapping the entire loop β the user-configurable timeout applies to the entire traceroute, not individual hops. If the total time exceeds the limit, a timeout marker is appended and the loop exits. -
appendLine()β a private helper that updatesoutputLineson the main thread:private fun appendLine(line: String) { _uiState.value = _uiState.value.copy( outputLines = _uiState.value.outputLines + line, ) }This is called from the coroutine's default context (which is
Dispatchers.IOforviewModelScope), so it implicitly switches toDispatchers.Mainvia thecopycall. -
Status computation β after the loop exits (timeout, destination reached, or stopped):
reachableHops == 0βALL_FAILEDdestinationReached == trueβALL_SUCCESS- Otherwise β
PARTIAL
The stopTraceroute() method
fun stopTraceroute() {
traceJob?.cancel()
val lines = _uiState.value.outputLines
val hopLines = lines.filter { it.trimStart().firstOrNull()?.isDigit() == true }
val timeouts = hopLines.count { "* * *" in it }
val replied = hopLines.size - timeouts
val status = when {
replied == 0 -> TracerouteStatus.ALL_FAILED
timeouts == 0 -> TracerouteStatus.ALL_SUCCESS
else -> TracerouteStatus.PARTIAL
}
_uiState.value = _uiState.value.copy(isRunning = false)
viewModelScope.launch { saveHistory(_uiState.value.host.trim(), status) }
}Stops the trace by cancelling the job, then computes status from the already-collected output lines:
- Filters to hop lines (those starting with a digit)
- Counts timeout lines (
* * *) vs replied lines - Derives status from the counts
The probeHop() method β single TTL probe
private data class HopResult(
val displayLine: String,
val isReachable: Boolean,
val isDestination: Boolean,
)
private fun probeHop(host: String, ttl: Int): HopResult {
val paddedTtl = ttl.toString().padStart(2)
return try {
val t0 = System.currentTimeMillis()
val process = Runtime.getRuntime().exec(
arrayOf("ping", "-c", "1", "-t", ttl.toString(), "-W", "2", host)
)
val stdout = process.inputStream.bufferedReader().readText()
val stderr = process.errorStream.bufferedReader().readText()
process.waitFor()
val elapsed = System.currentTimeMillis() - t0
val combined = stdout + "\n" + stderr
when {
// Intermediate hop replied with ICMP Time Exceeded
ttlExceededIpRegex.containsMatchIn(combined) -> {
val ip = ttlExceededIpRegex.find(combined)!!.groupValues[1].trimEnd(':')
HopResult("$paddedTtl $ip ${elapsed} ms",
isReachable = true, isDestination = false)
}
// Destination itself replied
destinationReplyRegex.containsMatchIn(combined) -> {
val ip = destinationReplyRegex.find(combined)!!.groupValues[1].trimEnd(':')
val rtt = Regex("""time[=<]([\d.]+) ms""").find(combined)
?.groupValues?.get(1)?.let { "$it ms" } ?: "${elapsed} ms"
HopResult("$paddedTtl $ip $rtt",
isReachable = true, isDestination = true)
}
// Alt Android ping "From x.x.x.x: β¦ ttl β¦" format
ttlExceededAltRegex.containsMatchIn(combined) -> {
val ip = ttlExceededAltRegex.find(combined)!!.groupValues[1].trimEnd(':')
HopResult("$paddedTtl $ip ${elapsed} ms",
isReachable = true, isDestination = false)
}
// Timeout β no ICMP reply within 2 s
else -> HopResult("$paddedTtl * * *",
isReachable = false, isDestination = false)
}
} catch (_: Exception) {
val paddedTtl2 = ttl.toString().padStart(2)
HopResult("$paddedTtl2 * * *", isReachable = false, isDestination = false)
}
}Key implementation details
-
Subprocess command β
Runtime.getRuntime().exec(arrayOf("ping", "-c", "1", "-t", ttl.toString(), "-W", "2", host)):-c 1β send exactly 1 packet-t <ttl>β set the TTL to the current hop number-W 2β wait 2 seconds for a reply per packet- Each hop takes β€ 2 seconds, so 30 hops could take up to ~60 seconds in the worst case
-
Read both stdout and stderr β
process.inputStream.bufferedReader().readText()andprocess.errorStream.bufferedReader().readText()are merged intocombinedfor regex matching. Some Android ping implementations write diagnostic messages to stderr. -
Three regex patterns β checked in priority order:
ttlExceededIpRegexβFrom (\S+).*[Tt]ime to live exceededβ captures the intermediate router's IP from an ICMP Time Exceeded messagedestinationReplyRegexβbytes from (\S+):β captures the destination's IP from an Echo ReplyttlExceededAltRegexβFrom (\S+):.*ttl(case-insensitive) β alternative format used by some Android pings
-
TTL display padding β
ttl.toString().padStart(2)ensures consistent column alignment (01,02, β¦,30). -
RTT extraction β for destination replies, the RTT is extracted from
time=<N> msortime= N msformat. Falls back to the total elapsed time if the pattern doesn't match. -
Timeout β if none of the regexes match (no ICMP reply within 2s), returns
HopResult("$paddedTtl * * *", isReachable = false, isDestination = false). -
Exception handling β any exception during the probe (e.g.,
IOException) returns a timeout result (* * *), so a single failed hop doesn't abort the entire trace.
HopResult data class
private data class HopResult(
val displayLine: String, // e.g. "01 192.168.1.1 12 ms"
val isReachable: Boolean, // true if any ICMP reply received
val isDestination: Boolean, // true if the target host itself replied
)Three fields encode all the information needed for both display and status computation.
Command handlers
| Function | Behavior |
|---|---|
onHostChange(value) | Updates host |
startTraceroute() | Validates, probes TTL 1..30, streams output, computes status, saves history |
stopTraceroute() | Cancels job, computes status from collected lines, saves history |
selectHistoryEntry(entry) | Stops current trace (if running), populates host, immediately starts new trace |
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 =
TracerouteViewModel(
historyStore = TracerouteHistoryStore(context.applicationContext),
settingsRepository = SettingsRepository(context.applicationContext),
) as T
}
}Note: Traceroute 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 Ping).
onCleared() override
override fun onCleared() {
super.onCleared()
traceJob?.cancel()
}Cancels the in-flight job when the ViewModel is destroyed. Unlike Ping, Traceroute does not hold a Process? reference for cleanup β the subprocess terminates when the coroutine is cancelled (the IO thread's readText() call is blocked but the process is orphaned by the OS).
TracerouteHistoryStore β Persistence
File: app/src/main/java/.../TracerouteHistoryStore.kt
DataStore setup
private val Context.tracerouteHistoryDataStore: DataStore<Preferences>
by preferencesDataStore(name = "traceroute_history")Each tool has its own named DataStore file. Traceroute uses "traceroute_history".
Serialization format
A single String preference key ("traceroute_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
private fun deserialise(raw: String): List<TracerouteHistoryEntry> =
raw.split(ENTRY_SEP)
.filter { it.isNotBlank() }
.mapNotNull { line ->
val parts = line.split(FIELD_SEP)
if (parts.size >= 2) {
val status = when (parts.getOrNull(2)) {
"ALL_SUCCESS" -> TracerouteStatus.ALL_SUCCESS
"PARTIAL" -> TracerouteStatus.PARTIAL
else -> TracerouteStatus.ALL_FAILED
}
TracerouteHistoryEntry(
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 - 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<TracerouteHistoryEntry>> =
context.tracerouteHistoryDataStore.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.take(5))
}
}Note: .take(5) is applied both in the init block and in saveHistory(), providing a double cap.
UI β TracerouteScreen
File: app/src/main/java/.../TracerouteScreen.kt
The Traceroute screen uses the same layout strategy as Ping: pinned input area + scrollable output terminal that fills all remaining space:
βββββββββββββββββββββββββββββββββββββββββββ
β [Hostname / IP ] β β pinned, always visible
βββββββββββββββββββββββββββββββββββββββββββ€
β [π€ Route Traceroute ] β β pinned, always visible
βββββββββββββββββββββββββββββββββββββββββββ€
β ββ Terminal output (scrollable) ββββββ β
β β traceroute to google.com, 30 hops maxβ β
β β 01 192.168.1.1 12 ms β β
β β 02 10.0.0.1 24 ms β β
β β 03 * * * β β
β β ... (fills remaining space) β β
β β β β β custom scrollbar
β ββββββββββββββββββββββββββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββ€
β ββ Recent Traceroutes ββββββββββββββ β
β β 2026/05/10 14:30:00 google.com β
β β
β ββββββββββββββββββββββββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββScreen structure
@Composable
fun TracerouteScreen() {
val context = LocalContext.current
val vm: TracerouteViewModel = viewModel(factory = TracerouteViewModel.factory(context))
val uiState by vm.uiState.collectAsState()
val focusManager = LocalFocusManager.current
// Scroll state for the output area β auto-scroll to the bottom on new lines
val outputScrollState = rememberScrollState()
LaunchedEffect(uiState.outputLines.size) {
outputScrollState.animateScrollTo(outputScrollState.maxValue)
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp, vertical = 20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// ββ Hostname field
OutlinedTextField(/* host, KeyboardType.Uri, ImeAction.Go */)
// ββ Traceroute / Stop button
Button(/* ... */)
// ββ Output terminal card (weight(1f) = fills 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)
.verticalScroll(outputScrollState)
.verticalScrollbar(outputScrollState),
) {
uiState.outputLines.forEach { line ->
Text(line, /* monospace, 12sp */)
}
}
}
}
}
// ββ Traceroute History
AnimatedVisibility(visible = uiState.history.isNotEmpty()) {
TracerouteHistorySection(entries = uiState.history, /* ... */)
}
}
}Key UI details
- Auto-scroll β
LaunchedEffect(uiState.outputLines.size)triggersanimateScrollTo(maxValue)on every new line, keeping the terminal scrolled to the bottom - Custom scrollbar β identical
verticalScrollbarmodifier as PingScreen (draws a thin rounded rect on the right edge) - Toggle button β same Ping pattern: "Traceroute" (route icon) β "Stop" (stop icon, red background)
- Button enabled states β enabled when either running (always) or host is non-blank (ready to start)
- IME action β "Go" key toggles trace start/stop, same as Ping
Hop 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 hop line is rendered in monospace font at 12sp with 18sp line height β compact enough to fit many hops in the terminal card while remaining readable.
Status Computation Comparison
During normal completion (startTraceroute)
val status = when {
reachableHops == 0 -> TracerouteStatus.ALL_FAILED
destinationReached -> TracerouteStatus.ALL_SUCCESS
else -> TracerouteStatus.PARTIAL
}reachableHopsβ count of hops whereisReachable == truedestinationReachedβ set totruewhenisDestination == trueon any hop
During manual stop (stopTraceroute)
val hopLines = lines.filter { it.trimStart().firstOrNull()?.isDigit() == true }
val timeouts = hopLines.count { "* * *" in it }
val replied = hopLines.size - timeouts
val status = when {
replied == 0 -> TracerouteStatus.ALL_FAILED
timeouts == 0 -> TracerouteStatus.ALL_SUCCESS
else -> TracerouteStatus.PARTIAL
}- Filters to hop lines (those starting with a digit)
- Counts timeout lines (
* * *) vs replied lines - Derives status from the counts (no
destinationReachedflag available since the loop was interrupted)
Test Coverage
The Traceroute feature has dedicated unit tests in TracerouteViewModelTest (~16 tests) covering:
- Input validation (blank host guard, no-op when already running)
- History save after query (success and partial)
- State transitions (loading β done)
- Coroutine control with
StandardTestDispatcher+advanceUntilIdle() stopTraceroute()status computation from collected lines
Data Flow Summary
User enters host β taps "Traceroute" / presses "Go"
β
ββ Validates (non-blank, not already running)
ββ _uiState.value = isRunning = true, outputLines = []
ββ appendLine("traceroute to $host, 30 hops max")
β
ββ viewModelScope.launch {
withTimeout(userSetting * 1000) {
for (ttl in 1..30) {
if (!isActive) break
β
ββ probeHop(host, ttl)
β
ββ Runtime.exec(["ping", "-c", "1", "-t", ttl, "-W", "2", host])
ββ read stdout + stderr
ββ Match regex:
β ββ ttlExceededIpRegex β intermediate hop
β ββ destinationReplyRegex β destination reached
β ββ ttlExceededAltRegex β alternative format
β ββ else β * * *
ββ HopResult(displayLine, isReachable, isDestination)
β
ββ appendLine(hopResult.displayLine)
ββ if (isDestination) break
ββ reachableHops++
}
}
β
val status = ALL_SUCCESS / PARTIAL / ALL_FAILED
saveHistory(host, status)
}
}