@desklink/react-native 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/NOTICE +18 -0
- package/README.md +131 -0
- package/android/build.gradle +29 -0
- package/android/gradle.properties +1 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/expo/modules/desklink/DesklinkModule.kt +199 -0
- package/android/src/main/java/expo/modules/desklink/DesktopRenderer.kt +284 -0
- package/android/src/main/java/expo/modules/desklink/DesktopSession.kt +478 -0
- package/android/src/main/java/expo/modules/desklink/DesktopView.kt +1019 -0
- package/android/src/main/java/expo/modules/desklink/ImeDeletion.kt +20 -0
- package/expo-module.config.json +6 -0
- package/index.ts +1 -0
- package/package.json +33 -0
- package/src/DesktopView.tsx +61 -0
- package/src/DesktopView.web.tsx +56 -0
- package/src/availability.ts +5 -0
- package/src/availability.web.ts +2 -0
- package/src/index.ts +21 -0
- package/src/native.ts +65 -0
- package/src/native.web.ts +1164 -0
- package/src/protocol.ts +151 -0
- package/src/stickyModifiers.ts +66 -0
- package/src/useDesktopSession.ts +661 -0
- package/src/webKeyboardMotion.ts +57 -0
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
package expo.modules.desklink
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.os.Handler
|
|
5
|
+
import android.os.Looper
|
|
6
|
+
import android.util.Log
|
|
7
|
+
import org.json.JSONObject
|
|
8
|
+
import org.webrtc.DataChannel
|
|
9
|
+
import org.webrtc.DefaultVideoDecoderFactory
|
|
10
|
+
import org.webrtc.EglBase
|
|
11
|
+
import org.webrtc.IceCandidate
|
|
12
|
+
import org.webrtc.MediaConstraints
|
|
13
|
+
import org.webrtc.MediaStream
|
|
14
|
+
import org.webrtc.MediaStreamTrack
|
|
15
|
+
import org.webrtc.PeerConnection
|
|
16
|
+
import org.webrtc.PeerConnectionFactory
|
|
17
|
+
import org.webrtc.RtpReceiver
|
|
18
|
+
import org.webrtc.RtpTransceiver
|
|
19
|
+
import org.webrtc.SdpObserver
|
|
20
|
+
import org.webrtc.SessionDescription
|
|
21
|
+
import org.webrtc.SoftwareVideoDecoderFactory
|
|
22
|
+
import org.webrtc.VideoDecoder
|
|
23
|
+
import org.webrtc.VideoDecoderFactory
|
|
24
|
+
import org.webrtc.VideoCodecInfo
|
|
25
|
+
import org.webrtc.VideoTrack
|
|
26
|
+
import java.nio.ByteBuffer
|
|
27
|
+
import java.nio.charset.StandardCharsets
|
|
28
|
+
import java.util.UUID
|
|
29
|
+
import java.util.concurrent.ExecutorService
|
|
30
|
+
import java.util.concurrent.Executors
|
|
31
|
+
|
|
32
|
+
private const val TAG = "DesklinkSession"
|
|
33
|
+
private const val MOVE_BUFFER_LIMIT = 4096L
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* One desktop session, native side.
|
|
37
|
+
*
|
|
38
|
+
* Owns its **own** [PeerConnectionFactory] rather than reusing the app's shared
|
|
39
|
+
* WebRTC module, for two reasons:
|
|
40
|
+
*
|
|
41
|
+
* - the shared factory picks hardware H.264 and *software* VP9/VP8/AV1, so
|
|
42
|
+
* rendering a desktop through it would decode VP9 on the CPU;
|
|
43
|
+
* - its decoder factory is process-global and set once, so changing it would
|
|
44
|
+
* change every other WebRTC user in the app.
|
|
45
|
+
*
|
|
46
|
+
* The factory below asks for [DefaultVideoDecoderFactory], which is
|
|
47
|
+
* hardware-first across VP8/VP9/H264/AV1 with a software fallback, and it is
|
|
48
|
+
* scoped to this session. The underlying WebRTC native library is the app's
|
|
49
|
+
* existing one: this class is compiled against the `org.webrtc` classes the
|
|
50
|
+
* installed binding already ships, so no second copy of the native library is
|
|
51
|
+
* linked in.
|
|
52
|
+
*/
|
|
53
|
+
class DesktopSession(
|
|
54
|
+
private val context: Context,
|
|
55
|
+
internal val eglBase: EglBase.Context,
|
|
56
|
+
private val onEvent: (String, Map<String, Any?>) -> Unit,
|
|
57
|
+
) {
|
|
58
|
+
private val io: ExecutorService = Executors.newSingleThreadExecutor { runnable ->
|
|
59
|
+
Thread(runnable, "desklink-session")
|
|
60
|
+
}
|
|
61
|
+
private val ui = Handler(Looper.getMainLooper())
|
|
62
|
+
private var inputSeq = 0L
|
|
63
|
+
private var pendingMove: JSONObject? = null
|
|
64
|
+
|
|
65
|
+
@Volatile private var factory: PeerConnectionFactory? = null
|
|
66
|
+
@Volatile private var peer: PeerConnection? = null
|
|
67
|
+
@Volatile private var channel: DataChannel? = null
|
|
68
|
+
@Volatile private var videoTrack: VideoTrack? = null
|
|
69
|
+
@Volatile private var epoch: Long = 0
|
|
70
|
+
@Volatile private var closed = false
|
|
71
|
+
@Volatile var inputEnabled = false
|
|
72
|
+
private set
|
|
73
|
+
|
|
74
|
+
@Synchronized
|
|
75
|
+
fun setInputEnabled(enabled: Boolean) {
|
|
76
|
+
inputEnabled = enabled
|
|
77
|
+
if (!enabled) {
|
|
78
|
+
pendingMove = null
|
|
79
|
+
sendJson(mapOf("kind" to "release_all"))
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
private val pendingCandidates = mutableListOf<IceCandidate>()
|
|
83
|
+
@Volatile private var remoteDescriptionSet = false
|
|
84
|
+
@Volatile private var presented = false
|
|
85
|
+
|
|
86
|
+
/** The renderer, attached by the view. A sink is (re)registered when it changes. */
|
|
87
|
+
@Volatile var frameSink: ((VideoTrack) -> Unit)? = null
|
|
88
|
+
set(value) {
|
|
89
|
+
field = value
|
|
90
|
+
// Negotiation can deliver the track before React mounts its view.
|
|
91
|
+
// Both attachment paths run on the UI thread, including late/remounts.
|
|
92
|
+
ui.post { if (!closed) videoTrack?.let { frameSink?.invoke(it) } }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
fun start(iceServersJson: String) {
|
|
96
|
+
io.execute {
|
|
97
|
+
if (closed) return@execute
|
|
98
|
+
try {
|
|
99
|
+
val servers = parseIceServers(iceServersJson)
|
|
100
|
+
val configuration = PeerConnection.RTCConfiguration(servers).apply {
|
|
101
|
+
sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN
|
|
102
|
+
}
|
|
103
|
+
val created = buildFactory()
|
|
104
|
+
factory = created
|
|
105
|
+
val target = ++epoch
|
|
106
|
+
val connection = created.createPeerConnection(configuration, observer(target))
|
|
107
|
+
?: throw IllegalStateException("WebRTC refused the peer connection configuration")
|
|
108
|
+
peer = connection
|
|
109
|
+
// The engine offers; this side answers. The engine also creates the
|
|
110
|
+
// control channel, so the client waits for it in `onDataChannel`.
|
|
111
|
+
emit("ready", emptyMap())
|
|
112
|
+
} catch (error: Throwable) {
|
|
113
|
+
Log.w(TAG, "Could not start the desktop session", error)
|
|
114
|
+
fail("transport", error.message ?: "the desktop session could not start")
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private fun buildFactory(): PeerConnectionFactory {
|
|
120
|
+
// Initialisation is process-wide and idempotent; the app's binding has
|
|
121
|
+
// already done it, so a second call is tolerated rather than required.
|
|
122
|
+
runCatching {
|
|
123
|
+
PeerConnectionFactory.initialize(
|
|
124
|
+
PeerConnectionFactory.InitializationOptions.builder(context)
|
|
125
|
+
.setEnableInternalTracer(false)
|
|
126
|
+
.createInitializationOptions(),
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
// Deliberately no global mutation: the app's shared factory options are left
|
|
130
|
+
// exactly as they were.
|
|
131
|
+
return PeerConnectionFactory.builder()
|
|
132
|
+
.setVideoDecoderFactory(hardwareFirstDecoderFactory())
|
|
133
|
+
.createPeerConnectionFactory()
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Hardware-first, VP9 included, with an explicit software fallback so a device
|
|
138
|
+
* without a VP9 hardware decoder still shows the desktop instead of a black
|
|
139
|
+
* rectangle.
|
|
140
|
+
*/
|
|
141
|
+
private fun hardwareFirstDecoderFactory(): VideoDecoderFactory =
|
|
142
|
+
DefaultVideoDecoderFactory(eglBase)
|
|
143
|
+
|
|
144
|
+
fun setRemoteDescription(type: String, sdp: String) {
|
|
145
|
+
io.execute {
|
|
146
|
+
val connection = peer ?: return@execute
|
|
147
|
+
val target = epoch
|
|
148
|
+
if (type != "offer") {
|
|
149
|
+
fail("transport", "the engine must send an offer")
|
|
150
|
+
return@execute
|
|
151
|
+
}
|
|
152
|
+
connection.setRemoteDescription(
|
|
153
|
+
object : SdpObserver {
|
|
154
|
+
override fun onCreateSuccess(description: SessionDescription?) = Unit
|
|
155
|
+
|
|
156
|
+
override fun onSetSuccess() {
|
|
157
|
+
if (target != epoch) return
|
|
158
|
+
remoteDescriptionSet = true
|
|
159
|
+
for (candidate in pendingCandidates) connection.addIceCandidate(candidate)
|
|
160
|
+
pendingCandidates.clear()
|
|
161
|
+
connection.createAnswer(
|
|
162
|
+
object : SdpObserver {
|
|
163
|
+
override fun onCreateSuccess(answer: SessionDescription?) {
|
|
164
|
+
if (answer == null || target != epoch) return
|
|
165
|
+
connection.setLocalDescription(
|
|
166
|
+
object : SdpObserver {
|
|
167
|
+
override fun onCreateSuccess(description: SessionDescription?) = Unit
|
|
168
|
+
override fun onSetSuccess() {
|
|
169
|
+
if (target == epoch) {
|
|
170
|
+
emit("answer", mapOf("sdp" to answer.description))
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
override fun onCreateFailure(error: String?) = fail("transport", error)
|
|
175
|
+
override fun onSetFailure(error: String?) = fail("transport", error)
|
|
176
|
+
},
|
|
177
|
+
answer,
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
override fun onSetSuccess() = Unit
|
|
182
|
+
override fun onCreateFailure(error: String?) = fail("transport", error)
|
|
183
|
+
override fun onSetFailure(error: String?) = fail("transport", error)
|
|
184
|
+
},
|
|
185
|
+
MediaConstraints(),
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
override fun onCreateFailure(error: String?) = fail("transport", error)
|
|
190
|
+
override fun onSetFailure(error: String?) = fail("transport", error)
|
|
191
|
+
},
|
|
192
|
+
SessionDescription(SessionDescription.Type.OFFER, sdp),
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
fun addRemoteCandidate(candidate: String, sdpMid: String?, sdpMLineIndex: Int) {
|
|
198
|
+
io.execute {
|
|
199
|
+
val connection = peer ?: return@execute
|
|
200
|
+
val ice = IceCandidate(sdpMid, sdpMLineIndex, candidate)
|
|
201
|
+
if (remoteDescriptionSet) {
|
|
202
|
+
connection.addIceCandidate(ice)
|
|
203
|
+
} else {
|
|
204
|
+
pendingCandidates.add(ice)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Stamp and send under one lock: UI gestures and JS use different threads. */
|
|
210
|
+
@Synchronized
|
|
211
|
+
private fun send(message: JSONObject) {
|
|
212
|
+
if (closed || (!inputEnabled && message.optString("kind") in setOf("pointer", "wheel", "key", "text", "clipboard_read", "clipboard_write"))) return
|
|
213
|
+
val active = channel ?: return
|
|
214
|
+
if (active.state() != DataChannel.State.OPEN) return
|
|
215
|
+
if (message.optString("kind") == "pointer" && message.optString("phase") == "move") {
|
|
216
|
+
if (active.bufferedAmount() > MOVE_BUFFER_LIMIT) pendingMove = message
|
|
217
|
+
else {
|
|
218
|
+
pendingMove = null
|
|
219
|
+
sendNow(active, message)
|
|
220
|
+
}
|
|
221
|
+
return
|
|
222
|
+
}
|
|
223
|
+
pendingMove?.let {
|
|
224
|
+
pendingMove = null
|
|
225
|
+
sendNow(active, it)
|
|
226
|
+
}
|
|
227
|
+
if (!closed) sendNow(active, message)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
private fun sendNow(active: DataChannel, message: JSONObject) {
|
|
231
|
+
message.put("seq", ++inputSeq)
|
|
232
|
+
val sent = active.send(DataChannel.Buffer(
|
|
233
|
+
ByteBuffer.wrap(message.toString().toByteArray(StandardCharsets.UTF_8)), false,
|
|
234
|
+
))
|
|
235
|
+
if (!sent) {
|
|
236
|
+
fail("transport", "the desktop control channel could not send")
|
|
237
|
+
close()
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
fun sendStamped(message: String) = send(JSONObject(message))
|
|
242
|
+
|
|
243
|
+
fun sendPointer(phase: String, x: Int, y: Int, withButton: Boolean = false) {
|
|
244
|
+
val message = mutableMapOf<String, Any?>(
|
|
245
|
+
"kind" to "pointer",
|
|
246
|
+
"phase" to phase,
|
|
247
|
+
"x" to x,
|
|
248
|
+
"y" to y,
|
|
249
|
+
)
|
|
250
|
+
if (withButton || phase == "down") message["button"] = 1
|
|
251
|
+
sendJson(message)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** A right click where the finger rests: the context menu every desktop app offers. */
|
|
255
|
+
fun sendRightClick(x: Int, y: Int) {
|
|
256
|
+
for (phase in listOf("down", "up")) {
|
|
257
|
+
sendJson(mapOf("kind" to "pointer", "phase" to phase, "x" to x, "y" to y, "button" to 3))
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Wheel detents; fractions are smooth scrolling on desktops that support it. */
|
|
262
|
+
fun sendWheel(dx: Float, dy: Float) {
|
|
263
|
+
sendJson(mapOf("kind" to "wheel", "dx" to Math.round(dx * 1000) / 1000.0, "dy" to Math.round(dy * 1000) / 1000.0))
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
fun sendKey(name: String, modifiers: List<String>, down: Boolean) {
|
|
267
|
+
sendJson(
|
|
268
|
+
mapOf(
|
|
269
|
+
"kind" to "key",
|
|
270
|
+
"name" to name,
|
|
271
|
+
"modifiers" to modifiers,
|
|
272
|
+
"down" to down,
|
|
273
|
+
),
|
|
274
|
+
)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* A chorded character (Ctrl+C). The engine presses the key through the
|
|
279
|
+
* desktop's own layout and holds the named modifiers for that key alone.
|
|
280
|
+
*/
|
|
281
|
+
fun sendCharacter(character: String, modifiers: List<String>, down: Boolean) {
|
|
282
|
+
sendJson(
|
|
283
|
+
mapOf(
|
|
284
|
+
"kind" to "key",
|
|
285
|
+
"character" to character,
|
|
286
|
+
"modifiers" to modifiers,
|
|
287
|
+
"down" to down,
|
|
288
|
+
),
|
|
289
|
+
)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
fun sendText(text: String) {
|
|
293
|
+
sendJson(mapOf("kind" to "text", "text" to text))
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Release everything the desktop is holding, without ending the session. */
|
|
297
|
+
fun sendCancel() {
|
|
298
|
+
sendJson(mapOf("kind" to "release_all"))
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
fun sendClipboardRead(request: String) {
|
|
302
|
+
sendJson(mapOf("kind" to "clipboard_read", "request" to request))
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
fun sendClipboardWrite(request: String, text: String) {
|
|
306
|
+
sendJson(
|
|
307
|
+
mapOf("kind" to "clipboard_write", "request" to request, "text" to text),
|
|
308
|
+
)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
private fun sendJson(message: Map<String, Any?>) {
|
|
312
|
+
send(JSONObject(message))
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Typing held back for an armed sticky modifier; the app sends it as a chord. */
|
|
316
|
+
fun emitKeyboard(input: Map<String, Any?>) = emit("keyboard", input)
|
|
317
|
+
|
|
318
|
+
fun markPresented() {
|
|
319
|
+
if (!presented) {
|
|
320
|
+
presented = true
|
|
321
|
+
emit("presented", emptyMap())
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
@Synchronized
|
|
326
|
+
fun close() {
|
|
327
|
+
if (closed) return
|
|
328
|
+
closed = true
|
|
329
|
+
pendingMove = null
|
|
330
|
+
++epoch
|
|
331
|
+
io.execute {
|
|
332
|
+
// Releasing everything the far side held is the engine's job on session
|
|
333
|
+
// close; this side only has to stop using the channel.
|
|
334
|
+
runCatching { channel?.unregisterObserver() }
|
|
335
|
+
runCatching { channel?.close() }
|
|
336
|
+
runCatching { channel?.dispose() }
|
|
337
|
+
runCatching { peer?.close() }
|
|
338
|
+
runCatching { peer?.dispose() }
|
|
339
|
+
runCatching { factory?.dispose() }
|
|
340
|
+
channel = null
|
|
341
|
+
peer = null
|
|
342
|
+
factory = null
|
|
343
|
+
pendingCandidates.clear()
|
|
344
|
+
remoteDescriptionSet = false
|
|
345
|
+
emit("closed", emptyMap())
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
fun shutdown() {
|
|
350
|
+
close()
|
|
351
|
+
runCatching { io.shutdown() }
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
private fun parseIceServers(json: String): List<PeerConnection.IceServer> {
|
|
355
|
+
if (json.isBlank()) return emptyList()
|
|
356
|
+
val array = org.json.JSONArray(json)
|
|
357
|
+
val servers = mutableListOf<PeerConnection.IceServer>()
|
|
358
|
+
for (index in 0 until array.length()) {
|
|
359
|
+
val entry = array.optJSONObject(index) ?: continue
|
|
360
|
+
val urls = entry.optJSONArray("urls") ?: continue
|
|
361
|
+
for (urlIndex in 0 until urls.length()) {
|
|
362
|
+
val url = urls.optString(urlIndex)
|
|
363
|
+
if (url.isBlank()) continue
|
|
364
|
+
val builder = PeerConnection.IceServer.builder(url)
|
|
365
|
+
entry.optString("username").takeIf { it.isNotEmpty() }?.let { builder.setUsername(it) }
|
|
366
|
+
entry.optString("credential").takeIf { it.isNotEmpty() }?.let { builder.setPassword(it) }
|
|
367
|
+
servers.add(builder.createIceServer())
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return servers
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
private fun observer(target: Long) = object : PeerConnection.Observer {
|
|
374
|
+
override fun onSignalingChange(state: PeerConnection.SignalingState?) = Unit
|
|
375
|
+
|
|
376
|
+
override fun onIceConnectionChange(state: PeerConnection.IceConnectionState?) {
|
|
377
|
+
if (target != epoch) return
|
|
378
|
+
emit("ice", mapOf("state" to state?.toString()))
|
|
379
|
+
if (state == PeerConnection.IceConnectionState.CONNECTED &&
|
|
380
|
+
context.applicationInfo.flags and android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE != 0
|
|
381
|
+
) {
|
|
382
|
+
// Bounded debug-build evidence: reception/decoding is not presentation.
|
|
383
|
+
// Never include SDP, addresses, credentials or control payloads.
|
|
384
|
+
val fields = setOf("kind", "packetsReceived", "packetsLost", "bytesReceived", "framesReceived",
|
|
385
|
+
"framesDecoded", "keyFramesDecoded", "framesDropped", "framesPerSecond", "frameWidth", "frameHeight",
|
|
386
|
+
"nackCount", "pliCount", "firCount", "totalDecodeTime", "decoderImplementation", "jitterBufferDelay")
|
|
387
|
+
val sample = object : Runnable {
|
|
388
|
+
override fun run() {
|
|
389
|
+
if (closed || target != epoch) return
|
|
390
|
+
peer?.getStats { report ->
|
|
391
|
+
val video = report.statsMap.values.filter { it.type == "inbound-rtp" }
|
|
392
|
+
.map { JSONObject(it.members.filterKeys { key -> key in fields }) }
|
|
393
|
+
Log.i(TAG, "video receive stats: ${org.json.JSONArray(video)}")
|
|
394
|
+
}
|
|
395
|
+
ui.postDelayed(this, 5000)
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
ui.postDelayed(sample, 2000)
|
|
399
|
+
}
|
|
400
|
+
if (state == PeerConnection.IceConnectionState.FAILED) {
|
|
401
|
+
fail("transport", "the connection to the desktop was lost")
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
override fun onIceConnectionReceivingChange(receiving: Boolean) = Unit
|
|
406
|
+
override fun onIceGatheringChange(state: PeerConnection.IceGatheringState?) = Unit
|
|
407
|
+
|
|
408
|
+
override fun onIceCandidate(candidate: IceCandidate?) {
|
|
409
|
+
if (candidate == null || target != epoch) return
|
|
410
|
+
emit(
|
|
411
|
+
"candidate",
|
|
412
|
+
mapOf(
|
|
413
|
+
"candidate" to candidate.sdp,
|
|
414
|
+
"sdpMid" to candidate.sdpMid,
|
|
415
|
+
"sdpMLineIndex" to candidate.sdpMLineIndex,
|
|
416
|
+
),
|
|
417
|
+
)
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
override fun onIceCandidatesRemoved(candidates: Array<out IceCandidate>?) = Unit
|
|
421
|
+
override fun onAddStream(stream: MediaStream?) = Unit
|
|
422
|
+
override fun onRemoveStream(stream: MediaStream?) = Unit
|
|
423
|
+
override fun onRenegotiationNeeded() = Unit
|
|
424
|
+
override fun onAddTrack(receiver: RtpReceiver?, streams: Array<out MediaStream>?) = Unit
|
|
425
|
+
|
|
426
|
+
override fun onDataChannel(dataChannel: DataChannel?) {
|
|
427
|
+
if (dataChannel == null || target != epoch) return
|
|
428
|
+
channel = dataChannel
|
|
429
|
+
dataChannel.registerObserver(object : DataChannel.Observer {
|
|
430
|
+
override fun onBufferedAmountChange(amount: Long) {
|
|
431
|
+
synchronized(this@DesktopSession) {
|
|
432
|
+
if (!closed && inputEnabled && channel === dataChannel && dataChannel.state() == DataChannel.State.OPEN &&
|
|
433
|
+
dataChannel.bufferedAmount() <= MOVE_BUFFER_LIMIT
|
|
434
|
+
) {
|
|
435
|
+
pendingMove?.let {
|
|
436
|
+
pendingMove = null
|
|
437
|
+
sendNow(dataChannel, it)
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
override fun onStateChange() {
|
|
444
|
+
if (target == epoch) {
|
|
445
|
+
emit("channel", mapOf("state" to dataChannel.state().toString()))
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
override fun onMessage(buffer: DataChannel.Buffer) {
|
|
450
|
+
val bytes = ByteArray(buffer.data.remaining())
|
|
451
|
+
buffer.data.get(bytes)
|
|
452
|
+
emit("control", mapOf("message" to String(bytes, StandardCharsets.UTF_8)))
|
|
453
|
+
}
|
|
454
|
+
})
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
override fun onTrack(transceiver: RtpTransceiver?) {
|
|
458
|
+
val track = transceiver?.receiver?.track() as? VideoTrack ?: return
|
|
459
|
+
if (target != epoch) return
|
|
460
|
+
videoTrack = track
|
|
461
|
+
Log.i(TAG, "remote video track; view sink registered=${frameSink != null}")
|
|
462
|
+
ui.post { if (target == epoch && !closed) frameSink?.invoke(track) }
|
|
463
|
+
emit("track", emptyMap())
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
private fun emit(name: String, payload: Map<String, Any?>) {
|
|
468
|
+
ui.post { onEvent(name, payload) }
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
private fun fail(code: String, message: String?) {
|
|
472
|
+
ui.post { onEvent("failure", mapOf("code" to code, "message" to (message ?: "unknown"))) }
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
companion object {
|
|
476
|
+
fun newRequestId(): String = UUID.randomUUID().toString()
|
|
477
|
+
}
|
|
478
|
+
}
|