Skip to content

Commit 7af9443

Browse files
committed
Fix mdns issue on hotspot network
1 parent 24ddd83 commit 7af9443

12 files changed

Lines changed: 433 additions & 76 deletions

File tree

app/build.gradle.kts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,9 +81,9 @@ android {
8181
else -> 0
8282
}
8383

84-
val vCode = 535
84+
val vCode = 538
8585
versionCode = vCode - singleAbiNum
86-
versionName = "3.0.12"
86+
versionName = "3.0.13"
8787

8888
ndk {
8989
//noinspection ChromeOsAbiSupport

app/src/main/AndroidManifest.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,7 @@
217217
android:name=".receivers.ServiceStopBroadcastReceiver"
218218
android:exported="true">
219219
<intent-filter>
220+
<action android:name="${applicationId}.action.START_HTTP_SERVER" />
220221
<action android:name="${applicationId}.action.STOP_HTTP_SERVER" />
221222
<action android:name="${applicationId}.action.STOP_SCREEN_MIRROR" />
222223
<action android:name="${applicationId}.action.REPOST_HTTP_NOTIFICATION" />

app/src/main/java/com/ismartcoding/plain/Constants.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ object Constants {
1414
const val MAX_MESSAGE_LENGTH = 2048 // Maximum length of a message in the chat
1515
const val TEXT_FILE_SUMMARY_LENGTH = 250
1616
const val AUTHORITY = "${BuildConfig.APPLICATION_ID}.provider"
17+
const val ACTION_START_HTTP_SERVER = "${BuildConfig.APPLICATION_ID}.action.START_HTTP_SERVER"
1718
const val ACTION_STOP_HTTP_SERVER = "${BuildConfig.APPLICATION_ID}.action.STOP_HTTP_SERVER"
1819
const val ACTION_STOP_SCREEN_MIRROR = "${BuildConfig.APPLICATION_ID}.action.STOP_SCREEN_MIRROR"
1920
const val ACTION_PEER_CHAT_REPLY = "${BuildConfig.APPLICATION_ID}.action.PEER_CHAT_REPLY"

app/src/main/java/com/ismartcoding/plain/receivers/ServiceStopBroadcastReceiver.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ class ServiceStopBroadcastReceiver : BroadcastReceiver() {
1818
intent: Intent,
1919
) {
2020
when (intent.action) {
21+
Constants.ACTION_START_HTTP_SERVER -> {
22+
coIO { WebPreference.putAsync(context, true) }
23+
ContextCompat.startForegroundService(context, Intent(context, HttpServerService::class.java))
24+
}
2125
Constants.ACTION_STOP_HTTP_SERVER -> coIO {
2226
WebPreference.putAsync(context, false)
2327
HttpServerManager.stopServiceAsync(context)

app/src/main/java/com/ismartcoding/plain/web/HttpModule.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -934,7 +934,7 @@ object HttpModule {
934934
if (decryptedBytes != null) {
935935
LogCat.d("ws: add session ${session.id}, ts: ${decryptedBytes.decodeToString()}")
936936
HttpServerManager.wsSessions.add(session)
937-
HttpServerManager.wsSessionCount.value = HttpServerManager.wsSessions.size
937+
HttpServerManager.wsSessionCount.value = HttpServerManager.wsSessions.distinctBy { it.clientId }.size
938938
} else {
939939
LogCat.d("ws: invalid_request")
940940
close(CloseReason(CloseReason.Codes.TRY_AGAIN_LATER, "invalid_request"))
@@ -954,7 +954,7 @@ object HttpModule {
954954
} finally {
955955
LogCat.d("ws: remove session ${session.id}")
956956
HttpServerManager.wsSessions.removeIf { it.id == session.id }
957-
HttpServerManager.wsSessionCount.value = HttpServerManager.wsSessions.size
957+
HttpServerManager.wsSessionCount.value = HttpServerManager.wsSessions.distinctBy { it.clientId }.size
958958
}
959959
}
960960
}

app/src/main/java/com/ismartcoding/plain/web/HttpServerManager.kt

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -320,19 +320,18 @@ object HttpServerManager {
320320
// When there are active sessions/clients we update frequently, otherwise we back off.
321321
val activeIntervalMs = 5_000L
322322
val idleIntervalMs = 60_000L
323-
val activeWindowMs = 5_000L
324-
323+
var lastSyncTs = 0L
325324
clientTsJob = coIO {
326325
while (kotlinx.coroutines.currentCoroutineContext().isActive) {
327-
val now = System.currentTimeMillis()
328326
val updates =
329327
clientRequestTs
330-
.filter { it.value + activeWindowMs > now }
328+
.filter { it.value > lastSyncTs }
331329
.map { SessionClientTsUpdate(it.key, Instant.fromEpochMilliseconds(it.value)) }
332-
333330
if (updates.isNotEmpty()) {
331+
val maxTsInThisBatch = updates.maxOf { it.updatedAt.toEpochMilliseconds() }
334332
runCatching {
335333
AppDatabase.instance.sessionDao().updateTs(updates)
334+
lastSyncTs = maxTsInThisBatch
336335
}.onFailure {
337336
LogCat.e("Failed to update client session timestamps: ${it.message}")
338337
}

app/src/main/java/com/ismartcoding/plain/web/MdnsHostResponder.kt

Lines changed: 74 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@ package com.ismartcoding.plain.web
22

33
import android.content.Context
44
import android.net.wifi.WifiManager
5-
import com.ismartcoding.lib.helpers.NetworkHelper
65
import com.ismartcoding.lib.logcat.LogCat
76
import java.net.DatagramPacket
7+
import java.net.DatagramSocket
88
import java.net.Inet4Address
99
import java.net.InetAddress
1010
import java.net.InetSocketAddress
@@ -13,117 +13,124 @@ import java.net.NetworkInterface
1313
import 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
*/
1932
object 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"
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package com.ismartcoding.plain.web
2+
3+
import android.content.BroadcastReceiver
4+
import android.content.Context
5+
import android.content.Intent
6+
import android.content.IntentFilter
7+
import android.os.Build
8+
import com.ismartcoding.lib.logcat.LogCat
9+
10+
/**
11+
* Listens for Android Wi-Fi AP (hotspot) state changes and invokes [onStateChanged].
12+
*
13+
* ConnectivityManager.NetworkCallback does not fire on the host device when hotspot
14+
* is toggled (the tethered network belongs to the clients, not the host). This watcher
15+
* fills the gap so MdnsReregistrar can restart the responder on the correct interfaces
16+
* whenever the hotspot interface (ap0 / wlan1) appears or disappears.
17+
*
18+
* The action and state constants are `@hide` in the public SDK; raw values are used
19+
* directly to avoid compilation errors on all target SDK versions.
20+
*/
21+
internal class MdnsHotspotWatcher(
22+
private val context: Context,
23+
private val onStateChanged: () -> Unit,
24+
) {
25+
// android.net.wifi.WifiManager constants (all @hide; raw values are stable since API 1)
26+
private val apStateChangedAction = "android.net.wifi.WIFI_AP_STATE_CHANGED"
27+
private val extraApState = "wifi_state"
28+
private val apStateEnabled = 13
29+
private val apStateDisabled = 11
30+
31+
private var receiver: BroadcastReceiver? = null
32+
33+
fun start() {
34+
if (receiver != null) return
35+
val r = object : BroadcastReceiver() {
36+
override fun onReceive(ctx: Context, intent: Intent) {
37+
val s = intent.getIntExtra(extraApState, -1)
38+
if (s == apStateEnabled || s == apStateDisabled) onStateChanged()
39+
}
40+
}
41+
val filter = IntentFilter(apStateChangedAction)
42+
runCatching {
43+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
44+
context.registerReceiver(r, filter, Context.RECEIVER_NOT_EXPORTED)
45+
} else {
46+
@Suppress("UnspecifiedRegisterReceiverFlag")
47+
context.registerReceiver(r, filter)
48+
}
49+
receiver = r
50+
LogCat.d("MdnsHotspotWatcher started")
51+
}.onFailure { LogCat.e("MdnsHotspotWatcher start failed: ${it.message}") }
52+
}
53+
54+
fun stop() {
55+
val r = receiver ?: return
56+
receiver = null
57+
runCatching { context.unregisterReceiver(r) }
58+
.onFailure { LogCat.e("MdnsHotspotWatcher stop failed: ${it.message}") }
59+
}
60+
}

0 commit comments

Comments
 (0)