DIG (DNS) β Implementation Deep Dive
How our DIG works: dnsjava resolution, NXDOMAIN detection, and dig-style output formatting
Overview
The DIG Test screen performs DNS lookups using dnsjava β a pure-Java DNS client library that bypasses the OS resolver entirely. This means the app can query arbitrary DNS servers (8.8.8.8, 1.1.1.1, custom resolvers) and see raw answer-section records with TTL, class, type, and rdata.
The feature rxeports:
- QUESTION section β the query that was sent (e.g.
pool.ntp.org. IN A) - ANSWER section β all resolved records (A + AAAA), formatted like
digCLI output - NXDOMAIN β when neither A nor AAAA records exist for the FQDN
- Resolver errors β when the DNS server itself is unreachable or times out
The feature is implemented across four files that follow the project's standard screen module pattern:
| File | Role |
|---|---|
DigRepository.kt | Network I/O β SimpleResolver from dnsjava |
DigViewModel.kt | State management β StateFlow<DigUiState>, coroutine orchestration |
DigHistoryStore.kt | Persistence β DataStore-backed history (last 5 entries) |
DigScreen.kt | UI β Jetpack Compose screen with Material 3 |
DigRepository β DNS Resolution
File: app/src/main/java/.../DigRepository.kt
The Repository wraps org.xbill.DNS.SimpleResolver from dnsjava 3.6.2. All logic is in a single suspend fun resolve():
suspend fun resolve(dnsServerHost: String, fqdn: String): DigResult =
withContext(Dispatchers.IO) {
val server = dnsServerHost.ifBlank { "8.8.8.8" }
try {
val resolver = SimpleResolver(server)
resolver.setTimeout(TIMEOUT) // Duration.ofSeconds(5)
// Ensure the FQDN is absolute (trailing dot)
val name: Name = try {
val raw = if (fqdn.endsWith(".")) fqdn else "$fqdn."
Name.fromString(raw)
} catch (e: Exception) {
return@withContext DigResult.Error("Invalid FQDN: $fqdn")
}
// Query A records (CNAME chain is included automatically)
val questionRecord = Record.newRecord(name, Type.A, DClass.IN)
val query = Message.newQuery(questionRecord)
val response: Message = resolver.send(query)
val answerRecords = response.getSection(Section.ANSWER)
// Build question-section string
val questionSection = ";${name} IN A"
if (answerRecords.isNullOrEmpty()) {
// Also try AAAA before declaring NXDOMAIN
val query6 = Message.newQuery(Record.newRecord(name, Type.AAAA, DClass.IN))
val response6: Message = resolver.send(query6)
val aaaa = response6.getSection(Section.ANSWER)
if (aaaa.isNullOrEmpty()) {
return@withContext DigResult.NxDomain(fqdn)
}
return@withContext DigResult.Success(
questionSection = ";${name} IN AAAA",
records = formatRecords(aaaa),
dnsServer = server,
)
}
// Also append AAAA records if present
val query6 = Message.newQuery(Record.newRecord(name, Type.AAAA, DClass.IN))
val response6: Message = resolver.send(query6)
val aaaaRecords = response6.getSection(Section.ANSWER) ?: emptyList()
val allRecords = (answerRecords + aaaaRecords)
.distinctBy { it.toString() }
DigResult.Success(
questionSection = questionSection,
records = formatRecords(allRecords),
dnsServer = server,
)
} catch (e: UnknownHostException) {
// The DNS *server* host itself can't be resolved
DigResult.DnsServerError(
"Cannot reach DNS server \"$dnsServerHost\": ${e.localizedMessage}"
)
} catch (e: java.net.SocketTimeoutException) {
DigResult.DnsServerError(
"DNS server \"$dnsServerHost\" did not respond within ${TIMEOUT.seconds}s"
)
} catch (e: java.net.SocketException) {
val msg = e.message ?: ""
if (msg.contains("unreachable", ignoreCase = true) ||
msg.contains("connect failed", ignoreCase = true)
) {
DigResult.NoNetwork
} else {
DigResult.DnsServerError("Socket error: $msg")
}
} catch (e: Exception) {
DigResult.Error(e.localizedMessage ?: e.toString())
}
}Key implementation details
-
Pure-Java DNS β
SimpleResolversends UDP queries directly to the target DNS server. No AndroidInetAddressor system resolver is involved. This means the app works consistently across all API levels and can query servers that the OS resolver might block or redirect. -
FQDN normalization β dnsjava's
Name.fromString()requires a trailing dot for absolute names. The Repository adds it automatically:if (!fqdn.endsWith(".")) fqdn + ".". -
Dual A + AAAA query strategy β after the A query returns empty, the Repository makes a second query for AAAA records before declaring NXDOMAIN. This ensures users get IPv6 results even when querying "A" by default. If AAAA also returns empty,
NxDomainis returned. -
CNAME chain inclusion β dnsjava automatically follows CNAME chains and includes all intermediate records in the ANSWER section. So a query for
pool.ntp.orgβ CNAMEntp.example.com.β A1.2.3.4will show both the CNAME and the final A record. -
AAAA appended to A results β when the A query succeeds, the Repository also queries AAAA and merges both result sets (deduplicated by
toString()). This gives users both IPv4 and IPv6 addresses in a single query. -
Error classification β four distinct error types:
UnknownHostExceptionβ the DNS server hostname itself is unresolvable βDnsServerErrorSocketTimeoutExceptionβ the server didn't respond in 5s βDnsServerErrorSocketExceptionwith "unreachable"/"connect failed" β device has no network βNoNetwork- Everything else β generic
Error
Result sealed hierarchy
sealed class DigResult {
data class Success(val questionSection: String, val records: List<String>, val dnsServer: String) : DigResult()
data class NxDomain(val fqdn: String) : DigResult()
data class DnsServerError(val detail: String) : DigResult()
object NoNetwork : DigResult()
data class Error(val message: String) : DigResult()
}Five variants cover all possible outcomes: success (with A + AAAA records), NXDOMAIN, resolver error, no network, and generic errors.
Record formatting
The formatRecords() method produces dig-style output with proper column alignment:
private fun formatRecords(records: List<Record>): List<String> {
if (records.isEmpty()) return emptyList()
// Compute column widths from the actual data
val nameWidth = records.maxOf { it.name.toString().length }.coerceAtLeast(20)
val ttlWidth = records.maxOf { it.ttl.toString().length }.coerceAtLeast(5)
val typeWidth = records.maxOf { Type.string(it.type).length }.coerceAtLeast(5)
return records.map { record ->
val name = record.name.toString().padEnd(nameWidth)
val ttl = record.ttl.toString().padStart(ttlWidth)
val type = Type.string(record.type).padEnd(typeWidth)
val rdata = record.rdataToString()
"$name $ttl IN $type $rdata"
}
}This produces output like:
pool.ntp.org. 300 IN A 1.2.3.4
ntp.example.com. 600 IN CNAME pool.ntp.org.The column widths are computed dynamically from the actual data (minimum 20 chars for name, 5 for TTL and type), ensuring right-aligned TTL values and left-aligned names β matching real dig output.
DigViewModel β State Management
File: app/src/main/java/.../DigViewModel.kt
class DigViewModel(
private val repository: DigRepository = DigRepository(),
private val historyStore: DigHistoryStore,
private val settingsRepository: SettingsRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow(DigUiState())
val uiState: StateFlow<DigUiState> = _uiState.asStateFlow()
private var queryJob: Job? = null
init {
viewModelScope.launch {
val saved = historyStore.historyFlow.first()
_uiState.value = _uiState.value.copy(history = saved)
}
}UI State
data class DigUiState(
val dnsServer: String = "",
val fqdn: String = "",
val isLoading: Boolean = false,
val result: DigResult? = null,
/** Up to 5 most recent distinct queries, newest first. */
val history: List<DigHistoryEntry> = emptyList(),
)The data class approach uses a nullable result field and an isLoading boolean.
Command handlers
| Function | Behavior |
|---|---|
onDnsServerChange(value) | Updates dnsServer, clears result (so old result is hidden) |
onFqdnChange(value) | Updates fqdn, clears result |
runDigQuery() | Cancels previous job, sets isLoading = true, launches coroutine |
selectHistoryEntry(entry) | Populates dnsServer/fqdn from history, immediately runs runDigQuery() |
The runDigQuery() lifecycle
fun runDigQuery() {
val server = _uiState.value.dnsServer.trim()
val fqdn = _uiState.value.fqdn.trim()
if (fqdn.isBlank()) return
queryJob?.cancel()
_uiState.value = _uiState.value.copy(isLoading = true, result = null)
queryJob = viewModelScope.launch {
val timeoutMs = settingsRepository.timeoutSecondsFlow.first() * 1000L
val result = try {
withTimeout(timeoutMs) {
repository.resolve(server, fqdn)
}
} catch (_: TimeoutCancellationException) {
DigResult.Error("Operation timed out")
}
_uiState.value = _uiState.value.copy(isLoading = false, result = result)
saveHistory(server, fqdn, result)
}
}Key behaviors:
- Input validation β empty FQDN returns early; empty DNS server defaults to
8.8.8.8inside the Repository - Job cancellation β
queryJob?.cancel()prevents overlapping queries - Dual timeout β
withTimeout()in ViewModel (user-configurable, default 5s) wrapsrepository.resolve()which has its own 5s socket timeout - History deduplication β old entries with the same (dnsServer, fqdn) pair are removed before prepending the new entry, so the history list never contains duplicates
- Persistence β
historyStore.save()is called at the end of the coroutine, so history is saved for both success and failure
The saveHistory() method
private suspend fun saveHistory(server: String, fqdn: String, result: DigResult) {
if (fqdn.isBlank()) return
val status = if (result is DigResult.Success) DigStatus.SUCCESS else DigStatus.FAILED
val timestamp = LocalDateTime.now()
.format(DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm:ss"))
val newEntry = DigHistoryEntry(
timestamp = timestamp,
dnsServer = server,
fqdn = fqdn,
status = status,
)
// Deduplicate by (dnsServer, fqdn) pair β keep only the latest
val updatedHistory = (listOf(newEntry) + _uiState.value.history
.filter { it.dnsServer != server || it.fqdn != fqdn })
.take(5)
_uiState.value = _uiState.value.copy(history = updatedHistory)
historyStore.save(updatedHistory)
}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 =
DigViewModel(
repository = DigRepository(),
historyStore = DigHistoryStore(context.applicationContext),
settingsRepository = SettingsRepository(context.applicationContext),
) as T
}
}Uses context.applicationContext to avoid leaking the Activity context.
DigHistoryStore β Persistence
File: app/src/main/java/.../DigHistoryStore.kt
DataStore setup
private val Context.digHistoryDataStore: DataStore<Preferences>
by preferencesDataStore(name = "dig_history")Each tool has its own named DataStore file. DIG uses "dig_history".
Serialization format
A single String preference key ("dig_history") contains all entries, one per line, fields separated by |:
2026/05/10 14:30:00|8.8.8.8|pool.ntp.org|SUCCESS
2026/05/09 09:15:22|1.1.1.1|time.google.com|FAILED| Field | Example | Notes |
|---|---|---|
timestamp | 2026/05/10 14:30:00 | yyyy/MM/dd HH:mm:ss |
dnsServer | 8.8.8.8 | IP or FQDN of resolver used |
fqdn | pool.ntp.org | Domain queried |
status | SUCCESS / FAILED | Enum serialized as .name |
Deserialization
private fun deserialise(raw: String): List<DigHistoryEntry> =
raw.split(ENTRY_SEP)
.filter { it.isNotBlank() }
.mapNotNull { line ->
val parts = line.split(FIELD_SEP)
if (parts.size >= 3) {
val status = when (parts.getOrNull(3)) {
"SUCCESS" -> DigStatus.SUCCESS
else -> DigStatus.FAILED
}
DigHistoryEntry(
timestamp = parts[0],
dnsServer = parts[1],
fqdn = parts[2],
status = status,
)
} else null
}
.take(MAX_ENTRIES)- Requires at least 3 fields (timestamp, dnsServer, fqdn); 4th field (
status) defaults toFAILEDif 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<DigHistoryEntry>> = context.digHistoryDataStore.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)
}
}This means history is restored immediately on ViewModel construction β including after process death and device reboots, since DataStore files survive on disk.
UI β DigScreen
File: app/src/main/java/.../DigScreen.kt
The DIG screen is a full-screen Compose layout with five sections:
βββββββββββββββββββββββββββββββββββββββββββ
β dig @DNS_SERVER FQDN_TO_QUERY β β Usage hint (monospace)
βββββββββββββββββββββββββββββββββββββββββββ€
β [DNS Server (IP or FQDN) ] [Icon] β
βββββββββββββββββββββββββββββββββββββββββββ€
β [FQDN to query ] β
βββββββββββββββββββββββββββββββββββββββββββ€
β [ Run DIG ] β
βββββββββββββββββββββββββββββββββββββββββββ€
β ββ Resolved βββββββββββββββββββββββββ β
β β ;; SERVER: 8.8.8.8 β β
β β ;; QUESTION SECTION: β β
β β pool.ntp.org. 300 IN A 1.2.3.4β β
β β ;; ANSWER SECTION: β β
β β ... β β
β βββββββββββββββββββββββββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββ€
β ββ Recent DIG Queries ββββββββββββββ β
β β 2026/05/10 14:30:00 @8.8.8.8 β β
β β pool.ntp.org β
β β
β βββββββββββββββββββββββββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββScreen structure
@Composable
fun DigScreen() {
val context = LocalContext.current
val viewModel: DigViewModel = viewModel(factory = DigViewModel.factory(context))
val uiState by viewModel.uiState.collectAsState()
val focusManager = LocalFocusManager.current
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp, vertical = 20.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// Usage hint
Text("dig @DNS_SERVER FQDN_TO_QUERY", /* monospace style */)
// DNS Server field
OutlinedTextField(/* dnsServer, KeyboardType.Uri, ImeAction.Next */)
// FQDN field
OutlinedTextField(/* fqdn, KeyboardType.Uri, ImeAction.Go */)
// Run DIG button
Button(onClick = { viewModel.runDigQuery() }) { /* ... */ }
// Result card (animated)
AnimatedVisibility(visible = uiState.result != null) {
uiState.result?.let { DigResultCard(it) }
}
// History section (animated)
AnimatedVisibility(visible = uiState.history.isNotEmpty()) {
DigHistorySection(entries = uiState.history, /* ... */)
}
}
}UI behavior highlights
- Usage hint β monospace text showing
digCLI syntax so users understand the command pattern - Dual keyboard types β both fields use
KeyboardType.Uri(notText), which shows a keyboard with.and/keys prominent - IME actions β DNS server field shows "Next", FQDN field shows "Go" which triggers
runDigQuery() - Button enabled states β disabled when
isLoadingorfqdnis blank - Animated visibility β result card slides in from below; history fades in. Both use
tween()easing. - Color-coded result cards β Success uses
secondaryContainer(green-ish), errors useerrorContainer(red-ish) - History re-query β tapping a history entry populates both fields and immediately starts a new query
DigResultCard β result display
@Composable
private fun DigResultCard(result: DigResult) {
val isSuccess = result is DigResult.Success
val cardColor = if (isSuccess)
MaterialTheme.colorScheme.secondaryContainer
else
MaterialTheme.colorScheme.errorContainer
Card(/* containerColor = cardColor */) {
Column {
DigStatusHeader(result) // icon + label row
when (result) {
is DigResult.Success -> {
Text(";; SERVER: ${result.dnsServer}")
DnsSection(";; QUESTION SECTION:", listOf(result.questionSection))
DnsSection(";; ANSWER SECTION:", result.records)
}
is DigResult.NxDomain -> {
Text("\"${result.fqdn}\" could not be resolved (NXDOMAIN). ...")
}
is DigResult.DnsServerError -> Text(result.detail)
is DigResult.NoNetwork -> Text("Please check your internet connection...")
is DigResult.Error -> Text(result.message)
}
}
}
}DnsSection β monospace DNS output block
@Composable
private fun DnsSection(header: String, lines: List<String>) {
Column {
Text(header, /* monospace, semi-bold */)
Spacer(Modifier.height(4.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.background(
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.5f),
shape = RoundedCornerShape(8.dp),
)
.padding(horizontal = 10.dp, vertical = 8.dp),
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
lines.forEach { line ->
Text(line, /* monospace, 12sp */)
}
}
}
}
}This renders the QUESTION and ANSWER sections as monospace text blocks on a dark background β mimicking a terminal.
Test Coverage
The DIG feature has dedicated unit tests in DigViewModelTest (~14 tests) covering:
- State mutations on input changes (
onDnsServerChange,onFqdnChange) - Repository mocking for
Success,NxDomain,DnsServerError,NoNetwork,Error - History save after query (success and failure)
- State transitions (loading β done)
- Coroutine control with
StandardTestDispatcher+advanceUntilIdle()
Data Flow Summary
User taps "Run DIG" β ViewModel.runDigQuery()
β
ββ Validates input (non-empty FQDN)
ββ queryJob?.cancel() β cancel in-flight query
ββ _uiState.value = loading
β
ββ viewModelScope.launch {
ββ withTimeout(userSetting * 1000) {
β repository.resolve(server, fqdn)
β β SimpleResolver (dnsjava)
β 1. Query A records
β 2. If empty β query AAAA
β 3. If still empty β NxDomain
β 4. If A succeeded β also append AAAA
β }
β
ββ Save history (SUCCESS / FAILED)
ββ Deduplicate by (dnsServer, fqdn)
ββ _uiState.value = result + history
ββ historyStore.save(updatedHistory) β DataStore write
}