@adeeliore/p2p-lan-signaling 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.
Files changed (43) hide show
  1. package/API_REFERENCE.md +158 -0
  2. package/CHANGELOG.md +9 -0
  3. package/LICENSE +12 -0
  4. package/README.md +118 -0
  5. package/app.plugin.js +1 -0
  6. package/dist/constants.d.ts +17 -0
  7. package/dist/constants.d.ts.map +1 -0
  8. package/dist/constants.js +19 -0
  9. package/dist/discovery.d.ts +3 -0
  10. package/dist/discovery.d.ts.map +1 -0
  11. package/dist/discovery.js +10 -0
  12. package/dist/errors.d.ts +16 -0
  13. package/dist/errors.d.ts.map +1 -0
  14. package/dist/errors.js +16 -0
  15. package/dist/host.d.ts +5 -0
  16. package/dist/host.d.ts.map +1 -0
  17. package/dist/host.js +23 -0
  18. package/dist/index.d.ts +11 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +35 -0
  21. package/dist/messageValidation.d.ts +3 -0
  22. package/dist/messageValidation.d.ts.map +1 -0
  23. package/dist/messageValidation.js +90 -0
  24. package/dist/nativeModule.d.ts +7 -0
  25. package/dist/nativeModule.d.ts.map +1 -0
  26. package/dist/nativeModule.js +39 -0
  27. package/dist/provider.d.ts +20 -0
  28. package/dist/provider.d.ts.map +1 -0
  29. package/dist/provider.js +91 -0
  30. package/dist/providerUtils.d.ts +3 -0
  31. package/dist/providerUtils.d.ts.map +1 -0
  32. package/dist/providerUtils.js +18 -0
  33. package/dist/roomUtils.d.ts +3 -0
  34. package/dist/roomUtils.d.ts.map +1 -0
  35. package/dist/roomUtils.js +15 -0
  36. package/dist/types.d.ts +99 -0
  37. package/dist/types.d.ts.map +1 -0
  38. package/dist/types.js +2 -0
  39. package/package.json +54 -0
  40. package/plugin/templates/android/OfflineSignalingPackage.kt +609 -0
  41. package/plugin/templates/ios/OfflineSignalingHost.swift +703 -0
  42. package/plugin/templates/ios/OfflineSignalingHostBridge.m +22 -0
  43. package/plugin/withP2PLanSignaling.js +144 -0
@@ -0,0 +1,609 @@
1
+ package com.anonymous.DrawingApp.offline
2
+
3
+ import android.net.Uri
4
+ import android.content.Context
5
+ import android.net.nsd.NsdManager
6
+ import android.net.nsd.NsdServiceInfo
7
+ import com.facebook.react.ReactPackage
8
+ import com.facebook.react.bridge.Arguments
9
+ import com.facebook.react.bridge.WritableArray
10
+ import com.facebook.react.bridge.NativeModule
11
+ import com.facebook.react.bridge.Promise
12
+ import com.facebook.react.bridge.ReactApplicationContext
13
+ import com.facebook.react.bridge.ReactContextBaseJavaModule
14
+ import com.facebook.react.bridge.ReactMethod
15
+ import com.facebook.react.bridge.WritableMap
16
+ import com.facebook.react.uimanager.ViewManager
17
+ import org.json.JSONArray
18
+ import org.json.JSONObject
19
+ import java.io.BufferedReader
20
+ import java.io.EOFException
21
+ import java.io.InputStream
22
+ import java.io.InputStreamReader
23
+ import java.io.OutputStream
24
+ import java.net.Inet4Address
25
+ import java.net.NetworkInterface
26
+ import java.net.ServerSocket
27
+ import java.net.Socket
28
+ import java.net.SocketException
29
+ import java.nio.charset.StandardCharsets
30
+ import java.security.MessageDigest
31
+ import java.util.Base64
32
+ import java.util.UUID
33
+ import java.util.Collections
34
+ import java.util.concurrent.ConcurrentHashMap
35
+ import java.util.concurrent.CopyOnWriteArrayList
36
+ import java.util.concurrent.Executors
37
+ import java.util.concurrent.atomic.AtomicBoolean
38
+
39
+ @Suppress("OVERRIDE_DEPRECATION")
40
+ class OfflineSignalingPackage : ReactPackage {
41
+ override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
42
+ return listOf(OfflineSignalingHostModule(reactContext))
43
+ }
44
+
45
+ override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
46
+ return emptyList()
47
+ }
48
+ }
49
+
50
+ class OfflineSignalingHostModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
51
+ private val manager = OfflineSignalingHostManager(reactContext)
52
+
53
+ override fun getName(): String = OfflineSignalingContract.MODULE_NAME
54
+
55
+ @ReactMethod
56
+ fun startHost(port: Int, roomId: String?, displayName: String?, roomSecret: String?, promise: Promise) {
57
+ try {
58
+ promise.resolve(toWritableMap(manager.start(port, roomId, displayName, roomSecret)))
59
+ } catch (error: Exception) {
60
+ promise.reject("offline_host_start_failed", error.message, error)
61
+ }
62
+ }
63
+
64
+ @ReactMethod
65
+ fun stopHost(promise: Promise) {
66
+ manager.stop()
67
+ promise.resolve(null)
68
+ }
69
+
70
+ @ReactMethod
71
+ fun getStatus(promise: Promise) {
72
+ promise.resolve(toWritableMap(manager.getStatus()))
73
+ }
74
+
75
+ @ReactMethod
76
+ fun discoverHosts(timeoutMs: Int, promise: Promise) {
77
+ try {
78
+ promise.resolve(toWritableArray(manager.discoverHosts(timeoutMs)))
79
+ } catch (error: Exception) {
80
+ promise.reject("offline_host_discovery_failed", error.message, error)
81
+ }
82
+ }
83
+
84
+ private fun toWritableMap(status: OfflineHostStatus): WritableMap {
85
+ return Arguments.createMap().apply {
86
+ putBoolean("running", status.running)
87
+ putString("hostAddress", status.hostAddress)
88
+ if (status.port == null) putNull("port") else putInt("port", status.port)
89
+ putString("url", status.url)
90
+ putString("roomId", status.roomId)
91
+ putString("serviceName", status.serviceName)
92
+ putString("serviceType", status.serviceType)
93
+ }
94
+ }
95
+
96
+ private fun toWritableArray(hosts: List<OfflineDiscoveredHost>): WritableArray {
97
+ return Arguments.createArray().apply {
98
+ hosts.forEach { host ->
99
+ pushMap(Arguments.createMap().apply {
100
+ putString("serviceName", host.serviceName)
101
+ putString("serviceType", host.serviceType)
102
+ putString("hostAddress", host.hostAddress)
103
+ putInt("port", host.port)
104
+ putString("url", host.url)
105
+ putString("roomId", host.roomId)
106
+ putString("displayName", host.displayName)
107
+ })
108
+ }
109
+ }
110
+ }
111
+ }
112
+
113
+ data class OfflineHostStatus(
114
+ val running: Boolean,
115
+ val hostAddress: String?,
116
+ val port: Int?,
117
+ val url: String?,
118
+ val roomId: String? = null,
119
+ val serviceName: String? = null,
120
+ val serviceType: String? = null,
121
+ )
122
+
123
+ data class OfflineDiscoveredHost(
124
+ val serviceName: String,
125
+ val serviceType: String,
126
+ val hostAddress: String,
127
+ val port: Int,
128
+ val url: String,
129
+ val roomId: String?,
130
+ val displayName: String?,
131
+ )
132
+
133
+ object OfflineSecurityPolicy {
134
+ const val MAX_PARTICIPANTS = 10
135
+ const val MAX_TEXT_FRAME_BYTES = 65536
136
+ val sessionIdRegex = Regex("^[A-Za-z0-9_-]{8,64}$")
137
+ val clientIdRegex = Regex("^[A-Za-z0-9_-]{3,64}$")
138
+ }
139
+
140
+ object OfflineSignalingContract {
141
+ const val MODULE_NAME = "OfflineSignalingHost"
142
+ const val SERVICE_TYPE = "_drawin._tcp"
143
+ const val ANDROID_SERVICE_TYPE = "$SERVICE_TYPE."
144
+ const val WEBSOCKET_PATH = "/ws"
145
+ const val QUERY_SESSION_ID = "sessionId"
146
+ const val QUERY_CLIENT_ID = "clientId"
147
+ const val QUERY_ACCESS_TOKEN = "accessToken"
148
+ const val QUERY_ROOM_SECRET = "roomSecret"
149
+ const val TXT_ROOM_ID = "roomId"
150
+ const val TXT_DISPLAY_NAME = "displayName"
151
+ const val DISCOVERY_MIN_TIMEOUT_MS = 500
152
+ const val DISCOVERY_MAX_TIMEOUT_MS = 10000
153
+ }
154
+
155
+ class OfflineSignalingHostManager(context: Context) {
156
+ private val discovery = LanServiceDiscovery(context)
157
+ private var server: OfflineSignalingServer? = null
158
+
159
+ @Synchronized
160
+ fun start(port: Int, roomId: String?, displayName: String?, roomSecret: String?): OfflineHostStatus {
161
+ val existing = server
162
+ if (existing != null && existing.status.running) {
163
+ if (existing.matches(port, roomId, displayName, roomSecret)) {
164
+ return existing.status
165
+ }
166
+ existing.stop()
167
+ server = null
168
+ }
169
+
170
+ val next = OfflineSignalingServer(port, roomId, displayName, roomSecret, discovery)
171
+ server = next
172
+ return next.start()
173
+ }
174
+
175
+ @Synchronized
176
+ fun stop() {
177
+ server?.stop()
178
+ server = null
179
+ }
180
+
181
+ @Synchronized
182
+ fun getStatus(): OfflineHostStatus {
183
+ return server?.status ?: OfflineHostStatus(false, null, null, null, null, null, OfflineSignalingContract.SERVICE_TYPE)
184
+ }
185
+
186
+ fun discoverHosts(timeoutMs: Int): List<OfflineDiscoveredHost> {
187
+ return discovery.discover(timeoutMs)
188
+ }
189
+ }
190
+
191
+ class OfflineSignalingServer(
192
+ private val requestedPort: Int,
193
+ private val roomId: String?,
194
+ private val displayName: String?,
195
+ private val roomSecret: String?,
196
+ private val discovery: LanServiceDiscovery,
197
+ ) {
198
+ private val running = AtomicBoolean(false)
199
+ private val executor = Executors.newCachedThreadPool()
200
+ private val clients = CopyOnWriteArrayList<OfflineSignalingClient>()
201
+ private val router = OfflineSignalingRouter()
202
+ private var serverSocket: ServerSocket? = null
203
+ private var hostAddress: String? = null
204
+
205
+ val status: OfflineHostStatus
206
+ get() {
207
+ val actualPort = serverSocket?.localPort?.takeIf { it > 0 } ?: requestedPort
208
+ val address = hostAddress
209
+ return OfflineHostStatus(
210
+ running = running.get(),
211
+ hostAddress = address,
212
+ port = actualPort,
213
+ url = if (running.get() && address != null) "ws://$address:$actualPort" else null,
214
+ roomId = roomId,
215
+ serviceName = if (running.get()) serviceName() else null,
216
+ serviceType = OfflineSignalingContract.SERVICE_TYPE,
217
+ )
218
+ }
219
+
220
+ fun start(): OfflineHostStatus {
221
+ if (running.get()) return status
222
+ hostAddress = LocalNetworkAddressResolver.resolveIpv4Address()
223
+ val socket = ServerSocket(requestedPort)
224
+ serverSocket = socket
225
+ running.set(true)
226
+ discovery.register(serviceName(), socket.localPort, roomId, displayName)
227
+ executor.execute {
228
+ while (running.get()) {
229
+ try {
230
+ val clientSocket = socket.accept()
231
+ val client = OfflineSignalingClient(clientSocket, router, roomSecret)
232
+ clients.add(client)
233
+ executor.execute {
234
+ client.run()
235
+ clients.remove(client)
236
+ }
237
+ } catch (_: SocketException) {
238
+ break
239
+ } catch (_: Exception) {
240
+ }
241
+ }
242
+ }
243
+ return status
244
+ }
245
+
246
+ fun stop() {
247
+ if (!running.getAndSet(false)) return
248
+ clients.forEach { it.close() }
249
+ clients.clear()
250
+ try { serverSocket?.close() } catch (_: Exception) {}
251
+ discovery.unregister()
252
+ serverSocket = null
253
+ }
254
+
255
+ fun matches(port: Int, nextRoomId: String?, nextDisplayName: String?, nextRoomSecret: String?): Boolean {
256
+ val actualPort = serverSocket?.localPort?.takeIf { it > 0 } ?: requestedPort
257
+ return actualPort == port &&
258
+ roomId.orEmpty() == nextRoomId.orEmpty() &&
259
+ displayName.orEmpty() == nextDisplayName.orEmpty() &&
260
+ roomSecret.orEmpty() == nextRoomSecret.orEmpty()
261
+ }
262
+
263
+ private fun serviceName(): String {
264
+ val suffix = roomId?.takeIf { it.isNotBlank() } ?: requestedPort.toString()
265
+ return displayName?.takeIf { it.isNotBlank() } ?: "Drawin $suffix"
266
+ }
267
+ }
268
+
269
+ class OfflineSignalingClient(
270
+ private val socket: Socket,
271
+ private val router: OfflineSignalingRouter,
272
+ private val roomSecret: String?,
273
+ ) : Runnable {
274
+ private val running = AtomicBoolean(true)
275
+ private val output = socket.getOutputStream()
276
+ var sessionId: String? = null
277
+ private set
278
+ var clientId: String? = null
279
+ private set
280
+
281
+ fun attach(sessionId: String, clientId: String) {
282
+ this.sessionId = sessionId
283
+ this.clientId = clientId
284
+ }
285
+
286
+ override fun run() {
287
+ try {
288
+ val request = WebSocketHandshake.accept(socket.getInputStream(), output, roomSecret)
289
+ router.register(request.sessionId, request.clientId, this)
290
+ while (running.get() && !socket.isClosed) {
291
+ val message = WebSocketFrameCodec.readText(socket.getInputStream()) ?: break
292
+ router.handleClientMessage(this, message)
293
+ }
294
+ } catch (_: Exception) {
295
+ } finally {
296
+ close()
297
+ router.unregister(this)
298
+ }
299
+ }
300
+
301
+ fun send(message: String) {
302
+ if (!running.get() || socket.isClosed) return
303
+ try {
304
+ WebSocketFrameCodec.writeText(output, message)
305
+ } catch (_: Exception) {
306
+ close()
307
+ }
308
+ }
309
+
310
+ fun close() {
311
+ if (!running.getAndSet(false)) return
312
+ try { socket.close() } catch (_: Exception) {}
313
+ }
314
+ }
315
+
316
+ class OfflineSignalingRouter {
317
+ private val sessions = ConcurrentHashMap<String, LinkedHashMap<String, OfflineSignalingClient>>()
318
+
319
+ @Synchronized
320
+ fun register(sessionId: String, requestedClientId: String?, client: OfflineSignalingClient): String {
321
+ if (!OfflineSecurityPolicy.sessionIdRegex.matches(sessionId)) {
322
+ client.send(JSONObject().put("type", "error").put("code", "invalid_session_id").put("message", "Invalid session id").toString())
323
+ client.close()
324
+ return ""
325
+ }
326
+
327
+ val session = sessions.getOrPut(sessionId) { linkedMapOf() }
328
+ if (session.size >= OfflineSecurityPolicy.MAX_PARTICIPANTS) {
329
+ client.send(JSONObject().put("type", "error").put("code", "session_full").put("message", "Session is full").toString())
330
+ client.close()
331
+ return ""
332
+ }
333
+
334
+ val clientId = resolveClientId(session, requestedClientId)
335
+ if (!OfflineSecurityPolicy.clientIdRegex.matches(clientId)) {
336
+ client.send(JSONObject().put("type", "error").put("code", "invalid_client_id").put("message", "Invalid client id").toString())
337
+ client.close()
338
+ return ""
339
+ }
340
+
341
+ client.attach(sessionId, clientId)
342
+ val peers = session.keys.map { JSONObject().put("clientId", it) }
343
+ session[clientId] = client
344
+ client.send(JSONObject().put("type", "welcome").put("sessionId", sessionId).put("clientId", clientId).put("peers", JSONArray(peers)).toString())
345
+ broadcastExcept(sessionId, clientId, JSONObject().put("type", "peer-joined").put("sessionId", sessionId).put("clientId", clientId).toString())
346
+ return clientId
347
+ }
348
+
349
+ @Synchronized
350
+ fun unregister(client: OfflineSignalingClient) {
351
+ val sessionId = client.sessionId ?: return
352
+ val clientId = client.clientId ?: return
353
+ val session = sessions[sessionId] ?: return
354
+ session.remove(clientId)
355
+ if (session.isEmpty()) {
356
+ sessions.remove(sessionId)
357
+ return
358
+ }
359
+ broadcast(sessionId, JSONObject().put("type", "peer-left").put("sessionId", sessionId).put("clientId", clientId).toString())
360
+ }
361
+
362
+ fun handleClientMessage(client: OfflineSignalingClient, rawMessage: String) {
363
+ if (rawMessage.isBlank()) return
364
+ val json = JSONObject(rawMessage)
365
+ when (val type = json.optString("type")) {
366
+ "ping" -> client.send(JSONObject().put("type", "pong").toString())
367
+ "offer", "answer", "ice" -> relay(client, type, json)
368
+ else -> client.send(JSONObject().put("type", "error").put("code", "unsupported_message").put("message", "Unsupported signaling message type: $type").toString())
369
+ }
370
+ }
371
+
372
+ @Synchronized
373
+ private fun relay(client: OfflineSignalingClient, type: String, json: JSONObject) {
374
+ val sessionId = client.sessionId ?: return
375
+ val from = client.clientId ?: return
376
+ val to = json.optString("to").takeIf { it.isNotBlank() } ?: return
377
+ if (!OfflineSecurityPolicy.clientIdRegex.matches(to)) {
378
+ client.send(JSONObject().put("type", "error").put("code", "invalid_to").put("message", "Invalid relay target").toString())
379
+ return
380
+ }
381
+ val target = sessions[sessionId]?.get(to)
382
+ if (target == null) {
383
+ client.send(JSONObject().put("type", "error").put("code", "peer_not_found").put("message", "Peer not found: $to").toString())
384
+ return
385
+ }
386
+ val message = JSONObject().put("type", type).put("sessionId", sessionId).put("from", from).put("to", to)
387
+ if (json.has("payload")) message.put("payload", json.get("payload"))
388
+ target.send(message.toString())
389
+ }
390
+
391
+ @Synchronized
392
+ private fun broadcast(sessionId: String, message: String) {
393
+ sessions[sessionId]?.values?.forEach { it.send(message) }
394
+ }
395
+
396
+ @Synchronized
397
+ private fun broadcastExcept(sessionId: String, exceptClientId: String, message: String) {
398
+ sessions[sessionId]?.filterKeys { it != exceptClientId }?.values?.forEach { it.send(message) }
399
+ }
400
+
401
+ private fun resolveClientId(session: Map<String, OfflineSignalingClient>, requestedClientId: String?): String {
402
+ val base = requestedClientId?.takeIf { it.isNotBlank() } ?: "user-\${UUID.randomUUID().toString().take(4)}"
403
+ if (!OfflineSecurityPolicy.clientIdRegex.matches(base)) return "user-\${UUID.randomUUID().toString().take(4)}"
404
+ if (!session.containsKey(base)) return base
405
+ var suffix = 2
406
+ while (session.containsKey("$base-$suffix")) suffix += 1
407
+ return "$base-$suffix"
408
+ }
409
+ }
410
+
411
+ data class WebSocketHandshakeRequest(val sessionId: String, val clientId: String?)
412
+
413
+ object WebSocketHandshake {
414
+ private const val WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
415
+
416
+ fun accept(input: InputStream, output: OutputStream, roomSecret: String?): WebSocketHandshakeRequest {
417
+ val reader = BufferedReader(InputStreamReader(input))
418
+ val requestLine = reader.readLine() ?: throw IllegalArgumentException("Missing WebSocket request line")
419
+ val path = requestLine.split(" ").getOrNull(1) ?: throw IllegalArgumentException("Invalid WebSocket request line")
420
+ val headers = mutableMapOf<String, String>()
421
+ while (true) {
422
+ val line = reader.readLine() ?: break
423
+ if (line.isEmpty()) break
424
+ val separator = line.indexOf(':')
425
+ if (separator > 0) headers[line.substring(0, separator).trim().lowercase()] = line.substring(separator + 1).trim()
426
+ }
427
+ val key = headers["sec-websocket-key"] ?: throw IllegalArgumentException("Missing Sec-WebSocket-Key")
428
+ val acceptKey = Base64.getEncoder().encodeToString(MessageDigest.getInstance("SHA-1").digest((key + WS_GUID).toByteArray()))
429
+ output.write(("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: $acceptKey\r\n\r\n").toByteArray())
430
+ output.flush()
431
+ val uri = Uri.parse("ws://localhost$path")
432
+ if (uri.path != OfflineSignalingContract.WEBSOCKET_PATH) throw IllegalArgumentException("Unsupported WebSocket path")
433
+ val sessionId = uri.getQueryParameter(OfflineSignalingContract.QUERY_SESSION_ID)?.trim().orEmpty()
434
+ if (!OfflineSecurityPolicy.sessionIdRegex.matches(sessionId)) throw IllegalArgumentException("Invalid sessionId")
435
+ val clientId = uri.getQueryParameter(OfflineSignalingContract.QUERY_CLIENT_ID)?.trim()?.takeIf { it.isNotEmpty() }
436
+ if (clientId != null && !OfflineSecurityPolicy.clientIdRegex.matches(clientId)) throw IllegalArgumentException("Invalid clientId")
437
+ if (!roomSecret.isNullOrBlank()) {
438
+ val providedSecret = uri.getQueryParameter(OfflineSignalingContract.QUERY_ACCESS_TOKEN)?.trim()
439
+ ?: uri.getQueryParameter(OfflineSignalingContract.QUERY_ROOM_SECRET)?.trim()
440
+ if (providedSecret != roomSecret) throw IllegalArgumentException("Invalid room secret")
441
+ }
442
+ return WebSocketHandshakeRequest(sessionId, clientId)
443
+ }
444
+ }
445
+
446
+ object WebSocketFrameCodec {
447
+ fun readText(input: InputStream): String? {
448
+ val first = input.read()
449
+ if (first == -1) throw EOFException("Socket closed")
450
+ val opcode = first and 0x0F
451
+ val second = readByte(input)
452
+ val masked = (second and 0x80) != 0
453
+ var length = (second and 0x7F).toLong()
454
+ if (length == 126L) length = ((readByte(input) shl 8) or readByte(input)).toLong()
455
+ if (length == 127L) {
456
+ length = 0
457
+ repeat(8) { length = (length shl 8) or readByte(input).toLong() }
458
+ }
459
+ if (length > OfflineSecurityPolicy.MAX_TEXT_FRAME_BYTES) throw IllegalArgumentException("WebSocket frame is too large")
460
+ val mask = if (masked) ByteArray(4).also { readFully(input, it) } else null
461
+ val payload = ByteArray(length.toInt())
462
+ readFully(input, payload)
463
+ if (mask != null) {
464
+ payload.indices.forEach { payload[it] = (payload[it].toInt() xor mask[it % 4].toInt()).toByte() }
465
+ }
466
+ if (opcode == 0x8) return null
467
+ if (opcode != 0x1) return ""
468
+ return String(payload, StandardCharsets.UTF_8)
469
+ }
470
+
471
+ @Synchronized
472
+ fun writeText(output: OutputStream, message: String) {
473
+ val payload = message.toByteArray(StandardCharsets.UTF_8)
474
+ output.write(0x81)
475
+ when {
476
+ payload.size < 126 -> output.write(payload.size)
477
+ payload.size <= 65535 -> {
478
+ output.write(126)
479
+ output.write((payload.size ushr 8) and 0xFF)
480
+ output.write(payload.size and 0xFF)
481
+ }
482
+ else -> {
483
+ output.write(127)
484
+ for (shift in 56 downTo 0 step 8) output.write(((payload.size.toLong() ushr shift) and 0xFF).toInt())
485
+ }
486
+ }
487
+ output.write(payload)
488
+ output.flush()
489
+ }
490
+
491
+ private fun readByte(input: InputStream): Int {
492
+ val value = input.read()
493
+ if (value == -1) throw EOFException("Socket closed")
494
+ return value
495
+ }
496
+
497
+ private fun readFully(input: InputStream, buffer: ByteArray) {
498
+ var offset = 0
499
+ while (offset < buffer.size) {
500
+ val read = input.read(buffer, offset, buffer.size - offset)
501
+ if (read == -1) throw EOFException("Socket closed")
502
+ offset += read
503
+ }
504
+ }
505
+ }
506
+
507
+ @Suppress("DEPRECATION")
508
+ class LanServiceDiscovery(context: Context) {
509
+ private val nsdManager = context.getSystemService(Context.NSD_SERVICE) as NsdManager
510
+ private var registrationListener: NsdManager.RegistrationListener? = null
511
+
512
+ @Synchronized
513
+ fun register(serviceName: String, port: Int, roomId: String?, displayName: String?) {
514
+ unregister()
515
+ val serviceInfo = NsdServiceInfo().apply {
516
+ this.serviceName = serviceName
517
+ this.serviceType = OfflineSignalingContract.ANDROID_SERVICE_TYPE
518
+ this.port = port
519
+ roomId?.takeIf { it.isNotBlank() }?.let { setAttribute(OfflineSignalingContract.TXT_ROOM_ID, it) }
520
+ displayName?.takeIf { it.isNotBlank() }?.let { setAttribute(OfflineSignalingContract.TXT_DISPLAY_NAME, it) }
521
+ }
522
+
523
+ val listener = object : NsdManager.RegistrationListener {
524
+ override fun onServiceRegistered(info: NsdServiceInfo) {}
525
+ override fun onRegistrationFailed(info: NsdServiceInfo, errorCode: Int) {}
526
+ override fun onServiceUnregistered(info: NsdServiceInfo) {}
527
+ override fun onUnregistrationFailed(info: NsdServiceInfo, errorCode: Int) {}
528
+ }
529
+
530
+ registrationListener = listener
531
+ try {
532
+ nsdManager.registerService(serviceInfo, NsdManager.PROTOCOL_DNS_SD, listener)
533
+ } catch (_: Exception) {
534
+ registrationListener = null
535
+ }
536
+ }
537
+
538
+ @Synchronized
539
+ fun unregister() {
540
+ val listener = registrationListener ?: return
541
+ try { nsdManager.unregisterService(listener) } catch (_: Exception) {}
542
+ registrationListener = null
543
+ }
544
+
545
+ fun discover(timeoutMs: Int): List<OfflineDiscoveredHost> {
546
+ val results = Collections.synchronizedList(mutableListOf<OfflineDiscoveredHost>())
547
+ val listener = object : NsdManager.DiscoveryListener {
548
+ override fun onDiscoveryStarted(serviceType: String) {}
549
+ override fun onDiscoveryStopped(serviceType: String) {}
550
+ override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
551
+ try { nsdManager.stopServiceDiscovery(this) } catch (_: Exception) {}
552
+ }
553
+ override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {}
554
+ override fun onServiceLost(serviceInfo: NsdServiceInfo) {}
555
+ override fun onServiceFound(serviceInfo: NsdServiceInfo) {
556
+ if (!serviceInfo.serviceType.equals(OfflineSignalingContract.ANDROID_SERVICE_TYPE, ignoreCase = true)) return
557
+ try {
558
+ nsdManager.resolveService(serviceInfo, object : NsdManager.ResolveListener {
559
+ override fun onResolveFailed(info: NsdServiceInfo, errorCode: Int) {}
560
+ override fun onServiceResolved(info: NsdServiceInfo) {
561
+ val address = info.host?.hostAddress ?: return
562
+ val port = info.port.takeIf { it > 0 } ?: return
563
+ val roomId = info.attributes[OfflineSignalingContract.TXT_ROOM_ID]?.toString(StandardCharsets.UTF_8) ?: parseRoomId(info.serviceName)
564
+ val displayName = info.attributes[OfflineSignalingContract.TXT_DISPLAY_NAME]?.toString(StandardCharsets.UTF_8)
565
+ results.add(
566
+ OfflineDiscoveredHost(
567
+ serviceName = info.serviceName,
568
+ serviceType = info.serviceType,
569
+ hostAddress = address,
570
+ port = port,
571
+ url = "ws://$address:$port",
572
+ roomId = roomId,
573
+ displayName = displayName,
574
+ )
575
+ )
576
+ }
577
+ })
578
+ } catch (_: Exception) {
579
+ }
580
+ }
581
+ }
582
+
583
+ try {
584
+ nsdManager.discoverServices(OfflineSignalingContract.ANDROID_SERVICE_TYPE, NsdManager.PROTOCOL_DNS_SD, listener)
585
+ Thread.sleep(timeoutMs.coerceIn(OfflineSignalingContract.DISCOVERY_MIN_TIMEOUT_MS, OfflineSignalingContract.DISCOVERY_MAX_TIMEOUT_MS).toLong())
586
+ } finally {
587
+ try { nsdManager.stopServiceDiscovery(listener) } catch (_: Exception) {}
588
+ }
589
+
590
+ return results.distinctBy { it.url + ":" + it.roomId.orEmpty() }
591
+ }
592
+
593
+ private fun parseRoomId(serviceName: String): String? {
594
+ val match = Regex("(ROOM-[A-Z0-9]+)").find(serviceName)
595
+ return match?.value
596
+ }
597
+ }
598
+
599
+ object LocalNetworkAddressResolver {
600
+ fun resolveIpv4Address(): String? {
601
+ val candidates = NetworkInterface.getNetworkInterfaces().toList()
602
+ .filter { it.isUp && !it.isLoopback && !it.isVirtual }
603
+ .flatMap { it.inetAddresses.toList() }
604
+ .filterIsInstance<Inet4Address>()
605
+ .filter { !it.isLoopbackAddress && !it.isLinkLocalAddress }
606
+ .mapNotNull { it.hostAddress }
607
+ return candidates.firstOrNull { it.startsWith("192.168.") || it.startsWith("10.") || it.startsWith("172.") } ?: candidates.firstOrNull()
608
+ }
609
+ }