@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,73 @@
|
|
|
1
|
+
package com.mentra.acsmeeting.source
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Where the meeting's glasses video comes from, as sent by the host.
|
|
5
|
+
*
|
|
6
|
+
* A sealed type rather than a nullable URL because the two transports need different inputs and
|
|
7
|
+
* have no meaningful intersection: WHEP needs a URL it is given, while SoftAP produces one only
|
|
8
|
+
* after binding a listener. Modelling it as `whepUrl: String?` invites the exact bug this replaces
|
|
9
|
+
* — an empty string reaching the WHEP subscriber and failing several seconds later as an opaque
|
|
10
|
+
* HTTP error.
|
|
11
|
+
*/
|
|
12
|
+
sealed interface MeetingVideoSourceSpec {
|
|
13
|
+
|
|
14
|
+
val kind: SourceKind
|
|
15
|
+
|
|
16
|
+
/** Subscribe to a Cloudflare WHEP endpoint. */
|
|
17
|
+
data class Whep(val url: String) : MeetingVideoSourceSpec {
|
|
18
|
+
override val kind = SourceKind.WHEP
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Serve a WHIP endpoint on the glasses hotspot.
|
|
23
|
+
*
|
|
24
|
+
* @param bindAddress the phone's own IPv4 on the hotspot. The orchestrator joins the network and
|
|
25
|
+
* supplies this; it cannot be known before the join completes.
|
|
26
|
+
*/
|
|
27
|
+
data class SoftAp(val bindAddress: String?) : MeetingVideoSourceSpec {
|
|
28
|
+
override val kind = SourceKind.SOFTAP
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Locally generated frames, for the synthetic diagnostic arm. */
|
|
32
|
+
data object Synthetic : MeetingVideoSourceSpec {
|
|
33
|
+
override val kind = SourceKind.DIRECT
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
fun toConfig(): SourceConfig = when (this) {
|
|
37
|
+
is Whep -> SourceConfig(url, SourceKind.WHEP)
|
|
38
|
+
is SoftAp -> SourceConfig("", SourceKind.SOFTAP, bindAddress)
|
|
39
|
+
Synthetic -> SourceConfig("", SourceKind.DIRECT)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
companion object {
|
|
43
|
+
const val TYPE_WHEP = "whep"
|
|
44
|
+
const val TYPE_SOFTAP = "softap"
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Parses the `videoSource` map from the JS bridge, falling back to a bare `whepUrl` so a host
|
|
48
|
+
* that predates the union keeps working.
|
|
49
|
+
*
|
|
50
|
+
* Throws rather than defaulting. A malformed source must fail at the bridge, where the message
|
|
51
|
+
* names the field, instead of surfacing later as a call that joins and shows nothing.
|
|
52
|
+
*/
|
|
53
|
+
fun parse(videoSource: Map<*, *>?, legacyWhepUrl: String?): MeetingVideoSourceSpec {
|
|
54
|
+
if (videoSource == null) {
|
|
55
|
+
val url = legacyWhepUrl?.trim().orEmpty()
|
|
56
|
+
require(url.isNotEmpty()) { "videoSource or whepUrl is required" }
|
|
57
|
+
return Whep(url)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return when (val type = (videoSource["type"] as? String)?.trim()?.lowercase()) {
|
|
61
|
+
TYPE_WHEP -> {
|
|
62
|
+
val url = (videoSource["url"] as? String)?.trim() ?: legacyWhepUrl?.trim().orEmpty()
|
|
63
|
+
require(url.isNotEmpty()) { "videoSource.url is required for a whep source" }
|
|
64
|
+
Whep(url)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
TYPE_SOFTAP -> SoftAp((videoSource["bindAddress"] as? String)?.trim()?.ifEmpty { null })
|
|
68
|
+
|
|
69
|
+
else -> throw IllegalArgumentException("unsupported videoSource.type: $type")
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
package com.mentra.acsmeeting.source
|
|
2
|
+
|
|
3
|
+
import org.webrtc.SdpObserver
|
|
4
|
+
import org.webrtc.SessionDescription
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* No-op [SdpObserver] so call sites override only the one callback they care about.
|
|
8
|
+
*
|
|
9
|
+
* Shared by the Cloudflare and SoftAP paths, which negotiate in opposite directions and therefore
|
|
10
|
+
* each ignore a different half of this interface.
|
|
11
|
+
*/
|
|
12
|
+
internal open class SdpAdapter : SdpObserver {
|
|
13
|
+
override fun onCreateSuccess(sdp: SessionDescription) {}
|
|
14
|
+
override fun onSetSuccess() {}
|
|
15
|
+
override fun onCreateFailure(error: String) {}
|
|
16
|
+
override fun onSetFailure(error: String) {}
|
|
17
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
package com.mentra.acsmeeting.source
|
|
2
|
+
|
|
3
|
+
import com.mentra.acsmeeting.network.Ipv4Prefix
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Reads the path media is *actually* taking out of a WebRTC stats sample.
|
|
7
|
+
*
|
|
8
|
+
* The SDP guard checks what each side offered. That is necessary and not sufficient: an answer can
|
|
9
|
+
* carry a perfectly good hotspot candidate and ICE can still select a different pair, at which point
|
|
10
|
+
* the SoftAP call is running over an interface that either cannot reach the glasses or defeats the
|
|
11
|
+
* whole point of the transport. The only honest question is which pair got nominated.
|
|
12
|
+
*
|
|
13
|
+
* ## Why the selection is followed rather than inferred
|
|
14
|
+
*
|
|
15
|
+
* This used to pick "a nominated succeeded pair, else any succeeded pair, else whichever pair has
|
|
16
|
+
* the most received bytes". None of those identify the current path. A succeeded pair is only a pair
|
|
17
|
+
* that once passed a connectivity check, and historical bytes belong to a pair that may have been
|
|
18
|
+
* replaced. Using it as acceptance evidence meant a run could be graded on a pair that was not
|
|
19
|
+
* carrying the call.
|
|
20
|
+
*
|
|
21
|
+
* The transport's `selectedCandidatePairId` is the specification's answer to exactly this question,
|
|
22
|
+
* so it is read and followed. When it is not available the verdict is [IcePathVerdict.Unknown] —
|
|
23
|
+
* insufficient evidence, never a pass.
|
|
24
|
+
*
|
|
25
|
+
* Kept free of `org.webrtc` types so the decision is unit testable; [LocalWhipIngestSource] adapts
|
|
26
|
+
* `RTCStatsReport` into these shapes.
|
|
27
|
+
*/
|
|
28
|
+
data class IceCandidatePairStats(
|
|
29
|
+
val id: String,
|
|
30
|
+
/** `succeeded`, `in-progress`, `failed`, … as libwebrtc spells it. */
|
|
31
|
+
val state: String?,
|
|
32
|
+
val nominated: Boolean,
|
|
33
|
+
val localCandidateId: String?,
|
|
34
|
+
val remoteCandidateId: String?,
|
|
35
|
+
val bytesReceived: Long,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
data class IceCandidateStats(
|
|
39
|
+
val id: String,
|
|
40
|
+
val address: String?,
|
|
41
|
+
val candidateType: String?,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
/** The transport entry, for the one field that names the pair in use. */
|
|
45
|
+
data class IceTransportStats(val id: String, val selectedCandidatePairId: String?)
|
|
46
|
+
|
|
47
|
+
sealed interface IcePathVerdict {
|
|
48
|
+
/**
|
|
49
|
+
* Media is on the network we joined.
|
|
50
|
+
*
|
|
51
|
+
* [pairId] and [iceGeneration] are carried so two samples can be compared as the same path. Byte
|
|
52
|
+
* growth across a pair change says nothing, and comparing it anyway is how an idle path could
|
|
53
|
+
* read as a flowing one.
|
|
54
|
+
*/
|
|
55
|
+
data class OnHotspot(
|
|
56
|
+
val pairId: String,
|
|
57
|
+
val iceGeneration: Int,
|
|
58
|
+
val local: String,
|
|
59
|
+
val remote: String?,
|
|
60
|
+
val bytesReceived: Long,
|
|
61
|
+
) : IcePathVerdict
|
|
62
|
+
|
|
63
|
+
/** A pair was selected and it is not on the hotspot. This is the silent failure worth killing. */
|
|
64
|
+
data class OffHotspot(val pairId: String, val local: String?, val prefix: String) : IcePathVerdict
|
|
65
|
+
|
|
66
|
+
/** Not enough information to judge. Never a pass, and never on its own a reason to fail a call. */
|
|
67
|
+
data class Unknown(val reason: String) : IcePathVerdict
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
object SelectedIcePair {
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The pair the transport says is selected, or null with the reason it could not be identified.
|
|
74
|
+
*
|
|
75
|
+
* Deliberately no fallback. Every fallback that was here answered a different question than
|
|
76
|
+
* "which pair is carrying this call".
|
|
77
|
+
*/
|
|
78
|
+
fun selected(
|
|
79
|
+
transports: List<IceTransportStats>,
|
|
80
|
+
pairs: List<IceCandidatePairStats>,
|
|
81
|
+
): Result<IceCandidatePairStats> {
|
|
82
|
+
if (transports.isEmpty()) return Result.failure(Missing("no_transport_stats"))
|
|
83
|
+
val selectedId =
|
|
84
|
+
transports.firstNotNullOfOrNull { it.selectedCandidatePairId }
|
|
85
|
+
?: return Result.failure(Missing("no_selected_pair"))
|
|
86
|
+
val pair =
|
|
87
|
+
pairs.firstOrNull { it.id == selectedId }
|
|
88
|
+
?: return Result.failure(Missing("selected_pair_absent_from_report"))
|
|
89
|
+
return Result.success(pair)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Carries the reason a selection could not be identified, for the trace. */
|
|
93
|
+
class Missing(val reason: String) : Exception(reason)
|
|
94
|
+
|
|
95
|
+
fun verdict(
|
|
96
|
+
transports: List<IceTransportStats>,
|
|
97
|
+
pairs: List<IceCandidatePairStats>,
|
|
98
|
+
candidates: Map<String, IceCandidateStats>,
|
|
99
|
+
prefix: Ipv4Prefix?,
|
|
100
|
+
iceGeneration: Int,
|
|
101
|
+
): IcePathVerdict {
|
|
102
|
+
val pair =
|
|
103
|
+
selected(transports, pairs).getOrElse { error ->
|
|
104
|
+
return IcePathVerdict.Unknown((error as? Missing)?.reason ?: "selection_failed")
|
|
105
|
+
}
|
|
106
|
+
// No prefix means the host could not describe the network it joined. Reporting that as
|
|
107
|
+
// "off hotspot" would fail healthy calls on any host whose LinkProperties read came back empty.
|
|
108
|
+
if (prefix == null) return IcePathVerdict.Unknown("no_scoped_prefix")
|
|
109
|
+
val local = pair.localCandidateId?.let { candidates[it] }?.address
|
|
110
|
+
?: return IcePathVerdict.Unknown("no_local_candidate")
|
|
111
|
+
val remote = pair.remoteCandidateId?.let { candidates[it] }?.address
|
|
112
|
+
if (!prefix.contains(local)) return IcePathVerdict.OffHotspot(pair.id, local, prefix.toString())
|
|
113
|
+
return IcePathVerdict.OnHotspot(pair.id, iceGeneration, local, remote, pair.bytesReceived)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Outcome of comparing two samples. Only [Flow.Compared] says anything about media. */
|
|
117
|
+
sealed interface Flow {
|
|
118
|
+
data class Compared(val before: Long, val after: Long) : Flow {
|
|
119
|
+
val flowing: Boolean
|
|
120
|
+
get() = after > before
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The samples are not the same path, so growth between them is not evidence either way. */
|
|
124
|
+
data class NotComparable(val reason: String) : Flow
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Compare byte counters only when both samples are the same pair on the same ICE generation.
|
|
129
|
+
*
|
|
130
|
+
* The previous check compared two numbers with no pair identity at all, which a renomination or a
|
|
131
|
+
* session restart silently invalidated.
|
|
132
|
+
*/
|
|
133
|
+
fun flow(first: IcePathVerdict, second: IcePathVerdict.OnHotspot): Flow {
|
|
134
|
+
val before = first as? IcePathVerdict.OnHotspot ?: return Flow.NotComparable("first_sample_unusable")
|
|
135
|
+
if (before.iceGeneration != second.iceGeneration) return Flow.NotComparable("ice_generation_changed")
|
|
136
|
+
if (before.pairId != second.pairId) return Flow.NotComparable("pair_changed")
|
|
137
|
+
return Flow.Compared(before.bytesReceived, second.bytesReceived)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
package com.mentra.acsmeeting.source
|
|
2
|
+
|
|
3
|
+
import com.mentra.acsmeeting.network.Ipv4Prefix
|
|
4
|
+
import com.mentra.acsmeeting.network.ScopedNetworkChangeDetector.PublishedEntry
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Separates the causes of "the answer carried no hotspot candidate".
|
|
8
|
+
*
|
|
9
|
+
* That single symptom has been produced on device by four different faults, and a coarse error code
|
|
10
|
+
* cannot tell them apart — which is how one of them was misattributed and "fixed" by a change that
|
|
11
|
+
* created the next. Each fault is distinguished by state that only exists at the moment of
|
|
12
|
+
* gathering, so it is captured there and reduced to a [Fault] rather than inferred from logs later:
|
|
13
|
+
*
|
|
14
|
+
* - the hotspot address is not in the kernel's interface table, so nothing could have gathered it
|
|
15
|
+
* - the address is there but the inventory libwebrtc holds does not contain the hotspot, or two
|
|
16
|
+
* entries share a handle so one overwrote the other
|
|
17
|
+
* - both are fine and still nothing was gathered, which leaves the socket bind
|
|
18
|
+
* - candidates were gathered, none on the hotspot, so the wrong address is being advertised
|
|
19
|
+
*
|
|
20
|
+
* The [Fault] is a hypothesis narrowed by observation, not a proof; the raw fields travel with it so
|
|
21
|
+
* a run can be re-read without trusting the classifier.
|
|
22
|
+
*/
|
|
23
|
+
object SoftApIceDiagnosis {
|
|
24
|
+
|
|
25
|
+
/** The narrowed cause. Ordered from "cannot possibly work" to "worked, wrong answer". */
|
|
26
|
+
enum class Fault {
|
|
27
|
+
/** The scoped address is absent from `getifaddrs`. ICE has nothing to gather. */
|
|
28
|
+
MISSING_INTERFACE,
|
|
29
|
+
|
|
30
|
+
/** The hotspot is not in libwebrtc's inventory, or a handle collision dropped it. */
|
|
31
|
+
ERASED_ENTRY,
|
|
32
|
+
|
|
33
|
+
/** Address present, entry published, zero candidates: the socket could not bind. */
|
|
34
|
+
FAILED_BINDING,
|
|
35
|
+
|
|
36
|
+
/** Candidates gathered, none on the hotspot: a truthful port with the wrong address. */
|
|
37
|
+
WRONG_ADDRESS,
|
|
38
|
+
|
|
39
|
+
/** The observations do not narrow to one cause. */
|
|
40
|
+
INDETERMINATE,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A gathered candidate reduced to the two things that matter: its address and whose it is. */
|
|
44
|
+
data class CandidateFact(val address: String?, val owner: String?, val onHotspot: Boolean) {
|
|
45
|
+
override fun toString(): String = "${address ?: "?"}@${owner ?: "ABSENT"}"
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
data class Diagnosis(
|
|
49
|
+
val code: String,
|
|
50
|
+
val fault: Fault,
|
|
51
|
+
val scopedAddress: String?,
|
|
52
|
+
val scopedOwner: String?,
|
|
53
|
+
val scopedPrefix: Ipv4Prefix?,
|
|
54
|
+
val published: List<PublishedEntry>,
|
|
55
|
+
val candidates: List<CandidateFact>,
|
|
56
|
+
) {
|
|
57
|
+
val scopedInTable: Boolean
|
|
58
|
+
get() = scopedOwner != null
|
|
59
|
+
|
|
60
|
+
val hotspotCandidates: List<CandidateFact>
|
|
61
|
+
get() = candidates.filter { it.onHotspot }
|
|
62
|
+
|
|
63
|
+
val offHotspot: List<CandidateFact>
|
|
64
|
+
get() = candidates.filterNot { it.onHotspot }
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Flattened for [SoftApTrace][com.mentra.acsmeeting.trace.SoftApTrace] and the analyzer.
|
|
68
|
+
* Field names are the contract `softap-call-proof.mjs` reads, so they are stable.
|
|
69
|
+
*/
|
|
70
|
+
fun fields(): Array<Pair<String, Any?>> =
|
|
71
|
+
arrayOf(
|
|
72
|
+
"code" to code,
|
|
73
|
+
"fault" to fault.name,
|
|
74
|
+
"scopedAddress" to (scopedAddress ?: "none"),
|
|
75
|
+
"scopedOwner" to (scopedOwner ?: "ABSENT"),
|
|
76
|
+
"scopedInTable" to scopedInTable,
|
|
77
|
+
"scopedPrefix" to (scopedPrefix?.toString() ?: "unknown"),
|
|
78
|
+
"publishedInventory" to
|
|
79
|
+
published.joinToString(",").ifEmpty { "none" },
|
|
80
|
+
"publishedHotspot" to publishesHotspot(),
|
|
81
|
+
"handleCollision" to hasHandleCollision(),
|
|
82
|
+
"gathered" to candidates.size,
|
|
83
|
+
"hotspotCandidates" to hotspotCandidates.size,
|
|
84
|
+
"offHotspot" to offHotspot.joinToString(",").ifEmpty { "none" },
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
internal fun publishesHotspot(): Boolean =
|
|
88
|
+
scopedAddress != null && published.any { scopedAddress in it.addresses }
|
|
89
|
+
|
|
90
|
+
internal fun hasHandleCollision(): Boolean {
|
|
91
|
+
val handles = published.map { it.handle }
|
|
92
|
+
return handles.size != handles.distinct().size
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Coarse label: nothing was gathered at all. */
|
|
97
|
+
const val CODE_NO_CANDIDATES = "no_candidates_gathered"
|
|
98
|
+
|
|
99
|
+
/** Coarse label: candidates exist, but every one is off the hotspot. */
|
|
100
|
+
const val CODE_ONLY_NON_HOTSPOT = "only_non_hotspot_candidates"
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Classify a failed gathering pass.
|
|
104
|
+
*
|
|
105
|
+
* [scopedOwner] is the interface the kernel says owns [scopedAddress], normally from
|
|
106
|
+
* `NetworkFacts.ownerOf`; null means the address is not in the table.
|
|
107
|
+
*/
|
|
108
|
+
fun diagnose(
|
|
109
|
+
scopedAddress: String?,
|
|
110
|
+
scopedOwner: String?,
|
|
111
|
+
scopedPrefix: Ipv4Prefix?,
|
|
112
|
+
published: List<PublishedEntry>,
|
|
113
|
+
candidates: List<CandidateFact>,
|
|
114
|
+
): Diagnosis {
|
|
115
|
+
val code =
|
|
116
|
+
if (candidates.isEmpty()) CODE_NO_CANDIDATES else CODE_ONLY_NON_HOTSPOT
|
|
117
|
+
val partial =
|
|
118
|
+
Diagnosis(
|
|
119
|
+
code,
|
|
120
|
+
Fault.INDETERMINATE,
|
|
121
|
+
scopedAddress,
|
|
122
|
+
scopedOwner,
|
|
123
|
+
scopedPrefix,
|
|
124
|
+
published,
|
|
125
|
+
candidates,
|
|
126
|
+
)
|
|
127
|
+
return partial.copy(fault = classify(partial))
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Order is load-bearing. A missing interface also looks like a failed bind, and an erased entry
|
|
132
|
+
* also looks like one, so the checks that rule those out have to come first — otherwise every
|
|
133
|
+
* run is blamed on the socket and the actual cause is never seen.
|
|
134
|
+
*/
|
|
135
|
+
private fun classify(diagnosis: Diagnosis): Fault =
|
|
136
|
+
when {
|
|
137
|
+
diagnosis.scopedAddress == null -> Fault.MISSING_INTERFACE
|
|
138
|
+
!diagnosis.scopedInTable -> Fault.MISSING_INTERFACE
|
|
139
|
+
!diagnosis.publishesHotspot() || diagnosis.hasHandleCollision() -> Fault.ERASED_ENTRY
|
|
140
|
+
diagnosis.candidates.isEmpty() -> Fault.FAILED_BINDING
|
|
141
|
+
diagnosis.hotspotCandidates.isEmpty() -> Fault.WRONG_ADDRESS
|
|
142
|
+
else -> Fault.INDETERMINATE
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
package com.mentra.acsmeeting.source
|
|
2
|
+
|
|
3
|
+
import org.webrtc.PeerConnectionFactory
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Keeps the SoftAP peer's ICE on the hotspot.
|
|
7
|
+
*
|
|
8
|
+
* Answering with a cellular candidate is the quiet failure mode: the glasses have no route to the
|
|
9
|
+
* phone's mobile address, so ICE either fails after a full timeout or, worse, connects over some
|
|
10
|
+
* other shared network and the "direct" link is a fiction. Rather than hope ICE prefers the right
|
|
11
|
+
* interface, this removes the wrong ones from consideration entirely.
|
|
12
|
+
*
|
|
13
|
+
* [networkIgnoreMask] is a real libwebrtc knob — `PeerConnectionFactory.Options.networkIgnoreMask`
|
|
14
|
+
* — unlike the socket factory the design originally assumed existed. Combined with an empty ICE
|
|
15
|
+
* server list (so no srflx or relay candidate can be gathered at all) it leaves exactly one kind of
|
|
16
|
+
* candidate available: a Wi-Fi host candidate, which on a phone joined to the glasses hotspot is
|
|
17
|
+
* the hotspot address.
|
|
18
|
+
*
|
|
19
|
+
* Pure integer arithmetic, so it is unit tested rather than assumed.
|
|
20
|
+
*/
|
|
21
|
+
object SoftApIcePolicy {
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Adapters the SoftAP peer must not gather on.
|
|
25
|
+
*
|
|
26
|
+
* Cellular is the dangerous one and the reason this exists. VPN is excluded because a corporate
|
|
27
|
+
* VPN on the phone would otherwise offer a tunnel address the glasses cannot reach. Loopback is
|
|
28
|
+
* excluded because it is never reachable from another device and only adds candidates to sift
|
|
29
|
+
* through. Ethernet is left available: a USB-tethered debug setup is legitimate, and it is not a
|
|
30
|
+
* route that can silently substitute for the hotspot.
|
|
31
|
+
*/
|
|
32
|
+
fun networkIgnoreMask(): Int =
|
|
33
|
+
PeerConnectionFactory.Options.ADAPTER_TYPE_CELLULAR or
|
|
34
|
+
PeerConnectionFactory.Options.ADAPTER_TYPE_VPN or
|
|
35
|
+
PeerConnectionFactory.Options.ADAPTER_TYPE_LOOPBACK or
|
|
36
|
+
PeerConnectionFactory.Options.ADAPTER_TYPE_ETHERNET or
|
|
37
|
+
PeerConnectionFactory.Options.ADAPTER_TYPE_ANY
|
|
38
|
+
|
|
39
|
+
/** Whether [adapterType] is gathered on, given [mask]. Mirrors libwebrtc's own bitwise test. */
|
|
40
|
+
fun allowsAdapter(mask: Int, adapterType: Int): Boolean = (mask and adapterType) == 0
|
|
41
|
+
|
|
42
|
+
/** Factory options for the SoftAP peer. The network monitor stays on; we need it to see Wi-Fi. */
|
|
43
|
+
fun factoryOptions(): PeerConnectionFactory.Options = PeerConnectionFactory.Options().apply {
|
|
44
|
+
networkIgnoreMask = networkIgnoreMask()
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
package com.mentra.acsmeeting.source
|
|
2
|
+
|
|
3
|
+
import com.mentra.acsmeeting.network.Ipv4Prefix
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Checks that a SoftAP negotiation is actually staying on the SoftAP.
|
|
7
|
+
*
|
|
8
|
+
* The failure this exists to catch is silent. If the phone answers with a candidate on its cellular
|
|
9
|
+
* or home-Wi-Fi interface, or the glasses offer one, ICE may still connect — over a path that
|
|
10
|
+
* defeats the entire point of SoftAP and, in the cellular case, cannot work at all because the
|
|
11
|
+
* glasses have no route to it. Media either never flows or flows the long way while the logs look
|
|
12
|
+
* healthy. So both the offer we receive and the answer we send are asserted against the hotspot
|
|
13
|
+
* subnet before either is used.
|
|
14
|
+
*
|
|
15
|
+
* When the host can describe the network it joined, candidates are matched against that exact
|
|
16
|
+
* prefix — the real invariant is "on the network we joined", and RFC1918 is only a stand-in for it.
|
|
17
|
+
* Cellular carriers hand out 10/8 addresses, so the stand-in accepts precisely the interface this
|
|
18
|
+
* gate exists to reject. Without a prefix (no scoped handle) the check falls back to RFC1918, which
|
|
19
|
+
* deliberately matches `com.mentra.asg_client.io.streaming.config.IcePostPolicy` on the glasses. The
|
|
20
|
+
* two cannot share code across the module boundary, so
|
|
21
|
+
* [SoftApSdpGuardTest][com.mentra.acsmeeting.source.SoftApSdpGuardTest] pins the same cases the
|
|
22
|
+
* glasses-side test pins.
|
|
23
|
+
*/
|
|
24
|
+
object SoftApSdpGuard {
|
|
25
|
+
|
|
26
|
+
sealed interface Verdict {
|
|
27
|
+
/** At least one usable hotspot host candidate. [routableCandidates] is non-fatal, see below. */
|
|
28
|
+
data class Ok(val hostCandidates: List<String>, val routableCandidates: List<String>) : Verdict
|
|
29
|
+
|
|
30
|
+
data class Rejected(val code: String, val detail: String) : Verdict
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* No `typ host` candidate on the hotspot subnet, split by whether anything was gathered at all.
|
|
35
|
+
*
|
|
36
|
+
* One code covered both for a long time and it cost real debugging time: "nothing gathered" and
|
|
37
|
+
* "gathered the wrong address" are different faults with different fixes, and the single code
|
|
38
|
+
* made a run that had regressed from one to the other look unchanged.
|
|
39
|
+
* [SoftApIceDiagnosis] narrows them further; these two are the coarse label.
|
|
40
|
+
*/
|
|
41
|
+
const val REASON_NO_CANDIDATES = SoftApIceDiagnosis.CODE_NO_CANDIDATES
|
|
42
|
+
|
|
43
|
+
/** See [REASON_NO_CANDIDATES]. Candidates exist, none of them on the hotspot. */
|
|
44
|
+
const val REASON_ONLY_NON_HOTSPOT = SoftApIceDiagnosis.CODE_ONLY_NON_HOTSPOT
|
|
45
|
+
|
|
46
|
+
/** Not SDP, or SDP with no media section. */
|
|
47
|
+
const val REASON_MALFORMED = "malformed_sdp"
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Candidates that are not host candidates on a private subnet — srflx, relay, or a host candidate
|
|
51
|
+
* on a public address. In host-only mode none should appear; if they do, ICE could select one, so
|
|
52
|
+
* they are reported for logging. They are not rejected on the offer side: the glasses may run an
|
|
53
|
+
* older build that still gathers them, and dropping the call would be worse than a warning when a
|
|
54
|
+
* valid hotspot candidate is also present.
|
|
55
|
+
*/
|
|
56
|
+
fun inspect(sdp: String?, prefix: Ipv4Prefix? = null): Verdict {
|
|
57
|
+
if (sdp.isNullOrBlank()) return Verdict.Rejected(REASON_MALFORMED, "empty sdp")
|
|
58
|
+
if (!sdp.contains("m=")) return Verdict.Rejected(REASON_MALFORMED, "no media section")
|
|
59
|
+
|
|
60
|
+
val candidates = candidateLines(sdp)
|
|
61
|
+
val host = candidates.filter { isSoftApHostCandidate(it, prefix) }
|
|
62
|
+
val routable = candidates.filterNot { isSoftApHostCandidate(it, prefix) }
|
|
63
|
+
|
|
64
|
+
if (host.isEmpty()) {
|
|
65
|
+
val scope = if (prefix != null) " (hotspot is $prefix)" else ""
|
|
66
|
+
return if (candidates.isEmpty()) {
|
|
67
|
+
Verdict.Rejected(REASON_NO_CANDIDATES, "no candidates at all$scope")
|
|
68
|
+
} else {
|
|
69
|
+
Verdict.Rejected(
|
|
70
|
+
REASON_ONLY_NON_HOTSPOT,
|
|
71
|
+
"only non-hotspot candidates$scope: ${routable.joinToString("; ")}",
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return Verdict.Ok(host, routable)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Every `a=candidate:` attribute, with the `a=` prefix stripped. */
|
|
79
|
+
fun candidateLines(sdp: String): List<String> =
|
|
80
|
+
sdp.lineSequence()
|
|
81
|
+
.map { it.trim() }
|
|
82
|
+
.filter { it.startsWith("a=candidate:") }
|
|
83
|
+
.map { it.removePrefix("a=") }
|
|
84
|
+
.toList()
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* A `typ host` candidate on the hotspot: inside [prefix] when we know it, RFC1918 otherwise.
|
|
88
|
+
*
|
|
89
|
+
* mDNS-obfuscated candidates (`<uuid>.local`) are rejected on purpose. libwebrtc emits them to
|
|
90
|
+
* hide private addresses from remote peers, and the glasses cannot resolve them, so a negotiation
|
|
91
|
+
* that only offers those is broken however healthy it looks. Seeing this fire means the answering
|
|
92
|
+
* peer needs its mDNS obfuscation disabled.
|
|
93
|
+
*/
|
|
94
|
+
fun isSoftApHostCandidate(candidate: String, prefix: Ipv4Prefix? = null): Boolean {
|
|
95
|
+
if (!candidate.contains("typ host")) return false
|
|
96
|
+
val onHotspot: (String) -> Boolean = if (prefix != null) prefix::contains else ::isPrivateIpv4
|
|
97
|
+
return candidate.split(Regex("\\s+")).any(onHotspot)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* What [pinHostAddresses] did to one answer.
|
|
102
|
+
*
|
|
103
|
+
* [rewritten] is the host-candidate addresses that were off the hotspot and got replaced.
|
|
104
|
+
* Empty means the gathered SDP already advertised the scoped IP, so signaling and native state
|
|
105
|
+
* already match and nothing was edited.
|
|
106
|
+
*/
|
|
107
|
+
data class PinResult(val sdp: String, val rewritten: List<String>)
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Force every `typ host` candidate onto [scopedAddress].
|
|
111
|
+
*
|
|
112
|
+
* Needed because a bindable network handle marks the ICE sockets onto the SoftAP — which is what
|
|
113
|
+
* actually delivers UDP — and then libwebrtc substitutes the phone's default-route address into
|
|
114
|
+
* the candidate. The glasses have no route to that address. Replacing only the connection address
|
|
115
|
+
* of host lines (field 5, not `raddr`) keeps srflx/relay untouched and leaves a candidate that
|
|
116
|
+
* was already on the hotspot alone.
|
|
117
|
+
*
|
|
118
|
+
* The socket is still the marked one libwebrtc created. This only changes what the glasses are
|
|
119
|
+
* told to send to, so it stays honest only when that socket is on [scopedAddress]'s network.
|
|
120
|
+
*/
|
|
121
|
+
fun pinHostAddresses(sdp: String, scopedAddress: String, prefix: Ipv4Prefix): PinResult {
|
|
122
|
+
val rewritten = mutableListOf<String>()
|
|
123
|
+
val lines = sdp.split(Regex("(?<=\\r?\\n)", RegexOption.MULTILINE))
|
|
124
|
+
val pinned =
|
|
125
|
+
lines.joinToString("") { line ->
|
|
126
|
+
val body = line.trimEnd('\r', '\n')
|
|
127
|
+
if (!body.contains("candidate:") || !body.contains("typ host")) return@joinToString line
|
|
128
|
+
val address = candidateAddress(body) ?: return@joinToString line
|
|
129
|
+
if (prefix.contains(address) || address == scopedAddress) return@joinToString line
|
|
130
|
+
rewritten += address
|
|
131
|
+
line.replaceFirst(address, scopedAddress)
|
|
132
|
+
}
|
|
133
|
+
return PinResult(pinned, rewritten)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The connection address of a candidate line, for attributing it to a real interface.
|
|
138
|
+
*
|
|
139
|
+
* Field 5 of `candidate:<foundation> <component> <transport> <priority> <address> <port> ...`,
|
|
140
|
+
* per RFC 5245. Read positionally rather than by scanning for anything dotted-quad-shaped: a
|
|
141
|
+
* `raddr` on an srflx line would otherwise win and point at the wrong interface.
|
|
142
|
+
*/
|
|
143
|
+
fun candidateAddress(candidate: String): String? {
|
|
144
|
+
val fields = candidate.trim().removePrefix("a=").split(Regex("\\s+"))
|
|
145
|
+
return fields.getOrNull(4)?.takeIf { it.count { char -> char == '.' } == 3 }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** RFC1918: 10/8, 172.16/12, 192.168/16. Rejects loopback, link-local and public addresses. */
|
|
149
|
+
fun isPrivateIpv4(token: String): Boolean {
|
|
150
|
+
val octets = token.split('.')
|
|
151
|
+
if (octets.size != 4) return false
|
|
152
|
+
val values = octets.map { it.toIntOrNull() ?: return false }
|
|
153
|
+
if (values.any { it < 0 || it > 255 }) return false
|
|
154
|
+
return when {
|
|
155
|
+
values[0] == 10 -> true
|
|
156
|
+
values[0] == 172 && values[1] in 16..31 -> true
|
|
157
|
+
else -> values[0] == 192 && values[1] == 168
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -4,7 +4,9 @@ package com.mentra.acsmeeting.source
|
|
|
4
4
|
* Dedupe WebRTC track attachments by native track id.
|
|
5
5
|
* Three observer callbacks can deliver distinct Java wrappers of one track;
|
|
6
6
|
* object identity cannot be used. [reset] is load-bearing: [CloudflareWhepSource.start]
|
|
7
|
-
* calls stop first, and a stale claim set makes reconnect never attach a sink.
|
|
7
|
+
* calls stop first, and a stale claim set makes reconnect never attach a sink. SoftAP
|
|
8
|
+
* WHIP renegotiate reuses the same track id (`video0`); without a reset the new peer
|
|
9
|
+
* decodes frames that never reach the ACS sink.
|
|
8
10
|
*/
|
|
9
11
|
class TrackRegistry {
|
|
10
12
|
private val ids = mutableSetOf<String>()
|
|
@@ -8,6 +8,8 @@ enum class DecoderMode { TEXTURE, BYTE_BUFFER }
|
|
|
8
8
|
|
|
9
9
|
enum class PixelFormatArm { I420, NV12 }
|
|
10
10
|
|
|
11
|
+
enum class OutgoingRateArm { ADVERTISE_REQUESTED, CLAMP_TO_SOFTWARE_CEILING }
|
|
12
|
+
|
|
11
13
|
/**
|
|
12
14
|
* Investigation arm. SYNTHETIC bypasses glasses, Cloudflare, WHEP and decode.
|
|
13
15
|
* Ships as WHEP. Flip locally to run the experiment; do not commit SYNTHETIC.
|
|
@@ -33,6 +35,73 @@ object AcsInvestigation {
|
|
|
33
35
|
val decoderMode = DecoderMode.TEXTURE
|
|
34
36
|
val zeroCopy = false
|
|
35
37
|
val pixelFormat = PixelFormatArm.I420
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The rate we declare to ACS.
|
|
41
|
+
*
|
|
42
|
+
* CLAMP_TO_SOFTWARE_CEILING pins it to [VideoProfile.SOFTWARE_ENCODER_FPS]
|
|
43
|
+
* whenever the profile asks for more. It was the default on the strength of
|
|
44
|
+
* soaks that reported `codecName: "h264 sw"` alongside wire ≈ 8.8 fps, read as
|
|
45
|
+
* an encoder ceiling. Two things in the device logs say it was never the
|
|
46
|
+
* encoder.
|
|
47
|
+
*
|
|
48
|
+
* 720p15 and 540p15 both reported ~8.8 fps. 540p is 52% of the pixels of 720p,
|
|
49
|
+
* so a CPU-bound encoder would have been close to twice as fast at the smaller
|
|
50
|
+
* size; a limit that does not move with pixel count is not a pixel cost. And
|
|
51
|
+
* once [FramePacer] delivered a steady cadence, `wire` tracked what we handed
|
|
52
|
+
* ACS — 6.9 against 7.1 admitted — which is an encoder keeping up, not one
|
|
53
|
+
* saturating. Both of those soaks ran through a send path that could not hold a
|
|
54
|
+
* rate, so what they measured was our delivery.
|
|
55
|
+
*
|
|
56
|
+
* ADVERTISE_REQUESTED runs the configuration that was never actually tried:
|
|
57
|
+
* declare the profile's own fps and let the corrected pacer deliver it. Read
|
|
58
|
+
* the `P7 rate` verdict for what binds; it distinguishes an encoder that cannot
|
|
59
|
+
* keep up from a rate controller starving one that can, and those two want
|
|
60
|
+
* opposite fixes.
|
|
61
|
+
*/
|
|
62
|
+
val outgoingRate = OutgoingRateArm.ADVERTISE_REQUESTED
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* libwebrtc's own `LS_INFO` stream during a SoftAP call.
|
|
66
|
+
*
|
|
67
|
+
* The one place that says which interfaces `BasicNetworkManager` kept, what adapter type it gave
|
|
68
|
+
* each one, and every `BindSocketToNetwork` result — the evidence that separated a hotspot the
|
|
69
|
+
* kernel had from one libwebrtc had erased. It is also loud, and it is the native stack's whole
|
|
70
|
+
* log, not ours.
|
|
71
|
+
*
|
|
72
|
+
* Flip to false once the SoftAP path is green on device. Keep the `NetworkFacts` stages either
|
|
73
|
+
* way: they are ours, bounded, and the thing that makes the next regression readable.
|
|
74
|
+
*/
|
|
75
|
+
const val LIBWEBRTC_VERBOSE = true
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* How long to hold outgoing microphone PCM before the resampler, to align the wearer's voice
|
|
79
|
+
* with their video at the far end.
|
|
80
|
+
*
|
|
81
|
+
* Ships at 0, and 0 is not a placeholder: the audio and the video reach ACS over different
|
|
82
|
+
* transports (BLE LC3 versus SoftAP WebRTC) with different latencies, but *how* different is a
|
|
83
|
+
* receiver-side measurement, not something either side can infer. Deriving it from first-frame
|
|
84
|
+
* arrival times measures startup skew and would be wrong by however long the camera took to boot.
|
|
85
|
+
*
|
|
86
|
+
* Calibrate the ingest path with a clap in frame and read `AVSYNC clap audioLeadMs`. That is
|
|
87
|
+
* the offset the delay line can cancel. ACS/Teams jitter after ingest still needs a Teams
|
|
88
|
+
* recording. It is logged as `configuredDelayMs` and deliberately never reported as a measured
|
|
89
|
+
* offset.
|
|
90
|
+
*/
|
|
91
|
+
const val acsAudioDelayMs = 0
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Whether outgoing audio buffers carry a presentation timestamp.
|
|
95
|
+
*
|
|
96
|
+
* Whether ACS honours these for lip-sync on *raw* audio is unanswered — the SDK exposes the
|
|
97
|
+
* setter and documents nothing about the receiver. The only way to find out is to record the
|
|
98
|
+
* Teams receiver with this on and with it off and compare, so it has to be switchable; a constant
|
|
99
|
+
* true would leave two of the four measurement configurations unreachable.
|
|
100
|
+
*
|
|
101
|
+
* Defaults to true because the fallback is worse for an unrelated reason: a run of zero
|
|
102
|
+
* timestamps is what makes the Teams jitter buffer hold.
|
|
103
|
+
*/
|
|
104
|
+
const val acsAudioTimestamps = true
|
|
36
105
|
}
|
|
37
106
|
|
|
38
107
|
data class SyntheticConfig(
|