prober: per-network ICMP, real errqueue traceroute, sharper mDNS/ip-monitor

- IcmpProbe: attempt the echo on the default network AND each active
  network (Network.bindSocket + per-network DNS). Run 1's ping6 EAGAIN was
  topology (v6 only on cellular), not capability — now the report shows
  which networks carry which family instead of a bare ERROR.
- New traceroute.udp4 (TracerouteProbe): actual UDP traceroute reading
  ICMP time-exceeded via Os.recvmsg(MSG_ERRQUEUE) with cmsg parsing —
  reflection per repo convention (API surface exists ~34+). If this
  returns hops, the C-over-JNI shim is unnecessary on that device.
  sock_extended_err layout constants documented in OsAbi.
- MulticastProbe: 10 s window; meta-query PLUS concrete types
  (_http._tcp, _googlecast._tcp) — run 1 showed the meta-query alone
  returning 0 on a network with live services; capture names + failure
  codes as evidence.
- ShizukuProbe: ip monitor window 2s -> 5s, gateway ping in background to
  provoke a NEIGH transition instead of hoping for ambient churn.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mrambossek
2026-07-30 12:59:43 +02:00
co-authored by Claude Opus 5
parent 81d13ebb08
commit e695fe7aee
6 changed files with 329 additions and 55 deletions
@@ -4,13 +4,15 @@
package app.echo_lot.prober.probe
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.system.Os
import android.system.OsConstants
import android.system.StructTimeval
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.FileDescriptor
import java.net.Inet4Address
import java.net.InetAddress
import java.nio.ByteBuffer
import java.util.Locale
@@ -19,6 +21,11 @@ import java.util.Locale
* Probes the unprivileged ICMP echo path: socket(AF_INET, SOCK_DGRAM, IPPROTO_ICMP).
* Android opens ping_group_range to all UIDs, so this should work without root or CAP_NET_RAW.
* If it does, the production app never needs to shell out to /system/bin/ping.
*
* The echo is attempted once per active network (socket bound via Network.bindSocket), not just
* on the default network: the first device run showed why — v6 lived only on cellular while wifi
* was the v4-only default, so a default-network ping6 EAGAINed and looked like a capability
* failure. Per-network results turn that into topology evidence.
*/
class IcmpProbe(
private val v6: Boolean = false,
@@ -29,23 +36,65 @@ class IcmpProbe(
else "ICMPv4 echo (unprivileged datagram socket)"
override val tier = Tier.APP
private data class Attempt(val ok: Boolean, val detail: String)
override suspend fun run(context: Context): ProbeResult = withContext(Dispatchers.IO) {
val start = System.nanoTime()
val ev = LinkedHashMap<String, String>()
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
// Default-network attempt first — this is what a naive app gets.
val def = attempt(network = null)
ev["default"] = def.detail
var anyOk = def.ok
var okNet: String? = if (def.ok) "default" else null
@Suppress("DEPRECATION")
for (net in cm.allNetworks) {
val caps = cm.getNetworkCapabilities(net) ?: continue
val label = transportLabel(caps)
val res = attempt(net)
ev["net.$label"] = res.detail
if (res.ok && !anyOk) { anyOk = true; okNet = label }
if (res.ok && okNet == null) okNet = label
}
val durationMs = (System.nanoTime() - start) / 1_000_000
if (anyOk) {
val summary = if (def.ok) "Echo reply on the default network"
else "Echo reply on $okNet only — default network has no ${if (v6) "v6" else "v4"} path"
ProbeResult.of(this@IcmpProbe, Verdict.SUPPORTED, summary, ev, durationMs)
} else {
val permission = ev.values.any { "EACCES" in it || "EPERM" in it }
val verdict = if (permission) Verdict.UNSUPPORTED else Verdict.ERROR
ProbeResult.of(this@IcmpProbe, verdict,
"No echo reply on any of ${ev.size} attempt(s)", ev, durationMs)
}
}
private fun transportLabel(caps: NetworkCapabilities): String = when {
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "wifi"
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "cellular"
caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> "ethernet"
caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN) -> "vpn"
else -> "other"
}
/** One socket → optional bind to [network] → send → timed recv. Never throws. */
private fun attempt(network: Network?): Attempt {
var fd: FileDescriptor? = null
try {
return try {
val proto = if (v6) OsConstants.IPPROTO_ICMPV6 else OsConstants.IPPROTO_ICMP
val family = if (v6) OsConstants.AF_INET6 else OsConstants.AF_INET
fd = Os.socket(family, OsConstants.SOCK_DGRAM, proto)
ev["socket"] = "opened family=$family proto=$proto"
network?.bindSocket(fd)
Os.setsockoptTimeval(
fd, OsConstants.SOL_SOCKET, OsConstants.SO_RCVTIMEO,
StructTimeval.fromMillis(3000),
)
val addr = InetAddress.getByName(targetHost)
ev["target"] = addr.hostAddress ?: targetHost
// Resolve via the bound network where there is one — the default resolver may
// not even have records for the other family.
val addr = network?.getByName(targetHost) ?: InetAddress.getByName(targetHost)
val ident = (Os.getpid() and 0xFFFF)
val packet = buildEchoRequest(v6, ident.toShort(), seq = 1)
@@ -55,42 +104,21 @@ class IcmpProbe(
val buf = ByteBuffer.allocate(1500)
val received = Os.recvfrom(fd, buf, 0, null)
val rttMs = (System.nanoTime() - t0) / 1_000_000.0
ev["bytes_received"] = received.toString()
// Locale.ROOT: the device's locale must not leak into the report ("38,1" broke
// the schema's number format on the first Austrian-locale device).
ev["rtt_ms"] = "%.1f".format(Locale.ROOT, rttMs)
// ICMP datagram (ping) sockets deliver the ICMP message with NO IP header, so the
// type byte is at offset 0 for both families. v4 echo reply = 0, v6 echo reply = 129.
val replyType = if (received > 0) buf.get(0).toInt() and 0xFF else -1
ev["reply_type"] = replyType.toString()
ev["reply_type_meaning"] = when (replyType) {
0 -> "echo reply (v4)"; 129 -> "echo reply (v6)"; else -> "other/$replyType"
}
ProbeResult.of(
this@IcmpProbe, Verdict.SUPPORTED,
"Echo reply from ${ev["target"]} in ${ev["rtt_ms"]} ms",
ev, (System.nanoTime() - start) / 1_000_000,
)
val ok = replyType == (if (v6) 129 else 0)
// Locale.ROOT: the device's locale must not leak into the report ("38,1" broke
// the schema's number format on the first Austrian-locale device).
Attempt(ok, "reply type=$replyType rtt_ms=${"%.1f".format(Locale.ROOT, rttMs)} " +
"bytes=$received target=${addr.hostAddress}")
} catch (e: Throwable) {
ev["error"] = e.message ?: e.javaClass.simpleName
val verdict = if (isPermissionLike(e)) Verdict.UNSUPPORTED else Verdict.ERROR
ProbeResult.of(
this@IcmpProbe, verdict,
"ICMP datagram socket failed: ${ev["error"]}",
ev, (System.nanoTime() - start) / 1_000_000,
)
Attempt(false, "error: ${e.message ?: e.javaClass.simpleName}")
} finally {
fd?.let { runCatching { Os.close(it) } }
}
}
private fun isPermissionLike(e: Throwable): Boolean {
val m = (e.message ?: "").uppercase()
return "EACCES" in m || "EPERM" in m || "EAFNOSUPPORT" in m || "EPROTONOSUPPORT" in m
}
/** Minimal ICMP echo request; kernel fills checksum for ICMPv6, we compute it for ICMPv4. */
private fun buildEchoRequest(v6: Boolean, ident: Short, seq: Short): ByteArray {
val type = if (v6) 128 else 8 // echo request
@@ -10,18 +10,43 @@ import android.net.wifi.WifiManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import java.util.concurrent.atomic.AtomicInteger
import java.util.Collections
/**
* Probes multicast reception (MulticastLock + NSD/mDNS discovery). Confirms the app can do the
* mDNS/SSDP/LLMNR service inventory that doubles as the VLAN-leakage detector. Uses NsdManager
* as the least-privileged path; a raw 224.0.0.251:5353 listener is the fuller implementation.
*
* Runs the `_services._dns-sd._udp.` meta-query AND concrete types in parallel: run 1 on a
* network with live mDNS services returned 0 via the meta-query alone — NsdManager's meta-query
* support is unreliable on many builds, while concrete-type discovery is what it is actually
* built for. A device where concrete types answer but the meta-query stays empty is itself a
* finding the production inventory needs to know about.
*/
class MulticastProbe : Probe {
override val id = "local.mdns_discover"
override val title = "Multicast reception (MulticastLock + mDNS/NSD)"
override val tier = Tier.APP
// Meta-query + common concrete types (HTTP covers HA/printers/NAS; googlecast is ubiquitous).
private val queries = listOf(
"meta" to "_services._dns-sd._udp.",
"http" to "_http._tcp.",
"googlecast" to "_googlecast._tcp.",
)
private class Recorder : NsdManager.DiscoveryListener {
val names: MutableList<String> = Collections.synchronizedList(mutableListOf())
@Volatile var started = false
@Volatile var startFailCode: Int? = null
override fun onStartDiscoveryFailed(t: String?, code: Int) { startFailCode = code }
override fun onStopDiscoveryFailed(t: String?, code: Int) {}
override fun onDiscoveryStarted(t: String?) { started = true }
override fun onDiscoveryStopped(t: String?) {}
override fun onServiceFound(s: NsdServiceInfo?) { s?.serviceName?.let { names.add(it) } }
override fun onServiceLost(s: NsdServiceInfo?) {}
}
override suspend fun run(context: Context): ProbeResult = withContext(Dispatchers.IO) {
val start = System.nanoTime()
val ev = LinkedHashMap<String, String>()
@@ -33,35 +58,37 @@ class MulticastProbe : Probe {
ev["multicast_lock"] = if (lock?.isHeld == true) "acquired" else "not held"
val nsd = context.getSystemService(NsdManager::class.java)
val found = AtomicInteger(0)
val started = java.util.concurrent.atomic.AtomicBoolean(false)
val serviceType = "_services._dns-sd._udp." // meta-query: enumerates service types
val listener = object : NsdManager.DiscoveryListener {
override fun onStartDiscoveryFailed(t: String?, code: Int) {}
override fun onStopDiscoveryFailed(t: String?, code: Int) {}
override fun onDiscoveryStarted(t: String?) { started.set(true) }
override fun onDiscoveryStopped(t: String?) {}
override fun onServiceFound(s: NsdServiceInfo?) { found.incrementAndGet() }
override fun onServiceLost(s: NsdServiceInfo?) {}
val recorders = queries.map { (label, type) ->
val r = Recorder()
runCatching { nsd.discoverServices(type, NsdManager.PROTOCOL_DNS_SD, r) }
.onFailure { r.startFailCode = -1 }
Triple(label, type, r)
}
try {
nsd.discoverServices(serviceType, NsdManager.PROTOCOL_DNS_SD, listener)
delay(4000)
runCatching { nsd.stopServiceDiscovery(listener) }
ev["discovery_started"] = started.get().toString()
ev["services_found"] = found.get().toString()
val verdict = when {
started.get() -> Verdict.SUPPORTED
else -> Verdict.INCONCLUSIVE
delay(10_000) // 4 s missed services that a longer listen catches (run-1 lesson)
var total = 0
var anyStarted = false
for ((label, type, r) in recorders) {
runCatching { nsd.stopServiceDiscovery(r) }
anyStarted = anyStarted || r.started
total += r.names.size
ev["$label.started"] = r.startFailCode?.let { "failed code=$it" } ?: r.started.toString()
ev["$label.found"] = r.names.size.toString()
if (r.names.isNotEmpty()) {
ev["$label.names"] = r.names.distinct().joinToString(", ").take(300)
}
ev["$label.type"] = type
}
val verdict = if (anyStarted) Verdict.SUPPORTED else Verdict.INCONCLUSIVE
ProbeResult.of(this@MulticastProbe, verdict,
"mDNS discovery ${if (started.get()) "ran" else "did not start"}; ${found.get()} service type(s) seen",
"mDNS discovery ${if (anyStarted) "ran" else "did not start"}; $total service(s)/type(s) seen across ${queries.size} queries",
ev, (System.nanoTime() - start) / 1_000_000)
} catch (e: Throwable) {
ev["error"] = e.message ?: e.javaClass.simpleName
ProbeResult.of(this@MulticastProbe, Verdict.ERROR, "mDNS probe failed", ev,
(System.nanoTime() - start) / 1_000_000)
} finally {
recorders.forEach { (_, _, r) -> runCatching { nsd.stopServiceDiscovery(r) } }
runCatching { lock?.release() }
}
}
@@ -28,6 +28,18 @@ object OsAbi {
const val IPV6_UNICAST_HOPS = 16
const val IPV6_PMTUDISC_PROBE = 3
// recv flags — not in OsConstants on any current API level
const val MSG_ERRQUEUE = 0x2000
const val MSG_DONTWAIT = 0x40
// struct sock_extended_err (uapi/linux/errqueue.h), fixed layout on all Android ABIs:
// u32 ee_errno; u8 ee_origin; u8 ee_type; u8 ee_code; u8 ee_pad; u32 ee_info; u32 ee_data;
// followed directly by the offender sockaddr (SO_EE_OFFENDER).
const val SOCK_EE_SIZE = 16
const val SO_EE_ORIGIN_ICMP = 2
const val ICMP_TIME_EXCEEDED = 11
const val ICMP_DEST_UNREACH = 3
/** Try setsockoptInt; return null on success, or the errno name on failure. */
fun trySetIntOpt(fd: FileDescriptor, level: Int, opt: Int, value: Int): String? =
try {
@@ -11,6 +11,7 @@ object ProbeRegistry {
IcmpProbe(v6 = true),
SockOptProbe(),
ErrqueueProbe(),
TracerouteProbe(),
MultiNetworkProbe(),
MulticastProbe(),
BleAdvertiseProbe(),
@@ -26,7 +26,9 @@ class ShizukuProbe : Probe {
"ip_neigh" to "ip neigh show",
"ip6_route" to "ip -6 route show table all",
"ip_addr" to "ip addr show",
"ip_monitor" to "timeout 2 ip monitor all || true",
// 5 s window (2 s caught nothing on a quiet net); ping the gateway concurrently so at
// least one NEIGH transition is provoked rather than hoping for ambient churn.
"ip_monitor" to "(ping -c 2 -W 1 \$(ip route show default | head -1 | cut -d' ' -f3) >/dev/null 2>&1 &); timeout 5 ip monitor all || true",
"dhcp_log" to "dumpsys network_stack 2>/dev/null | grep -iA2 -m 20 -e dhcp -e 'IpClient' || true",
"wifi_dump" to "dumpsys wifi 2>/dev/null | grep -iA1 -m 20 -e 'mDhcpResults' -e 'Gateway' -e 'DNS' || true",
)
@@ -0,0 +1,204 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.prober.probe
import android.content.Context
import android.system.Os
import android.system.OsConstants
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import java.io.FileDescriptor
import java.net.InetAddress
import java.net.InetSocketAddress
import java.nio.ByteBuffer
import java.nio.ByteOrder
/**
* The real thing that trace.errqueue_reachable only smoke-tests: a UDP traceroute reading ICMP
* time-exceeded off the socket error queue via Os.recvmsg(MSG_ERRQUEUE) — no root, no raw socket,
* no native code. If this returns actual hop addresses, the planned C-over-JNI shim is dead on
* this device; if the Os surface falls short, the evidence says exactly where.
*
* StructMsghdr/StructCmsghdr/recvmsg are reached via reflection (repo convention for uncertain
* OS paths): present since roughly API 34, absent before, and the probe must run — and report —
* on both.
*/
class TracerouteProbe(
private val targetHost: String = "1.1.1.1",
private val maxHops: Int = 6,
) : Probe {
override val id = "traceroute.udp4"
override val title = "UDP traceroute via MSG_ERRQUEUE (no root, no JNI)"
override val tier = Tier.APP
override suspend fun run(context: Context): ProbeResult = withContext(Dispatchers.IO) {
val start = System.nanoTime()
val ev = LinkedHashMap<String, String>()
var fd: FileDescriptor? = null
val api = ErrqueueApi.resolve()
ev["recvmsg_api"] = api?.describe() ?: "unavailable (StructMsghdr/recvmsg not on this API level)"
if (api == null) {
return@withContext ProbeResult.of(this@TracerouteProbe, Verdict.PARTIAL,
"Os.recvmsg surface missing — errqueue traceroute needs the native shim here",
ev, (System.nanoTime() - start) / 1_000_000)
}
try {
fd = Os.socket(OsConstants.AF_INET, OsConstants.SOCK_DGRAM, OsConstants.IPPROTO_UDP)
OsAbi.trySetIntOpt(fd, OsConstants.IPPROTO_IP, OsAbi.IP_RECVERR, 1)?.let {
ev["IP_RECVERR"] = "reject: $it"
return@withContext ProbeResult.of(this@TracerouteProbe, Verdict.UNSUPPORTED,
"IP_RECVERR rejected", ev, (System.nanoTime() - start) / 1_000_000)
}
val target = InetAddress.getByName(targetHost)
ev["target"] = target.hostAddress ?: targetHost
var hopsSeen = 0
var reachedTarget = false
for (ttl in 1..maxHops) {
OsAbi.trySetIntOpt(fd, OsConstants.IPPROTO_IP, OsAbi.IP_TTL, ttl)
val t0 = System.nanoTime()
val sent = runCatching {
Os.sendto(fd, ByteArray(32), 0, 32, 0, target, 33434 + ttl)
}
if (sent.isFailure) {
ev["hop.$ttl"] = "sendto failed: ${sent.exceptionOrNull()?.message}"
continue
}
// The ICMP error takes one RTT to come back; poll the errqueue briefly.
var hop: ErrqueueApi.ErrEvent? = null
val deadline = System.nanoTime() + 900_000_000L
while (hop == null && System.nanoTime() < deadline) {
hop = api.pollErrqueue(fd)
if (hop == null) delay(40)
}
val rttMs = (System.nanoTime() - t0) / 1_000_000
when {
hop == null -> ev["hop.$ttl"] = "* (no errqueue event in 900 ms)"
hop.parseError != null ->
ev["hop.$ttl"] = "errqueue event, cmsg unparsed: ${hop.parseError}"
else -> {
hopsSeen++
ev["hop.$ttl"] = "${hop.offender ?: "?"} icmp type=${hop.icmpType} " +
"origin=${hop.origin} ~${rttMs} ms"
if (hop.icmpType == OsAbi.ICMP_DEST_UNREACH) reachedTarget = true
}
}
if (reachedTarget) break
}
val verdict = when {
hopsSeen > 0 -> Verdict.SUPPORTED
else -> Verdict.PARTIAL // API present, sent fine, but no parseable events
}
val summary = when {
hopsSeen > 0 && reachedTarget ->
"$hopsSeen hop(s) via errqueue, target reached — no native shim needed"
hopsSeen > 0 -> "$hopsSeen hop(s) read via errqueue — no native shim needed"
else -> "recvmsg present but no errqueue events surfaced — shim still the fallback"
}
ProbeResult.of(this@TracerouteProbe, verdict, summary, ev,
(System.nanoTime() - start) / 1_000_000)
} catch (e: Throwable) {
ev["error"] = e.message ?: e.javaClass.simpleName
ProbeResult.of(this@TracerouteProbe, Verdict.ERROR, "traceroute probe failed", ev,
(System.nanoTime() - start) / 1_000_000)
} finally {
fd?.let { runCatching { Os.close(it) } }
}
}
}
/**
* Reflection facade over android.system.{StructMsghdr, StructCmsghdr, Os.recvmsg}.
* Resolved once; null if any piece is missing on this API level.
*/
private class ErrqueueApi private constructor(
private val msghdrCtor: java.lang.reflect.Constructor<*>,
private val recvmsg: java.lang.reflect.Method,
private val cmsgLevel: java.lang.reflect.Field,
private val cmsgType: java.lang.reflect.Field,
private val cmsgData: java.lang.reflect.Field,
private val msgControl: java.lang.reflect.Field,
) {
class ErrEvent(
val offender: String?,
val icmpType: Int,
val origin: Int,
val parseError: String? = null,
)
fun describe() = "StructMsghdr + Os.recvmsg via reflection"
/** One non-blocking MSG_ERRQUEUE read; null when the queue is empty. */
fun pollErrqueue(fd: FileDescriptor): ErrEvent? {
return try {
val iov = arrayOf(ByteBuffer.allocate(512))
// (SocketAddress msg_name, ByteBuffer[] msg_iov, StructCmsghdr[] msg_control, flags)
val msghdr = msghdrCtor.newInstance(
InetSocketAddress(0), iov, null, 0,
)
recvmsg.invoke(null, fd, msghdr, OsAbi.MSG_ERRQUEUE or OsAbi.MSG_DONTWAIT)
val control = msgControl.get(msghdr) as? Array<*>
?: return ErrEvent(null, -1, -1, "msg_control empty after recvmsg")
for (cmsg in control.filterNotNull()) {
val level = cmsgLevel.getInt(cmsg)
val type = cmsgType.getInt(cmsg)
if (level == OsConstants.IPPROTO_IP && type == OsAbi.IP_RECVERR) {
return parseSockExtendedErr(cmsgData.get(cmsg))
}
}
ErrEvent(null, -1, -1, "no IP_RECVERR cmsg among ${control.size}")
} catch (e: Throwable) {
val cause = (e as? java.lang.reflect.InvocationTargetException)?.cause ?: e
val msg = cause.message ?: cause.javaClass.simpleName
if ("EAGAIN" in msg || "EWOULDBLOCK" in msg) null // queue empty — not an error
else ErrEvent(null, -1, -1, msg)
}
}
/** cmsg_data = struct sock_extended_err + offender sockaddr_in (see OsAbi). */
private fun parseSockExtendedErr(data: Any?): ErrEvent {
val bytes: ByteArray = when (data) {
is ByteArray -> data
is ByteBuffer -> ByteArray(data.remaining()).also { data.duplicate().get(it) }
else -> return ErrEvent(null, -1, -1, "cmsg_data is ${data?.javaClass?.name}")
}
if (bytes.size < OsAbi.SOCK_EE_SIZE) {
return ErrEvent(null, -1, -1, "cmsg_data too short: ${bytes.size}")
}
val origin = bytes[4].toInt() and 0xFF
val icmpType = bytes[5].toInt() and 0xFF
// SO_EE_OFFENDER: sockaddr_in directly after the fixed struct; sin_addr at offset +4.
val offender = if (bytes.size >= OsAbi.SOCK_EE_SIZE + 8) {
val family = ByteBuffer.wrap(bytes, OsAbi.SOCK_EE_SIZE, 2)
.order(ByteOrder.nativeOrder()).short.toInt()
if (family == OsConstants.AF_INET) {
val a = bytes.copyOfRange(OsAbi.SOCK_EE_SIZE + 4, OsAbi.SOCK_EE_SIZE + 8)
InetAddress.getByAddress(a).hostAddress
} else null
} else null
return ErrEvent(offender, icmpType, origin)
}
companion object {
fun resolve(): ErrqueueApi? = runCatching {
val msghdrCls = Class.forName("android.system.StructMsghdr")
val cmsghdrCls = Class.forName("android.system.StructCmsghdr")
ErrqueueApi(
msghdrCtor = msghdrCls.constructors.first { it.parameterCount == 4 },
recvmsg = Os::class.java.getMethod(
"recvmsg", FileDescriptor::class.java, msghdrCls, Int::class.javaPrimitiveType,
),
cmsgLevel = cmsghdrCls.getField("cmsg_level"),
cmsgType = cmsghdrCls.getField("cmsg_type"),
cmsgData = cmsghdrCls.getField("cmsg_data"),
msgControl = msghdrCls.getField("msg_control"),
)
}.getOrNull()
}
}