@mentra/acs-meeting 3.2.0-dev.221 → 3.2.0-dev.223
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 +4 -2
- package/android/build.gradle +23 -0
- package/android/src/androidTest/java/com/mentra/acsmeeting/network/SoftApFeasibilityGateTest.kt +291 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/com/mentra/acsmeeting/AbandonedCallAgent.kt +36 -0
- package/android/src/main/java/com/mentra/acsmeeting/AcsMeetingModule.kt +392 -22
- package/android/src/main/java/com/mentra/acsmeeting/AcsMeetingSession.kt +909 -56
- package/android/src/main/java/com/mentra/acsmeeting/CallCapabilities.kt +33 -0
- package/android/src/main/java/com/mentra/acsmeeting/audio/AcsAudioPolicy.kt +21 -0
- package/android/src/main/java/com/mentra/acsmeeting/audio/AudioUplinkChain.kt +126 -0
- package/android/src/main/java/com/mentra/acsmeeting/audio/PcmBridge.kt +27 -4
- package/android/src/main/java/com/mentra/acsmeeting/audio/UplinkPacer.kt +45 -10
- package/android/src/main/java/com/mentra/acsmeeting/audio/UplinkSender.kt +110 -5
- package/android/src/main/java/com/mentra/acsmeeting/network/InternetHold.kt +501 -0
- package/android/src/main/java/com/mentra/acsmeeting/network/Ipv4Prefix.kt +51 -0
- package/android/src/main/java/com/mentra/acsmeeting/network/NetworkFacts.kt +81 -0
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkChangeDetector.kt +355 -0
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkError.kt +109 -0
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkObserver.kt +160 -0
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkReadiness.kt +103 -0
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkRequestSpec.kt +53 -0
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkState.kt +127 -0
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedSoftApNetwork.kt +573 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/CloudflareWhepSource.kt +10 -101
- package/android/src/main/java/com/mentra/acsmeeting/source/DecodedTrackRelay.kt +147 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/GlassesMediaSource.kt +47 -5
- package/android/src/main/java/com/mentra/acsmeeting/source/LocalWhipIngestSource.kt +772 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/MeetingVideoSourceSpec.kt +73 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/SdpAdapter.kt +17 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/SelectedIcePair.kt +139 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/SoftApIceDiagnosis.kt +144 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/SoftApIcePolicy.kt +46 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/SoftApSdpGuard.kt +160 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/TrackRegistry.kt +3 -1
- package/android/src/main/java/com/mentra/acsmeeting/source/VideoSourceArm.kt +69 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/WhipIngestProtocol.kt +207 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/WhipIngestServer.kt +356 -0
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/AvSyncProbe.kt +155 -0
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/PipelineStats.kt +94 -3
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/PipelineTicker.kt +33 -5
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/RingPercentile.kt +15 -0
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/VideoQuality.kt +178 -0
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/VideoRateVerdict.kt +145 -0
- package/android/src/main/java/com/mentra/acsmeeting/trace/SoftApTrace.kt +143 -0
- package/android/src/main/java/com/mentra/acsmeeting/video/AcsFrameSender.kt +23 -4
- package/android/src/main/java/com/mentra/acsmeeting/video/FramePacer.kt +70 -0
- package/android/src/main/java/com/mentra/acsmeeting/video/I420FormatSpec.kt +1 -1
- package/android/src/main/java/com/mentra/acsmeeting/video/VideoProfile.kt +36 -3
- package/android/src/test/java/com/mentra/acsmeeting/AbandonedCallAgentTest.kt +74 -0
- package/android/src/test/java/com/mentra/acsmeeting/CallCapabilitiesTest.kt +47 -0
- package/android/src/test/java/com/mentra/acsmeeting/audio/AudioUplinkChainTest.kt +250 -0
- package/android/src/test/java/com/mentra/acsmeeting/audio/CapturePolicyTest.kt +44 -0
- package/android/src/test/java/com/mentra/acsmeeting/audio/UplinkPacerTest.kt +34 -4
- package/android/src/test/java/com/mentra/acsmeeting/audio/UplinkSenderTest.kt +96 -0
- package/android/src/test/java/com/mentra/acsmeeting/network/InternetHoldLifecycleTest.kt +102 -0
- package/android/src/test/java/com/mentra/acsmeeting/network/InternetHoldTest.kt +148 -0
- package/android/src/test/java/com/mentra/acsmeeting/network/Ipv4PrefixTest.kt +59 -0
- package/android/src/test/java/com/mentra/acsmeeting/network/NetworkFactsTest.kt +59 -0
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedLocalNetworkPermissionTest.kt +35 -0
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkChangeDetectorTest.kt +165 -0
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkErrorTest.kt +96 -0
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkLifecycleTest.kt +187 -0
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkObserverTest.kt +172 -0
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkReadinessTest.kt +141 -0
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkRequestSpecTest.kt +69 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/AcsInvestigationTest.kt +40 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/DecodedTrackRelayTest.kt +41 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/GlassesMediaSourceTest.kt +26 -8
- package/android/src/test/java/com/mentra/acsmeeting/source/MeetingVideoSourceSpecTest.kt +109 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/SelectedIcePairTest.kt +165 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/SoftApIceDiagnosisTest.kt +148 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/SoftApIcePolicyTest.kt +76 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/SoftApSdpGuardTest.kt +275 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/TrackRegistryTest.kt +9 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/WhipIngestProtocolTest.kt +356 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/WhipIngestServerLoopbackTest.kt +411 -0
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/AvSyncProbeTest.kt +83 -0
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/PipelineStatsTest.kt +69 -3
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/VideoQualityTest.kt +182 -0
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/VideoRateVerdictTest.kt +149 -0
- package/android/src/test/java/com/mentra/acsmeeting/trace/SoftApTraceTest.kt +97 -0
- package/android/src/test/java/com/mentra/acsmeeting/video/AcsTimestampTest.kt +34 -0
- package/android/src/test/java/com/mentra/acsmeeting/video/FramePacerTest.kt +105 -0
- package/android/src/test/java/com/mentra/acsmeeting/video/I420FormatSpecTest.kt +5 -0
- package/android/src/test/java/com/mentra/acsmeeting/video/VideoProfileTest.kt +38 -3
- package/build/AcsMeeting.types.d.ts +5 -0
- package/build/AcsMeeting.types.d.ts.map +1 -1
- package/build/AcsMeeting.types.js.map +1 -1
- package/build/AcsMeetingModule.d.ts +23 -0
- package/build/AcsMeetingModule.d.ts.map +1 -1
- package/build/AcsMeetingModule.js.map +1 -1
- package/build/AcsMeetingModule.web.d.ts +15 -0
- package/build/AcsMeetingModule.web.d.ts.map +1 -1
- package/build/AcsMeetingModule.web.js +13 -0
- package/build/AcsMeetingModule.web.js.map +1 -1
- package/ios/AcsMeetingModule.swift +213 -2
- package/package.json +1 -1
- package/src/AcsMeeting.types.ts +5 -0
- package/src/AcsMeetingModule.ts +13 -0
- package/src/AcsMeetingModule.web.ts +13 -0
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
package com.mentra.acsmeeting.source
|
|
2
|
+
|
|
3
|
+
import com.mentra.acsmeeting.network.Ipv4Prefix
|
|
4
|
+
import org.assertj.core.api.Assertions.assertThat
|
|
5
|
+
import org.junit.Test
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Pins the candidate classification the SoftAP path depends on.
|
|
9
|
+
*
|
|
10
|
+
* The cases here deliberately mirror
|
|
11
|
+
* `com.mentra.asg_client.io.streaming.config.IcePostPolicyTest` on the glasses. The two
|
|
12
|
+
* implementations cannot share code across the module boundary, and if they disagree the symptom is
|
|
13
|
+
* a call that negotiates and then carries nothing: one side posts an offer it considers ready while
|
|
14
|
+
* the other rejects it, or worse, both accept a candidate pair that cannot actually reach across
|
|
15
|
+
* the hotspot.
|
|
16
|
+
*/
|
|
17
|
+
class SoftApSdpGuardTest {
|
|
18
|
+
|
|
19
|
+
private val hostCandidate =
|
|
20
|
+
"a=candidate:1 1 udp 2122260223 192.168.43.20 51234 typ host generation 0"
|
|
21
|
+
private val srflxCandidate =
|
|
22
|
+
"a=candidate:2 1 udp 1686052607 203.0.113.7 51234 typ srflx raddr 192.168.43.20 rport 51234"
|
|
23
|
+
|
|
24
|
+
private fun sdp(vararg lines: String) =
|
|
25
|
+
(listOf("v=0", "m=video 9 UDP/TLS/RTP/SAVPF 96") + lines).joinToString("\r\n")
|
|
26
|
+
|
|
27
|
+
// -----------------------------------------------------------------
|
|
28
|
+
// Accepting a usable negotiation
|
|
29
|
+
// -----------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
@Test
|
|
32
|
+
fun `an sdp with a hotspot host candidate is accepted`() {
|
|
33
|
+
val verdict = SoftApSdpGuard.inspect(sdp(hostCandidate))
|
|
34
|
+
|
|
35
|
+
assertThat(verdict).isInstanceOf(SoftApSdpGuard.Verdict.Ok::class.java)
|
|
36
|
+
val ok = verdict as SoftApSdpGuard.Verdict.Ok
|
|
37
|
+
assertThat(ok.hostCandidates).hasSize(1)
|
|
38
|
+
assertThat(ok.routableCandidates).isEmpty()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Not a rejection. A stale glasses build may still gather srflx candidates, and dropping the call
|
|
43
|
+
* over it would be worse than a warning when a usable hotspot candidate is also present — but it
|
|
44
|
+
* is reported, because ICE could otherwise select it.
|
|
45
|
+
*/
|
|
46
|
+
@Test
|
|
47
|
+
fun `a stray srflx candidate is reported but does not reject`() {
|
|
48
|
+
val verdict = SoftApSdpGuard.inspect(sdp(hostCandidate, srflxCandidate))
|
|
49
|
+
|
|
50
|
+
val ok = verdict as SoftApSdpGuard.Verdict.Ok
|
|
51
|
+
assertThat(ok.hostCandidates).hasSize(1)
|
|
52
|
+
assertThat(ok.routableCandidates).hasSize(1)
|
|
53
|
+
assertThat(ok.routableCandidates.single()).contains("typ srflx")
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
@Test
|
|
57
|
+
fun `any rfc1918 subnet counts, not just the default hotspot range`() {
|
|
58
|
+
// A hotspot on a non-default subnet must not silently fail the gate.
|
|
59
|
+
for (address in listOf("192.168.1.5", "10.0.0.9", "172.16.4.4", "172.31.255.254")) {
|
|
60
|
+
val verdict = SoftApSdpGuard.inspect(
|
|
61
|
+
sdp("a=candidate:1 1 udp 2122260223 $address 51234 typ host generation 0"),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
assertThat(verdict)
|
|
65
|
+
.describedAs(address)
|
|
66
|
+
.isInstanceOf(SoftApSdpGuard.Verdict.Ok::class.java)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// -----------------------------------------------------------------
|
|
71
|
+
// Rejecting an unusable negotiation
|
|
72
|
+
// -----------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
@Test
|
|
75
|
+
fun `an sdp with only a srflx candidate is rejected`() {
|
|
76
|
+
val verdict = SoftApSdpGuard.inspect(sdp(srflxCandidate))
|
|
77
|
+
|
|
78
|
+
val rejected = verdict as SoftApSdpGuard.Verdict.Rejected
|
|
79
|
+
assertThat(rejected.code).isEqualTo(SoftApSdpGuard.REASON_ONLY_NON_HOTSPOT)
|
|
80
|
+
assertThat(rejected.detail).contains("typ srflx")
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
@Test
|
|
84
|
+
fun `an sdp with no candidates at all is rejected and says so`() {
|
|
85
|
+
val verdict = SoftApSdpGuard.inspect(sdp())
|
|
86
|
+
|
|
87
|
+
val rejected = verdict as SoftApSdpGuard.Verdict.Rejected
|
|
88
|
+
assertThat(rejected.code).isEqualTo(SoftApSdpGuard.REASON_NO_CANDIDATES)
|
|
89
|
+
assertThat(rejected.detail).isEqualTo("no candidates at all")
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Public and loopback host candidates are unreachable from the glasses. */
|
|
93
|
+
@Test
|
|
94
|
+
fun `a host candidate on a public or loopback address is rejected`() {
|
|
95
|
+
for (address in listOf("203.0.113.7", "127.0.0.1", "8.8.8.8", "172.32.0.1", "192.169.0.1")) {
|
|
96
|
+
val verdict = SoftApSdpGuard.inspect(
|
|
97
|
+
sdp("a=candidate:1 1 udp 2122260223 $address 51234 typ host generation 0"),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
assertThat(verdict)
|
|
101
|
+
.describedAs(address)
|
|
102
|
+
.isInstanceOf(SoftApSdpGuard.Verdict.Rejected::class.java)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* A link-local address means the interface never got a DHCP lease from the glasses. ICE would
|
|
108
|
+
* gather it and connect to nothing.
|
|
109
|
+
*/
|
|
110
|
+
@Test
|
|
111
|
+
fun `a link local host candidate is rejected`() {
|
|
112
|
+
val verdict = SoftApSdpGuard.inspect(
|
|
113
|
+
sdp("a=candidate:1 1 udp 2122260223 169.254.10.20 51234 typ host generation 0"),
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
assertThat(verdict).isInstanceOf(SoftApSdpGuard.Verdict.Rejected::class.java)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* libwebrtc emits these to hide private addresses from remote peers. The glasses cannot resolve
|
|
121
|
+
* them, so a negotiation carrying only mDNS candidates is broken however healthy it looks.
|
|
122
|
+
*/
|
|
123
|
+
@Test
|
|
124
|
+
fun `an mdns obfuscated candidate is rejected`() {
|
|
125
|
+
val verdict = SoftApSdpGuard.inspect(
|
|
126
|
+
sdp(
|
|
127
|
+
"a=candidate:1 1 udp 2122260223 " +
|
|
128
|
+
"b8e7c1f2-0000-4000-8000-000000000000.local 51234 typ host generation 0",
|
|
129
|
+
),
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
val rejected = verdict as SoftApSdpGuard.Verdict.Rejected
|
|
133
|
+
assertThat(rejected.code).isEqualTo(SoftApSdpGuard.REASON_ONLY_NON_HOTSPOT)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
@Test
|
|
137
|
+
fun `an ipv6 host candidate does not satisfy the gate`() {
|
|
138
|
+
// The SoftAP link is IPv4 only, so an IPv6 candidate cannot carry this media.
|
|
139
|
+
val verdict = SoftApSdpGuard.inspect(
|
|
140
|
+
sdp("a=candidate:1 1 udp 2122260223 fe80::1 51234 typ host generation 0"),
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
assertThat(verdict).isInstanceOf(SoftApSdpGuard.Verdict.Rejected::class.java)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
@Test
|
|
147
|
+
fun `empty and non sdp input is malformed rather than candidate-less`() {
|
|
148
|
+
for (input in listOf(null, "", " ", "v=0\r\no=- 0 0 IN IP4 0.0.0.0")) {
|
|
149
|
+
val verdict = SoftApSdpGuard.inspect(input)
|
|
150
|
+
|
|
151
|
+
assertThat((verdict as SoftApSdpGuard.Verdict.Rejected).code)
|
|
152
|
+
.describedAs(input.orEmpty())
|
|
153
|
+
.isEqualTo(SoftApSdpGuard.REASON_MALFORMED)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// -----------------------------------------------------------------
|
|
158
|
+
// Parsing details
|
|
159
|
+
// -----------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
@Test
|
|
162
|
+
fun `candidate extraction strips the attribute prefix and ignores other lines`() {
|
|
163
|
+
val candidates = SoftApSdpGuard.candidateLines(
|
|
164
|
+
sdp("a=mid:0", hostCandidate, "a=sendrecv", srflxCandidate),
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
assertThat(candidates).hasSize(2)
|
|
168
|
+
assertThat(candidates).allSatisfy { assertThat(it).startsWith("candidate:") }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
@Test
|
|
172
|
+
fun `candidate extraction tolerates trailing whitespace and lone newlines`() {
|
|
173
|
+
val candidates = SoftApSdpGuard.candidateLines("m=video 9\n$hostCandidate \n")
|
|
174
|
+
|
|
175
|
+
assertThat(candidates).hasSize(1)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
@Test
|
|
179
|
+
fun `private ipv4 boundaries match rfc1918 exactly`() {
|
|
180
|
+
assertThat(SoftApSdpGuard.isPrivateIpv4("10.0.0.0")).isTrue()
|
|
181
|
+
assertThat(SoftApSdpGuard.isPrivateIpv4("10.255.255.255")).isTrue()
|
|
182
|
+
assertThat(SoftApSdpGuard.isPrivateIpv4("172.15.0.1")).isFalse()
|
|
183
|
+
assertThat(SoftApSdpGuard.isPrivateIpv4("172.16.0.0")).isTrue()
|
|
184
|
+
assertThat(SoftApSdpGuard.isPrivateIpv4("172.31.0.0")).isTrue()
|
|
185
|
+
assertThat(SoftApSdpGuard.isPrivateIpv4("172.32.0.0")).isFalse()
|
|
186
|
+
assertThat(SoftApSdpGuard.isPrivateIpv4("192.168.0.0")).isTrue()
|
|
187
|
+
assertThat(SoftApSdpGuard.isPrivateIpv4("192.167.0.0")).isFalse()
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
@Test
|
|
191
|
+
fun `malformed addresses are not private`() {
|
|
192
|
+
for (token in listOf("192.168.1", "192.168.1.1.1", "192.168.1.256", "a.b.c.d", "", "host")) {
|
|
193
|
+
assertThat(SoftApSdpGuard.isPrivateIpv4(token)).describedAs(token).isFalse()
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
@Test
|
|
198
|
+
fun `a candidate must be typ host to count`() {
|
|
199
|
+
assertThat(SoftApSdpGuard.isSoftApHostCandidate(hostCandidate.removePrefix("a="))).isTrue()
|
|
200
|
+
assertThat(
|
|
201
|
+
SoftApSdpGuard.isSoftApHostCandidate(
|
|
202
|
+
"candidate:1 1 udp 2122260223 192.168.43.20 51234 typ relay",
|
|
203
|
+
),
|
|
204
|
+
).isFalse()
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
@Test
|
|
208
|
+
fun `the candidate address is read positionally, with or without the a= prefix`() {
|
|
209
|
+
assertThat(SoftApSdpGuard.candidateAddress(hostCandidate)).isEqualTo("192.168.43.20")
|
|
210
|
+
assertThat(
|
|
211
|
+
SoftApSdpGuard.candidateAddress(
|
|
212
|
+
"candidate:3558496604 1 udp 2122260223 10.48.51.202 35664 typ host generation 0",
|
|
213
|
+
),
|
|
214
|
+
).isEqualTo("10.48.51.202")
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** `raddr` would win a scan for anything dotted-quad-shaped and blame the wrong interface. */
|
|
218
|
+
@Test
|
|
219
|
+
fun `a reflexive candidate reports its local address, not its raddr`() {
|
|
220
|
+
assertThat(
|
|
221
|
+
SoftApSdpGuard.candidateAddress(
|
|
222
|
+
"candidate:2 1 udp 1686052607 203.0.113.9 44444 typ srflx raddr 192.168.43.20 rport 51234",
|
|
223
|
+
),
|
|
224
|
+
).isEqualTo("203.0.113.9")
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
@Test
|
|
228
|
+
fun `a line without an address reports none rather than a stray token`() {
|
|
229
|
+
assertThat(SoftApSdpGuard.candidateAddress("a=mid:0")).isNull()
|
|
230
|
+
assertThat(SoftApSdpGuard.candidateAddress("")).isNull()
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// -----------------------------------------------------------------
|
|
234
|
+
// Pinning a cellular-substituted host address back onto the hotspot
|
|
235
|
+
// -----------------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
@Test
|
|
238
|
+
fun `a cellular host address is rewritten to the scoped SoftAP address`() {
|
|
239
|
+
val prefix = Ipv4Prefix("192.168.43.79", 24)
|
|
240
|
+
val original =
|
|
241
|
+
sdp("a=candidate:1 1 udp 2122260223 10.48.51.202 35664 typ host generation 0")
|
|
242
|
+
|
|
243
|
+
val pinned = SoftApSdpGuard.pinHostAddresses(original, "192.168.43.79", prefix)
|
|
244
|
+
|
|
245
|
+
assertThat(pinned.rewritten).containsExactly("10.48.51.202")
|
|
246
|
+
assertThat(pinned.sdp).contains("192.168.43.79 35664 typ host")
|
|
247
|
+
assertThat(pinned.sdp).doesNotContain("10.48.51.202")
|
|
248
|
+
assertThat(SoftApSdpGuard.inspect(pinned.sdp, prefix)).isInstanceOf(SoftApSdpGuard.Verdict.Ok::class.java)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
@Test
|
|
252
|
+
fun `a host address already on the hotspot is left alone`() {
|
|
253
|
+
val prefix = Ipv4Prefix("192.168.43.79", 24)
|
|
254
|
+
val original = sdp(hostCandidate)
|
|
255
|
+
|
|
256
|
+
val pinned = SoftApSdpGuard.pinHostAddresses(original, "192.168.43.79", prefix)
|
|
257
|
+
|
|
258
|
+
assertThat(pinned.rewritten).isEmpty()
|
|
259
|
+
assertThat(pinned.sdp).isEqualTo(original)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
@Test
|
|
263
|
+
fun `a reflexive raddr is not treated as the connection address to pin`() {
|
|
264
|
+
// Field 5 is 203.0.113.7; raddr is already the hotspot. Rewriting raddr would hide a srflx
|
|
265
|
+
// that ICE could still select, and would not fix the address the glasses send to.
|
|
266
|
+
val prefix = Ipv4Prefix("192.168.43.79", 24)
|
|
267
|
+
val original = sdp(srflxCandidate)
|
|
268
|
+
|
|
269
|
+
val pinned = SoftApSdpGuard.pinHostAddresses(original, "192.168.43.79", prefix)
|
|
270
|
+
|
|
271
|
+
assertThat(pinned.rewritten).isEmpty()
|
|
272
|
+
assertThat(pinned.sdp).contains("203.0.113.7")
|
|
273
|
+
assertThat(pinned.sdp).contains("raddr 192.168.43.20")
|
|
274
|
+
}
|
|
275
|
+
}
|
|
@@ -33,4 +33,13 @@ class TrackRegistryTest {
|
|
|
33
33
|
assertThat(registry.claim("track-a")).isTrue()
|
|
34
34
|
assertThat(registry.size()).isEqualTo(1)
|
|
35
35
|
}
|
|
36
|
+
|
|
37
|
+
@Test
|
|
38
|
+
fun softapWhipReconnectCanReclaimVideo0() {
|
|
39
|
+
val registry = TrackRegistry()
|
|
40
|
+
assertThat(registry.claim("video0")).isTrue()
|
|
41
|
+
assertThat(registry.claim("video0")).isFalse()
|
|
42
|
+
registry.reset()
|
|
43
|
+
assertThat(registry.claim("video0")).isTrue()
|
|
44
|
+
}
|
|
36
45
|
}
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
package com.mentra.acsmeeting.source
|
|
2
|
+
|
|
3
|
+
import com.mentra.acsmeeting.source.WhipIngestProtocol.Action
|
|
4
|
+
import com.mentra.acsmeeting.source.WhipIngestProtocol.Endpoint
|
|
5
|
+
import com.mentra.acsmeeting.source.WhipIngestProtocol.Request
|
|
6
|
+
import com.mentra.acsmeeting.source.WhipIngestProtocol.State
|
|
7
|
+
import org.assertj.core.api.Assertions.assertThat
|
|
8
|
+
import org.junit.Test
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The endpoint's decision table.
|
|
12
|
+
*
|
|
13
|
+
* These are the cases that are painful to provoke against a live device and cheap to state here: a
|
|
14
|
+
* duplicate POST during a gather, a DELETE against an id that has already been replaced, a POST
|
|
15
|
+
* that lands after teardown. Each one has a specific status the glasses' WHIP client reacts to
|
|
16
|
+
* differently, so the statuses are pinned rather than merely "not 200".
|
|
17
|
+
*/
|
|
18
|
+
class WhipIngestProtocolTest {
|
|
19
|
+
|
|
20
|
+
private val endpoint = Endpoint("192.168.43.20", 8790)
|
|
21
|
+
private val free = State()
|
|
22
|
+
private val offer = "v=0\r\nm=video 9 UDP/TLS/RTP/SAVPF 96\r\n"
|
|
23
|
+
|
|
24
|
+
private fun post(
|
|
25
|
+
body: String = offer,
|
|
26
|
+
contentType: String? = "application/sdp",
|
|
27
|
+
length: Long? = body.length.toLong(),
|
|
28
|
+
target: String = "/whip",
|
|
29
|
+
) = Request("POST", target, contentType, length, body)
|
|
30
|
+
|
|
31
|
+
// -----------------------------------------------------------------
|
|
32
|
+
// The happy path
|
|
33
|
+
// -----------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
@Test
|
|
36
|
+
fun `a valid offer on a free endpoint negotiates`() {
|
|
37
|
+
val action = WhipIngestProtocol.decide(post(), free, "sess-1")
|
|
38
|
+
|
|
39
|
+
assertThat(action).isInstanceOf(Action.Negotiate::class.java)
|
|
40
|
+
val negotiate = action as Action.Negotiate
|
|
41
|
+
assertThat(negotiate.sessionId).isEqualTo("sess-1")
|
|
42
|
+
assertThat(negotiate.offer).isEqualTo(offer)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
@Test
|
|
46
|
+
fun `a lowercase method still negotiates`() {
|
|
47
|
+
// HTTP methods are case-sensitive in the spec, but accepting the variant costs nothing and
|
|
48
|
+
// failing it would present as an unexplained 405 on device.
|
|
49
|
+
val action = WhipIngestProtocol.decide(post().copy(method = "post"), free, "sess-1")
|
|
50
|
+
|
|
51
|
+
assertThat(action).isInstanceOf(Action.Negotiate::class.java)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
@Test
|
|
55
|
+
fun `a query string does not hide the base path`() {
|
|
56
|
+
val action = WhipIngestProtocol.decide(post(target = "/whip?x=1"), free, "sess-1")
|
|
57
|
+
|
|
58
|
+
assertThat(action).isInstanceOf(Action.Negotiate::class.java)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
@Test
|
|
62
|
+
fun `a trailing slash is the same resource`() {
|
|
63
|
+
val action = WhipIngestProtocol.decide(post(target = "/whip/"), free, "sess-1")
|
|
64
|
+
|
|
65
|
+
assertThat(action).isInstanceOf(Action.Negotiate::class.java)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
@Test
|
|
69
|
+
fun `a content type with parameters is still sdp`() {
|
|
70
|
+
val action =
|
|
71
|
+
WhipIngestProtocol.decide(post(contentType = "application/SDP; charset=utf-8"), free, "s")
|
|
72
|
+
|
|
73
|
+
assertThat(action).isInstanceOf(Action.Negotiate::class.java)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// -----------------------------------------------------------------
|
|
77
|
+
// 201 carries the absolute Location the client will DELETE
|
|
78
|
+
// -----------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Absolute, not `/whip/<id>`. WHIP terminations go to the URL in `Location`, and the glasses'
|
|
82
|
+
* client has not been verified to resolve a relative one against the request URL.
|
|
83
|
+
*/
|
|
84
|
+
@Test
|
|
85
|
+
fun `201 returns an absolute location and the answer`() {
|
|
86
|
+
val response = WhipIngestProtocol.respondNegotiated(endpoint, "sess-1", "v=0 answer")
|
|
87
|
+
|
|
88
|
+
assertThat(response.status).isEqualTo(201)
|
|
89
|
+
assertThat(response.headers["Location"]).isEqualTo("http://192.168.43.20:8790/whip/sess-1")
|
|
90
|
+
assertThat(response.headers["Content-Type"]).isEqualTo("application/sdp")
|
|
91
|
+
assertThat(response.body).isEqualTo("v=0 answer")
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
@Test
|
|
95
|
+
fun `the location url is what a delete is accepted on`() {
|
|
96
|
+
val sessionUrl = endpoint.sessionUrl("sess-1")
|
|
97
|
+
val path = sessionUrl.removePrefix("http://192.168.43.20:8790")
|
|
98
|
+
|
|
99
|
+
val action = WhipIngestProtocol.decide(
|
|
100
|
+
Request("DELETE", path),
|
|
101
|
+
State(activeSessionId = "sess-1"),
|
|
102
|
+
"unused",
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
assertThat(action).isEqualTo(Action.Terminate("sess-1"))
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// -----------------------------------------------------------------
|
|
109
|
+
// One publisher
|
|
110
|
+
// -----------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* A second POST usually means the glasses retried a request whose reply they never saw. Replacing
|
|
114
|
+
* a live peer with a speculative one would drop a working call, so the live session wins.
|
|
115
|
+
*/
|
|
116
|
+
@Test
|
|
117
|
+
fun `a second offer while publishing conflicts and keeps the live session`() {
|
|
118
|
+
val action = WhipIngestProtocol.decide(post(), State(activeSessionId = "sess-1"), "sess-2")
|
|
119
|
+
|
|
120
|
+
val response = (action as Action.Reply).response
|
|
121
|
+
assertThat(response.status).isEqualTo(409)
|
|
122
|
+
assertThat(response.headers["Location"]).isEqualTo("sess-1")
|
|
123
|
+
assertThat(response.body).contains("sess-1")
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// -----------------------------------------------------------------
|
|
127
|
+
// Termination
|
|
128
|
+
// -----------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
@Test
|
|
131
|
+
fun `deleting the active session terminates it`() {
|
|
132
|
+
val action =
|
|
133
|
+
WhipIngestProtocol.decide(Request("DELETE", "/whip/sess-1"), State("sess-1"), "unused")
|
|
134
|
+
|
|
135
|
+
assertThat(action).isEqualTo(Action.Terminate("sess-1"))
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* A stale DELETE must not tear down whoever holds the slot now. This is the rejoin race: the
|
|
140
|
+
* glasses DELETE an old session while a new one is already publishing.
|
|
141
|
+
*/
|
|
142
|
+
@Test
|
|
143
|
+
fun `deleting a stale session is a 404 and spares the current publisher`() {
|
|
144
|
+
val action =
|
|
145
|
+
WhipIngestProtocol.decide(Request("DELETE", "/whip/old"), State("sess-2"), "unused")
|
|
146
|
+
|
|
147
|
+
assertThat((action as Action.Reply).response.status).isEqualTo(404)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
@Test
|
|
151
|
+
fun `deleting when nothing is active is a 404`() {
|
|
152
|
+
val action = WhipIngestProtocol.decide(Request("DELETE", "/whip/sess-1"), free, "unused")
|
|
153
|
+
|
|
154
|
+
assertThat((action as Action.Reply).response.status).isEqualTo(404)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
@Test
|
|
158
|
+
fun `termination returns no content`() {
|
|
159
|
+
assertThat(WhipIngestProtocol.terminated().status).isEqualTo(204)
|
|
160
|
+
assertThat(WhipIngestProtocol.terminated().body).isEmpty()
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// -----------------------------------------------------------------
|
|
164
|
+
// The tombstone
|
|
165
|
+
// -----------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* After stop, a well-formed POST is still refused. Granting it a session would attach a publisher
|
|
169
|
+
* to an endpoint whose peer and scoped network are already gone.
|
|
170
|
+
*/
|
|
171
|
+
@Test
|
|
172
|
+
fun `a well formed offer after stop is gone, not created`() {
|
|
173
|
+
val action = WhipIngestProtocol.decide(post(), State(stopped = true), "sess-1")
|
|
174
|
+
|
|
175
|
+
assertThat((action as Action.Reply).response.status).isEqualTo(410)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
@Test
|
|
179
|
+
fun `a delete after stop is gone rather than 404`() {
|
|
180
|
+
val action = WhipIngestProtocol.decide(
|
|
181
|
+
Request("DELETE", "/whip/sess-1"),
|
|
182
|
+
State(activeSessionId = "sess-1", stopped = true),
|
|
183
|
+
"unused",
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
assertThat((action as Action.Reply).response.status).isEqualTo(410)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// -----------------------------------------------------------------
|
|
190
|
+
// Malformed requests
|
|
191
|
+
// -----------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
@Test
|
|
194
|
+
fun `a non post on the base path is 405 and advertises post`() {
|
|
195
|
+
val action = WhipIngestProtocol.decide(Request("GET", "/whip"), free, "sess-1")
|
|
196
|
+
|
|
197
|
+
val response = (action as Action.Reply).response
|
|
198
|
+
assertThat(response.status).isEqualTo(405)
|
|
199
|
+
assertThat(response.headers["Allow"]).isEqualTo("POST")
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
@Test
|
|
203
|
+
fun `a non delete on the session path is 405 and advertises delete`() {
|
|
204
|
+
val action = WhipIngestProtocol.decide(Request("GET", "/whip/sess-1"), State("sess-1"), "x")
|
|
205
|
+
|
|
206
|
+
val response = (action as Action.Reply).response
|
|
207
|
+
assertThat(response.status).isEqualTo(405)
|
|
208
|
+
assertThat(response.headers["Allow"]).isEqualTo("DELETE")
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
@Test
|
|
212
|
+
fun `a non sdp content type is 415`() {
|
|
213
|
+
val action = WhipIngestProtocol.decide(post(contentType = "application/json"), free, "s")
|
|
214
|
+
|
|
215
|
+
assertThat((action as Action.Reply).response.status).isEqualTo(415)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
@Test
|
|
219
|
+
fun `a missing content type is 415`() {
|
|
220
|
+
val action = WhipIngestProtocol.decide(post(contentType = null), free, "s")
|
|
221
|
+
|
|
222
|
+
assertThat((action as Action.Reply).response.status).isEqualTo(415)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Length is checked before type, because an oversized body is never read and so never typed. */
|
|
226
|
+
@Test
|
|
227
|
+
fun `an oversized offer is 413 even before the type is considered`() {
|
|
228
|
+
val action = WhipIngestProtocol.decide(
|
|
229
|
+
post(contentType = "application/json", length = WhipIngestProtocol.MAX_BODY_BYTES + 1),
|
|
230
|
+
free,
|
|
231
|
+
"s",
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
assertThat((action as Action.Reply).response.status).isEqualTo(413)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
@Test
|
|
238
|
+
fun `an offer exactly at the cap is accepted`() {
|
|
239
|
+
val action = WhipIngestProtocol.decide(
|
|
240
|
+
post(length = WhipIngestProtocol.MAX_BODY_BYTES),
|
|
241
|
+
free,
|
|
242
|
+
"s",
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
assertThat(action).isInstanceOf(Action.Negotiate::class.java)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
@Test
|
|
249
|
+
fun `an empty offer body is 400`() {
|
|
250
|
+
val action = WhipIngestProtocol.decide(post(body = " ", length = 3), free, "s")
|
|
251
|
+
|
|
252
|
+
assertThat((action as Action.Reply).response.status).isEqualTo(400)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
@Test
|
|
256
|
+
fun `an unrelated path is 404`() {
|
|
257
|
+
val action = WhipIngestProtocol.decide(post(target = "/health"), free, "s")
|
|
258
|
+
|
|
259
|
+
assertThat((action as Action.Reply).response.status).isEqualTo(404)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
@Test
|
|
263
|
+
fun `a nested session path is not a session id`() {
|
|
264
|
+
val action =
|
|
265
|
+
WhipIngestProtocol.decide(Request("DELETE", "/whip/a/b"), State("a"), "unused")
|
|
266
|
+
|
|
267
|
+
assertThat((action as Action.Reply).response.status).isEqualTo(404)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
@Test
|
|
271
|
+
fun `negotiation failure is a 500, not a client error`() {
|
|
272
|
+
// The publisher did nothing wrong and a retry may succeed, so this must not read as 4xx.
|
|
273
|
+
val response = WhipIngestProtocol.respondNegotiationFailed("gather_timeout")
|
|
274
|
+
|
|
275
|
+
assertThat(response.status).isEqualTo(500)
|
|
276
|
+
assertThat(response.body).isEqualTo("gather_timeout")
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// -----------------------------------------------------------------
|
|
280
|
+
// Wire rendering
|
|
281
|
+
// -----------------------------------------------------------------
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* A missing `Content-Length` turns a clean refusal into a hang: the client waits for a body that
|
|
285
|
+
* never ends, then retries on timeout, which is exactly what the tombstone is meant to prevent.
|
|
286
|
+
*/
|
|
287
|
+
@Test
|
|
288
|
+
fun `every response carries a content length and closes the connection`() {
|
|
289
|
+
val rendered = WhipIngestProtocol.render(WhipIngestProtocol.terminated())
|
|
290
|
+
|
|
291
|
+
assertThat(rendered).startsWith("HTTP/1.1 204 No Content\r\n")
|
|
292
|
+
assertThat(rendered).contains("Content-Length: 0\r\n")
|
|
293
|
+
assertThat(rendered).contains("Connection: close\r\n")
|
|
294
|
+
assertThat(rendered).endsWith("\r\n\r\n")
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
@Test
|
|
298
|
+
fun `the rendered length counts utf8 bytes rather than characters`() {
|
|
299
|
+
val rendered = WhipIngestProtocol.render(
|
|
300
|
+
WhipIngestProtocol.Response(500, "Internal Server Error", body = "café"),
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
assertThat(rendered).contains("Content-Length: 5\r\n")
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
@Test
|
|
307
|
+
fun `an sdp answer keeps its own content type`() {
|
|
308
|
+
val rendered =
|
|
309
|
+
WhipIngestProtocol.render(WhipIngestProtocol.respondNegotiated(endpoint, "s", "v=0"))
|
|
310
|
+
|
|
311
|
+
assertThat(rendered).contains("Content-Type: application/sdp\r\n")
|
|
312
|
+
assertThat(rendered).doesNotContain("text/plain")
|
|
313
|
+
assertThat(rendered).endsWith("\r\n\r\nv=0")
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
@Test
|
|
317
|
+
fun `a plain text body gets a default content type`() {
|
|
318
|
+
val rendered =
|
|
319
|
+
WhipIngestProtocol.render(WhipIngestProtocol.Response(410, "Gone", body = "stopped"))
|
|
320
|
+
|
|
321
|
+
assertThat(rendered).contains("Content-Type: text/plain; charset=utf-8\r\n")
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// -----------------------------------------------------------------
|
|
325
|
+
// Request line and header parsing
|
|
326
|
+
// -----------------------------------------------------------------
|
|
327
|
+
|
|
328
|
+
@Test
|
|
329
|
+
fun `a request line parses into method and target`() {
|
|
330
|
+
assertThat(WhipIngestProtocol.parseRequestLine("POST /whip HTTP/1.1"))
|
|
331
|
+
.isEqualTo("POST" to "/whip")
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
@Test
|
|
335
|
+
fun `a request line with extra spacing still parses`() {
|
|
336
|
+
assertThat(WhipIngestProtocol.parseRequestLine(" DELETE /whip/s1 HTTP/1.1 "))
|
|
337
|
+
.isEqualTo("DELETE" to "/whip/s1")
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
@Test
|
|
341
|
+
fun `an absolute or garbage request target is rejected`() {
|
|
342
|
+
// Only origin-form targets are accepted; anything else would bypass the path routing above.
|
|
343
|
+
assertThat(WhipIngestProtocol.parseRequestLine("POST http://host/whip HTTP/1.1")).isNull()
|
|
344
|
+
assertThat(WhipIngestProtocol.parseRequestLine("garbage")).isNull()
|
|
345
|
+
assertThat(WhipIngestProtocol.parseRequestLine("")).isNull()
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
@Test
|
|
349
|
+
fun `header lookup ignores case as clients assume`() {
|
|
350
|
+
val headers = mapOf("content-TYPE" to "application/sdp", "Content-Length" to "12")
|
|
351
|
+
|
|
352
|
+
assertThat(WhipIngestProtocol.headerValue(headers, "Content-Type")).isEqualTo("application/sdp")
|
|
353
|
+
assertThat(WhipIngestProtocol.headerValue(headers, "CONTENT-LENGTH")).isEqualTo("12")
|
|
354
|
+
assertThat(WhipIngestProtocol.headerValue(headers, "Missing")).isNull()
|
|
355
|
+
}
|
|
356
|
+
}
|