en
Developers
Traceroute

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):

FileRole
TracerouteViewModel.ktState management + TTL probing β€” StateFlow<TracerouteUiState>, Runtime.exec()
TracerouteHistoryStore.ktPersistence β€” DataStore-backed history (last 5 entries)
TracerouteScreen.ktUI β€” 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

  1. No-op if already running β€” if (host.isBlank() || _uiState.value.isRunning) return prevents duplicate traces.

  2. 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 outputLines on the main thread
    • Breaks early if isDestination (destination reached)
  3. isActive check β€” if (!isActive) break inside the loop checks coroutine cancellation on each iteration. This is the primary way stopTraceroute() halts the trace.

  4. 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.

  5. appendLine() β€” a private helper that updates outputLines on 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.IO for viewModelScope), so it implicitly switches to Dispatchers.Main via the copy call.

  6. Status computation β€” after the loop exits (timeout, destination reached, or stopped):

    • reachableHops == 0 β†’ ALL_FAILED
    • destinationReached == 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

  1. 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
  2. Read both stdout and stderr β€” process.inputStream.bufferedReader().readText() and process.errorStream.bufferedReader().readText() are merged into combined for regex matching. Some Android ping implementations write diagnostic messages to stderr.

  3. 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 message
    • destinationReplyRegex β€” bytes from (\S+): β€” captures the destination's IP from an Echo Reply
    • ttlExceededAltRegex β€” From (\S+):.*ttl (case-insensitive) β€” alternative format used by some Android pings
  4. TTL display padding β€” ttl.toString().padStart(2) ensures consistent column alignment (01, 02, …, 30).

  5. RTT extraction β€” for destination replies, the RTT is extracted from time=<N> ms or time= N ms format. Falls back to the total elapsed time if the pattern doesn't match.

  6. Timeout β€” if none of the regexes match (no ICMP reply within 2s), returns HopResult("$paddedTtl * * *", isReachable = false, isDestination = false).

  7. 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

FunctionBehavior
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
FieldExampleNotes
timestamp2026/05/10 14:30:00yyyy/MM/dd HH:mm:ss
hostgoogle.comHostname or IP
statusALL_SUCCESSEnum 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 to ALL_FAILED if 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) triggers animateScrollTo(maxValue) on every new line, keeping the terminal scrolled to the bottom
  • Custom scrollbar β€” identical verticalScrollbar modifier 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 where isReachable == true
  • destinationReached β€” set to true when isDestination == true on 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 destinationReached flag 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)
               }
           }

MIT 2026 Β© Nextra.