@@ -2,9 +2,9 @@ package com.ismartcoding.plain.web
22
33import android.content.Context
44import android.net.wifi.WifiManager
5- import com.ismartcoding.lib.helpers.NetworkHelper
65import com.ismartcoding.lib.logcat.LogCat
76import java.net.DatagramPacket
7+ import java.net.DatagramSocket
88import java.net.Inet4Address
99import java.net.InetAddress
1010import java.net.InetSocketAddress
@@ -13,117 +13,124 @@ import java.net.NetworkInterface
1313import java.net.SocketTimeoutException
1414
1515/* *
16- * Lightweight mDNS responder for hostname-to-IPv4 resolution (A record).
17- * Keeps local hostname pingable (e.g. plainapp.local) without external libs.
16+ * Lightweight mDNS responder — single receive socket, per-packet unicast reply.
17+ *
18+ * RECEIVE: One MulticastSocket bound to 0.0.0.0:5353 joins 224.0.0.251 on every
19+ * valid LAN interface (wlan0 Wi-Fi, ap0/wlan1 hotspot, both when active).
20+ * A single socket avoids the Linux SO_REUSEPORT limitation.
21+ *
22+ * SEND: For each query the candidate interface list is re-fetched fresh (never
23+ * cached), then a throwaway DatagramSocket bound to localIp:0 sends a unicast
24+ * reply to the querier's source IP. Binding to a specific local IP forces the
25+ * kernel to route the packet via the interface that owns localIp — no
26+ * IP_MULTICAST_IF mutation on the shared receive socket is needed.
27+ *
28+ * Restart lifecycle: MdnsReregistrar (ConnectivityManager) + MdnsHotspotWatcher
29+ * (WIFI_AP_STATE_CHANGED) recreate the socket whenever the active interface set
30+ * changes, keeping receive memberships current.
1831 */
1932object MdnsHostResponder {
2033 private const val MDNS_GROUP = " 224.0.0.251"
2134 private const val MDNS_PORT = 5353
2235
23- @Volatile
24- private var hostname = " plainapp.local"
36+ @Volatile private var hostname = " plainapp.local"
2537
26- @Volatile
27- private var worker: Thread ? = null
28-
29- @Volatile
38+ private val stateLock = Any ()
3039 private var socket: MulticastSocket ? = null
31-
32- @Volatile
40+ private var worker: Thread ? = null
3341 private var multicastLock: WifiManager .MulticastLock ? = null
3442
3543 fun start (context : Context , mdnsHostname : String ): Boolean {
3644 val normalized = normalizeHostname(mdnsHostname)
3745 if (normalized.isEmpty()) {
38- LogCat .e(" mDNS responder start skipped: empty hostname" )
46+ LogCat .e(" mDNS start skipped: empty hostname" )
3947 return false
4048 }
41-
4249 stop()
4350 hostname = normalized
4451
45- val group = InetAddress .getByName(MDNS_GROUP )
46- val lock = acquireMulticastLock(context)
52+ val candidates = candidateInterfaces()
53+ if (candidates.isEmpty()) {
54+ LogCat .e(" mDNS: no candidate interfaces found" )
55+ return false
56+ }
4757
48- return runCatching {
49- val s = MulticastSocket (null ).apply {
50- reuseAddress = true
51- soTimeout = 1000
52- bind(InetSocketAddress (MDNS_PORT ))
53- timeToLive = 255
54- joinGroup(group)
58+ val multicastGroup = InetAddress .getByName(MDNS_GROUP )
59+ val groupSockAddr = InetSocketAddress (multicastGroup, MDNS_PORT )
60+ synchronized(stateLock) {
61+ val lock = acquireMulticastLock(context)
62+ val s = runCatching {
63+ MulticastSocket (null ).apply {
64+ reuseAddress = true
65+ soTimeout = 1000
66+ bind(InetSocketAddress (MDNS_PORT ))
67+ for ((iface, ip) in candidates) {
68+ runCatching { joinGroup(groupSockAddr, iface) }
69+ .onSuccess { LogCat .d(" mDNS joined ${iface.name} (${ip.hostAddress} )" ) }
70+ .onFailure { LogCat .e(" mDNS joinGroup ${iface.name} : ${it.message} " ) }
71+ }
72+ }
73+ }.getOrElse {
74+ lock?.let { l -> runCatching { l.release() } }
75+ LogCat .e(" mDNS socket create failed: ${it.message} " )
76+ return false
5577 }
5678 socket = s
5779 multicastLock = lock
58-
59- worker = Thread {
60- runLoop(s, group)
61- }.apply {
80+ worker = Thread { runLoop(s) }.apply {
6281 name = " plain-mdns-responder"
6382 isDaemon = true
6483 start()
6584 }
66- LogCat .d(" mDNS responder started for $hostname " )
67- true
68- }.getOrElse {
69- lock?.let { l -> runCatching { l.release() } }
70- LogCat .e(" Failed to start mDNS responder: ${it.message} " )
71- false
7285 }
86+ LogCat .d(" mDNS responder started for $hostname on ${candidates.size} interface(s)" )
87+ return true
7388 }
7489
7590 fun stop () {
76- val t = worker
77- worker = null
78-
79- val s = socket
80- socket = null
81- runCatching { s?.close() }
82-
83- runCatching { t?.join(300 ) }
84-
85- multicastLock?.let { lock ->
86- runCatching {
87- if (lock.isHeld) lock.release()
88- }
91+ synchronized(stateLock) {
92+ val t = worker; worker = null
93+ val s = socket; socket = null
94+ runCatching { s?.close() }
95+ runCatching { t?.join(300 ) }
96+ multicastLock?.let { ml -> runCatching { if (ml.isHeld) ml.release() } }
97+ multicastLock = null
8998 }
90- multicastLock = null
9199 }
92100
93- private fun runLoop (socket : MulticastSocket , group : InetAddress ) {
94- val buffer = ByteArray (1500 )
95- while (Thread .currentThread().isAlive ) {
96- val packet = DatagramPacket (buffer, buffer .size)
101+ private fun runLoop (s : MulticastSocket ) {
102+ val buf = ByteArray (1500 )
103+ while (! s.isClosed ) {
104+ val packet = DatagramPacket (buf, buf .size)
97105 try {
98- socket.receive(packet)
106+ s.receive(packet)
107+ val senderIp = packet.address as ? Inet4Address ? : continue
108+ val fresh = candidateInterfaces()
109+ if (fresh.isEmpty()) continue
110+ val (_, localIp) = findResponseIface(senderIp, fresh)
99111 val response = MdnsPacketCodec .buildResponseIfMatch(
100112 query = packet.data.copyOf(packet.length),
101113 hostname = hostname,
102- ips = getResponderIps(),
103- )
104- if (response != null ) {
105- socket.send(DatagramPacket (response, response.size, group, MDNS_PORT ))
106- }
114+ ips = listOf (localIp),
115+ ) ? : continue
116+ sendUnicast(response, localIp, senderIp)
107117 } catch (_: SocketTimeoutException ) {
108- // timeout keeps the thread responsive to stop()
118+ // expected — keeps thread responsive to socket close
109119 } catch (_: Exception ) {
110- if (worker == null ) break
120+ if (s.isClosed ) break
111121 }
112122 }
113123 }
114124
115- private fun getResponderIps (): List <Inet4Address > {
116- return NetworkHelper .getDeviceIP4s()
117- .mapNotNull { ip -> runCatching { InetAddress .getByName(ip) }.getOrNull() }
118- .filterIsInstance<Inet4Address >()
119- .filter { ip ->
120- val ni = runCatching { NetworkInterface .getByInetAddress(ip) }.getOrNull()
121- ni != null && ! NetworkHelper .isVpnInterface(ni.name) && ! ip.isLoopbackAddress
125+ internal fun sendUnicast (response : ByteArray , localIp : Inet4Address , dest : Inet4Address ) {
126+ runCatching {
127+ DatagramSocket (InetSocketAddress (localIp, 0 )).use { ds ->
128+ ds.send(DatagramPacket (response, response.size, dest, MDNS_PORT ))
122129 }
123- .distinctBy { it.hostAddress }
130+ }.onFailure { LogCat .e( " mDNS send to ${dest.hostAddress} : ${ it.message} " ) }
124131 }
125132
126- private fun normalizeHostname (value : String ): String {
133+ internal fun normalizeHostname (value : String ): String {
127134 val trimmed = value.trim().trim(' .' ).lowercase()
128135 if (trimmed.isEmpty()) return " "
129136 return if (trimmed.endsWith(" .local" )) trimmed else " $trimmed .local"
0 commit comments