@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.
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,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
+ }