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