@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
|
@@ -48,7 +48,7 @@ class GlassesMediaSourceContractTest {
|
|
|
48
48
|
private fun contract(source: FakeGlassesMediaSource) {
|
|
49
49
|
val pcm = mutableListOf<ByteArray>()
|
|
50
50
|
source.pcmListener = PcmListener { bytes, _, _ -> pcm.add(bytes) }
|
|
51
|
-
val controller = GlassesMediaController { _, listener ->
|
|
51
|
+
val controller = GlassesMediaController { _, listener, _ ->
|
|
52
52
|
source.pcmListener = listener
|
|
53
53
|
source
|
|
54
54
|
}
|
|
@@ -92,15 +92,33 @@ class GlassesMediaSourceContractTest {
|
|
|
92
92
|
val config = SourceConfig(url)
|
|
93
93
|
// A resumed glasses uplink re-sends the same URL. ACS never lost its
|
|
94
94
|
// subscription, so rebuilding would only restart the wait for a first frame.
|
|
95
|
-
assertThat(
|
|
95
|
+
assertThat(SourceReusePolicy.canReuse(url, SourceState.LIVE, config)).isTrue()
|
|
96
96
|
// Includes the answer → first frame window, which LIVE no longer covers.
|
|
97
|
-
assertThat(
|
|
97
|
+
assertThat(SourceReusePolicy.canReuse(url, SourceState.CONNECTING, config)).isTrue()
|
|
98
98
|
// ICE dropped, or the answer never produced a frame: rebuild.
|
|
99
|
-
assertThat(
|
|
99
|
+
assertThat(SourceReusePolicy.canReuse(url, SourceState.FAILED, config)).isFalse()
|
|
100
100
|
// A new stream means a new WHEP URL, and a stopped source has none.
|
|
101
|
-
assertThat(
|
|
102
|
-
assertThat(
|
|
103
|
-
assertThat(
|
|
101
|
+
assertThat(SourceReusePolicy.canReuse(url, SourceState.LIVE, SourceConfig("https://example.com/whep-2"))).isFalse()
|
|
102
|
+
assertThat(SourceReusePolicy.canReuse(null, SourceState.IDLE, config)).isFalse()
|
|
103
|
+
assertThat(SourceReusePolicy.canReuse(url, SourceState.LIVE, SourceConfig(url, SourceKind.DIRECT))).isFalse()
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
@Test
|
|
107
|
+
fun softApIsNeverReusedBecauseTheListenerBelongsToOneAttempt() {
|
|
108
|
+
// A SoftAP attempt owns a listener, a port and a peer. There is no URL to keep across a
|
|
109
|
+
// rebuild — the URL is an output of binding, not an input — so reuse must always be refused,
|
|
110
|
+
// even when the config and state would otherwise look reusable.
|
|
111
|
+
val config = SourceConfig("", SourceKind.SOFTAP, bindAddress = "192.168.43.20")
|
|
112
|
+
|
|
113
|
+
assertThat(SourceReusePolicy.canReuse("", SourceState.LIVE, config)).isFalse()
|
|
114
|
+
assertThat(SourceReusePolicy.canReuse("", SourceState.CONNECTING, config)).isFalse()
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
@Test
|
|
118
|
+
fun onlySoftApReportsAnIngestUrl() {
|
|
119
|
+
// Every other kind is told its URL, so exposing one would be a contract error waiting to be
|
|
120
|
+
// read by the orchestrator that hands the URL to the glasses.
|
|
121
|
+
assertThat(FakeGlassesMediaSource().ingestUrl).isNull()
|
|
104
122
|
}
|
|
105
123
|
|
|
106
124
|
@Test
|
|
@@ -110,7 +128,7 @@ class GlassesMediaSourceContractTest {
|
|
|
110
128
|
stats = com.mentra.acsmeeting.telemetry.PipelineStats(),
|
|
111
129
|
isReady = { false },
|
|
112
130
|
)
|
|
113
|
-
val controller = GlassesMediaController { _, _ -> source }
|
|
131
|
+
val controller = GlassesMediaController { _, _, _ -> source }
|
|
114
132
|
|
|
115
133
|
assertThat(controller.state).isEqualTo(SourceState.IDLE)
|
|
116
134
|
controller.attach(
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
package com.mentra.acsmeeting.source
|
|
2
|
+
|
|
3
|
+
import org.assertj.core.api.Assertions.assertThat
|
|
4
|
+
import org.assertj.core.api.Assertions.assertThatThrownBy
|
|
5
|
+
import org.junit.Test
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The bridge boundary between the host's `videoSource` union and the native source classes.
|
|
9
|
+
*
|
|
10
|
+
* Worth pinning because this is the last place a malformed source can be rejected with a message
|
|
11
|
+
* that names the field. Past here it becomes a call that joins and shows nothing.
|
|
12
|
+
*/
|
|
13
|
+
class MeetingVideoSourceSpecTest {
|
|
14
|
+
|
|
15
|
+
@Test
|
|
16
|
+
fun `a whep source parses to a whep config`() {
|
|
17
|
+
val spec = MeetingVideoSourceSpec.parse(
|
|
18
|
+
mapOf("type" to "whep", "url" to " https://example.com/whep "),
|
|
19
|
+
null,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
assertThat(spec).isEqualTo(MeetingVideoSourceSpec.Whep("https://example.com/whep"))
|
|
23
|
+
assertThat(spec.toConfig()).isEqualTo(
|
|
24
|
+
SourceConfig("https://example.com/whep", SourceKind.WHEP),
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
@Test
|
|
29
|
+
fun `a softap source parses to a softap config carrying the bind address`() {
|
|
30
|
+
val spec = MeetingVideoSourceSpec.parse(
|
|
31
|
+
mapOf("type" to "softap", "bindAddress" to "192.168.43.20"),
|
|
32
|
+
null,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
assertThat(spec).isEqualTo(MeetingVideoSourceSpec.SoftAp("192.168.43.20"))
|
|
36
|
+
assertThat(spec.toConfig())
|
|
37
|
+
.isEqualTo(SourceConfig("", SourceKind.SOFTAP, "192.168.43.20"))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The orchestrator may not know the address yet when it builds the request; the source falls
|
|
42
|
+
* back to asking the joined network. A blank string must not become a bind target.
|
|
43
|
+
*/
|
|
44
|
+
@Test
|
|
45
|
+
fun `a softap source without a usable bind address carries null`() {
|
|
46
|
+
assertThat(MeetingVideoSourceSpec.parse(mapOf("type" to "softap"), null))
|
|
47
|
+
.isEqualTo(MeetingVideoSourceSpec.SoftAp(null))
|
|
48
|
+
assertThat(MeetingVideoSourceSpec.parse(mapOf("type" to "softap", "bindAddress" to " "), null))
|
|
49
|
+
.isEqualTo(MeetingVideoSourceSpec.SoftAp(null))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
@Test
|
|
53
|
+
fun `the type is matched case insensitively`() {
|
|
54
|
+
assertThat(MeetingVideoSourceSpec.parse(mapOf("type" to "SoftAP"), null).kind)
|
|
55
|
+
.isEqualTo(SourceKind.SOFTAP)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// -----------------------------------------------------------------
|
|
59
|
+
// Back-compatibility with a host that predates the union
|
|
60
|
+
// -----------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
@Test
|
|
63
|
+
fun `a bare whepUrl with no videoSource still parses`() {
|
|
64
|
+
assertThat(MeetingVideoSourceSpec.parse(null, "https://example.com/whep"))
|
|
65
|
+
.isEqualTo(MeetingVideoSourceSpec.Whep("https://example.com/whep"))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
@Test
|
|
69
|
+
fun `a whep source with no url falls back to the legacy field`() {
|
|
70
|
+
assertThat(MeetingVideoSourceSpec.parse(mapOf("type" to "whep"), "https://legacy/whep"))
|
|
71
|
+
.isEqualTo(MeetingVideoSourceSpec.Whep("https://legacy/whep"))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// -----------------------------------------------------------------
|
|
75
|
+
// Rejections
|
|
76
|
+
// -----------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
@Test
|
|
79
|
+
fun `an unknown type is rejected rather than defaulting to whep`() {
|
|
80
|
+
assertThatThrownBy { MeetingVideoSourceSpec.parse(mapOf("type" to "quic"), null) }
|
|
81
|
+
.isInstanceOf(IllegalArgumentException::class.java)
|
|
82
|
+
.hasMessageContaining("quic")
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
@Test
|
|
86
|
+
fun `a whep source with no url anywhere is rejected`() {
|
|
87
|
+
assertThatThrownBy { MeetingVideoSourceSpec.parse(mapOf("type" to "whep"), " ") }
|
|
88
|
+
.isInstanceOf(IllegalArgumentException::class.java)
|
|
89
|
+
.hasMessageContaining("url")
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
@Test
|
|
93
|
+
fun `no source and no legacy url is rejected`() {
|
|
94
|
+
assertThatThrownBy { MeetingVideoSourceSpec.parse(null, null) }
|
|
95
|
+
.isInstanceOf(IllegalArgumentException::class.java)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
@Test
|
|
99
|
+
fun `a missing type is rejected`() {
|
|
100
|
+
assertThatThrownBy { MeetingVideoSourceSpec.parse(mapOf("url" to "https://x/whep"), null) }
|
|
101
|
+
.isInstanceOf(IllegalArgumentException::class.java)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
@Test
|
|
105
|
+
fun `the synthetic arm maps to the direct kind`() {
|
|
106
|
+
assertThat(MeetingVideoSourceSpec.Synthetic.toConfig())
|
|
107
|
+
.isEqualTo(SourceConfig("", SourceKind.DIRECT))
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
package com.mentra.acsmeeting.source
|
|
2
|
+
|
|
3
|
+
import com.mentra.acsmeeting.network.Ipv4Prefix
|
|
4
|
+
import org.junit.Assert.assertEquals
|
|
5
|
+
import org.junit.Assert.assertTrue
|
|
6
|
+
import org.junit.Test
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* This verifier is acceptance evidence for the SoftAP path, so its failure mode matters as much as
|
|
10
|
+
* its success one. The cases below pin that it follows the transport's selection instead of
|
|
11
|
+
* guessing, and that it reports insufficient evidence rather than passing on a plausible-looking
|
|
12
|
+
* pair — the previous behaviour, which could grade a run on a pair that was not carrying the call.
|
|
13
|
+
*/
|
|
14
|
+
class SelectedIcePairTest {
|
|
15
|
+
|
|
16
|
+
private val prefix = Ipv4Prefix("192.168.43.20", 24)
|
|
17
|
+
|
|
18
|
+
private fun pair(
|
|
19
|
+
id: String,
|
|
20
|
+
state: String? = "succeeded",
|
|
21
|
+
nominated: Boolean = false,
|
|
22
|
+
local: String? = "local-1",
|
|
23
|
+
remote: String? = "remote-1",
|
|
24
|
+
bytes: Long = 0,
|
|
25
|
+
) = IceCandidatePairStats(id, state, nominated, local, remote, bytes)
|
|
26
|
+
|
|
27
|
+
private fun transport(selected: String? = "a", id: String = "T1") = IceTransportStats(id, selected)
|
|
28
|
+
|
|
29
|
+
private fun candidates(vararg entries: Pair<String, String>): Map<String, IceCandidateStats> =
|
|
30
|
+
entries.associate { (id, address) -> id to IceCandidateStats(id, address, "host") }
|
|
31
|
+
|
|
32
|
+
private fun verdict(
|
|
33
|
+
transports: List<IceTransportStats> = listOf(transport()),
|
|
34
|
+
pairs: List<IceCandidatePairStats> = listOf(pair("a", bytes = 9000)),
|
|
35
|
+
candidateMap: Map<String, IceCandidateStats> =
|
|
36
|
+
candidates("local-1" to "192.168.43.20", "remote-1" to "192.168.43.1"),
|
|
37
|
+
scopedPrefix: Ipv4Prefix? = prefix,
|
|
38
|
+
iceGeneration: Int = 3,
|
|
39
|
+
) = SelectedIcePair.verdict(transports, pairs, candidateMap, scopedPrefix, iceGeneration)
|
|
40
|
+
|
|
41
|
+
@Test
|
|
42
|
+
fun `follows the pair the transport says is selected`() {
|
|
43
|
+
val chosen =
|
|
44
|
+
SelectedIcePair.selected(
|
|
45
|
+
listOf(transport(selected = "b")),
|
|
46
|
+
listOf(pair("a", nominated = true, bytes = 999_999), pair("b")),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
assertEquals("b", chosen.getOrNull()?.id)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The behaviour that had to go. A succeeded pair is one that once passed a check, and historical
|
|
54
|
+
* bytes can belong to a pair that has since been replaced; neither is the current path.
|
|
55
|
+
*/
|
|
56
|
+
@Test
|
|
57
|
+
fun `no fallback to a succeeded or high-traffic pair when nothing is selected`() {
|
|
58
|
+
val chosen =
|
|
59
|
+
SelectedIcePair.selected(
|
|
60
|
+
listOf(transport(selected = null)),
|
|
61
|
+
listOf(pair("a", nominated = true, bytes = 999_999)),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
assertTrue(chosen.isFailure)
|
|
65
|
+
assertEquals("no_selected_pair", (chosen.exceptionOrNull() as SelectedIcePair.Missing).reason)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
@Test
|
|
69
|
+
fun `a local candidate inside the prefix is on the hotspot`() {
|
|
70
|
+
assertEquals(
|
|
71
|
+
IcePathVerdict.OnHotspot("a", 3, "192.168.43.20", "192.168.43.1", 9000),
|
|
72
|
+
verdict(),
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The silent failure this exists for: SDP looked private, ICE picked cellular. */
|
|
77
|
+
@Test
|
|
78
|
+
fun `a cellular local candidate is off the hotspot`() {
|
|
79
|
+
val result =
|
|
80
|
+
verdict(
|
|
81
|
+
candidateMap = candidates("local-1" to "10.171.36.4", "remote-1" to "192.168.43.1"),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
assertEquals(IcePathVerdict.OffHotspot("a", "10.171.36.4", "192.168.43.20/24"), result)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
@Test
|
|
88
|
+
fun `every gap in the evidence is its own unknown reason`() {
|
|
89
|
+
assertEquals(
|
|
90
|
+
IcePathVerdict.Unknown("no_transport_stats"),
|
|
91
|
+
verdict(transports = emptyList()),
|
|
92
|
+
)
|
|
93
|
+
assertEquals(
|
|
94
|
+
IcePathVerdict.Unknown("no_selected_pair"),
|
|
95
|
+
verdict(transports = listOf(transport(selected = null))),
|
|
96
|
+
)
|
|
97
|
+
assertEquals(
|
|
98
|
+
IcePathVerdict.Unknown("selected_pair_absent_from_report"),
|
|
99
|
+
verdict(pairs = listOf(pair("z"))),
|
|
100
|
+
)
|
|
101
|
+
assertEquals(IcePathVerdict.Unknown("no_scoped_prefix"), verdict(scopedPrefix = null))
|
|
102
|
+
assertEquals(IcePathVerdict.Unknown("no_local_candidate"), verdict(candidateMap = emptyMap()))
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
@Test
|
|
106
|
+
fun `an OEM hotspot on another subnet still passes`() {
|
|
107
|
+
val result =
|
|
108
|
+
verdict(
|
|
109
|
+
candidateMap = candidates("local-1" to "192.168.49.37", "remote-1" to "192.168.49.1"),
|
|
110
|
+
scopedPrefix = Ipv4Prefix("192.168.49.37", 24),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
assertTrue(result is IcePathVerdict.OnHotspot)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// -----------------------------------------------------------------
|
|
117
|
+
// Byte growth, compared only within one path
|
|
118
|
+
// -----------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
private fun onHotspot(pairId: String = "a", generation: Int = 3, bytes: Long) =
|
|
121
|
+
IcePathVerdict.OnHotspot(pairId, generation, "192.168.43.20", "192.168.43.1", bytes)
|
|
122
|
+
|
|
123
|
+
@Test
|
|
124
|
+
fun `growth on the same pair and generation is a real comparison`() {
|
|
125
|
+
val flow = SelectedIcePair.flow(onHotspot(bytes = 1000), onHotspot(bytes = 4000))
|
|
126
|
+
|
|
127
|
+
assertEquals(SelectedIcePair.Flow.Compared(1000, 4000), flow)
|
|
128
|
+
assertTrue((flow as SelectedIcePair.Flow.Compared).flowing)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
@Test
|
|
132
|
+
fun `a flat counter on the same pair is not flowing`() {
|
|
133
|
+
val flow = SelectedIcePair.flow(onHotspot(bytes = 4000), onHotspot(bytes = 4000))
|
|
134
|
+
|
|
135
|
+
assertTrue(flow is SelectedIcePair.Flow.Compared)
|
|
136
|
+
assertTrue(!(flow as SelectedIcePair.Flow.Compared).flowing)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The regression that made the old check meaningless: two samples were compared with no pair
|
|
141
|
+
* identity, so a renomination could turn an idle path into an apparently flowing one.
|
|
142
|
+
*/
|
|
143
|
+
@Test
|
|
144
|
+
fun `a pair change between samples is not comparable rather than flowing`() {
|
|
145
|
+
val flow = SelectedIcePair.flow(onHotspot(pairId = "a", bytes = 0), onHotspot(pairId = "b", bytes = 9000))
|
|
146
|
+
|
|
147
|
+
assertEquals(SelectedIcePair.Flow.NotComparable("pair_changed"), flow)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
@Test
|
|
151
|
+
fun `a session restart between samples is not comparable`() {
|
|
152
|
+
val flow =
|
|
153
|
+
SelectedIcePair.flow(onHotspot(generation = 3, bytes = 0), onHotspot(generation = 4, bytes = 9000))
|
|
154
|
+
|
|
155
|
+
assertEquals(SelectedIcePair.Flow.NotComparable("ice_generation_changed"), flow)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** A first sample with no verdict cannot stand in for zero bytes; that would fake growth. */
|
|
159
|
+
@Test
|
|
160
|
+
fun `an unusable first sample is not treated as zero bytes`() {
|
|
161
|
+
val flow = SelectedIcePair.flow(IcePathVerdict.Unknown("no_selected_pair"), onHotspot(bytes = 9000))
|
|
162
|
+
|
|
163
|
+
assertEquals(SelectedIcePair.Flow.NotComparable("first_sample_unusable"), flow)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
package com.mentra.acsmeeting.source
|
|
2
|
+
|
|
3
|
+
import com.mentra.acsmeeting.network.Ipv4Prefix
|
|
4
|
+
import com.mentra.acsmeeting.network.ScopedNetworkChangeDetector.PublishedEntry
|
|
5
|
+
import org.assertj.core.api.Assertions.assertThat
|
|
6
|
+
import org.junit.Test
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Each case here is a fault that really happened on device and was reported under the same error
|
|
10
|
+
* code as the others. If two of these ever collapse to one [SoftApIceDiagnosis.Fault], the next
|
|
11
|
+
* regression gets misattributed the way the handle change was.
|
|
12
|
+
*/
|
|
13
|
+
class SoftApIceDiagnosisTest {
|
|
14
|
+
|
|
15
|
+
private val prefix = Ipv4Prefix("192.168.43.79", 24)
|
|
16
|
+
private val hotspot = PublishedEntry("wlan0", "CONNECTION_WIFI", 0L, listOf("192.168.43.79"))
|
|
17
|
+
|
|
18
|
+
private fun onHotspot(address: String = "192.168.43.79") =
|
|
19
|
+
SoftApIceDiagnosis.CandidateFact(address, "wlan0", true)
|
|
20
|
+
|
|
21
|
+
private fun offHotspot(address: String = "10.48.51.7", owner: String? = "rmnet_data0") =
|
|
22
|
+
SoftApIceDiagnosis.CandidateFact(address, owner, false)
|
|
23
|
+
|
|
24
|
+
private fun diagnose(
|
|
25
|
+
scopedAddress: String? = "192.168.43.79",
|
|
26
|
+
scopedOwner: String? = "wlan0",
|
|
27
|
+
published: List<PublishedEntry> = listOf(hotspot),
|
|
28
|
+
candidates: List<SoftApIceDiagnosis.CandidateFact> = emptyList(),
|
|
29
|
+
) = SoftApIceDiagnosis.diagnose(scopedAddress, scopedOwner, prefix, published, candidates)
|
|
30
|
+
|
|
31
|
+
/** The address landed in the table after `onAvailable`, so gathering had nothing to find. */
|
|
32
|
+
@Test
|
|
33
|
+
fun `an address absent from the kernel table is a missing interface`() {
|
|
34
|
+
val diagnosis = diagnose(scopedOwner = null)
|
|
35
|
+
|
|
36
|
+
assertThat(diagnosis.fault).isEqualTo(SoftApIceDiagnosis.Fault.MISSING_INTERFACE)
|
|
37
|
+
assertThat(diagnosis.code).isEqualTo(SoftApIceDiagnosis.CODE_NO_CANDIDATES)
|
|
38
|
+
assertThat(diagnosis.scopedInTable).isFalse
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
@Test
|
|
42
|
+
fun `no scoped address at all is a missing interface`() {
|
|
43
|
+
assertThat(diagnose(scopedAddress = null, scopedOwner = null).fault)
|
|
44
|
+
.isEqualTo(SoftApIceDiagnosis.Fault.MISSING_INTERFACE)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The hotspot never reached libwebrtc's inventory, so the interface was skipped entirely. */
|
|
48
|
+
@Test
|
|
49
|
+
fun `an inventory without the hotspot is an erased entry`() {
|
|
50
|
+
val diagnosis =
|
|
51
|
+
diagnose(published = listOf(PublishedEntry("rmnet_data0", "CONNECTION_4G", 7L, listOf("10.48.51.7"))))
|
|
52
|
+
|
|
53
|
+
assertThat(diagnosis.fault).isEqualTo(SoftApIceDiagnosis.Fault.ERASED_ENTRY)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The regression the decoys caused: two entries on [UNBINDABLE_HANDLE] overwrote each other in a
|
|
58
|
+
* monitor keyed by handle, and the survivor was whichever connected last.
|
|
59
|
+
*/
|
|
60
|
+
@Test
|
|
61
|
+
fun `two entries sharing a handle is an erased entry even when the hotspot is present`() {
|
|
62
|
+
val diagnosis =
|
|
63
|
+
diagnose(
|
|
64
|
+
published =
|
|
65
|
+
listOf(hotspot, PublishedEntry("rmnet_data0", "CONNECTION_4G", 0L, listOf("10.48.51.7"))),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
assertThat(diagnosis.fault).isEqualTo(SoftApIceDiagnosis.Fault.ERASED_ENTRY)
|
|
69
|
+
assertThat(diagnosis.hasHandleCollision()).isTrue
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Address present, entry published, nothing gathered: only the socket bind is left. */
|
|
73
|
+
@Test
|
|
74
|
+
fun `zero candidates with everything else in place is a failed binding`() {
|
|
75
|
+
val diagnosis = diagnose()
|
|
76
|
+
|
|
77
|
+
assertThat(diagnosis.fault).isEqualTo(SoftApIceDiagnosis.Fault.FAILED_BINDING)
|
|
78
|
+
assertThat(diagnosis.code).isEqualTo(SoftApIceDiagnosis.CODE_NO_CANDIDATES)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** A port truthfully on the hotspot that advertised the phone's cellular address. */
|
|
82
|
+
@Test
|
|
83
|
+
fun `candidates that are all off-hotspot is a wrong address`() {
|
|
84
|
+
val diagnosis = diagnose(candidates = listOf(offHotspot()))
|
|
85
|
+
|
|
86
|
+
assertThat(diagnosis.fault).isEqualTo(SoftApIceDiagnosis.Fault.WRONG_ADDRESS)
|
|
87
|
+
assertThat(diagnosis.code).isEqualTo(SoftApIceDiagnosis.CODE_ONLY_NON_HOTSPOT)
|
|
88
|
+
assertThat(diagnosis.offHotspot).hasSize(1)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
@Test
|
|
92
|
+
fun `a hotspot candidate among off-hotspot ones is not classified as wrong address`() {
|
|
93
|
+
val diagnosis = diagnose(candidates = listOf(offHotspot(), onHotspot()))
|
|
94
|
+
|
|
95
|
+
assertThat(diagnosis.fault).isEqualTo(SoftApIceDiagnosis.Fault.INDETERMINATE)
|
|
96
|
+
assertThat(diagnosis.hotspotCandidates).hasSize(1)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Order matters: a missing interface also has zero candidates and an erased entry also has zero,
|
|
101
|
+
* so blaming the bind first would hide both.
|
|
102
|
+
*/
|
|
103
|
+
@Test
|
|
104
|
+
fun `a missing interface outranks the collision and the bind`() {
|
|
105
|
+
val diagnosis =
|
|
106
|
+
diagnose(
|
|
107
|
+
scopedOwner = null,
|
|
108
|
+
published =
|
|
109
|
+
listOf(hotspot, PublishedEntry("rmnet_data0", "CONNECTION_4G", 0L, listOf("10.48.51.7"))),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
assertThat(diagnosis.fault).isEqualTo(SoftApIceDiagnosis.Fault.MISSING_INTERFACE)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The analyzer reads these names, so they are a contract rather than log prose. */
|
|
116
|
+
@Test
|
|
117
|
+
fun `the fields carry every observation the analyzer reads`() {
|
|
118
|
+
val fields = diagnose(candidates = listOf(offHotspot())).fields().toMap()
|
|
119
|
+
|
|
120
|
+
assertThat(fields["code"]).isEqualTo(SoftApIceDiagnosis.CODE_ONLY_NON_HOTSPOT)
|
|
121
|
+
assertThat(fields["fault"]).isEqualTo("WRONG_ADDRESS")
|
|
122
|
+
assertThat(fields["scopedAddress"]).isEqualTo("192.168.43.79")
|
|
123
|
+
assertThat(fields["scopedOwner"]).isEqualTo("wlan0")
|
|
124
|
+
assertThat(fields["scopedInTable"]).isEqualTo(true)
|
|
125
|
+
assertThat(fields["scopedPrefix"]).isEqualTo("192.168.43.79/24")
|
|
126
|
+
assertThat(fields["publishedInventory"]).isEqualTo("wlan0[CONNECTION_WIFI]#0(192.168.43.79)")
|
|
127
|
+
assertThat(fields["publishedHotspot"]).isEqualTo(true)
|
|
128
|
+
assertThat(fields["handleCollision"]).isEqualTo(false)
|
|
129
|
+
assertThat(fields["gathered"]).isEqualTo(1)
|
|
130
|
+
assertThat(fields["hotspotCandidates"]).isEqualTo(0)
|
|
131
|
+
assertThat(fields["offHotspot"]).isEqualTo("10.48.51.7@rmnet_data0")
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
@Test
|
|
135
|
+
fun `an unattributable candidate address is reported as absent rather than dropped`() {
|
|
136
|
+
val fields = diagnose(candidates = listOf(offHotspot(owner = null))).fields().toMap()
|
|
137
|
+
|
|
138
|
+
assertThat(fields["offHotspot"]).isEqualTo("10.48.51.7@ABSENT")
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
@Test
|
|
142
|
+
fun `an empty inventory reads as none rather than blank`() {
|
|
143
|
+
val fields = diagnose(published = emptyList()).fields().toMap()
|
|
144
|
+
|
|
145
|
+
assertThat(fields["publishedInventory"]).isEqualTo("none")
|
|
146
|
+
assertThat(fields["offHotspot"]).isEqualTo("none")
|
|
147
|
+
}
|
|
148
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
package com.mentra.acsmeeting.source
|
|
2
|
+
|
|
3
|
+
import org.assertj.core.api.Assertions.assertThat
|
|
4
|
+
import org.junit.Test
|
|
5
|
+
import org.webrtc.PeerConnectionFactory
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Pins which adapters the SoftAP peer may gather on.
|
|
9
|
+
*
|
|
10
|
+
* Getting this wrong is not a crash. An answer carrying a cellular candidate negotiates fine and
|
|
11
|
+
* then never carries a frame, because the glasses have no route to the phone's mobile address — and
|
|
12
|
+
* the logs show a healthy call the whole time.
|
|
13
|
+
*/
|
|
14
|
+
class SoftApIcePolicyTest {
|
|
15
|
+
|
|
16
|
+
private val mask = SoftApIcePolicy.networkIgnoreMask()
|
|
17
|
+
|
|
18
|
+
@Test
|
|
19
|
+
fun `cellular is excluded because the glasses cannot reach it`() {
|
|
20
|
+
assertThat(SoftApIcePolicy.allowsAdapter(mask, PeerConnectionFactory.Options.ADAPTER_TYPE_CELLULAR))
|
|
21
|
+
.isFalse()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
@Test
|
|
25
|
+
fun `vpn is excluded because a tunnel address is not on the hotspot`() {
|
|
26
|
+
assertThat(SoftApIcePolicy.allowsAdapter(mask, PeerConnectionFactory.Options.ADAPTER_TYPE_VPN))
|
|
27
|
+
.isFalse()
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
@Test
|
|
31
|
+
fun `loopback is excluded because no other device can reach it`() {
|
|
32
|
+
assertThat(SoftApIcePolicy.allowsAdapter(mask, PeerConnectionFactory.Options.ADAPTER_TYPE_LOOPBACK))
|
|
33
|
+
.isFalse()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The whole point: the hotspot presents as Wi-Fi, so Wi-Fi must survive the mask. */
|
|
37
|
+
@Test
|
|
38
|
+
fun `wifi is allowed`() {
|
|
39
|
+
assertThat(SoftApIcePolicy.allowsAdapter(mask, PeerConnectionFactory.Options.ADAPTER_TYPE_WIFI))
|
|
40
|
+
.isTrue()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The reason `ScopedNetworkChangeDetector` publishes decoy entries for interfaces it does not
|
|
45
|
+
* want. `ADAPTER_TYPE_UNKNOWN` is `0`, so no mask can ever exclude it: an interface the network
|
|
46
|
+
* monitor never announced is an interface ICE will gather on no matter what this mask says.
|
|
47
|
+
*/
|
|
48
|
+
@Test
|
|
49
|
+
fun `an unknown adapter cannot be masked, so no interface may go unannounced`() {
|
|
50
|
+
assertThat(PeerConnectionFactory.Options.ADAPTER_TYPE_UNKNOWN).isEqualTo(0)
|
|
51
|
+
assertThat(SoftApIcePolicy.allowsAdapter(mask, PeerConnectionFactory.Options.ADAPTER_TYPE_UNKNOWN))
|
|
52
|
+
.isTrue()
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
@Test
|
|
56
|
+
fun `ethernet and ANY are excluded so a leftover AutoDetect network cannot gather`() {
|
|
57
|
+
assertThat(SoftApIcePolicy.allowsAdapter(mask, PeerConnectionFactory.Options.ADAPTER_TYPE_ETHERNET))
|
|
58
|
+
.isFalse()
|
|
59
|
+
assertThat(SoftApIcePolicy.allowsAdapter(mask, PeerConnectionFactory.Options.ADAPTER_TYPE_ANY))
|
|
60
|
+
.isFalse()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
@Test
|
|
64
|
+
fun `the factory options carry the mask`() {
|
|
65
|
+
assertThat(SoftApIcePolicy.factoryOptions().networkIgnoreMask).isEqualTo(mask)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The network monitor must stay on. Disabling it would stop libwebrtc from noticing the Wi-Fi
|
|
70
|
+
* interface at all, which is the opposite of what this path needs.
|
|
71
|
+
*/
|
|
72
|
+
@Test
|
|
73
|
+
fun `the network monitor is left enabled`() {
|
|
74
|
+
assertThat(SoftApIcePolicy.factoryOptions().disableNetworkMonitor).isFalse()
|
|
75
|
+
}
|
|
76
|
+
}
|