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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/README.md +4 -2
  2. package/android/build.gradle +23 -0
  3. package/android/src/androidTest/java/com/mentra/acsmeeting/network/SoftApFeasibilityGateTest.kt +291 -0
  4. package/android/src/main/AndroidManifest.xml +2 -0
  5. package/android/src/main/java/com/mentra/acsmeeting/AbandonedCallAgent.kt +36 -0
  6. package/android/src/main/java/com/mentra/acsmeeting/AcsMeetingModule.kt +392 -22
  7. package/android/src/main/java/com/mentra/acsmeeting/AcsMeetingSession.kt +909 -56
  8. package/android/src/main/java/com/mentra/acsmeeting/CallCapabilities.kt +33 -0
  9. package/android/src/main/java/com/mentra/acsmeeting/audio/AcsAudioPolicy.kt +21 -0
  10. package/android/src/main/java/com/mentra/acsmeeting/audio/AudioUplinkChain.kt +126 -0
  11. package/android/src/main/java/com/mentra/acsmeeting/audio/PcmBridge.kt +27 -4
  12. package/android/src/main/java/com/mentra/acsmeeting/audio/UplinkPacer.kt +45 -10
  13. package/android/src/main/java/com/mentra/acsmeeting/audio/UplinkSender.kt +110 -5
  14. package/android/src/main/java/com/mentra/acsmeeting/network/InternetHold.kt +501 -0
  15. package/android/src/main/java/com/mentra/acsmeeting/network/Ipv4Prefix.kt +51 -0
  16. package/android/src/main/java/com/mentra/acsmeeting/network/NetworkFacts.kt +81 -0
  17. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkChangeDetector.kt +355 -0
  18. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkError.kt +109 -0
  19. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkObserver.kt +160 -0
  20. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkReadiness.kt +103 -0
  21. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkRequestSpec.kt +53 -0
  22. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkState.kt +127 -0
  23. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedSoftApNetwork.kt +573 -0
  24. package/android/src/main/java/com/mentra/acsmeeting/source/CloudflareWhepSource.kt +10 -101
  25. package/android/src/main/java/com/mentra/acsmeeting/source/DecodedTrackRelay.kt +147 -0
  26. package/android/src/main/java/com/mentra/acsmeeting/source/GlassesMediaSource.kt +47 -5
  27. package/android/src/main/java/com/mentra/acsmeeting/source/LocalWhipIngestSource.kt +772 -0
  28. package/android/src/main/java/com/mentra/acsmeeting/source/MeetingVideoSourceSpec.kt +73 -0
  29. package/android/src/main/java/com/mentra/acsmeeting/source/SdpAdapter.kt +17 -0
  30. package/android/src/main/java/com/mentra/acsmeeting/source/SelectedIcePair.kt +139 -0
  31. package/android/src/main/java/com/mentra/acsmeeting/source/SoftApIceDiagnosis.kt +144 -0
  32. package/android/src/main/java/com/mentra/acsmeeting/source/SoftApIcePolicy.kt +46 -0
  33. package/android/src/main/java/com/mentra/acsmeeting/source/SoftApSdpGuard.kt +160 -0
  34. package/android/src/main/java/com/mentra/acsmeeting/source/TrackRegistry.kt +3 -1
  35. package/android/src/main/java/com/mentra/acsmeeting/source/VideoSourceArm.kt +69 -0
  36. package/android/src/main/java/com/mentra/acsmeeting/source/WhipIngestProtocol.kt +207 -0
  37. package/android/src/main/java/com/mentra/acsmeeting/source/WhipIngestServer.kt +356 -0
  38. package/android/src/main/java/com/mentra/acsmeeting/telemetry/AvSyncProbe.kt +155 -0
  39. package/android/src/main/java/com/mentra/acsmeeting/telemetry/PipelineStats.kt +94 -3
  40. package/android/src/main/java/com/mentra/acsmeeting/telemetry/PipelineTicker.kt +33 -5
  41. package/android/src/main/java/com/mentra/acsmeeting/telemetry/RingPercentile.kt +15 -0
  42. package/android/src/main/java/com/mentra/acsmeeting/telemetry/VideoQuality.kt +178 -0
  43. package/android/src/main/java/com/mentra/acsmeeting/telemetry/VideoRateVerdict.kt +145 -0
  44. package/android/src/main/java/com/mentra/acsmeeting/trace/SoftApTrace.kt +143 -0
  45. package/android/src/main/java/com/mentra/acsmeeting/video/AcsFrameSender.kt +23 -4
  46. package/android/src/main/java/com/mentra/acsmeeting/video/FramePacer.kt +70 -0
  47. package/android/src/main/java/com/mentra/acsmeeting/video/I420FormatSpec.kt +1 -1
  48. package/android/src/main/java/com/mentra/acsmeeting/video/VideoProfile.kt +36 -3
  49. package/android/src/test/java/com/mentra/acsmeeting/AbandonedCallAgentTest.kt +74 -0
  50. package/android/src/test/java/com/mentra/acsmeeting/CallCapabilitiesTest.kt +47 -0
  51. package/android/src/test/java/com/mentra/acsmeeting/audio/AudioUplinkChainTest.kt +250 -0
  52. package/android/src/test/java/com/mentra/acsmeeting/audio/CapturePolicyTest.kt +44 -0
  53. package/android/src/test/java/com/mentra/acsmeeting/audio/UplinkPacerTest.kt +34 -4
  54. package/android/src/test/java/com/mentra/acsmeeting/audio/UplinkSenderTest.kt +96 -0
  55. package/android/src/test/java/com/mentra/acsmeeting/network/InternetHoldLifecycleTest.kt +102 -0
  56. package/android/src/test/java/com/mentra/acsmeeting/network/InternetHoldTest.kt +148 -0
  57. package/android/src/test/java/com/mentra/acsmeeting/network/Ipv4PrefixTest.kt +59 -0
  58. package/android/src/test/java/com/mentra/acsmeeting/network/NetworkFactsTest.kt +59 -0
  59. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedLocalNetworkPermissionTest.kt +35 -0
  60. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkChangeDetectorTest.kt +165 -0
  61. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkErrorTest.kt +96 -0
  62. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkLifecycleTest.kt +187 -0
  63. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkObserverTest.kt +172 -0
  64. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkReadinessTest.kt +141 -0
  65. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkRequestSpecTest.kt +69 -0
  66. package/android/src/test/java/com/mentra/acsmeeting/source/AcsInvestigationTest.kt +40 -0
  67. package/android/src/test/java/com/mentra/acsmeeting/source/DecodedTrackRelayTest.kt +41 -0
  68. package/android/src/test/java/com/mentra/acsmeeting/source/GlassesMediaSourceTest.kt +26 -8
  69. package/android/src/test/java/com/mentra/acsmeeting/source/MeetingVideoSourceSpecTest.kt +109 -0
  70. package/android/src/test/java/com/mentra/acsmeeting/source/SelectedIcePairTest.kt +165 -0
  71. package/android/src/test/java/com/mentra/acsmeeting/source/SoftApIceDiagnosisTest.kt +148 -0
  72. package/android/src/test/java/com/mentra/acsmeeting/source/SoftApIcePolicyTest.kt +76 -0
  73. package/android/src/test/java/com/mentra/acsmeeting/source/SoftApSdpGuardTest.kt +275 -0
  74. package/android/src/test/java/com/mentra/acsmeeting/source/TrackRegistryTest.kt +9 -0
  75. package/android/src/test/java/com/mentra/acsmeeting/source/WhipIngestProtocolTest.kt +356 -0
  76. package/android/src/test/java/com/mentra/acsmeeting/source/WhipIngestServerLoopbackTest.kt +411 -0
  77. package/android/src/test/java/com/mentra/acsmeeting/telemetry/AvSyncProbeTest.kt +83 -0
  78. package/android/src/test/java/com/mentra/acsmeeting/telemetry/PipelineStatsTest.kt +69 -3
  79. package/android/src/test/java/com/mentra/acsmeeting/telemetry/VideoQualityTest.kt +182 -0
  80. package/android/src/test/java/com/mentra/acsmeeting/telemetry/VideoRateVerdictTest.kt +149 -0
  81. package/android/src/test/java/com/mentra/acsmeeting/trace/SoftApTraceTest.kt +97 -0
  82. package/android/src/test/java/com/mentra/acsmeeting/video/AcsTimestampTest.kt +34 -0
  83. package/android/src/test/java/com/mentra/acsmeeting/video/FramePacerTest.kt +105 -0
  84. package/android/src/test/java/com/mentra/acsmeeting/video/I420FormatSpecTest.kt +5 -0
  85. package/android/src/test/java/com/mentra/acsmeeting/video/VideoProfileTest.kt +38 -3
  86. package/build/AcsMeeting.types.d.ts +5 -0
  87. package/build/AcsMeeting.types.d.ts.map +1 -1
  88. package/build/AcsMeeting.types.js.map +1 -1
  89. package/build/AcsMeetingModule.d.ts +23 -0
  90. package/build/AcsMeetingModule.d.ts.map +1 -1
  91. package/build/AcsMeetingModule.js.map +1 -1
  92. package/build/AcsMeetingModule.web.d.ts +15 -0
  93. package/build/AcsMeetingModule.web.d.ts.map +1 -1
  94. package/build/AcsMeetingModule.web.js +13 -0
  95. package/build/AcsMeetingModule.web.js.map +1 -1
  96. package/ios/AcsMeetingModule.swift +213 -2
  97. package/package.json +1 -1
  98. package/src/AcsMeeting.types.ts +5 -0
  99. package/src/AcsMeetingModule.ts +13 -0
  100. package/src/AcsMeetingModule.web.ts +13 -0
@@ -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(canReuseSource(url, SourceState.LIVE, config)).isTrue()
95
+ assertThat(SourceReusePolicy.canReuse(url, SourceState.LIVE, config)).isTrue()
96
96
  // Includes the answer → first frame window, which LIVE no longer covers.
97
- assertThat(canReuseSource(url, SourceState.CONNECTING, config)).isTrue()
97
+ assertThat(SourceReusePolicy.canReuse(url, SourceState.CONNECTING, config)).isTrue()
98
98
  // ICE dropped, or the answer never produced a frame: rebuild.
99
- assertThat(canReuseSource(url, SourceState.FAILED, config)).isFalse()
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(canReuseSource(url, SourceState.LIVE, SourceConfig("https://example.com/whep-2"))).isFalse()
102
- assertThat(canReuseSource(null, SourceState.IDLE, config)).isFalse()
103
- assertThat(canReuseSource(url, SourceState.LIVE, SourceConfig(url, SourceKind.DIRECT))).isFalse()
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
+ }