@mentra/acs-meeting 3.2.0-dev.217 → 3.2.0-dev.222

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 (100) hide show
  1. package/README.md +4 -2
  2. package/android/build.gradle +23 -0
  3. package/android/src/androidTest/java/com/mentra/acsmeeting/network/SoftApFeasibilityGateTest.kt +291 -0
  4. package/android/src/main/AndroidManifest.xml +2 -0
  5. package/android/src/main/java/com/mentra/acsmeeting/AbandonedCallAgent.kt +36 -0
  6. package/android/src/main/java/com/mentra/acsmeeting/AcsMeetingModule.kt +392 -22
  7. package/android/src/main/java/com/mentra/acsmeeting/AcsMeetingSession.kt +909 -56
  8. package/android/src/main/java/com/mentra/acsmeeting/CallCapabilities.kt +33 -0
  9. package/android/src/main/java/com/mentra/acsmeeting/audio/AcsAudioPolicy.kt +21 -0
  10. package/android/src/main/java/com/mentra/acsmeeting/audio/AudioUplinkChain.kt +126 -0
  11. package/android/src/main/java/com/mentra/acsmeeting/audio/PcmBridge.kt +27 -4
  12. package/android/src/main/java/com/mentra/acsmeeting/audio/UplinkPacer.kt +45 -10
  13. package/android/src/main/java/com/mentra/acsmeeting/audio/UplinkSender.kt +110 -5
  14. package/android/src/main/java/com/mentra/acsmeeting/network/InternetHold.kt +501 -0
  15. package/android/src/main/java/com/mentra/acsmeeting/network/Ipv4Prefix.kt +51 -0
  16. package/android/src/main/java/com/mentra/acsmeeting/network/NetworkFacts.kt +81 -0
  17. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkChangeDetector.kt +355 -0
  18. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkError.kt +109 -0
  19. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkObserver.kt +160 -0
  20. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkReadiness.kt +103 -0
  21. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkRequestSpec.kt +53 -0
  22. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkState.kt +127 -0
  23. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedSoftApNetwork.kt +573 -0
  24. package/android/src/main/java/com/mentra/acsmeeting/source/CloudflareWhepSource.kt +10 -101
  25. package/android/src/main/java/com/mentra/acsmeeting/source/DecodedTrackRelay.kt +147 -0
  26. package/android/src/main/java/com/mentra/acsmeeting/source/GlassesMediaSource.kt +47 -5
  27. package/android/src/main/java/com/mentra/acsmeeting/source/LocalWhipIngestSource.kt +772 -0
  28. package/android/src/main/java/com/mentra/acsmeeting/source/MeetingVideoSourceSpec.kt +73 -0
  29. package/android/src/main/java/com/mentra/acsmeeting/source/SdpAdapter.kt +17 -0
  30. package/android/src/main/java/com/mentra/acsmeeting/source/SelectedIcePair.kt +139 -0
  31. package/android/src/main/java/com/mentra/acsmeeting/source/SoftApIceDiagnosis.kt +144 -0
  32. package/android/src/main/java/com/mentra/acsmeeting/source/SoftApIcePolicy.kt +46 -0
  33. package/android/src/main/java/com/mentra/acsmeeting/source/SoftApSdpGuard.kt +160 -0
  34. package/android/src/main/java/com/mentra/acsmeeting/source/TrackRegistry.kt +3 -1
  35. package/android/src/main/java/com/mentra/acsmeeting/source/VideoSourceArm.kt +69 -0
  36. package/android/src/main/java/com/mentra/acsmeeting/source/WhipIngestProtocol.kt +207 -0
  37. package/android/src/main/java/com/mentra/acsmeeting/source/WhipIngestServer.kt +356 -0
  38. package/android/src/main/java/com/mentra/acsmeeting/telemetry/AvSyncProbe.kt +155 -0
  39. package/android/src/main/java/com/mentra/acsmeeting/telemetry/PipelineStats.kt +94 -3
  40. package/android/src/main/java/com/mentra/acsmeeting/telemetry/PipelineTicker.kt +33 -5
  41. package/android/src/main/java/com/mentra/acsmeeting/telemetry/RingPercentile.kt +15 -0
  42. package/android/src/main/java/com/mentra/acsmeeting/telemetry/VideoQuality.kt +178 -0
  43. package/android/src/main/java/com/mentra/acsmeeting/telemetry/VideoRateVerdict.kt +145 -0
  44. package/android/src/main/java/com/mentra/acsmeeting/trace/SoftApTrace.kt +143 -0
  45. package/android/src/main/java/com/mentra/acsmeeting/video/AcsFrameSender.kt +23 -4
  46. package/android/src/main/java/com/mentra/acsmeeting/video/FramePacer.kt +70 -0
  47. package/android/src/main/java/com/mentra/acsmeeting/video/I420FormatSpec.kt +1 -1
  48. package/android/src/main/java/com/mentra/acsmeeting/video/VideoProfile.kt +36 -3
  49. package/android/src/test/java/com/mentra/acsmeeting/AbandonedCallAgentTest.kt +74 -0
  50. package/android/src/test/java/com/mentra/acsmeeting/CallCapabilitiesTest.kt +47 -0
  51. package/android/src/test/java/com/mentra/acsmeeting/audio/AudioUplinkChainTest.kt +250 -0
  52. package/android/src/test/java/com/mentra/acsmeeting/audio/CapturePolicyTest.kt +44 -0
  53. package/android/src/test/java/com/mentra/acsmeeting/audio/UplinkPacerTest.kt +34 -4
  54. package/android/src/test/java/com/mentra/acsmeeting/audio/UplinkSenderTest.kt +96 -0
  55. package/android/src/test/java/com/mentra/acsmeeting/network/InternetHoldLifecycleTest.kt +102 -0
  56. package/android/src/test/java/com/mentra/acsmeeting/network/InternetHoldTest.kt +148 -0
  57. package/android/src/test/java/com/mentra/acsmeeting/network/Ipv4PrefixTest.kt +59 -0
  58. package/android/src/test/java/com/mentra/acsmeeting/network/NetworkFactsTest.kt +59 -0
  59. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedLocalNetworkPermissionTest.kt +35 -0
  60. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkChangeDetectorTest.kt +165 -0
  61. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkErrorTest.kt +96 -0
  62. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkLifecycleTest.kt +187 -0
  63. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkObserverTest.kt +172 -0
  64. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkReadinessTest.kt +141 -0
  65. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkRequestSpecTest.kt +69 -0
  66. package/android/src/test/java/com/mentra/acsmeeting/source/AcsInvestigationTest.kt +40 -0
  67. package/android/src/test/java/com/mentra/acsmeeting/source/DecodedTrackRelayTest.kt +41 -0
  68. package/android/src/test/java/com/mentra/acsmeeting/source/GlassesMediaSourceTest.kt +26 -8
  69. package/android/src/test/java/com/mentra/acsmeeting/source/MeetingVideoSourceSpecTest.kt +109 -0
  70. package/android/src/test/java/com/mentra/acsmeeting/source/SelectedIcePairTest.kt +165 -0
  71. package/android/src/test/java/com/mentra/acsmeeting/source/SoftApIceDiagnosisTest.kt +148 -0
  72. package/android/src/test/java/com/mentra/acsmeeting/source/SoftApIcePolicyTest.kt +76 -0
  73. package/android/src/test/java/com/mentra/acsmeeting/source/SoftApSdpGuardTest.kt +275 -0
  74. package/android/src/test/java/com/mentra/acsmeeting/source/TrackRegistryTest.kt +9 -0
  75. package/android/src/test/java/com/mentra/acsmeeting/source/WhipIngestProtocolTest.kt +356 -0
  76. package/android/src/test/java/com/mentra/acsmeeting/source/WhipIngestServerLoopbackTest.kt +411 -0
  77. package/android/src/test/java/com/mentra/acsmeeting/telemetry/AvSyncProbeTest.kt +83 -0
  78. package/android/src/test/java/com/mentra/acsmeeting/telemetry/PipelineStatsTest.kt +69 -3
  79. package/android/src/test/java/com/mentra/acsmeeting/telemetry/VideoQualityTest.kt +182 -0
  80. package/android/src/test/java/com/mentra/acsmeeting/telemetry/VideoRateVerdictTest.kt +149 -0
  81. package/android/src/test/java/com/mentra/acsmeeting/trace/SoftApTraceTest.kt +97 -0
  82. package/android/src/test/java/com/mentra/acsmeeting/video/AcsTimestampTest.kt +34 -0
  83. package/android/src/test/java/com/mentra/acsmeeting/video/FramePacerTest.kt +105 -0
  84. package/android/src/test/java/com/mentra/acsmeeting/video/I420FormatSpecTest.kt +5 -0
  85. package/android/src/test/java/com/mentra/acsmeeting/video/VideoProfileTest.kt +38 -3
  86. package/build/AcsMeeting.types.d.ts +5 -0
  87. package/build/AcsMeeting.types.d.ts.map +1 -1
  88. package/build/AcsMeeting.types.js.map +1 -1
  89. package/build/AcsMeetingModule.d.ts +23 -0
  90. package/build/AcsMeetingModule.d.ts.map +1 -1
  91. package/build/AcsMeetingModule.js.map +1 -1
  92. package/build/AcsMeetingModule.web.d.ts +15 -0
  93. package/build/AcsMeetingModule.web.d.ts.map +1 -1
  94. package/build/AcsMeetingModule.web.js +13 -0
  95. package/build/AcsMeetingModule.web.js.map +1 -1
  96. package/ios/AcsMeetingModule.swift +213 -2
  97. package/package.json +1 -1
  98. package/src/AcsMeeting.types.ts +5 -0
  99. package/src/AcsMeetingModule.ts +13 -0
  100. package/src/AcsMeetingModule.web.ts +13 -0
@@ -0,0 +1,207 @@
1
+ package com.mentra.acsmeeting.source
2
+
3
+ /**
4
+ * The WHIP endpoint's request handling, as a pure function.
5
+ *
6
+ * Everything here is decided from the request plus [IngestState]; sockets, peers and SDP generation
7
+ * live in [WhipIngestServer]. That split exists because the interesting failures are protocol
8
+ * failures — a second POST while a session is live, a DELETE against a stale id, a POST that
9
+ * arrives after teardown — and those are painful to provoke through a socket and trivial to state
10
+ * as a table.
11
+ *
12
+ * The glasses are the offerer and keep the WHIP client they already use against Cloudflare. Only
13
+ * the endpoint moves onto the phone. So this implements the endpoint half of WHIP, and only the
14
+ * parts one publisher on a private link needs: no trickle `PATCH`, no ICE restart, no auth.
15
+ */
16
+ object WhipIngestProtocol {
17
+
18
+ /** Base resource the glasses POST an offer to. */
19
+ const val BASE_PATH = "/whip"
20
+
21
+ /**
22
+ * Largest offer we will buffer. Real offers are 2-6 KB; the cap is generous but finite so a
23
+ * confused or hostile client on the hotspot cannot make the phone allocate without bound.
24
+ */
25
+ const val MAX_BODY_BYTES = 256 * 1024L
26
+
27
+ const val SDP_CONTENT_TYPE = "application/sdp"
28
+
29
+ /** A parsed request. [body] is only populated once the length check has passed. */
30
+ data class Request(
31
+ val method: String,
32
+ val target: String,
33
+ val contentType: String? = null,
34
+ val contentLength: Long? = null,
35
+ val body: String = "",
36
+ )
37
+
38
+ /**
39
+ * @param activeSessionId the one publisher currently negotiated or negotiating, if any
40
+ * @param stopped set by [WhipIngestServer.stop]. The listener stays up briefly afterwards so a
41
+ * late POST gets a real `410 Gone` instead of a connection reset it cannot distinguish from a
42
+ * network glitch.
43
+ */
44
+ data class State(val activeSessionId: String? = null, val stopped: Boolean = false)
45
+
46
+ /** Where to point the `Location` header. WHIP clients DELETE against exactly this URL. */
47
+ data class Endpoint(val host: String, val port: Int) {
48
+ fun sessionUrl(sessionId: String) = "http://$host:$port$BASE_PATH/$sessionId"
49
+ }
50
+
51
+ data class Response(
52
+ val status: Int,
53
+ val reason: String,
54
+ val headers: Map<String, String> = emptyMap(),
55
+ val body: String = "",
56
+ )
57
+
58
+ sealed interface Action {
59
+ /**
60
+ * A valid offer for a free endpoint. The shell must answer it and reply `201` with the answer
61
+ * and an absolute `Location`; [respondNegotiated] and [respondNegotiationFailed] build those.
62
+ */
63
+ data class Negotiate(val sessionId: String, val offer: String) : Action
64
+
65
+ /** A DELETE matching the live session. The shell tears down, then replies with [terminated]. */
66
+ data class Terminate(val sessionId: String) : Action
67
+
68
+ /** Nothing to do but write this. */
69
+ data class Reply(val response: Response) : Action
70
+ }
71
+
72
+ /**
73
+ * @param newSessionId id to assign if this request opens a session. Passed in rather than minted
74
+ * here so the decision stays deterministic under test.
75
+ */
76
+ fun decide(request: Request, state: State, newSessionId: String): Action {
77
+ // Tombstone first. A POST arriving after stop() is well-formed and would otherwise be granted
78
+ // a session on an endpoint whose peer and scoped network are already gone.
79
+ if (state.stopped) {
80
+ return Action.Reply(Response(410, "Gone", body = "endpoint stopped"))
81
+ }
82
+
83
+ val target = request.target.substringBefore('?')
84
+ val sessionId = sessionIdOf(target)
85
+
86
+ return when {
87
+ target == BASE_PATH || target == "$BASE_PATH/" -> decideOnBase(request, state, newSessionId)
88
+ sessionId != null -> decideOnSession(request, state, sessionId)
89
+ else -> Action.Reply(Response(404, "Not Found", body = "no such resource"))
90
+ }
91
+ }
92
+
93
+ private fun decideOnBase(request: Request, state: State, newSessionId: String): Action {
94
+ if (!request.method.equals("POST", ignoreCase = true)) {
95
+ return Action.Reply(
96
+ Response(405, "Method Not Allowed", mapOf("Allow" to "POST"), "POST an SDP offer"),
97
+ )
98
+ }
99
+ // Length before type: we refuse to read the body at all, so we cannot have parsed its type.
100
+ val length = request.contentLength
101
+ if (length != null && length > MAX_BODY_BYTES) {
102
+ return Action.Reply(
103
+ Response(413, "Content Too Large", body = "offer exceeds $MAX_BODY_BYTES bytes"),
104
+ )
105
+ }
106
+ if (!isSdp(request.contentType)) {
107
+ return Action.Reply(
108
+ Response(415, "Unsupported Media Type", body = "expected $SDP_CONTENT_TYPE"),
109
+ )
110
+ }
111
+ if (request.body.isBlank()) {
112
+ return Action.Reply(Response(400, "Bad Request", body = "empty offer"))
113
+ }
114
+ // One publisher. A second POST means the glasses re-published without deleting — usually a
115
+ // retry after a reply they never saw. 409 keeps the live session rather than replacing a
116
+ // working peer with a speculative one; the orchestrator's teardown is what frees the slot.
117
+ state.activeSessionId?.let {
118
+ return Action.Reply(
119
+ Response(409, "Conflict", mapOf("Location" to it), "session $it already publishing"),
120
+ )
121
+ }
122
+ return Action.Negotiate(newSessionId, request.body)
123
+ }
124
+
125
+ private fun decideOnSession(request: Request, state: State, sessionId: String): Action {
126
+ if (!request.method.equals("DELETE", ignoreCase = true)) {
127
+ return Action.Reply(
128
+ Response(405, "Method Not Allowed", mapOf("Allow" to "DELETE"), "DELETE to terminate"),
129
+ )
130
+ }
131
+ if (sessionId != state.activeSessionId) {
132
+ return Action.Reply(Response(404, "Not Found", body = "session $sessionId is not active"))
133
+ }
134
+ return Action.Terminate(sessionId)
135
+ }
136
+
137
+ /** `201` with the answer. WHIP requires the endpoint to gather fully before answering. */
138
+ fun respondNegotiated(endpoint: Endpoint, sessionId: String, answerSdp: String) = Response(
139
+ status = 201,
140
+ reason = "Created",
141
+ headers = mapOf(
142
+ "Location" to endpoint.sessionUrl(sessionId),
143
+ "Content-Type" to SDP_CONTENT_TYPE,
144
+ ),
145
+ body = answerSdp,
146
+ )
147
+
148
+ /**
149
+ * The offer was acceptable but we could not answer it — no answer in time, no host candidate on
150
+ * the hotspot subnet, peer creation failed. `500` and not `4xx`: the publisher did nothing wrong,
151
+ * and a retry may well succeed.
152
+ */
153
+ fun respondNegotiationFailed(reason: String) =
154
+ Response(500, "Internal Server Error", body = reason)
155
+
156
+ /** WHIP terminations return no content. */
157
+ fun terminated() = Response(204, "No Content")
158
+
159
+ private fun isSdp(contentType: String?): Boolean {
160
+ val value = contentType?.substringBefore(';')?.trim() ?: return false
161
+ return value.equals(SDP_CONTENT_TYPE, ignoreCase = true)
162
+ }
163
+
164
+ /** `/whip/<id>` -> `<id>`, for a single non-empty path segment. Anything else is not a session. */
165
+ private fun sessionIdOf(target: String): String? {
166
+ if (!target.startsWith("$BASE_PATH/")) return null
167
+ val remainder = target.removePrefix("$BASE_PATH/")
168
+ if (remainder.isEmpty() || remainder.contains('/')) return null
169
+ return remainder
170
+ }
171
+
172
+ /**
173
+ * Renders a response to the wire. `Content-Length` is always present and the connection always
174
+ * closes: the glasses' WHIP client makes one request per connection, and a missing length turns
175
+ * a clean `410` into a hang while the client waits for a body that never ends.
176
+ */
177
+ fun render(response: Response): String {
178
+ val bodyBytes = response.body.toByteArray(Charsets.UTF_8)
179
+ val head = StringBuilder("HTTP/1.1 ${response.status} ${response.reason}\r\n")
180
+ for ((name, value) in response.headers) {
181
+ head.append("$name: $value\r\n")
182
+ }
183
+ if (!response.headers.keys.any { it.equals("Content-Type", ignoreCase = true) } &&
184
+ bodyBytes.isNotEmpty()
185
+ ) {
186
+ head.append("Content-Type: text/plain; charset=utf-8\r\n")
187
+ }
188
+ head.append("Content-Length: ${bodyBytes.size}\r\n")
189
+ head.append("Connection: close\r\n\r\n")
190
+ head.append(response.body)
191
+ return head.toString()
192
+ }
193
+
194
+ /** Parses `POST /whip HTTP/1.1`. Null when the line is not a plausible request line. */
195
+ fun parseRequestLine(line: String): Pair<String, String>? {
196
+ val parts = line.trim().split(' ').filter { it.isNotEmpty() }
197
+ if (parts.size < 2) return null
198
+ val method = parts[0]
199
+ val target = parts[1]
200
+ if (method.isEmpty() || !target.startsWith("/")) return null
201
+ return method to target
202
+ }
203
+
204
+ /** Case-insensitive header lookup, as HTTP requires and OkHttp clients assume. */
205
+ fun headerValue(headers: Map<String, String>, name: String): String? =
206
+ headers.entries.firstOrNull { it.key.equals(name, ignoreCase = true) }?.value
207
+ }
@@ -0,0 +1,356 @@
1
+ package com.mentra.acsmeeting.source
2
+
3
+ import android.util.Log
4
+ import com.mentra.acsmeeting.trace.SoftApTrace
5
+ import java.io.BufferedOutputStream
6
+ import java.io.IOException
7
+ import java.io.InputStream
8
+ import java.net.InetAddress
9
+ import java.net.InetSocketAddress
10
+ import java.net.ServerSocket
11
+ import java.net.Socket
12
+ import java.net.SocketException
13
+ import java.util.UUID
14
+ import java.util.concurrent.Executors
15
+ import java.util.concurrent.TimeUnit
16
+ import java.util.concurrent.atomic.AtomicInteger
17
+
18
+ /**
19
+ * The WHIP endpoint, moved off Cloudflare and onto the phone.
20
+ *
21
+ * The glasses keep publishing exactly as they do today — POST an SDP offer, read the answer, DELETE
22
+ * the `Location` when done. Only the address changes, from an HTTPS Cloudflare URL to
23
+ * `http://192.168.43.x:port/whip` on the hotspot. Keeping the glasses as the offerer is what makes
24
+ * this a small change on the device that is hardest to debug.
25
+ *
26
+ * The listener is bound to the SoftAP [InetAddress] itself rather than to the wildcard address.
27
+ * `Network.bindSocket` has no `ServerSocket` overload, and it is not needed: binding to that one
28
+ * local address is what restricts which interface can accept these connections, and it keeps the
29
+ * endpoint off the phone's other networks. Binding libwebrtc's own UDP sockets is a separate
30
+ * problem and is handled by the host-only ICE configuration in [LocalWhipIngestSource] plus
31
+ * [com.mentra.acsmeeting.network.ScopedNetworkChangeDetector].
32
+ *
33
+ * Protocol decisions live in [WhipIngestProtocol]; this class owns sockets, threads and the session
34
+ * slot.
35
+ */
36
+ class WhipIngestServer(
37
+ private val negotiator: Negotiator,
38
+ private val idFactory: () -> String = { UUID.randomUUID().toString().replace("-", "").take(12) },
39
+ ) {
40
+
41
+ /**
42
+ * Turns an offer into an answer. Separate from the server so the protocol and socket behaviour
43
+ * test without WebRTC, and so the peer can be owned by the session rather than by the listener.
44
+ */
45
+ interface Negotiator {
46
+ /**
47
+ * Called off the accept thread and expected to block until ICE gathering completes: WHIP
48
+ * requires the endpoint to gather fully before answering, since neither side does trickle
49
+ * `PATCH` here. Returning a failure produces `500` and frees the session slot.
50
+ */
51
+ fun negotiate(sessionId: String, offer: String): Result<String>
52
+
53
+ /** Release the peer for [sessionId]. Must be idempotent; DELETE and stop() can both call it. */
54
+ fun terminate(sessionId: String)
55
+
56
+ /**
57
+ * ICE on the live publisher is already dead. A second POST then replaces instead of 409:
58
+ * the glasses reconnect after `PeerConnection disconnected` without a DELETE that freed
59
+ * the slot, and 409 is what left the meeting black.
60
+ */
61
+ fun publisherFailed(): Boolean = false
62
+ }
63
+
64
+ private val lock = Any()
65
+ private var state = WhipIngestProtocol.State()
66
+ private var server: ServerSocket? = null
67
+ private var endpoint: WhipIngestProtocol.Endpoint? = null
68
+ private var acceptThread: Thread? = null
69
+ private val connections = Executors.newCachedThreadPool { runnable ->
70
+ Thread(runnable, "whip-ingest-conn").apply { isDaemon = true }
71
+ }
72
+ private val accepted = AtomicInteger()
73
+
74
+ /** Bound endpoint, or null before [start]. This is the URL the glasses must be told to POST to. */
75
+ val boundEndpoint: WhipIngestProtocol.Endpoint?
76
+ get() = synchronized(lock) { endpoint }
77
+
78
+ /**
79
+ * Binds and begins accepting.
80
+ *
81
+ * @param address the phone's own address on the SoftAP, from
82
+ * [com.mentra.acsmeeting.network.ScopedSoftApNetwork.localIpv4]
83
+ * @param port 0 to let the OS choose, which is what production does; the fixed-port form exists
84
+ * for tests
85
+ */
86
+ fun start(address: InetAddress, port: Int = 0): WhipIngestProtocol.Endpoint {
87
+ synchronized(lock) {
88
+ check(server == null) { "ingest server already started" }
89
+ val socket = ServerSocket()
90
+ socket.reuseAddress = true
91
+ socket.bind(InetSocketAddress(address, port), BACKLOG)
92
+ val bound = WhipIngestProtocol.Endpoint(address.hostAddress ?: "127.0.0.1", socket.localPort)
93
+ server = socket
94
+ endpoint = bound
95
+ state = WhipIngestProtocol.State()
96
+ acceptThread = Thread({ acceptLoop(socket) }, "whip-ingest-accept").apply {
97
+ isDaemon = true
98
+ start()
99
+ }
100
+ SoftApTrace.stage(
101
+ "whip_listener_bound",
102
+ "host" to bound.host,
103
+ "port" to bound.port,
104
+ "url" to "${bound.host}:${bound.port}${WhipIngestProtocol.BASE_PATH}",
105
+ )
106
+ return bound
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Stops accepting new publishers and tears the live session down.
112
+ *
113
+ * The listener is not closed immediately. For [TOMBSTONE_MS] it stays up answering `410 Gone`, so
114
+ * a POST or DELETE already in flight from the glasses gets a real HTTP status instead of a
115
+ * connection reset — which a client cannot tell apart from a Wi-Fi glitch, and would retry.
116
+ */
117
+ fun stop() {
118
+ val live: String?
119
+ synchronized(lock) {
120
+ if (server == null) return
121
+ live = state.activeSessionId
122
+ state = WhipIngestProtocol.State(activeSessionId = null, stopped = true)
123
+ }
124
+ live?.let { runCatching { negotiator.terminate(it) } }
125
+ SoftApTrace.stage("whip_listener_tombstoned", "graceMs" to TOMBSTONE_MS, "session" to live)
126
+
127
+ val closer = Thread({
128
+ Thread.sleep(TOMBSTONE_MS)
129
+ closeListener()
130
+ }, "whip-ingest-tombstone")
131
+ closer.isDaemon = true
132
+ closer.start()
133
+ }
134
+
135
+ /** Closes the listener without waiting out the tombstone. For tests and hard teardown. */
136
+ fun closeNow() {
137
+ synchronized(lock) {
138
+ if (server == null) return
139
+ state = WhipIngestProtocol.State(activeSessionId = null, stopped = true)
140
+ }
141
+ closeListener()
142
+ }
143
+
144
+ private fun closeListener() {
145
+ val socket = synchronized(lock) {
146
+ val current = server
147
+ server = null
148
+ endpoint = null
149
+ current
150
+ } ?: return
151
+ runCatching { socket.close() }
152
+ connections.shutdownNow()
153
+ SoftApTrace.stage("whip_listener_closed", "accepted" to accepted.get())
154
+ }
155
+
156
+ private fun acceptLoop(socket: ServerSocket) {
157
+ while (!socket.isClosed) {
158
+ val connection = try {
159
+ socket.accept()
160
+ } catch (_: SocketException) {
161
+ return // closed under us; the only expected exit
162
+ } catch (error: IOException) {
163
+ Log.w(TAG, "accept failed", error)
164
+ return
165
+ }
166
+ accepted.incrementAndGet()
167
+ // Each connection on its own thread: a negotiation blocks for the length of an ICE gather,
168
+ // and a DELETE arriving during one must not queue behind it.
169
+ try {
170
+ connections.execute { serve(connection) }
171
+ } catch (_: java.util.concurrent.RejectedExecutionException) {
172
+ runCatching { connection.close() }
173
+ }
174
+ }
175
+ }
176
+
177
+ private fun serve(connection: Socket) {
178
+ connection.use { socket ->
179
+ socket.soTimeout = READ_TIMEOUT_MS
180
+ val output = BufferedOutputStream(socket.getOutputStream())
181
+ try {
182
+ val request = readRequest(socket.getInputStream())
183
+ if (request == null) {
184
+ write(output, WhipIngestProtocol.Response(400, "Bad Request", body = "malformed request"))
185
+ return
186
+ }
187
+ write(output, handle(request))
188
+ } catch (error: Exception) {
189
+ Log.w(TAG, "connection failed", error)
190
+ runCatching {
191
+ write(output, WhipIngestProtocol.Response(400, "Bad Request", body = "read failed"))
192
+ }
193
+ }
194
+ }
195
+ }
196
+
197
+ private fun handle(request: WhipIngestProtocol.Request): WhipIngestProtocol.Response {
198
+ val action: WhipIngestProtocol.Action
199
+ val endpointSnapshot: WhipIngestProtocol.Endpoint?
200
+ var deadSession: String? = null
201
+ synchronized(lock) {
202
+ val decided = WhipIngestProtocol.decide(request, state, idFactory())
203
+ endpointSnapshot = endpoint
204
+ val replaceDead =
205
+ decided is WhipIngestProtocol.Action.Reply &&
206
+ decided.response.status == 409 &&
207
+ negotiator.publisherFailed()
208
+ if (replaceDead) {
209
+ deadSession = state.activeSessionId
210
+ val replacement = WhipIngestProtocol.Action.Negotiate(idFactory(), request.body)
211
+ state = state.copy(activeSessionId = replacement.sessionId)
212
+ action = replacement
213
+ } else {
214
+ action = decided
215
+ // Reserve the slot inside the lock so a duplicate POST during a gather gets 409 rather than
216
+ // creating a second peer for the same publisher.
217
+ when (decided) {
218
+ is WhipIngestProtocol.Action.Negotiate ->
219
+ state = state.copy(activeSessionId = decided.sessionId)
220
+ is WhipIngestProtocol.Action.Terminate ->
221
+ state = state.copy(activeSessionId = null)
222
+ is WhipIngestProtocol.Action.Reply -> Unit
223
+ }
224
+ }
225
+ }
226
+ deadSession?.let { released ->
227
+ SoftApTrace.stage(
228
+ "whip_replace_dead_publisher",
229
+ "released" to released,
230
+ "session" to (action as? WhipIngestProtocol.Action.Negotiate)?.sessionId,
231
+ )
232
+ runCatching { negotiator.terminate(released) }
233
+ }
234
+
235
+ return when (action) {
236
+ is WhipIngestProtocol.Action.Reply -> action.response.also {
237
+ if (it.status >= 400) {
238
+ SoftApTrace.stage("whip_request_rejected", "status" to it.status, "path" to request.target)
239
+ }
240
+ }
241
+
242
+ is WhipIngestProtocol.Action.Terminate -> {
243
+ SoftApTrace.stage("whip_session_deleted", "session" to action.sessionId)
244
+ runCatching { negotiator.terminate(action.sessionId) }
245
+ WhipIngestProtocol.terminated()
246
+ }
247
+
248
+ is WhipIngestProtocol.Action.Negotiate -> {
249
+ SoftApTrace.stage(
250
+ "whip_offer_received",
251
+ "session" to action.sessionId,
252
+ "offerBytes" to action.offer.length,
253
+ )
254
+ val answer = runCatching { negotiator.negotiate(action.sessionId, action.offer) }
255
+ .getOrElse { Result.failure(it) }
256
+ answer.fold(
257
+ onSuccess = { sdp ->
258
+ val target = endpointSnapshot
259
+ if (target == null) {
260
+ releaseSlot(action.sessionId)
261
+ WhipIngestProtocol.respondNegotiationFailed("endpoint closed during negotiation")
262
+ } else {
263
+ SoftApTrace.stage(
264
+ "whip_answer_sent",
265
+ "session" to action.sessionId,
266
+ "answerBytes" to sdp.length,
267
+ )
268
+ WhipIngestProtocol.respondNegotiated(target, action.sessionId, sdp)
269
+ }
270
+ },
271
+ onFailure = { error ->
272
+ releaseSlot(action.sessionId)
273
+ val reason = error.message ?: error.javaClass.simpleName
274
+ SoftApTrace.failure(
275
+ "whip_negotiation_failed",
276
+ "reason" to reason,
277
+ "session" to action.sessionId,
278
+ )
279
+ WhipIngestProtocol.respondNegotiationFailed(reason)
280
+ },
281
+ )
282
+ }
283
+ }
284
+ }
285
+
286
+ /** Frees the slot only if this session still holds it, so a newer publisher is not evicted. */
287
+ private fun releaseSlot(sessionId: String) {
288
+ synchronized(lock) {
289
+ if (state.activeSessionId == sessionId) {
290
+ state = state.copy(activeSessionId = null)
291
+ }
292
+ }
293
+ runCatching { negotiator.terminate(sessionId) }
294
+ }
295
+
296
+ private fun write(output: BufferedOutputStream, response: WhipIngestProtocol.Response) {
297
+ output.write(WhipIngestProtocol.render(response).toByteArray(Charsets.UTF_8))
298
+ output.flush()
299
+ }
300
+
301
+ companion object {
302
+ private const val TAG = "ACS-SPIKE"
303
+ private const val BACKLOG = 4
304
+ private const val READ_TIMEOUT_MS = 10_000
305
+
306
+ /**
307
+ * How long the endpoint answers `410` after [stop]. Long enough for an in-flight request from
308
+ * the glasses to land, short enough not to hold the port across a rejoin.
309
+ */
310
+ const val TOMBSTONE_MS = 3_000L
311
+
312
+ /**
313
+ * Reads a request into [WhipIngestProtocol.Request]. Only `Content-Length` bodies are
314
+ * supported: the glasses' WHIP client sends one, and `chunked` would be a silent 400 otherwise,
315
+ * so it is rejected explicitly by the missing-length path below.
316
+ */
317
+ fun readRequest(stream: InputStream): WhipIngestProtocol.Request? {
318
+ val reader = stream.bufferedReader(Charsets.UTF_8)
319
+ val (method, target) = WhipIngestProtocol.parseRequestLine(
320
+ reader.readLine() ?: return null,
321
+ ) ?: return null
322
+
323
+ val headers = mutableMapOf<String, String>()
324
+ while (true) {
325
+ val line = reader.readLine() ?: break
326
+ if (line.isEmpty()) break
327
+ val separator = line.indexOf(':')
328
+ if (separator <= 0) continue
329
+ headers[line.substring(0, separator).trim()] = line.substring(separator + 1).trim()
330
+ }
331
+
332
+ val contentType = WhipIngestProtocol.headerValue(headers, "Content-Type")
333
+ val contentLength = WhipIngestProtocol.headerValue(headers, "Content-Length")?.toLongOrNull()
334
+
335
+ // Do not read a body we have already decided to refuse. decide() checks the length first for
336
+ // exactly this reason.
337
+ if (contentLength == null || contentLength <= 0 ||
338
+ contentLength > WhipIngestProtocol.MAX_BODY_BYTES
339
+ ) {
340
+ return WhipIngestProtocol.Request(method, target, contentType, contentLength)
341
+ }
342
+
343
+ val body = CharArray(contentLength.toInt())
344
+ var read = 0
345
+ while (read < body.size) {
346
+ val count = reader.read(body, read, body.size - read)
347
+ if (count < 0) break
348
+ read += count
349
+ }
350
+ return WhipIngestProtocol.Request(method, target, contentType, contentLength, String(body, 0, read))
351
+ }
352
+
353
+ /** Convenience for tests and teardown paths that must not outlive the tombstone. */
354
+ fun awaitTombstone() = TimeUnit.MILLISECONDS.sleep(TOMBSTONE_MS + 250)
355
+ }
356
+ }