Skip to content

Commit a3b2f6f

Browse files
committed
fix(webrtc): fix LAN ICE connection stall and wire camera WebRTC signaling lifecycle
1 parent 7e8847a commit a3b2f6f

7 files changed

Lines changed: 178 additions & 34 deletions

File tree

app/src/main/java/io/github/iokkai/ocularnode/server/CameraHttpServer.kt

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ class CameraHttpServer(
9292
var onActiveClientsChanged: ((Int) -> Unit)? = null
9393
var onControlCommand: ((String, String) -> Unit)? = null
9494
var onBatchConfigUpdated: ((String) -> Unit)? = null
95+
var onWebRtcSignalReceived: ((String) -> Boolean)? = null
9596

9697
val apiHandler = CameraApiHandler(
9798
context = context,
@@ -271,6 +272,22 @@ class CameraHttpServer(
271272
return
272273
}
273274

275+
// 1.5 WebRTC LAN 信令傳輸 (E2EE 加密 Payload)
276+
cleanPath == "/api/webrtc/signal" -> {
277+
if (method == "POST") {
278+
val success = onWebRtcSignalReceived?.invoke(body) ?: false
279+
if (success) {
280+
apiHandler.sendJsonResponse(output, 200, "{\"status\":\"ok\"}")
281+
} else {
282+
apiHandler.sendJsonResponse(output, 400, "{\"status\":\"error\",\"message\":\"Failed to process WebRTC signal\"}")
283+
}
284+
} else {
285+
apiHandler.sendJsonResponse(output, 405, "{\"status\":\"error\",\"message\":\"Method Not Allowed\"}")
286+
}
287+
socket.close()
288+
return
289+
}
290+
274291
// 2. MJPEG 即時影像串流 (需授權)
275292
path.startsWith("/mjpeg") || path.startsWith("/stream") || path.startsWith("/live") -> {
276293
if (!settingsManager.isMjpegStreamEnabled) {

app/src/main/java/io/github/iokkai/ocularnode/service/CameraStreamService.kt

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@ import io.github.iokkai.ocularnode.data.SettingsManager
3131
import io.github.iokkai.ocularnode.server.CameraHttpServer
3232
import io.github.iokkai.ocularnode.util.NetworkUtils
3333
import io.github.iokkai.ocularnode.util.TelegramNotifier
34+
import io.github.iokkai.ocularnode.webrtc.WebRtcSessionManager
35+
import io.github.iokkai.ocularnode.webrtc.crypto.AesGcmCipher
36+
import io.github.iokkai.ocularnode.webrtc.crypto.PairingSecretManager
37+
import io.github.iokkai.ocularnode.webrtc.server.WebRtcCameraServerManager
38+
import io.github.iokkai.ocularnode.webrtc.signaling.SignalingPayload
39+
import io.github.iokkai.ocularnode.webrtc.signaling.SmartSignalingRouter
3440
import kotlinx.coroutines.CoroutineScope
3541
import kotlinx.coroutines.Dispatchers
3642
import kotlinx.coroutines.SupervisorJob
@@ -81,6 +87,10 @@ class CameraStreamService : Service(), LifecycleOwner {
8187
private lateinit var motionPipelineManager: MotionPipelineManager
8288
private lateinit var remoteCommandHandler: RemoteCommandHandler
8389

90+
var webRtcServerManager: WebRtcCameraServerManager? = null
91+
private set
92+
private var webRtcRouter: SmartSignalingRouter? = null
93+
8494
private val _serviceStatus = MutableStateFlow("Initializing...")
8595
val serviceStatus: StateFlow<String> = _serviceStatus.asStateFlow()
8696

@@ -252,6 +262,20 @@ class CameraStreamService : Service(), LifecycleOwner {
252262
onBatchConfigUpdated = { configJsonStr ->
253263
remoteCommandHandler.handleBatchConfigUpdate(configJsonStr)
254264
}
265+
266+
onWebRtcSignalReceived = { encryptedBody ->
267+
try {
268+
val pairingSecretMgr = PairingSecretManager.getInstance(this@CameraStreamService)
269+
val secret = pairingSecretMgr.getOrCreateDeviceSecret()
270+
val decrypted = AesGcmCipher.decrypt(encryptedBody, secret)
271+
val payload = SignalingPayload.fromJson(decrypted)
272+
webRtcServerManager?.handleIncomingSignal(payload)
273+
true
274+
} catch (e: Exception) {
275+
Log.e("CameraStreamService", "Failed to process WebRTC signal payload: ${e.message}")
276+
false
277+
}
278+
}
255279
}
256280

257281
// Apply initial mode configuration
@@ -300,6 +324,43 @@ class CameraStreamService : Service(), LifecycleOwner {
300324
fun startServer() {
301325
cameraHelper.startCamera(this)
302326
httpServer.start(serviceScope)
327+
328+
try {
329+
val pairingSecretMgr = PairingSecretManager.getInstance(this)
330+
val deviceId = pairingSecretMgr.getOrCreateDeviceId()
331+
val secret = pairingSecretMgr.getOrCreateDeviceSecret()
332+
333+
val router = SmartSignalingRouter(
334+
channelKey = deviceId,
335+
secret = secret
336+
)
337+
webRtcRouter = router
338+
339+
val serverMgr = WebRtcCameraServerManager(
340+
context = this,
341+
sessionManager = WebRtcSessionManager.getInstance(this),
342+
scope = serviceScope,
343+
deviceId = deviceId,
344+
secret = secret,
345+
onDataCommandReceived = { cmd ->
346+
val legacy = cmd.toLegacyPair()
347+
remoteCommandHandler.handleRemoteControl(legacy.first, legacy.second)
348+
}
349+
)
350+
webRtcServerManager = serverMgr
351+
serverMgr.start(
352+
router = router,
353+
width = 1280,
354+
height = 720,
355+
fps = 30,
356+
useFrontCamera = (cameraHelper.lensFacing == CameraSelector.LENS_FACING_FRONT),
357+
thermalThrottleFlow = isThermalThrottled
358+
)
359+
Log.i("CameraStreamService", "WebRTC Camera Server & Signaling successfully started for device $deviceId")
360+
} catch (e: Exception) {
361+
Log.e("CameraStreamService", "Error starting WebRTC Camera Server", e)
362+
}
363+
303364
io.github.iokkai.ocularnode.util.NodeDiscoveryManager.startResponder(
304365
context = this,
305366
deviceName = settingsManager.cameraDeviceName,
@@ -451,6 +512,13 @@ class CameraStreamService : Service(), LifecycleOwner {
451512
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
452513
notificationManager?.cancel(1001)
453514

515+
try {
516+
webRtcServerManager?.stop()
517+
webRtcRouter?.close()
518+
} catch (e: Exception) {
519+
Log.e("CameraStreamService", "Error stopping WebRTC server", e)
520+
}
521+
454522
httpServer.stop()
455523
eventVideoRecorder?.release()
456524
cameraHelper.release()

app/src/main/java/io/github/iokkai/ocularnode/ui/viewer/LiveMonitorScreen.kt

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -298,15 +298,19 @@ fun LiveMonitorScreen(
298298

299299
Row(verticalAlignment = Alignment.CenterVertically) {
300300
val (statusBg, statusText) = when {
301-
isWebRtcRoaming -> AppWarning to "⏳ 漫遊重連中"
302-
isWebRtcConnecting -> AppPrimary to "🔄 ICE 連線中"
303-
isWebRtcConnected -> {
301+
isWebRtcConnected && remoteVideoTrack != null -> {
304302
when {
305303
!camera.ipv6Address.isNullOrBlank() -> AppSuccess to "⚡ P2P (IPv6)"
306304
viewModel.settingsManager.customTurnServerUrl.isNotBlank() -> AppPrimary to "🛡️ TURN 中繼"
307305
else -> AppSuccess to "⚡ P2P (STUN)"
308306
}
309307
}
308+
isWebRtcRoaming -> AppWarning to "⏳ 漫遊重連中"
309+
frame != null && isConnected -> {
310+
if (isWebRtcConnecting) AppSecondary to "📡 MJPEG (${fps}FPS) 🔄 P2P..."
311+
else AppSecondary to "📡 MJPEG (${fps}FPS)"
312+
}
313+
isWebRtcConnecting -> AppPrimary to "🔄 ICE 連線中"
310314
isConnected -> AppSecondary to "📡 MJPEG (${fps}FPS)"
311315
else -> AppError to stringResource(R.string.monitor_reconnecting)
312316
}

app/src/main/java/io/github/iokkai/ocularnode/webrtc/camera/WebRtcCameraCapturer.kt

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -106,15 +106,21 @@ class WebRtcCameraCapturer(
106106
val source = sessionManager.peerConnectionFactory.createVideoSource(false)
107107
videoSource = source
108108

109-
capturer.initialize(surfaceHelper, context.applicationContext, source.capturerObserver)
110-
capturer.startCapture(width, height, fps)
111-
112-
val track = sessionManager.peerConnectionFactory.createVideoTrack(VIDEO_TRACK_ID, source)
113-
track.setEnabled(true)
114-
videoTrack = track
115-
isCapturing = true
116-
117-
return track
109+
try {
110+
capturer.initialize(surfaceHelper, context.applicationContext, source.capturerObserver)
111+
capturer.startCapture(width, height, fps)
112+
val track = sessionManager.peerConnectionFactory.createVideoTrack(VIDEO_TRACK_ID, source)
113+
track.setEnabled(true)
114+
videoTrack = track
115+
isCapturing = true
116+
return track
117+
} catch (e: Exception) {
118+
Log.e(TAG, "Failed to start camera capture in WebRtcCameraCapturer", e)
119+
val fallbackTrack = sessionManager.peerConnectionFactory.createVideoTrack(VIDEO_TRACK_ID, source)
120+
fallbackTrack.setEnabled(false)
121+
videoTrack = fallbackTrack
122+
return fallbackTrack
123+
}
118124
}
119125

120126
fun switchCamera(onSuccess: ((Boolean) -> Unit)? = null) {

app/src/main/java/io/github/iokkai/ocularnode/webrtc/client/WebRtcViewerClient.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,16 @@ class WebRtcViewerClient(
207207
)
208208
router.dispatchMessage(requestStreamPayload)
209209
Log.i(TAG, "Dispatched initial REQUEST_STREAM for session $viewerSessionId")
210+
211+
// Initial connection timeout fallback
212+
scope.launch {
213+
delay(10_000L)
214+
if (_isConnecting.value && !_isConnected.value) {
215+
Log.w(TAG, "Initial WebRTC connection timed out (10s), falling back to compatibility mode")
216+
_isConnecting.value = false
217+
_statusMessage.value = "WebRTC 協商超時,啟用相容串流"
218+
}
219+
}
210220
}
211221
}
212222

app/src/main/java/io/github/iokkai/ocularnode/webrtc/server/WebRtcCameraServerManager.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ class WebRtcCameraServerManager(
132132
}
133133
}
134134

135-
private fun handleIncomingSignal(payload: SignalingPayload) {
135+
fun handleIncomingSignal(payload: SignalingPayload) {
136136
val sessionId = payload.sessionId
137137
if (sessionId.isBlank()) return
138138

app/src/main/java/io/github/iokkai/ocularnode/webrtc/signaling/SmartSignalingRouter.kt

Lines changed: 60 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@ package io.github.iokkai.ocularnode.webrtc.signaling
33
import android.util.Log
44
import kotlinx.coroutines.CoroutineScope
55
import kotlinx.coroutines.Dispatchers
6+
import kotlinx.coroutines.Job
67
import kotlinx.coroutines.async
78
import kotlinx.coroutines.coroutineScope
9+
import kotlinx.coroutines.joinAll
810
import kotlinx.coroutines.launch
911
import kotlinx.coroutines.selects.select
1012
import kotlinx.coroutines.withContext
@@ -160,46 +162,83 @@ class SmartSignalingRouter(
160162
/**
161163
* Races all channels in parallel.
162164
* LAN channels are wrapped in [withTimeoutOrNull] with [LAN_PROBE_TIMEOUT_MS] to prevent
163-
* blocking the [select] indefinitely if the camera is not on the local network.
165+
* blocking if the camera is not on the local network.
166+
* MQTT runs concurrently without a timeout ceiling as guaranteed fallback.
167+
* The first channel to successfully deliver the message wins.
164168
*/
165169
private suspend fun runHappyEyeballs(message: SignalingPayload): Boolean = coroutineScope {
166170
val lanIpv6 = localChannelIpv6
167171
val lanIpv4 = localChannelIpv4
168172
val mqtt = mqttChannel
169173

170-
val lanIpv6Deferred = async {
171-
withTimeoutOrNull(LAN_PROBE_TIMEOUT_MS) {
172-
if (lanIpv6 != null && lanIpv6.isReachable()) {
173-
if (lanIpv6.sendMessage(channelKey, secret, message)) lanIpv6 else null
174-
} else null
174+
val resultsChannel = kotlinx.coroutines.channels.Channel<SignalingChannel>(3)
175+
val jobs = mutableListOf<Job>()
176+
177+
if (lanIpv6 != null) {
178+
jobs += launch(Dispatchers.IO) {
179+
val ok = withTimeoutOrNull(LAN_PROBE_TIMEOUT_MS) {
180+
try {
181+
if (lanIpv6.isReachable() && lanIpv6.sendMessage(channelKey, secret, message)) {
182+
true
183+
} else false
184+
} catch (e: Exception) {
185+
false
186+
}
187+
} ?: false
188+
if (ok) {
189+
resultsChannel.trySend(lanIpv6)
190+
}
175191
}
176192
}
177193

178-
val lanIpv4Deferred = async {
179-
withTimeoutOrNull(LAN_PROBE_TIMEOUT_MS) {
180-
if (lanIpv4 != null && lanIpv4.isReachable()) {
181-
if (lanIpv4.sendMessage(channelKey, secret, message)) lanIpv4 else null
182-
} else null
194+
if (lanIpv4 != null) {
195+
jobs += launch(Dispatchers.IO) {
196+
val ok = withTimeoutOrNull(LAN_PROBE_TIMEOUT_MS) {
197+
try {
198+
if (lanIpv4.isReachable() && lanIpv4.sendMessage(channelKey, secret, message)) {
199+
true
200+
} else false
201+
} catch (e: Exception) {
202+
false
203+
}
204+
} ?: false
205+
if (ok) {
206+
resultsChannel.trySend(lanIpv4)
207+
}
183208
}
184209
}
185210

186-
// MQTT has no timeout ceiling — it is the guaranteed fallback
187-
val mqttDeferred = async {
188-
if (mqtt.sendMessage(channelKey, secret, message)) mqtt else null
211+
// MQTT fallback runs concurrently
212+
jobs += launch(Dispatchers.IO) {
213+
try {
214+
if (mqtt.sendMessage(channelKey, secret, message)) {
215+
resultsChannel.trySend(mqtt)
216+
}
217+
} catch (e: Exception) {
218+
Log.w(TAG, "MQTT send during Happy Eyeballs failed: ${e.message}")
219+
}
189220
}
190221

191-
// Whichever succeeds first (including null from timed-out LAN) wins
192-
val winnerChannel = select<SignalingChannel?> {
193-
lanIpv6Deferred.onAwait { it }
194-
lanIpv4Deferred.onAwait { it }
195-
mqttDeferred.onAwait { it }
222+
var winnerChannel: SignalingChannel? = null
223+
val waiterJob = launch(Dispatchers.IO) {
224+
val result = resultsChannel.receiveCatching()
225+
winnerChannel = result.getOrNull()
226+
if (winnerChannel != null) {
227+
jobs.forEach { job ->
228+
if (job !== coroutineContext[Job]) job.cancel()
229+
}
230+
}
196231
}
197232

233+
jobs.joinAll()
234+
resultsChannel.close()
235+
waiterJob.join()
236+
198237
if (winnerChannel != null) {
199238
activeChannel = winnerChannel
200239
activeChannelLastSuccessMs = System.currentTimeMillis()
201-
activeConnectionTier = resolveChannelTier(winnerChannel)
202-
val wssInfo = if (winnerChannel is MqttSignalingChannel && winnerChannel.isUsingWss) " (WSS Mode)" else ""
240+
activeConnectionTier = resolveChannelTier(winnerChannel!!)
241+
val wssInfo = if (winnerChannel is MqttSignalingChannel && (winnerChannel as MqttSignalingChannel).isUsingWss) " (WSS Mode)" else ""
203242
Log.i(TAG, "Happy Eyeballs selected channel: ${activeConnectionTier.label}$wssInfo")
204243
return@coroutineScope true
205244
}

0 commit comments

Comments
 (0)