@mentra/acs-meeting 3.2.0-dev.223 → 3.2.0-dev.225
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/README.md +14 -3
- package/android/build.gradle +1 -0
- package/android/src/main/java/com/mentra/acsmeeting/AcsMeetingModule.kt +10 -10
- package/android/src/main/java/com/mentra/acsmeeting/AcsMeetingSession.kt +37 -37
- package/android/src/main/java/com/mentra/acsmeeting/audio/UplinkSender.kt +4 -4
- package/android/src/main/java/com/mentra/acsmeeting/source/MeetingVideoSourceSpec.kt +3 -0
- package/android/src/main/java/com/mentra/acsmeeting/video/AcsFrameSender.kt +12 -10
- package/android/src/main/java/com/mentra/acsmeeting/video/I420FormatSpec.kt +2 -0
- package/android/src/main/java/com/mentra/acsmeeting/video/Nv12Packer.kt +2 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/{AcsInvestigationTest.kt → MediaDiagnosticsTest.kt} +10 -10
- package/android/src/test/java/com/mentra/acsmeeting/source/MeetingVideoSourceSpecTest.kt +3 -0
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/VideoQualityTest.kt +2 -2
- package/android/src/test/java/com/mentra/acsmeeting/video/I420FormatSpecTest.kt +2 -0
- package/android/src/test/java/com/mentra/acsmeeting/video/VideoProfileTest.kt +2 -2
- package/build/AcsMeeting.types.d.ts +10 -1
- package/build/AcsMeeting.types.d.ts.map +1 -1
- package/build/AcsMeeting.types.js.map +1 -1
- package/build/AcsMeetingModule.d.ts +2 -2
- package/build/AcsMeetingModule.d.ts.map +1 -1
- package/build/AcsMeetingModule.js.map +1 -1
- package/ios/AcsMeeting.podspec +1 -1
- package/ios/AcsMeetingModule.swift +284 -148
- package/ios/PolicyKit/Package.swift +2 -1
- package/ios/PolicyKit/Sources/AcsAudioPolicy/CallbackOperation.swift +60 -0
- package/ios/PolicyKit/Sources/AcsAudioPolicy/CapturePolicy.swift +5 -0
- package/ios/PolicyKit/Tests/AcsAudioPolicyTests/AcsAudioPolicyTests.swift +1 -0
- package/ios/PolicyKit/Tests/AcsAudioPolicyTests/CallAgentRetirementTests.swift +113 -0
- package/package.json +3 -2
- package/src/AcsMeeting.types.ts +4 -1
- package/src/AcsMeetingModule.ts +2 -2
- package/android/src/androidTest/java/com/mentra/acsmeeting/network/SoftApFeasibilityGateTest.kt +0 -291
- package/android/src/main/java/com/mentra/acsmeeting/network/InternetHold.kt +0 -501
- package/android/src/main/java/com/mentra/acsmeeting/network/Ipv4Prefix.kt +0 -51
- package/android/src/main/java/com/mentra/acsmeeting/network/NetworkFacts.kt +0 -81
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkChangeDetector.kt +0 -355
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkError.kt +0 -109
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkObserver.kt +0 -160
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkReadiness.kt +0 -103
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkRequestSpec.kt +0 -53
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkState.kt +0 -127
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedSoftApNetwork.kt +0 -573
- package/android/src/main/java/com/mentra/acsmeeting/source/BitmapFont.kt +0 -99
- package/android/src/main/java/com/mentra/acsmeeting/source/CloudflareWhepSource.kt +0 -487
- package/android/src/main/java/com/mentra/acsmeeting/source/DecodedTrackRelay.kt +0 -147
- package/android/src/main/java/com/mentra/acsmeeting/source/FirstFrameGate.kt +0 -45
- package/android/src/main/java/com/mentra/acsmeeting/source/GlassesMediaSource.kt +0 -143
- package/android/src/main/java/com/mentra/acsmeeting/source/LocalWhipIngestSource.kt +0 -772
- package/android/src/main/java/com/mentra/acsmeeting/source/MediaListeners.kt +0 -57
- package/android/src/main/java/com/mentra/acsmeeting/source/SdpAdapter.kt +0 -17
- package/android/src/main/java/com/mentra/acsmeeting/source/SelectedIcePair.kt +0 -139
- package/android/src/main/java/com/mentra/acsmeeting/source/SoftApIceDiagnosis.kt +0 -144
- package/android/src/main/java/com/mentra/acsmeeting/source/SoftApIcePolicy.kt +0 -46
- package/android/src/main/java/com/mentra/acsmeeting/source/SoftApSdpGuard.kt +0 -160
- package/android/src/main/java/com/mentra/acsmeeting/source/SyntheticFrameFactory.kt +0 -205
- package/android/src/main/java/com/mentra/acsmeeting/source/SyntheticI420Source.kt +0 -214
- package/android/src/main/java/com/mentra/acsmeeting/source/TrackRegistry.kt +0 -33
- package/android/src/main/java/com/mentra/acsmeeting/source/VideoSourceArm.kt +0 -110
- package/android/src/main/java/com/mentra/acsmeeting/source/WhipIngestProtocol.kt +0 -207
- package/android/src/main/java/com/mentra/acsmeeting/source/WhipIngestServer.kt +0 -356
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/AvSyncProbe.kt +0 -155
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/ChromaProbe.kt +0 -78
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/PipelineStats.kt +0 -372
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/PipelineTicker.kt +0 -79
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/ProcessCpu.kt +0 -15
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/RingPercentile.kt +0 -48
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/VideoQuality.kt +0 -178
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/VideoRateVerdict.kt +0 -145
- package/android/src/main/java/com/mentra/acsmeeting/trace/SoftApTrace.kt +0 -143
- package/android/src/main/java/com/mentra/acsmeeting/video/FrameGeometry.kt +0 -22
- package/android/src/main/java/com/mentra/acsmeeting/video/I420Packer.kt +0 -72
- package/android/src/test/java/com/mentra/acsmeeting/network/InternetHoldLifecycleTest.kt +0 -102
- package/android/src/test/java/com/mentra/acsmeeting/network/InternetHoldTest.kt +0 -148
- package/android/src/test/java/com/mentra/acsmeeting/network/Ipv4PrefixTest.kt +0 -59
- package/android/src/test/java/com/mentra/acsmeeting/network/NetworkFactsTest.kt +0 -59
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedLocalNetworkPermissionTest.kt +0 -35
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkChangeDetectorTest.kt +0 -165
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkErrorTest.kt +0 -96
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkLifecycleTest.kt +0 -187
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkObserverTest.kt +0 -172
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkReadinessTest.kt +0 -141
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkRequestSpecTest.kt +0 -69
- package/android/src/test/java/com/mentra/acsmeeting/source/BitmapFontTest.kt +0 -22
- package/android/src/test/java/com/mentra/acsmeeting/source/DecodedTrackRelayTest.kt +0 -41
- package/android/src/test/java/com/mentra/acsmeeting/source/FirstFrameGateTest.kt +0 -60
- package/android/src/test/java/com/mentra/acsmeeting/source/GlassesMediaSourceTest.kt +0 -151
- package/android/src/test/java/com/mentra/acsmeeting/source/I420PlanesTest.kt +0 -67
- package/android/src/test/java/com/mentra/acsmeeting/source/SelectedIcePairTest.kt +0 -165
- package/android/src/test/java/com/mentra/acsmeeting/source/SoftApIceDiagnosisTest.kt +0 -148
- package/android/src/test/java/com/mentra/acsmeeting/source/SoftApIcePolicyTest.kt +0 -76
- package/android/src/test/java/com/mentra/acsmeeting/source/SoftApSdpGuardTest.kt +0 -275
- package/android/src/test/java/com/mentra/acsmeeting/source/SyntheticFrameFactoryTest.kt +0 -193
- package/android/src/test/java/com/mentra/acsmeeting/source/SyntheticI420SourceTest.kt +0 -176
- package/android/src/test/java/com/mentra/acsmeeting/source/TrackRegistryTest.kt +0 -45
- package/android/src/test/java/com/mentra/acsmeeting/source/WhipIngestProtocolTest.kt +0 -356
- package/android/src/test/java/com/mentra/acsmeeting/source/WhipIngestServerLoopbackTest.kt +0 -411
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/AvSyncProbeTest.kt +0 -83
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/ChromaProbeTest.kt +0 -61
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/PipelineStatsTest.kt +0 -322
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/ProcessCpuTest.kt +0 -18
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/RingPercentileTest.kt +0 -49
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/VideoRateVerdictTest.kt +0 -149
- package/android/src/test/java/com/mentra/acsmeeting/trace/SoftApTraceTest.kt +0 -97
- package/android/src/test/java/com/mentra/acsmeeting/video/FrameGeometryTest.kt +0 -33
- package/android/src/test/java/com/mentra/acsmeeting/video/I420PackerTest.kt +0 -126
- package/ios/PolicyKit/Sources/AcsAudioPolicy/GlassesMediaSource.swift +0 -68
- package/ios/WhepVideoSource.swift +0 -356
|
@@ -1,207 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,356 +0,0 @@
|
|
|
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
|
-
}
|