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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/README.md +4 -2
  2. package/android/build.gradle +23 -0
  3. package/android/src/androidTest/java/com/mentra/acsmeeting/network/SoftApFeasibilityGateTest.kt +291 -0
  4. package/android/src/main/AndroidManifest.xml +2 -0
  5. package/android/src/main/java/com/mentra/acsmeeting/AbandonedCallAgent.kt +36 -0
  6. package/android/src/main/java/com/mentra/acsmeeting/AcsMeetingModule.kt +392 -22
  7. package/android/src/main/java/com/mentra/acsmeeting/AcsMeetingSession.kt +909 -56
  8. package/android/src/main/java/com/mentra/acsmeeting/CallCapabilities.kt +33 -0
  9. package/android/src/main/java/com/mentra/acsmeeting/audio/AcsAudioPolicy.kt +21 -0
  10. package/android/src/main/java/com/mentra/acsmeeting/audio/AudioUplinkChain.kt +126 -0
  11. package/android/src/main/java/com/mentra/acsmeeting/audio/PcmBridge.kt +27 -4
  12. package/android/src/main/java/com/mentra/acsmeeting/audio/UplinkPacer.kt +45 -10
  13. package/android/src/main/java/com/mentra/acsmeeting/audio/UplinkSender.kt +110 -5
  14. package/android/src/main/java/com/mentra/acsmeeting/network/InternetHold.kt +501 -0
  15. package/android/src/main/java/com/mentra/acsmeeting/network/Ipv4Prefix.kt +51 -0
  16. package/android/src/main/java/com/mentra/acsmeeting/network/NetworkFacts.kt +81 -0
  17. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkChangeDetector.kt +355 -0
  18. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkError.kt +109 -0
  19. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkObserver.kt +160 -0
  20. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkReadiness.kt +103 -0
  21. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkRequestSpec.kt +53 -0
  22. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkState.kt +127 -0
  23. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedSoftApNetwork.kt +573 -0
  24. package/android/src/main/java/com/mentra/acsmeeting/source/CloudflareWhepSource.kt +10 -101
  25. package/android/src/main/java/com/mentra/acsmeeting/source/DecodedTrackRelay.kt +147 -0
  26. package/android/src/main/java/com/mentra/acsmeeting/source/GlassesMediaSource.kt +47 -5
  27. package/android/src/main/java/com/mentra/acsmeeting/source/LocalWhipIngestSource.kt +772 -0
  28. package/android/src/main/java/com/mentra/acsmeeting/source/MeetingVideoSourceSpec.kt +73 -0
  29. package/android/src/main/java/com/mentra/acsmeeting/source/SdpAdapter.kt +17 -0
  30. package/android/src/main/java/com/mentra/acsmeeting/source/SelectedIcePair.kt +139 -0
  31. package/android/src/main/java/com/mentra/acsmeeting/source/SoftApIceDiagnosis.kt +144 -0
  32. package/android/src/main/java/com/mentra/acsmeeting/source/SoftApIcePolicy.kt +46 -0
  33. package/android/src/main/java/com/mentra/acsmeeting/source/SoftApSdpGuard.kt +160 -0
  34. package/android/src/main/java/com/mentra/acsmeeting/source/TrackRegistry.kt +3 -1
  35. package/android/src/main/java/com/mentra/acsmeeting/source/VideoSourceArm.kt +69 -0
  36. package/android/src/main/java/com/mentra/acsmeeting/source/WhipIngestProtocol.kt +207 -0
  37. package/android/src/main/java/com/mentra/acsmeeting/source/WhipIngestServer.kt +356 -0
  38. package/android/src/main/java/com/mentra/acsmeeting/telemetry/AvSyncProbe.kt +155 -0
  39. package/android/src/main/java/com/mentra/acsmeeting/telemetry/PipelineStats.kt +94 -3
  40. package/android/src/main/java/com/mentra/acsmeeting/telemetry/PipelineTicker.kt +33 -5
  41. package/android/src/main/java/com/mentra/acsmeeting/telemetry/RingPercentile.kt +15 -0
  42. package/android/src/main/java/com/mentra/acsmeeting/telemetry/VideoQuality.kt +178 -0
  43. package/android/src/main/java/com/mentra/acsmeeting/telemetry/VideoRateVerdict.kt +145 -0
  44. package/android/src/main/java/com/mentra/acsmeeting/trace/SoftApTrace.kt +143 -0
  45. package/android/src/main/java/com/mentra/acsmeeting/video/AcsFrameSender.kt +23 -4
  46. package/android/src/main/java/com/mentra/acsmeeting/video/FramePacer.kt +70 -0
  47. package/android/src/main/java/com/mentra/acsmeeting/video/I420FormatSpec.kt +1 -1
  48. package/android/src/main/java/com/mentra/acsmeeting/video/VideoProfile.kt +36 -3
  49. package/android/src/test/java/com/mentra/acsmeeting/AbandonedCallAgentTest.kt +74 -0
  50. package/android/src/test/java/com/mentra/acsmeeting/CallCapabilitiesTest.kt +47 -0
  51. package/android/src/test/java/com/mentra/acsmeeting/audio/AudioUplinkChainTest.kt +250 -0
  52. package/android/src/test/java/com/mentra/acsmeeting/audio/CapturePolicyTest.kt +44 -0
  53. package/android/src/test/java/com/mentra/acsmeeting/audio/UplinkPacerTest.kt +34 -4
  54. package/android/src/test/java/com/mentra/acsmeeting/audio/UplinkSenderTest.kt +96 -0
  55. package/android/src/test/java/com/mentra/acsmeeting/network/InternetHoldLifecycleTest.kt +102 -0
  56. package/android/src/test/java/com/mentra/acsmeeting/network/InternetHoldTest.kt +148 -0
  57. package/android/src/test/java/com/mentra/acsmeeting/network/Ipv4PrefixTest.kt +59 -0
  58. package/android/src/test/java/com/mentra/acsmeeting/network/NetworkFactsTest.kt +59 -0
  59. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedLocalNetworkPermissionTest.kt +35 -0
  60. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkChangeDetectorTest.kt +165 -0
  61. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkErrorTest.kt +96 -0
  62. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkLifecycleTest.kt +187 -0
  63. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkObserverTest.kt +172 -0
  64. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkReadinessTest.kt +141 -0
  65. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkRequestSpecTest.kt +69 -0
  66. package/android/src/test/java/com/mentra/acsmeeting/source/AcsInvestigationTest.kt +40 -0
  67. package/android/src/test/java/com/mentra/acsmeeting/source/DecodedTrackRelayTest.kt +41 -0
  68. package/android/src/test/java/com/mentra/acsmeeting/source/GlassesMediaSourceTest.kt +26 -8
  69. package/android/src/test/java/com/mentra/acsmeeting/source/MeetingVideoSourceSpecTest.kt +109 -0
  70. package/android/src/test/java/com/mentra/acsmeeting/source/SelectedIcePairTest.kt +165 -0
  71. package/android/src/test/java/com/mentra/acsmeeting/source/SoftApIceDiagnosisTest.kt +148 -0
  72. package/android/src/test/java/com/mentra/acsmeeting/source/SoftApIcePolicyTest.kt +76 -0
  73. package/android/src/test/java/com/mentra/acsmeeting/source/SoftApSdpGuardTest.kt +275 -0
  74. package/android/src/test/java/com/mentra/acsmeeting/source/TrackRegistryTest.kt +9 -0
  75. package/android/src/test/java/com/mentra/acsmeeting/source/WhipIngestProtocolTest.kt +356 -0
  76. package/android/src/test/java/com/mentra/acsmeeting/source/WhipIngestServerLoopbackTest.kt +411 -0
  77. package/android/src/test/java/com/mentra/acsmeeting/telemetry/AvSyncProbeTest.kt +83 -0
  78. package/android/src/test/java/com/mentra/acsmeeting/telemetry/PipelineStatsTest.kt +69 -3
  79. package/android/src/test/java/com/mentra/acsmeeting/telemetry/VideoQualityTest.kt +182 -0
  80. package/android/src/test/java/com/mentra/acsmeeting/telemetry/VideoRateVerdictTest.kt +149 -0
  81. package/android/src/test/java/com/mentra/acsmeeting/trace/SoftApTraceTest.kt +97 -0
  82. package/android/src/test/java/com/mentra/acsmeeting/video/AcsTimestampTest.kt +34 -0
  83. package/android/src/test/java/com/mentra/acsmeeting/video/FramePacerTest.kt +105 -0
  84. package/android/src/test/java/com/mentra/acsmeeting/video/I420FormatSpecTest.kt +5 -0
  85. package/android/src/test/java/com/mentra/acsmeeting/video/VideoProfileTest.kt +38 -3
  86. package/build/AcsMeeting.types.d.ts +5 -0
  87. package/build/AcsMeeting.types.d.ts.map +1 -1
  88. package/build/AcsMeeting.types.js.map +1 -1
  89. package/build/AcsMeetingModule.d.ts +23 -0
  90. package/build/AcsMeetingModule.d.ts.map +1 -1
  91. package/build/AcsMeetingModule.js.map +1 -1
  92. package/build/AcsMeetingModule.web.d.ts +15 -0
  93. package/build/AcsMeetingModule.web.d.ts.map +1 -1
  94. package/build/AcsMeetingModule.web.js +13 -0
  95. package/build/AcsMeetingModule.web.js.map +1 -1
  96. package/ios/AcsMeetingModule.swift +213 -2
  97. package/package.json +1 -1
  98. package/src/AcsMeeting.types.ts +5 -0
  99. package/src/AcsMeetingModule.ts +13 -0
  100. package/src/AcsMeetingModule.web.ts +13 -0
@@ -0,0 +1,772 @@
1
+ package com.mentra.acsmeeting.source
2
+
3
+ import android.content.Context
4
+ import android.media.AudioAttributes
5
+ import android.util.Log
6
+ import com.mentra.acsmeeting.network.NetworkFacts
7
+ import com.mentra.acsmeeting.network.ScopedNetworkChangeDetector
8
+ import com.mentra.acsmeeting.network.ScopedSoftApNetwork
9
+ import com.mentra.acsmeeting.telemetry.PipelineStats
10
+ import com.mentra.acsmeeting.trace.SoftApTrace
11
+ import org.webrtc.AudioTrack
12
+ import org.webrtc.DefaultVideoDecoderFactory
13
+ import org.webrtc.DefaultVideoEncoderFactory
14
+ import org.webrtc.EglBase
15
+ import org.webrtc.IceCandidate
16
+ import org.webrtc.Logging
17
+ import org.webrtc.MediaConstraints
18
+ import org.webrtc.MediaStream
19
+ import org.webrtc.PeerConnection
20
+ import org.webrtc.PeerConnectionFactory
21
+ import org.webrtc.RtpTransceiver
22
+ import org.webrtc.SdpObserver
23
+ import org.webrtc.SessionDescription
24
+ import org.webrtc.VideoTrack
25
+ import org.webrtc.audio.JavaAudioDeviceModule
26
+ import java.net.InetAddress
27
+ import java.util.concurrent.CopyOnWriteArrayList
28
+ import java.util.concurrent.CountDownLatch
29
+ import java.util.concurrent.TimeUnit
30
+ import java.util.concurrent.atomic.AtomicReference
31
+
32
+ /**
33
+ * Receives glasses media straight off the SoftAP, with no Cloudflare hop.
34
+ *
35
+ * This is the same job [CloudflareWhepSource] does with the roles reversed. There the phone offers
36
+ * and Cloudflare answers; here the glasses offer — using the WHIP client they already ship — and
37
+ * the phone answers on a listener bound to its own hotspot address. Keeping the glasses as the
38
+ * offerer is deliberate: it is by far the harder device to debug, and this way its publish path is
39
+ * unchanged apart from the URL it POSTs to and the absence of a STUN server.
40
+ *
41
+ * Three things make the local link work, and all three matter:
42
+ *
43
+ * - an empty ICE server list, so no server-reflexive or relay candidate can even be gathered;
44
+ * - [SoftApIcePolicy.networkIgnoreMask], so cellular and VPN adapters are excluded from gathering
45
+ * and cannot be selected over the hotspot;
46
+ * - [com.mentra.acsmeeting.network.ScopedNetworkChangeDetector], so libwebrtc sees the hotspot at
47
+ * all — its stock monitor only watches internet-capable networks, and this one deliberately is
48
+ * not.
49
+ *
50
+ * Past the decoder nothing is special, so frames and PCM go through the shared [DecodedTrackRelay].
51
+ *
52
+ * The session owns this object and this object owns the [WhipIngestServer] and the peer. The
53
+ * orchestrator only sequences; it never holds the listener. That keeps a single answer to "who
54
+ * tears the publisher down", which is what makes leave-during-negotiation safe.
55
+ */
56
+ class LocalWhipIngestSource(
57
+ private val context: Context,
58
+ videoListener: VideoFrameListener,
59
+ pcmListener: PcmListener,
60
+ private val stats: PipelineStats = PipelineStats(),
61
+ /**
62
+ * The joined hotspot. Passed so libwebrtc can be shown a network Android is hiding from it;
63
+ * null falls back to the stock monitor, which is only viable if the feasibility gate showed the
64
+ * hotspot is visible without help.
65
+ */
66
+ private val scopedNetwork: ScopedSoftApNetwork? = null,
67
+ ) : GlassesMediaSource, WhipIngestServer.Negotiator {
68
+
69
+ private val relay = DecodedTrackRelay(videoListener, pcmListener, stats) { notePromotableFrame() }
70
+ private val firstFrame = FirstFrameGate()
71
+ private val videoIds = TrackRegistry()
72
+ private val audioIds = TrackRegistry()
73
+ private val audioTracks = CopyOnWriteArrayList<AudioTrack>()
74
+ private val mainHandler = android.os.Handler(android.os.Looper.getMainLooper())
75
+
76
+ private var factory: PeerConnectionFactory? = null
77
+ private var egl: EglBase? = null
78
+ private var server: WhipIngestServer? = null
79
+
80
+ @Volatile private var pc: PeerConnection? = null
81
+ @Volatile private var attachedVideo: VideoTrack? = null
82
+ @Volatile private var boundUrl: String? = null
83
+ @Volatile private var stateListener: SourceStateListener? = null
84
+ @Volatile private var firstFrameDeadline: Runnable? = null
85
+ @Volatile private var selectedPairTask: Runnable? = null
86
+
87
+ /**
88
+ * Invalidates callbacks from a peer we are disposing. A negotiation can be mid-gather when the
89
+ * user leaves, and its completion must not resurrect a torn-down source.
90
+ */
91
+ @Volatile private var generation = 0
92
+
93
+ @Volatile override var state: SourceState = SourceState.IDLE
94
+ private set
95
+
96
+ override val ingestUrl: String?
97
+ get() = boundUrl
98
+
99
+ /**
100
+ * Binds the ingest listener. Returns as soon as the phone is ready to be published to — the
101
+ * glasses have not connected yet, so this is `CONNECTING`, not `LIVE`.
102
+ *
103
+ * [SourceConfig.bindAddress] must be the phone's address on the hotspot. Binding to that one
104
+ * address rather than the wildcard is what confines the endpoint to the SoftAP interface.
105
+ */
106
+ override fun start(config: SourceConfig) {
107
+ stop()
108
+ val bindAddress = requireNotNull(config.bindAddress ?: scopedNetwork?.localIpv4()) {
109
+ "SoftAP ingest needs the phone's hotspot address"
110
+ }
111
+ generation++
112
+ transition(SourceState.CONNECTING, "start")
113
+ ensureFactory()
114
+
115
+ val ingest = WhipIngestServer(this)
116
+ val endpoint = ingest.start(InetAddress.getByName(bindAddress))
117
+ server = ingest
118
+ boundUrl = "http://${endpoint.host}:${endpoint.port}${WhipIngestProtocol.BASE_PATH}"
119
+ SoftApTrace.stage("ingest_source_listening", "url" to boundUrl)
120
+ Log.i(TAG, "SoftAP ingest listening on $boundUrl")
121
+ }
122
+
123
+ /**
124
+ * A SoftAP restart is always a full rebuild. There is no URL to keep: the listener, the port and
125
+ * the peer all belong to one publish attempt, and [SourceReusePolicy] excludes this kind for that
126
+ * reason.
127
+ */
128
+ override fun restart(config: SourceConfig) = start(config)
129
+
130
+ override fun forceRestart() {
131
+ val url = boundUrl ?: return
132
+ Log.i(TAG, "SoftAP ingest forced rebuild state=$state url=$url")
133
+ start(SourceConfig("", SourceKind.SOFTAP, scopedNetwork?.localIpv4()))
134
+ }
135
+
136
+ override fun setStateListener(listener: SourceStateListener?) {
137
+ stateListener = listener
138
+ }
139
+
140
+ override fun setPcmDeliveryEnabled(enabled: Boolean) {
141
+ relay.setPcmDeliveryEnabled(enabled)
142
+ Log.i(TAG, "SoftAP ingest PCM delivery enabled=$enabled")
143
+ }
144
+
145
+ override fun setTargetSize(size: TargetSize?) = relay.setTargetSize(size)
146
+
147
+ override fun stop() {
148
+ boundUrl = null
149
+ cancelFirstFrameDeadline()
150
+ cancelSelectedPairProof()
151
+ firstFrame.reset()
152
+ detachTracks()
153
+ relay.resetRotationLog()
154
+ // Bump before disposing so in-flight observer callbacks see a stale generation rather than
155
+ // the IDLE we are about to publish.
156
+ generation++
157
+ transition(SourceState.IDLE, "stop")
158
+
159
+ // stop() leaves the listener answering 410 for a few seconds, so a POST the glasses already
160
+ // sent gets an answer it can act on instead of a reset it would retry.
161
+ server?.let { runCatching { it.stop() } }
162
+ server = null
163
+ disposePeer()
164
+ }
165
+
166
+ // -----------------------------------------------------------------
167
+ // WhipIngestServer.Negotiator — the glasses' offer arrives here
168
+ // -----------------------------------------------------------------
169
+
170
+ /**
171
+ * Answers the glasses' offer, blocking until ICE gathering completes.
172
+ *
173
+ * Blocking is correct here, not lazy: neither side implements WHIP's `PATCH` trickle, so the
174
+ * answer we return is the only answer the glasses will ever see and it must be complete. Local
175
+ * gathering with no STUN server takes milliseconds, so the wait is short in every healthy case
176
+ * and [GATHER_TIMEOUT_MS] only bounds the broken ones.
177
+ */
178
+ override fun negotiate(sessionId: String, offer: String): Result<String> {
179
+ val gen = generation
180
+ val currentFactory = factory ?: return Result.failure(IllegalStateException("factory_disposed"))
181
+
182
+ val prefix = scopedNetwork?.scopedPrefix()
183
+ when (val verdict = SoftApSdpGuard.inspect(offer, prefix)) {
184
+ is SoftApSdpGuard.Verdict.Rejected -> {
185
+ SoftApTrace.failure(
186
+ "ingest_offer_rejected",
187
+ "code" to verdict.code,
188
+ "detail" to verdict.detail,
189
+ )
190
+ transition(SourceState.FAILED, "offer_${verdict.code}")
191
+ return Result.failure(IllegalArgumentException(verdict.code))
192
+ }
193
+
194
+ is SoftApSdpGuard.Verdict.Ok -> {
195
+ if (verdict.routableCandidates.isNotEmpty()) {
196
+ // Not fatal: a valid hotspot candidate is present. Worth a line because it means the
197
+ // glasses gathered something they should not have in host-only mode.
198
+ Log.w(TAG, "SoftAP offer carried non-hotspot candidates: ${verdict.routableCandidates}")
199
+ }
200
+ SoftApTrace.stage(
201
+ "ingest_offer_accepted",
202
+ "session" to sessionId,
203
+ "hostCandidates" to verdict.hostCandidates.size,
204
+ )
205
+ }
206
+ }
207
+
208
+ // A glasses WHIP reconnect POSTs a new offer on the same listener. The previous peer's
209
+ // `video0` claim must not survive: that is the 17:48:28 miss where ICE came back, the
210
+ // decoder produced frames, and the ACS sink never saw one.
211
+ detachTracks()
212
+ if (state == SourceState.FAILED || state == SourceState.LIVE) {
213
+ transition(SourceState.CONNECTING, "renegotiate")
214
+ }
215
+
216
+ val gathered = CountDownLatch(1)
217
+ val peer = currentFactory.createPeerConnection(hostOnlyConfiguration(), observerFor(gen, gathered))
218
+ ?: return Result.failure(IllegalStateException("peer_create_failed"))
219
+ disposePeer()
220
+ pc = peer
221
+
222
+ val answer = AtomicReference<String?>(null)
223
+ val failure = AtomicReference<String?>(null)
224
+ val answered = CountDownLatch(1)
225
+
226
+ peer.setRemoteDescription(
227
+ object : SdpAdapter() {
228
+ override fun onSetSuccess() {
229
+ peer.createAnswer(
230
+ object : SdpAdapter() {
231
+ override fun onCreateSuccess(sdp: SessionDescription) {
232
+ peer.setLocalDescription(
233
+ object : SdpAdapter() {
234
+ override fun onSetSuccess() = answered.countDown()
235
+ override fun onSetFailure(error: String) {
236
+ failure.set("set_local_failed:$error")
237
+ answered.countDown()
238
+ }
239
+ },
240
+ sdp,
241
+ )
242
+ }
243
+
244
+ override fun onCreateFailure(error: String) {
245
+ failure.set("create_answer_failed:$error")
246
+ answered.countDown()
247
+ }
248
+ },
249
+ MediaConstraints(),
250
+ )
251
+ }
252
+
253
+ override fun onSetFailure(error: String) {
254
+ failure.set("set_remote_failed:$error")
255
+ answered.countDown()
256
+ }
257
+ },
258
+ SessionDescription(SessionDescription.Type.OFFER, offer),
259
+ )
260
+
261
+ if (!answered.await(ANSWER_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
262
+ return negotiationFailed(sessionId, "answer_timeout")
263
+ }
264
+ failure.get()?.let { return negotiationFailed(sessionId, it) }
265
+ if (!gathered.await(GATHER_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
266
+ return negotiationFailed(sessionId, "gather_timeout")
267
+ }
268
+ if (gen != generation) return negotiationFailed(sessionId, "superseded")
269
+
270
+ val gatheredSdp = peer.localDescription?.description
271
+ ?: return negotiationFailed(sessionId, "no_local_description")
272
+ val scopedAddress = scopedNetwork?.localIpv4()
273
+ // Real handle marks the ICE sockets onto the SoftAP. libwebrtc then advertises the default
274
+ // route (cellular) as the candidate address. The glasses cannot reach that, so the answer they
275
+ // get is pinned to the scoped IP. No-op when gathering already told the truth.
276
+ val pinned =
277
+ if (prefix != null && scopedAddress != null) {
278
+ SoftApSdpGuard.pinHostAddresses(gatheredSdp, scopedAddress, prefix)
279
+ } else {
280
+ SoftApSdpGuard.PinResult(gatheredSdp, emptyList())
281
+ }
282
+ if (pinned.rewritten.isNotEmpty()) {
283
+ SoftApTrace.stage(
284
+ "ingest_answer_pinned",
285
+ "session" to sessionId,
286
+ "scopedAddress" to scopedAddress,
287
+ "replaced" to pinned.rewritten.joinToString(","),
288
+ )
289
+ }
290
+ val local = pinned.sdp
291
+ answer.set(local)
292
+
293
+ // The full gathered set, attributed to real interfaces, captured at the moment of judgement.
294
+ // A missing hotspot candidate is the failure, and every observation that separates its causes
295
+ // exists only here: the kernel table, the inventory libwebrtc actually holds, and the address
296
+ // each candidate ended up carrying. Reconstructing any of it from later logs has already
297
+ // produced one wrong conclusion, so it is recorded together.
298
+ val table = NetworkFacts.snapshot()
299
+ val candidateFacts =
300
+ local.lineSequence()
301
+ .filter { it.contains("candidate:") }
302
+ .map { line ->
303
+ val address = SoftApSdpGuard.candidateAddress(line)
304
+ SoftApIceDiagnosis.CandidateFact(
305
+ address,
306
+ address?.let { NetworkFacts.ownerOf(it, table) },
307
+ SoftApSdpGuard.isSoftApHostCandidate(line.trim().removePrefix("a="), prefix),
308
+ )
309
+ }
310
+ .toList()
311
+ SoftApTrace.stage(
312
+ "ingest_answer_candidates",
313
+ "count" to candidateFacts.size,
314
+ "hotspotPrefix" to (prefix?.toString() ?: "unknown"),
315
+ "candidates" to candidateFacts.joinToString(" | ") { it.toString() },
316
+ "table" to NetworkFacts.render(NetworkFacts.gatherable(table)),
317
+ )
318
+
319
+ // The answer is checked with the same guard as the offer. If libwebrtc gathered nothing on the
320
+ // hotspot, returning this answer would produce a call that negotiates and then never carries a
321
+ // frame — the exact silent failure the feasibility gate exists to rule out.
322
+ when (val verdict = SoftApSdpGuard.inspect(local, prefix)) {
323
+ is SoftApSdpGuard.Verdict.Rejected -> {
324
+ val diagnosis =
325
+ SoftApIceDiagnosis.diagnose(
326
+ scopedAddress,
327
+ scopedAddress?.let { NetworkFacts.ownerOf(it, table) },
328
+ prefix,
329
+ ScopedNetworkChangeDetector.lastPublished,
330
+ candidateFacts,
331
+ )
332
+ SoftApTrace.failure(
333
+ "ingest_answer_rejected",
334
+ "detail" to verdict.detail,
335
+ *diagnosis.fields(),
336
+ )
337
+ return negotiationFailed(sessionId, verdict.code)
338
+ }
339
+
340
+ is SoftApSdpGuard.Verdict.Ok -> SoftApTrace.stage(
341
+ "ingest_answer_ready",
342
+ "session" to sessionId,
343
+ "hostCandidates" to verdict.hostCandidates.size,
344
+ "scopedPrefix" to prefix?.toString(),
345
+ )
346
+ }
347
+
348
+ armFirstFrame(gen)
349
+ return Result.success(local)
350
+ }
351
+
352
+ override fun publisherFailed(): Boolean = state == SourceState.FAILED
353
+
354
+ override fun terminate(sessionId: String) {
355
+ SoftApTrace.stage("ingest_session_terminated", "session" to sessionId)
356
+ generation++
357
+ cancelFirstFrameDeadline()
358
+ cancelSelectedPairProof()
359
+ detachTracks()
360
+ disposePeer()
361
+ if (state != SourceState.IDLE) transition(SourceState.FAILED, "publisher_terminated")
362
+ }
363
+
364
+ /**
365
+ * Drop sink attachments and id claims so the next peer can attach `video0` again.
366
+ *
367
+ * Glasses WHIP reconnect keeps the same track ids. [TrackRegistry.claim] then returns false,
368
+ * [attachVideo] skips `addSink`, WebRTC still decodes, and the first-frame gate expires.
369
+ */
370
+ private fun detachTracks() {
371
+ runCatching { attachedVideo?.removeSink(relay.videoSink) }
372
+ attachedVideo = null
373
+ for (track in audioTracks) {
374
+ runCatching { track.removeSink(relay.audioSink) }
375
+ }
376
+ audioTracks.clear()
377
+ videoIds.reset()
378
+ audioIds.reset()
379
+ }
380
+
381
+ private fun negotiationFailed(sessionId: String, reason: String): Result<String> {
382
+ SoftApTrace.failure("ingest_negotiation_failed", "session" to sessionId, "reason" to reason)
383
+ transition(SourceState.FAILED, reason)
384
+ disposePeer()
385
+ return Result.failure(IllegalStateException(reason))
386
+ }
387
+
388
+ // -----------------------------------------------------------------
389
+ // WebRTC plumbing
390
+ // -----------------------------------------------------------------
391
+
392
+ /**
393
+ * No ICE servers at all. Host-only is not a preference here, it is the only possibility: there is
394
+ * no route from the hotspot to a STUN server, so a configured one would just add a few seconds of
395
+ * doomed gathering to every call.
396
+ */
397
+ private fun hostOnlyConfiguration() = PeerConnection.RTCConfiguration(emptyList()).apply {
398
+ sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN
399
+ continualGatheringPolicy = PeerConnection.ContinualGatheringPolicy.GATHER_ONCE
400
+ // Local link, so a candidate pool buys nothing and TCP candidates only add noise.
401
+ iceCandidatePoolSize = 0
402
+ tcpCandidatePolicy = PeerConnection.TcpCandidatePolicy.DISABLED
403
+ networkPreference = PeerConnection.AdapterType.WIFI
404
+ }
405
+
406
+ private fun ensureFactory() {
407
+ if (factory != null) return
408
+ // Normally already installed by the module at creation; repeating it here is idempotent and
409
+ // covers a factory built from a code path that bypassed the module (tests, future callers).
410
+ scopedNetwork?.let { scoped -> ScopedNetworkChangeDetector.install { scoped } }
411
+ PeerConnectionFactory.initialize(
412
+ PeerConnectionFactory.InitializationOptions.builder(context).createInitializationOptions(),
413
+ )
414
+ // The decision we cannot otherwise see is libwebrtc's own: at LS_INFO the native stack logs each
415
+ // interface BasicNetworkManager kept and the adapter type it assigned, the ports it allocated,
416
+ // and every BindSocketToNetwork result. That is what showed the hotspot entry being erased by a
417
+ // handle collision. Loud, so it is behind a flag to flip once the path is green on device.
418
+ if (AcsInvestigation.LIBWEBRTC_VERBOSE) {
419
+ runCatching { Logging.enableLogToDebugOutput(Logging.Severity.LS_INFO) }
420
+ .onFailure { Log.w(TAG, "libwebrtc verbose logging unavailable: ${it.message}") }
421
+ }
422
+ val shared = EglBase.create().also { egl = it }.eglBaseContext
423
+ // Same silenced device module as the Cloudflare path: libwebrtc renders every received audio
424
+ // track through it, which would be the wearer's own microphone coming back out of the phone.
425
+ // Playout must still run so the AudioTrackSink is fed, so it runs inert.
426
+ val adm = JavaAudioDeviceModule.builder(context)
427
+ .setAudioAttributes(
428
+ AudioAttributes.Builder()
429
+ .setUsage(AudioAttributes.USAGE_MEDIA)
430
+ .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
431
+ .build(),
432
+ )
433
+ .setUseHardwareAcousticEchoCanceler(false)
434
+ .setUseHardwareNoiseSuppressor(false)
435
+ .setAudioRecordStateCallback(object : JavaAudioDeviceModule.AudioRecordStateCallback {
436
+ override fun onWebRtcAudioRecordStart() {
437
+ Log.e(TAG, "SoftAP ingest ADM started RECORDING — unexpected phone mic capture")
438
+ }
439
+
440
+ override fun onWebRtcAudioRecordStop() = Unit
441
+ })
442
+ .createAudioDeviceModule()
443
+ adm.setSpeakerMute(true)
444
+ adm.setAudioRecordEnabled(false)
445
+
446
+ factory = PeerConnectionFactory.builder()
447
+ .setOptions(SoftApIcePolicy.factoryOptions())
448
+ .setAudioDeviceModule(adm)
449
+ .setVideoEncoderFactory(DefaultVideoEncoderFactory(shared, true, true))
450
+ .setVideoDecoderFactory(DefaultVideoDecoderFactory(shared))
451
+ .createPeerConnectionFactory()
452
+ adm.release()
453
+ SoftApTrace.stage(
454
+ "ingest_factory_ready",
455
+ "networkIgnoreMask" to SoftApIcePolicy.networkIgnoreMask(),
456
+ "scopedNetwork" to (scopedNetwork?.isAvailable() == true),
457
+ )
458
+ }
459
+
460
+ private fun observerFor(gen: Int, gathered: CountDownLatch) = object : PeerConnection.Observer {
461
+ override fun onSignalingChange(state: PeerConnection.SignalingState) = Unit
462
+
463
+ override fun onIceConnectionChange(state: PeerConnection.IceConnectionState) {
464
+ Log.i(TAG, "SoftAP ingest ICE $state")
465
+ if (gen != generation) return
466
+ if (state == PeerConnection.IceConnectionState.FAILED ||
467
+ state == PeerConnection.IceConnectionState.DISCONNECTED
468
+ ) {
469
+ transition(SourceState.FAILED, "ice_${state.name.lowercase()}")
470
+ } else if (state == PeerConnection.IceConnectionState.CONNECTED ||
471
+ state == PeerConnection.IceConnectionState.COMPLETED
472
+ ) {
473
+ // Same bounce WHEP already handles: consent freshness can DISCONNECTED → CONNECTED
474
+ // without a new offer. Stay FAILED and the UI never leaves Reconnecting even after
475
+ // media is flowing again.
476
+ if (this@LocalWhipIngestSource.state == SourceState.FAILED) {
477
+ transition(SourceState.CONNECTING, "ice_recovered")
478
+ armFirstFrame(gen)
479
+ }
480
+ armSelectedPairProof(gen)
481
+ }
482
+ }
483
+
484
+ override fun onIceConnectionReceivingChange(receiving: Boolean) = Unit
485
+
486
+ override fun onIceGatheringChange(state: PeerConnection.IceGatheringState) {
487
+ Log.i(TAG, "SoftAP ingest ICE gathering $state")
488
+ if (state == PeerConnection.IceGatheringState.COMPLETE) gathered.countDown()
489
+ }
490
+
491
+ override fun onIceCandidate(candidate: IceCandidate) {
492
+ // Attribute the candidate to a real interface. `network-cost` in the SDP is libwebrtc's own
493
+ // verdict on the adapter type (10 wifi, 900 cellular, 50 unknown), so printing it next to
494
+ // the interface the kernel says owns the address shows exactly where the labels diverged.
495
+ val address = SoftApSdpGuard.candidateAddress(candidate.sdp)
496
+ SoftApTrace.stage(
497
+ "ingest_host_candidate",
498
+ "onHotspot" to SoftApSdpGuard.isSoftApHostCandidate(candidate.sdp, scopedNetwork?.scopedPrefix()),
499
+ "address" to (address ?: "none"),
500
+ "owner" to (address?.let { NetworkFacts.ownerOf(it) } ?: "ABSENT"),
501
+ "candidate" to candidate.sdp,
502
+ )
503
+ }
504
+
505
+ override fun onIceCandidatesRemoved(candidates: Array<out IceCandidate>) = Unit
506
+ override fun onAddStream(stream: MediaStream) = Unit
507
+ override fun onRemoveStream(stream: MediaStream) = Unit
508
+ override fun onDataChannel(channel: org.webrtc.DataChannel) = Unit
509
+ override fun onRenegotiationNeeded() = Unit
510
+ override fun onAddTrack(receiver: org.webrtc.RtpReceiver, streams: Array<out MediaStream>) = Unit
511
+
512
+ // Unified Plan: onTrack is the only attach path, and the glasses' offer is what creates the
513
+ // transceivers, so nothing is added here.
514
+ override fun onTrack(transceiver: RtpTransceiver) {
515
+ if (gen != generation) return
516
+ when (val track = transceiver.receiver.track()) {
517
+ is VideoTrack -> attachVideo(track)
518
+ is AudioTrack -> attachAudio(track)
519
+ }
520
+ }
521
+ }
522
+
523
+ private fun attachVideo(track: VideoTrack) {
524
+ if (!videoIds.claim(track.id())) {
525
+ stats.onDup()
526
+ return
527
+ }
528
+ attachedVideo = track
529
+ track.addSink(relay.videoSink)
530
+ Log.i(TAG, "SoftAP ingest attach kind=video id=${track.id()}")
531
+ }
532
+
533
+ private fun attachAudio(track: AudioTrack) {
534
+ if (!audioIds.claim(track.id())) {
535
+ stats.onDup()
536
+ return
537
+ }
538
+ audioTracks.add(track)
539
+ // Gain is applied after the raw sink callback, so volume 0 kills local playout while the sink
540
+ // still receives full-scale PCM for the ACS uplink.
541
+ track.setVolume(0.0)
542
+ track.setEnabled(true)
543
+ track.addSink(relay.audioSink)
544
+ Log.i(TAG, "SoftAP ingest attach kind=audio id=${track.id()} playoutVolume=0")
545
+ }
546
+
547
+ private fun disposePeer() {
548
+ val peer = pc
549
+ pc = null
550
+ try {
551
+ peer?.close()
552
+ peer?.dispose()
553
+ } catch (error: Exception) {
554
+ Log.w(TAG, "SoftAP ingest peer dispose failed", error)
555
+ }
556
+ }
557
+
558
+ /**
559
+ * `LIVE` means a frame reached the sink, never that the negotiation succeeded. An answered
560
+ * session that never delivers a frame reads as healthy behind a frozen tile otherwise, and
561
+ * nothing above this layer can tell.
562
+ */
563
+ private fun armFirstFrame(gen: Int) {
564
+ firstFrame.arm(gen)
565
+ cancelFirstFrameDeadline()
566
+ val task = Runnable {
567
+ firstFrameDeadline = null
568
+ if (gen != generation || !firstFrame.expired(gen)) return@Runnable
569
+ Log.w(TAG, "SoftAP ingest answered but delivered no frame in ${FIRST_FRAME_TIMEOUT_MS}ms")
570
+ SoftApTrace.failure("ingest_no_first_frame", "timeoutMs" to FIRST_FRAME_TIMEOUT_MS)
571
+ transition(SourceState.FAILED, "no_first_frame")
572
+ }
573
+ firstFrameDeadline = task
574
+ mainHandler.postDelayed(task, FIRST_FRAME_TIMEOUT_MS)
575
+ }
576
+
577
+ private fun cancelFirstFrameDeadline() {
578
+ firstFrameDeadline?.let { mainHandler.removeCallbacks(it) }
579
+ firstFrameDeadline = null
580
+ }
581
+
582
+ /**
583
+ * Prove media is on the hotspot, not merely that the SDP mentioned it.
584
+ *
585
+ * [SoftApSdpGuard] checks what each side *offered*; ICE then picks a pair, and nothing so far says
586
+ * it picked one on the network we joined. A call running over cellular or home Wi-Fi is the exact
587
+ * failure SoftAP exists to avoid, and it does not announce itself — the tile is either black or
588
+ * looks fine while taking the long way round. Two samples rather than one so the byte counter has
589
+ * something to be compared against.
590
+ */
591
+ private fun armSelectedPairProof(gen: Int) {
592
+ cancelSelectedPairProof()
593
+ val first = Runnable {
594
+ if (gen != generation) return@Runnable
595
+ sampleIcePath(gen) { firstSample ->
596
+ val second = Runnable {
597
+ if (gen != generation) return@Runnable
598
+ sampleIcePath(gen) { secondSample -> reportIcePath(gen, firstSample, secondSample) }
599
+ }
600
+ selectedPairTask = second
601
+ mainHandler.postDelayed(second, SELECTED_PAIR_SAMPLE_GAP_MS)
602
+ }
603
+ }
604
+ selectedPairTask = first
605
+ mainHandler.postDelayed(first, SELECTED_PAIR_FIRST_SAMPLE_MS)
606
+ }
607
+
608
+ private fun cancelSelectedPairProof() {
609
+ selectedPairTask?.let { mainHandler.removeCallbacks(it) }
610
+ selectedPairTask = null
611
+ }
612
+
613
+ /** Reads the prefix per sample: the phone can lose and rejoin the hotspot mid-call. */
614
+ private fun sampleIcePath(gen: Int, onSample: (IcePathVerdict) -> Unit) {
615
+ val peer = pc ?: return
616
+ val prefix = scopedNetwork?.scopedPrefix()
617
+ runCatching {
618
+ peer.getStats { report ->
619
+ if (gen != generation) return@getStats
620
+ onSample(
621
+ SelectedIcePair.verdict(
622
+ iceTransports(report),
623
+ candidatePairs(report),
624
+ iceCandidates(report),
625
+ prefix,
626
+ gen,
627
+ ),
628
+ )
629
+ }
630
+ }.onFailure { Log.w(TAG, "SoftAP ingest stats read failed", it) }
631
+ }
632
+
633
+ private fun reportIcePath(gen: Int, first: IcePathVerdict, second: IcePathVerdict) {
634
+ if (gen != generation) return
635
+ when (second) {
636
+ is IcePathVerdict.OnHotspot -> {
637
+ val flow = SelectedIcePair.flow(first, second)
638
+ val compared = flow as? SelectedIcePair.Flow.Compared
639
+ SoftApTrace.stage(
640
+ "ingest_selected_pair",
641
+ "pairId" to second.pairId,
642
+ "local" to second.local,
643
+ "remote" to second.remote,
644
+ "bytesReceived" to second.bytesReceived,
645
+ // Only ever true off a same-pair comparison. An incomparable sample reports the reason
646
+ // instead of a verdict, because growth across a pair change is not evidence.
647
+ "bytesFlowing" to (compared?.flowing ?: false),
648
+ "comparable" to (compared != null),
649
+ "notComparable" to (flow as? SelectedIcePair.Flow.NotComparable)?.reason,
650
+ )
651
+ Log.i(
652
+ TAG,
653
+ "SoftAP ingest selected pair ${second.pairId} local=${second.local} " +
654
+ "remote=${second.remote} bytes=${second.bytesReceived} flow=$flow",
655
+ )
656
+ if (compared != null && !compared.flowing) {
657
+ // The pair is right but nothing is arriving yet. Named so the log says so, and left
658
+ // non-terminal on purpose: the first-frame gate already owns that verdict, and one slow
659
+ // sample must not kill a call that is about to paint.
660
+ SoftApTrace.failure(
661
+ "ingest_selected_pair_idle",
662
+ "pairId" to second.pairId,
663
+ "local" to second.local,
664
+ "bytesReceived" to second.bytesReceived,
665
+ )
666
+ }
667
+ }
668
+
669
+ is IcePathVerdict.OffHotspot -> {
670
+ SoftApTrace.failure(
671
+ "ingest_selected_pair_off_hotspot",
672
+ "pairId" to second.pairId,
673
+ "local" to second.local,
674
+ "prefix" to second.prefix,
675
+ )
676
+ Log.e(TAG, "SoftAP ingest selected a pair off the hotspot: ${second.local} not in ${second.prefix}")
677
+ transition(SourceState.FAILED, "ice_off_hotspot")
678
+ }
679
+
680
+ is IcePathVerdict.Unknown ->
681
+ SoftApTrace.stage("ingest_selected_pair_unknown", "reason" to second.reason)
682
+ }
683
+ }
684
+
685
+ /**
686
+ * The transport entries, for `selectedCandidatePairId`.
687
+ *
688
+ * Read from the report rather than inferred, because inference is what made the previous verifier
689
+ * untrustworthy. `RTCStatsReport` also carries the pre-spec `selectedCandidatePairId` under the
690
+ * same name on this build, so no aliasing is needed.
691
+ */
692
+ private fun iceTransports(report: org.webrtc.RTCStatsReport): List<IceTransportStats> =
693
+ report.statsMap.values
694
+ .filter { it.type == "transport" }
695
+ .map { stats ->
696
+ IceTransportStats(
697
+ id = stats.id,
698
+ selectedCandidatePairId = stats.members["selectedCandidatePairId"] as? String,
699
+ )
700
+ }
701
+
702
+ private fun candidatePairs(report: org.webrtc.RTCStatsReport): List<IceCandidatePairStats> =
703
+ report.statsMap.values
704
+ .filter { it.type == "candidate-pair" }
705
+ .map { stats ->
706
+ IceCandidatePairStats(
707
+ id = stats.id,
708
+ state = stats.members["state"] as? String,
709
+ nominated = stats.members["nominated"] as? Boolean ?: false,
710
+ localCandidateId = stats.members["localCandidateId"] as? String,
711
+ remoteCandidateId = stats.members["remoteCandidateId"] as? String,
712
+ // libwebrtc reports 64-bit counters as BigInteger, which is a Number but not a Long.
713
+ bytesReceived = (stats.members["bytesReceived"] as? Number)?.toLong() ?: 0L,
714
+ )
715
+ }
716
+
717
+ private fun iceCandidates(report: org.webrtc.RTCStatsReport): Map<String, IceCandidateStats> =
718
+ report.statsMap.values
719
+ .filter { it.type == "local-candidate" || it.type == "remote-candidate" }
720
+ .associate { stats ->
721
+ stats.id to IceCandidateStats(
722
+ id = stats.id,
723
+ address = (stats.members["address"] ?: stats.members["ip"]) as? String,
724
+ candidateType = stats.members["candidateType"] as? String,
725
+ )
726
+ }
727
+
728
+ private fun notePromotableFrame() {
729
+ if (!firstFrame.onFrame(generation)) return
730
+ cancelFirstFrameDeadline()
731
+ SoftApTrace.stage("ingest_first_frame")
732
+ transition(SourceState.LIVE, "first_frame")
733
+ }
734
+
735
+ private fun transition(next: SourceState, reason: String) {
736
+ val previous = state
737
+ state = next
738
+ if (previous == next) return
739
+ Log.i(TAG, "SoftAP ingest source $previous -> $next ($reason)")
740
+ try {
741
+ stateListener?.onSourceState(next, reason)
742
+ } catch (error: Exception) {
743
+ Log.w(TAG, "source state listener threw", error)
744
+ }
745
+ }
746
+
747
+ companion object {
748
+ private const val TAG = "ACS-SPIKE"
749
+
750
+ /** createAnswer plus setLocalDescription. Local work; generous only to avoid flakiness. */
751
+ private const val ANSWER_TIMEOUT_MS = 5_000L
752
+
753
+ /**
754
+ * Local host gathering with no STUN server normally finishes in tens of milliseconds. This only
755
+ * bounds the case where libwebrtc cannot see the hotspot at all, which is the failure the
756
+ * feasibility gate and [com.mentra.acsmeeting.network.ScopedNetworkChangeDetector] address.
757
+ */
758
+ private const val GATHER_TIMEOUT_MS = 4_000L
759
+
760
+ /**
761
+ * Shorter than the Cloudflare path's 9 s: there is no CDN ingest, no transcode and no WAN hop
762
+ * here, so a first frame that has not arrived in 6 s is not late, it is not coming.
763
+ */
764
+ private const val FIRST_FRAME_TIMEOUT_MS = 6_000L
765
+
766
+ /** Let the nominated pair settle before reading it; ICE reports CONNECTED as it is choosing. */
767
+ private const val SELECTED_PAIR_FIRST_SAMPLE_MS = 500L
768
+
769
+ /** Long enough that a healthy 15 fps feed cannot show a flat byte counter across the two reads. */
770
+ private const val SELECTED_PAIR_SAMPLE_GAP_MS = 1_200L
771
+ }
772
+ }