Skip to content

Commit 1f702f8

Browse files
yinnhoclaude
andcommitted
Fix device discovery failures and re-initialization bugs (#1)
- Set SO_REUSEADDR before binding SSDP port 1900, fall back to an ephemeral port when it is held by another process - Run the SSDP response listener on a dedicated daemon thread started before M-SEARCH sends; read timeouts no longer terminate the loop - Acquire a WifiManager.MulticastLock during init so multicast SSDP traffic is not filtered by the Wi-Fi stack - Recreate coroutine scopes and the CacheManager on re-init so full cleanup() -> init() cycles work without restarting the process - Default the control URL port to 80 when the device location URL has no explicit port (URL.port returns -1) - Remove phantom VideoSelectorActivity declaration from library manifest - Drop unused okhttp/gson dependencies Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 5d9e0b1 commit 1f702f8

7 files changed

Lines changed: 125 additions & 55 deletions

File tree

app/build.gradle.kts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -86,12 +86,6 @@ dependencies {
8686
// 添加RecyclerView支持,用于本地视频选择器
8787
implementation(libs.androidx.recyclerview)
8888

89-
// Network related
90-
implementation(libs.okhttp)
91-
92-
// JSON parsing
93-
implementation(libs.gson)
94-
9589
// Local file server for local casting
9690
implementation(libs.nanohttpd)
9791

app/src/main/AndroidManifest.xml

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,6 @@
1212
android:allowBackup="true"
1313
android:supportsRtl="true"
1414
android:usesCleartextTraffic="true"
15-
tools:targetApi="31">
16-
17-
<!-- 库内置的本地视频选择器 -->
18-
<activity
19-
android:name=".internal.VideoSelectorActivity"
20-
android:exported="false"
21-
android:theme="@style/Theme.AppCompat.Light" />
22-
23-
</application>
15+
tools:targetApi="31" />
2416

25-
</manifest>
17+
</manifest>

app/src/main/java/com/yinnho/upnpcast/internal/core/CoreManager.kt

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.yinnho.upnpcast.internal.core
22

33
import android.content.Context
4+
import android.net.wifi.WifiManager
45
import android.util.Log
56
import com.yinnho.upnpcast.DLNACast.Device
67

@@ -29,10 +30,11 @@ internal class CoreManager {
2930
companion object {
3031
private const val TAG = "CoreManager"
3132

32-
private val cacheManager = CacheManager(ScopeManager.appScope)
33-
33+
private var cacheManager = CacheManager(ScopeManager.appScope)
34+
3435
private val devices = ConcurrentHashMap<String, RemoteDevice>()
3536
private var ssdpDiscovery: SsdpDeviceDiscovery? = null
37+
private var multicastLock: WifiManager.MulticastLock? = null
3638
@Volatile
3739
private var currentDevice: RemoteDevice? = null
3840
private var contextRef: WeakReference<Context>? = null
@@ -52,14 +54,45 @@ internal class CoreManager {
5254
* Initialize core manager with application context
5355
*/
5456
fun init(context: Context) {
55-
contextRef = WeakReference(context.applicationContext)
57+
val appContext = context.applicationContext
58+
contextRef = WeakReference(appContext)
59+
acquireMulticastLock(appContext)
60+
// Bind the cache manager to the current scopes so a full
61+
// cleanup() -> init() cycle never leaves it on a cancelled scope
62+
cacheManager = CacheManager(ScopeManager.appScope)
5663
ssdpDiscovery = SsdpDeviceDiscovery(
5764
onDeviceFound = { device ->
5865
addDevice(device)
5966
}
6067
)
6168
Log.i(TAG, "CoreManager initialized")
6269
}
70+
71+
/**
72+
* Hold a multicast lock while active: Android Wi-Fi stacks filter
73+
* multicast traffic by default, which silently drops SSDP NOTIFY
74+
* messages and M-SEARCH responses routed as multicast.
75+
*/
76+
private fun acquireMulticastLock(context: Context) {
77+
releaseMulticastLock()
78+
val wifi = context.getSystemService(Context.WIFI_SERVICE) as? WifiManager ?: run {
79+
Log.w(TAG, "WifiManager unavailable, multicast reception may be filtered")
80+
return
81+
}
82+
multicastLock = wifi.createMulticastLock("UPnPCast").apply {
83+
setReferenceCounted(false)
84+
acquire()
85+
}
86+
}
87+
88+
private fun releaseMulticastLock() {
89+
try {
90+
multicastLock?.takeIf { it.isHeld }?.release()
91+
} catch (e: Exception) {
92+
Log.w(TAG, "Failed to release multicast lock: ${e.message}")
93+
}
94+
multicastLock = null
95+
}
6396

6497
/**
6598
* Search for available DLNA devices
@@ -324,6 +357,7 @@ internal class CoreManager {
324357

325358
ssdpDiscovery?.shutdown()
326359
ssdpDiscovery = null
360+
releaseMulticastLock()
327361
devices.clear()
328362
currentDevice = null
329363
contextRef = null
Lines changed: 34 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,34 @@
1-
package com.yinnho.upnpcast.internal.core
2-
3-
import kotlinx.coroutines.*
4-
5-
/**
6-
* Unified coroutine scope management - minimal implementation
7-
*/
8-
internal object ScopeManager {
9-
10-
val appScope = CoroutineScope(Dispatchers.IO + SupervisorJob() + CoroutineName("UPnPCast"))
11-
val uiScope = CoroutineScope(Dispatchers.Main + SupervisorJob() + CoroutineName("UPnPCast-UI"))
12-
13-
fun cleanup() {
14-
appScope.cancel()
15-
uiScope.cancel()
16-
}
17-
}
1+
package com.yinnho.upnpcast.internal.core
2+
3+
import kotlinx.coroutines.*
4+
5+
/**
6+
* Unified coroutine scope management
7+
*
8+
* cleanup() cancels the current scopes and recreates fresh ones so that a
9+
* subsequent init() cycle works without restarting the process.
10+
*/
11+
internal object ScopeManager {
12+
13+
@Volatile
14+
private var _appScope: CoroutineScope = createAppScope()
15+
16+
@Volatile
17+
private var _uiScope: CoroutineScope = createUiScope()
18+
19+
val appScope: CoroutineScope get() = _appScope
20+
val uiScope: CoroutineScope get() = _uiScope
21+
22+
private fun createAppScope() =
23+
CoroutineScope(Dispatchers.IO + SupervisorJob() + CoroutineName("UPnPCast"))
24+
25+
private fun createUiScope() =
26+
CoroutineScope(Dispatchers.Main + SupervisorJob() + CoroutineName("UPnPCast-UI"))
27+
28+
fun cleanup() {
29+
_appScope.cancel()
30+
_uiScope.cancel()
31+
_appScope = createAppScope()
32+
_uiScope = createUiScope()
33+
}
34+
}

app/src/main/java/com/yinnho/upnpcast/internal/discovery/SsdpDeviceDiscovery.kt

Lines changed: 48 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import kotlinx.coroutines.Dispatchers
77
import kotlinx.coroutines.SupervisorJob
88
import kotlinx.coroutines.cancel
99
import kotlinx.coroutines.launch
10+
import java.net.BindException
1011
import java.net.DatagramPacket
1112
import java.net.InetAddress
1213
import java.net.InetSocketAddress
@@ -46,6 +47,7 @@ internal class SsdpDeviceDiscovery(
4647
}
4748

4849
private val isShutdown = AtomicBoolean(false)
50+
private val isListening = AtomicBoolean(false)
4951
private var socket: MulticastSocket? = null
5052
private val multicastGroup by lazy { InetSocketAddress(MULTICAST_ADDRESS, MULTICAST_PORT) }
5153
private val descriptionParser = DeviceDescriptionParser()
@@ -60,18 +62,34 @@ internal class SsdpDeviceDiscovery(
6062

6163
/**
6264
* Initialize Socket
65+
*
66+
* reuseAddress must be set before binding. Prefer port 1900 so that
67+
* multicast NOTIFY messages can be received; fall back to an ephemeral
68+
* port when 1900 is held by another process (unicast M-SEARCH responses
69+
* still reach an ephemeral source port).
6370
*/
6471
private fun initializeSocket() {
6572
if (socket != null) return
66-
73+
74+
val newSocket = MulticastSocket(null)
6775
try {
68-
socket = MulticastSocket(MULTICAST_PORT).apply {
69-
reuseAddress = true
70-
timeToLive = 4
71-
joinGroup(multicastGroup, null)
76+
newSocket.reuseAddress = true
77+
try {
78+
newSocket.bind(InetSocketAddress(MULTICAST_PORT))
79+
} catch (e: BindException) {
80+
Log.w(tag, "Port $MULTICAST_PORT unavailable, binding ephemeral port (NOTIFY reception disabled)")
81+
newSocket.bind(InetSocketAddress(0))
7282
}
83+
newSocket.timeToLive = 4
84+
newSocket.joinGroup(multicastGroup, null)
85+
socket = newSocket
7386
} catch (e: Exception) {
7487
Log.e(tag, "Failed to initialize socket", e)
88+
try {
89+
newSocket.close()
90+
} catch (ignore: Exception) {
91+
// Socket already closed
92+
}
7593
}
7694
}
7795

@@ -97,17 +115,21 @@ internal class SsdpDeviceDiscovery(
97115

98116
/**
99117
* Start SSDP device discovery
118+
*
119+
* The listener runs on its own daemon thread and is started before the
120+
* M-SEARCH requests are sent, so no response can arrive while nobody is
121+
* listening, and repeated searches never queue behind the listener loop.
100122
*/
101123
fun startSearch() {
102124
if (isShutdown.get()) return
103125

104126
initializeSocket()
127+
startResponseListener()
105128

106129
executor.execute {
107130
SEARCH_TARGETS.forEach { target ->
108131
sendSearchRequest(target)
109132
}
110-
startResponseListener()
111133
}
112134
}
113135

@@ -116,7 +138,7 @@ internal class SsdpDeviceDiscovery(
116138
*/
117139
private fun sendSearchRequest(target: String) {
118140
if (isShutdown.get()) return
119-
141+
120142
try {
121143
val message = """
122144
M-SEARCH * HTTP/1.1
@@ -125,48 +147,58 @@ internal class SsdpDeviceDiscovery(
125147
MX: 3
126148
ST: $target
127149
USER-AGENT: UPnPCast/1.0
128-
150+
129151
""".trimIndent()
130-
152+
131153
val bytes = message.toByteArray(Charsets.UTF_8)
132154
val packet = DatagramPacket(
133155
bytes,
134156
bytes.size,
135157
InetAddress.getByName(MULTICAST_ADDRESS),
136158
MULTICAST_PORT
137159
)
138-
160+
139161
socket?.send(packet)
140162
} catch (e: Exception) {
141163
Log.e(tag, "Failed to send search request: $target", e)
142164
}
143165
}
144166

145167
/**
146-
* Start response listener
168+
* Start response listener on a dedicated daemon thread
169+
*
170+
* Read timeouts are expected while waiting for packets and must not
171+
* terminate the loop; it exits only on shutdown or a real socket error.
147172
*/
148173
private fun startResponseListener() {
149174
if (isShutdown.get()) return
150-
151-
executor.execute {
175+
if (!isListening.compareAndSet(false, true)) return
176+
177+
val listener = Thread({
152178
try {
153179
val buffer = ByteArray(4096)
154180
val packet = DatagramPacket(buffer, buffer.size)
155-
181+
156182
socket?.soTimeout = SOCKET_TIMEOUT_MS
157-
183+
158184
while (!isShutdown.get()) {
159185
try {
160186
socket?.receive(packet)
161187
processResponse(packet)
188+
} catch (e: java.net.SocketTimeoutException) {
189+
// Expected between packets; keep listening
162190
} catch (e: Exception) {
163191
break
164192
}
165193
}
166194
} catch (e: Exception) {
167195
Log.e(tag, "Response listener failed", e)
196+
} finally {
197+
isListening.set(false)
168198
}
169-
}
199+
}, "SSDP-Listener")
200+
listener.isDaemon = true
201+
listener.start()
170202
}
171203

172204
/**

app/src/main/java/com/yinnho/upnpcast/internal/media/DlnaMediaController.kt

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,8 @@ internal class DlnaMediaController(private val device: RemoteDevice) {
8787
val location = device.details["location"] as? String
8888
if (location != null) {
8989
val url = java.net.URL(location)
90-
val baseUrl = "http://${device.address}:${url.port}"
90+
val port = url.port.takeIf { it > 0 } ?: 80
91+
val baseUrl = "http://${device.address}:$port"
9192
controlUrl = if (controlUrl.startsWith("/")) {
9293
"$baseUrl$controlUrl"
9394
} else {
@@ -105,14 +106,14 @@ internal class DlnaMediaController(private val device: RemoteDevice) {
105106
val port = if (location != null) {
106107
try {
107108
val url = java.net.URL(location)
108-
url.port
109+
url.port.takeIf { it > 0 } ?: 80
109110
} catch (e: Exception) {
110111
return null
111112
}
112113
} else {
113114
return null
114115
}
115-
116+
116117
"http://${device.address}:$port/$defaultPath"
117118
} catch (e: Exception) {
118119
null

gradlew

100644100755
File mode changed.

0 commit comments

Comments
 (0)