@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.
- package/API_REFERENCE.md +158 -0
- package/CHANGELOG.md +9 -0
- package/LICENSE +12 -0
- package/README.md +118 -0
- package/app.plugin.js +1 -0
- package/dist/constants.d.ts +17 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +19 -0
- package/dist/discovery.d.ts +3 -0
- package/dist/discovery.d.ts.map +1 -0
- package/dist/discovery.js +10 -0
- package/dist/errors.d.ts +16 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +16 -0
- package/dist/host.d.ts +5 -0
- package/dist/host.d.ts.map +1 -0
- package/dist/host.js +23 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +35 -0
- package/dist/messageValidation.d.ts +3 -0
- package/dist/messageValidation.d.ts.map +1 -0
- package/dist/messageValidation.js +90 -0
- package/dist/nativeModule.d.ts +7 -0
- package/dist/nativeModule.d.ts.map +1 -0
- package/dist/nativeModule.js +39 -0
- package/dist/provider.d.ts +20 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.js +91 -0
- package/dist/providerUtils.d.ts +3 -0
- package/dist/providerUtils.d.ts.map +1 -0
- package/dist/providerUtils.js +18 -0
- package/dist/roomUtils.d.ts +3 -0
- package/dist/roomUtils.d.ts.map +1 -0
- package/dist/roomUtils.js +15 -0
- package/dist/types.d.ts +99 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/package.json +54 -0
- package/plugin/templates/android/OfflineSignalingPackage.kt +609 -0
- package/plugin/templates/ios/OfflineSignalingHost.swift +703 -0
- package/plugin/templates/ios/OfflineSignalingHostBridge.m +22 -0
- 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
|
+
}
|