@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
@@ -0,0 +1,148 @@
1
+ package com.mentra.acsmeeting.network
2
+
3
+ import org.junit.Assert.assertEquals
4
+ import org.junit.Assert.assertFalse
5
+ import org.junit.Assert.assertTrue
6
+ import org.junit.Test
7
+
8
+ /**
9
+ * Covers the parts of [InternetHold] that decide something, which is the verdict on a default
10
+ * network and the name that verdict is reported under. The framework plumbing around them
11
+ * (`requestNetwork`, `activeNetwork`) is not reachable from a plain JVM test and is not what went
12
+ * wrong on device: the failure was proceeding on an unvalidated route, and that judgement lives
13
+ * here.
14
+ */
15
+ class InternetHoldTest {
16
+
17
+ @Test
18
+ fun `a present validated network is the only usable one`() {
19
+ assertTrue(InternetHold.DefaultNetwork("cellular", validated = true, present = true).usable)
20
+ }
21
+
22
+ @Test
23
+ fun `a network that is up but not validated is not usable`() {
24
+ // The device failure exactly: cellular present, carrying no TLS, and ACS joining over it.
25
+ val cellular = InternetHold.DefaultNetwork("cellular", validated = false, present = true)
26
+ assertFalse(cellular.usable)
27
+ assertEquals("cellular (unvalidated)", cellular.toString())
28
+ }
29
+
30
+ @Test
31
+ fun `no default network at all is reported as none rather than as an unvalidated one`() {
32
+ // Distinct causes need distinct copy: "no network" is airplane mode, "unvalidated" is a
33
+ // radio that is up and not working yet.
34
+ val absent = InternetHold.DefaultNetwork("none", validated = false, present = false)
35
+ assertFalse(absent.usable)
36
+ assertEquals("none", absent.toString())
37
+ }
38
+
39
+ @Test
40
+ fun `a validated network cannot be usable while absent`() {
41
+ // Guards against reading a stale capability snapshot as a live route.
42
+ assertFalse(InternetHold.DefaultNetwork("cellular", validated = true, present = false).usable)
43
+ }
44
+
45
+ @Test
46
+ fun `a validated leftover glasses hotspot is not usable internet`() {
47
+ // The 17:52:24 failure: Android marked MentraLive_* VALIDATED, teardown dropped the
48
+ // cellular pin, and the next teams:create left through SoftAP. Cloudflare answered 503.
49
+ val leftover =
50
+ InternetHold.DefaultNetwork("wifi", validated = true, present = true, glassesHotspot = true)
51
+ assertFalse(leftover.usable)
52
+ assertEquals("wifi (validated, leftover-hotspot)", leftover.toString())
53
+ }
54
+
55
+ @Test
56
+ fun `a validated cellular network renders with its transport and state`() {
57
+ assertEquals(
58
+ "cellular (validated)",
59
+ InternetHold.DefaultNetwork("cellular", validated = true, present = true).toString(),
60
+ )
61
+ }
62
+
63
+ @Test
64
+ fun `Mentra Live SoftAP SSIDs are leftover glasses hotspots`() {
65
+ assertTrue(InternetHold.isGlassesHotspotSsid("MentraLive_15f63c"))
66
+ assertTrue(InternetHold.isGlassesHotspotSsid("\"MentraLive_15f63c\""))
67
+ assertTrue(InternetHold.isGlassesHotspotSsid("mentralive_aabbcc"))
68
+ assertFalse(InternetHold.isGlassesHotspotSsid("<unknown ssid>"))
69
+ assertFalse(InternetHold.isGlassesHotspotSsid("Office-WiFi"))
70
+ }
71
+
72
+ @Test
73
+ fun `Android SoftAP IPv4 is the leftover glasses LAN`() {
74
+ assertTrue(InternetHold.isAndroidSoftApIpv4(byteArrayOf(192.toByte(), 168.toByte(), 43, 154.toByte())))
75
+ assertFalse(InternetHold.isAndroidSoftApIpv4(byteArrayOf(192.toByte(), 168.toByte(), 1, 10)))
76
+ assertFalse(InternetHold.isAndroidSoftApIpv4(byteArrayOf(10, 0, 0, 1)))
77
+ }
78
+
79
+ @Test
80
+ fun `a vpn is named ahead of the transport it runs over`() {
81
+ // A tunnel owning the app's UID is what decides whether ACS reaches the internet, and it
82
+ // stacks on cellular rather than replacing it. Naming the carrier would hide the cause of
83
+ // the EPERM failures we chased.
84
+ assertEquals(
85
+ "vpn",
86
+ InternetHold.transportNameOf(cellular = true, wifi = false, vpn = true, ethernet = false),
87
+ )
88
+ assertEquals(
89
+ "vpn",
90
+ InternetHold.transportNameOf(cellular = false, wifi = true, vpn = true, ethernet = false),
91
+ )
92
+ }
93
+
94
+ @Test
95
+ fun `each transport has a name and an unknown mix does not crash the trace`() {
96
+ assertEquals(
97
+ "cellular",
98
+ InternetHold.transportNameOf(cellular = true, wifi = false, vpn = false, ethernet = false),
99
+ )
100
+ assertEquals(
101
+ "wifi",
102
+ InternetHold.transportNameOf(cellular = false, wifi = true, vpn = false, ethernet = false),
103
+ )
104
+ assertEquals(
105
+ "ethernet",
106
+ InternetHold.transportNameOf(cellular = false, wifi = false, vpn = false, ethernet = true),
107
+ )
108
+ assertEquals(
109
+ "other",
110
+ InternetHold.transportNameOf(cellular = false, wifi = false, vpn = false, ethernet = false),
111
+ )
112
+ }
113
+
114
+ @Test
115
+ fun `cellular is named over wifi when both are present`() {
116
+ // Only reached when the app's default has both transports; cellular is the one this class
117
+ // holds up, so it is the one worth naming.
118
+ assertEquals(
119
+ "cellular",
120
+ InternetHold.transportNameOf(cellular = true, wifi = true, vpn = false, ethernet = false),
121
+ )
122
+ }
123
+
124
+ @Test
125
+ fun `a hold that could not be taken reports neither held nor validated`() {
126
+ val none = InternetHold.CellularHold(held = false, validated = false, waitedMs = 0)
127
+ assertFalse(none.held)
128
+ assertFalse(none.validated)
129
+ }
130
+
131
+ @Test
132
+ fun `a held request that never validated is still recorded as held`() {
133
+ // The request is deliberately kept after a validation timeout, so that a caller which
134
+ // continues anyway does not also lose the radio. Losing this distinction would make the
135
+ // release path look unnecessary.
136
+ val held = InternetHold.CellularHold(held = true, validated = false, waitedMs = 15_000)
137
+ assertTrue(held.held)
138
+ assertFalse(held.validated)
139
+ assertEquals(15_000, held.waitedMs)
140
+ }
141
+
142
+ @Test
143
+ fun `the cellular wait is longer than the default-route wait`() {
144
+ // Bringing a radio up from cold is slow; a default route swapping to a network already up
145
+ // is not. Equal budgets would either abort good calls or stall every one of them.
146
+ assertTrue(InternetHold.CELLULAR_WAIT_MS > InternetHold.DEFAULT_WAIT_MS)
147
+ }
148
+ }
@@ -0,0 +1,59 @@
1
+ package com.mentra.acsmeeting.network
2
+
3
+ import org.junit.Assert.assertEquals
4
+ import org.junit.Assert.assertFalse
5
+ import org.junit.Assert.assertNull
6
+ import org.junit.Assert.assertTrue
7
+ import org.junit.Test
8
+
9
+ class Ipv4PrefixTest {
10
+
11
+ @Test
12
+ fun `contains the phone and the gateway on a 24`() {
13
+ val prefix = Ipv4Prefix("192.168.43.20", 24)
14
+ assertTrue(prefix.contains("192.168.43.1"))
15
+ assertTrue(prefix.contains("192.168.43.20"))
16
+ assertTrue(prefix.contains("192.168.43.255"))
17
+ }
18
+
19
+ /** The whole point: a cellular 10/8 address is RFC1918 and still the wrong interface. */
20
+ @Test
21
+ fun `rejects a private address on another interface`() {
22
+ val prefix = Ipv4Prefix("192.168.43.20", 24)
23
+ assertFalse(prefix.contains("10.171.36.4"))
24
+ assertFalse(prefix.contains("192.168.1.20"))
25
+ assertFalse(prefix.contains("172.16.0.1"))
26
+ }
27
+
28
+ /** An OEM hotspot on another subnet must still pass, which is why /24 is never assumed. */
29
+ @Test
30
+ fun `honours the reported prefix length`() {
31
+ assertTrue(Ipv4Prefix("192.168.49.1", 16).contains("192.168.43.7"))
32
+ assertFalse(Ipv4Prefix("192.168.49.1", 24).contains("192.168.43.7"))
33
+ assertTrue(Ipv4Prefix("10.5.4.3", 8).contains("10.99.99.99"))
34
+ }
35
+
36
+ @Test
37
+ fun `non-addresses are not addresses`() {
38
+ val prefix = Ipv4Prefix("192.168.43.20", 24)
39
+ assertFalse(prefix.contains(null))
40
+ assertFalse(prefix.contains("typ"))
41
+ assertFalse(prefix.contains("192.168.43"))
42
+ assertFalse(prefix.contains("192.168.43.256"))
43
+ assertFalse(prefix.contains("192.168.43.20.1"))
44
+ assertFalse(prefix.contains("a4f1c2d3.local"))
45
+ }
46
+
47
+ @Test
48
+ fun `packs and prints`() {
49
+ assertEquals(0x0A000001, Ipv4Prefix.packIpv4("10.0.0.1"))
50
+ assertNull(Ipv4Prefix.packIpv4("not.an.ip.addr"))
51
+ assertEquals("192.168.43.20/24", Ipv4Prefix("192.168.43.20", 24).toString())
52
+ }
53
+
54
+ /** An unparseable own-address must match nothing rather than everything. */
55
+ @Test
56
+ fun `a malformed prefix contains nothing`() {
57
+ assertFalse(Ipv4Prefix("garbage", 24).contains("192.168.43.1"))
58
+ }
59
+ }
@@ -0,0 +1,59 @@
1
+ package com.mentra.acsmeeting.network
2
+
3
+ import com.mentra.acsmeeting.network.NetworkFacts.Iface
4
+ import org.assertj.core.api.Assertions.assertThat
5
+ import org.junit.Test
6
+
7
+ /**
8
+ * The attribution helpers decide what the next SoftAP diagnosis says, so they are pinned rather
9
+ * than eyeballed in a log line.
10
+ */
11
+ class NetworkFactsTest {
12
+
13
+ private fun iface(
14
+ name: String,
15
+ ipv4: List<String>,
16
+ up: Boolean = true,
17
+ loopback: Boolean = false,
18
+ ) = Iface(name, 1, up, loopback, false, false, 1500, ipv4)
19
+
20
+ private val table =
21
+ listOf(
22
+ iface("lo", listOf("127.0.0.1/8"), loopback = true),
23
+ iface("wlan0", listOf("192.168.43.79/24")),
24
+ iface("rmnet_data0", listOf("10.48.51.202/30")),
25
+ iface("dummy0", emptyList()),
26
+ iface("epdg0", listOf("10.9.9.9/32"), up = false),
27
+ )
28
+
29
+ @Test
30
+ fun `the owner of an address is the interface the table says carries it`() {
31
+ assertThat(NetworkFacts.ownerOf("192.168.43.79", table)).isEqualTo("wlan0")
32
+ assertThat(NetworkFacts.ownerOf("10.48.51.202", table)).isEqualTo("rmnet_data0")
33
+ }
34
+
35
+ /** An address ICE gathered that is absent here means our two views of the network disagree. */
36
+ @Test
37
+ fun `an address the table has never seen has no owner`() {
38
+ assertThat(NetworkFacts.ownerOf("192.168.50.7", table)).isNull()
39
+ }
40
+
41
+ @Test
42
+ fun `a prefix is not mistaken for the address`() {
43
+ assertThat(NetworkFacts.ownerOf("192.168.43.79/24", table)).isNull()
44
+ }
45
+
46
+ @Test
47
+ fun `gatherable drops loopback, down, and addressless interfaces`() {
48
+ assertThat(NetworkFacts.gatherable(table).map { it.name })
49
+ .containsExactly("wlan0", "rmnet_data0")
50
+ }
51
+
52
+ @Test
53
+ fun `render keeps the name, flags and every address`() {
54
+ val line = NetworkFacts.render(listOf(iface("wlan0", listOf("192.168.43.79/24", "10.1.2.3/24"))))
55
+
56
+ assertThat(line).contains("wlan0#1")
57
+ assertThat(line).contains("192.168.43.79/24,10.1.2.3/24")
58
+ }
59
+ }
@@ -0,0 +1,35 @@
1
+ package com.mentra.acsmeeting.network
2
+
3
+ import org.assertj.core.api.Assertions.assertThat
4
+ import org.junit.Test
5
+
6
+ class ScopedLocalNetworkPermissionTest {
7
+ @Test
8
+ fun `Android 17 preserves implicit access for the SDK 36 host`() {
9
+ assertThat(ScopedSoftApNetwork.hasLocalNetworkPermission(37, 36) {
10
+ error("Legacy targets must not query the unneeded runtime grant")
11
+ }).isTrue()
12
+ }
13
+
14
+ @Test
15
+ fun `older Android does not query a permission it cannot enforce`() {
16
+ assertThat(ScopedSoftApNetwork.hasLocalNetworkPermission(36, 37) {
17
+ error("Older Android must not query the new permission")
18
+ }).isTrue()
19
+ }
20
+
21
+ @Test
22
+ fun `Android 17 denies a target 37 host without the grant`() {
23
+ assertThat(ScopedSoftApNetwork.hasLocalNetworkPermission(37, 37) { false }).isFalse()
24
+ }
25
+
26
+ @Test
27
+ fun `Android 17 allows a target 37 host with the grant`() {
28
+ assertThat(ScopedSoftApNetwork.hasLocalNetworkPermission(37, 37) { true }).isTrue()
29
+ }
30
+
31
+ @Test
32
+ fun `future Android and target versions still enforce the grant`() {
33
+ assertThat(ScopedSoftApNetwork.hasLocalNetworkPermission(38, 38) { false }).isFalse()
34
+ }
35
+ }
@@ -0,0 +1,165 @@
1
+ package com.mentra.acsmeeting.network
2
+
3
+ import com.mentra.acsmeeting.network.ScopedNetworkChangeDetector.Companion.formatIpv4
4
+ import com.mentra.acsmeeting.network.ScopedNetworkChangeDetector.Companion.mergeScopedNetwork
5
+ import com.mentra.acsmeeting.network.ScopedNetworkChangeDetector.Companion.renderInventory
6
+ import com.mentra.acsmeeting.network.ScopedNetworkChangeDetector.Companion.toNetworkInformation
7
+ import com.mentra.acsmeeting.network.ScopedNetworkChangeDetector.ScopedInterface
8
+ import com.mentra.acsmeeting.source.SoftApIcePolicy
9
+ import org.webrtc.PeerConnectionFactory
10
+ import org.assertj.core.api.Assertions.assertThat
11
+ import org.junit.Test
12
+ import org.webrtc.NetworkChangeDetector
13
+
14
+ /**
15
+ * The merge is the part that decides whether ICE can see the hotspot at all, so it is tested
16
+ * directly rather than only through the on-device gate.
17
+ */
18
+ class ScopedNetworkChangeDetectorTest {
19
+
20
+ private fun ipv4(a: Int, b: Int, c: Int, d: Int) =
21
+ byteArrayOf(a.toByte(), b.toByte(), c.toByte(), d.toByte())
22
+
23
+ private fun softAp(name: String = "wlan1", handle: Long = 42L) =
24
+ ScopedInterface(name, handle, listOf(ipv4(192, 168, 43, 20)))
25
+
26
+ private fun detected(name: String) =
27
+ NetworkChangeDetector.NetworkInformation(
28
+ name,
29
+ NetworkChangeDetector.ConnectionType.CONNECTION_4G,
30
+ NetworkChangeDetector.ConnectionType.CONNECTION_NONE,
31
+ 7L,
32
+ arrayOf(NetworkChangeDetector.IPAddress(ipv4(10, 8, 0, 2))),
33
+ )
34
+
35
+ @Test
36
+ fun `the scoped SoftAP is the only Wi-Fi in the inventory`() {
37
+ val merged = mergeScopedNetwork(listOf(detected("rmnet0")), softAp())
38
+
39
+ assertThat(merged.map { it.name }).containsExactly("wlan1")
40
+ assertThat(merged.single().ipAddresses[0].address).isEqualTo(ipv4(192, 168, 43, 20))
41
+ }
42
+
43
+ /**
44
+ * The collision that produced `count=0` candidates on device when two entries shared handle 0.
45
+ * One entry with the real handle cannot collide with itself.
46
+ */
47
+ @Test
48
+ fun `the inventory is a single entry, so no two entries can share a handle`() {
49
+ val merged = mergeScopedNetwork(listOf(detected("rmnet_data0"), detected("wlan0")), softAp())
50
+
51
+ assertThat(merged).hasSize(1)
52
+ assertThat(merged.map { it.handle }.distinct()).hasSize(1)
53
+ }
54
+
55
+ /** The mask has to leave the hotspot gatherable, and nothing else is offered to it. */
56
+ @Test
57
+ fun `the published hotspot survives the ignore mask`() {
58
+ val merged = mergeScopedNetwork(emptyList(), softAp())
59
+ val mask = SoftApIcePolicy.networkIgnoreMask()
60
+
61
+ val gatherable = merged.filter { SoftApIcePolicy.allowsAdapter(mask, adapterTypeOf(it.type)) }
62
+
63
+ assertThat(gatherable.map { it.name }).containsExactly("wlan1")
64
+ }
65
+
66
+ /** Mirrors libwebrtc's `AdapterTypeFromNetworkType` for the two types this merge produces. */
67
+ private fun adapterTypeOf(type: NetworkChangeDetector.ConnectionType): Int =
68
+ when (type) {
69
+ NetworkChangeDetector.ConnectionType.CONNECTION_WIFI ->
70
+ PeerConnectionFactory.Options.ADAPTER_TYPE_WIFI
71
+ NetworkChangeDetector.ConnectionType.CONNECTION_4G ->
72
+ PeerConnectionFactory.Options.ADAPTER_TYPE_CELLULAR
73
+ else -> PeerConnectionFactory.Options.ADAPTER_TYPE_UNKNOWN
74
+ }
75
+
76
+ /** Without this, ICE never gathers a candidate on the hotspot subnet. */
77
+ @Test
78
+ fun `the scoped interface appears even when the delegate reports nothing`() {
79
+ val merged = mergeScopedNetwork(emptyList(), softAp())
80
+
81
+ assertThat(merged.map { it.name }).containsExactly("wlan1")
82
+ }
83
+
84
+ @Test
85
+ fun `a null delegate list is tolerated`() {
86
+ assertThat(mergeScopedNetwork(null, softAp())).hasSize(1)
87
+ }
88
+
89
+ @Test
90
+ fun `no scoped network leaves the inventory untouched`() {
91
+ val detectedList = listOf(detected("rmnet0"))
92
+
93
+ assertThat(mergeScopedNetwork(detectedList, null).map { it.name }).containsExactly("rmnet0")
94
+ }
95
+
96
+ @Test
97
+ fun `a same-named stock wlan0 is replaced by the scoped SoftAP address`() {
98
+ // This phone: stock detector reports wlan0 at 10.15.68.161 (internet Wi-Fi). SoftAP is a
99
+ // different Network that reuses the name wlan0 at 192.168.43.79. Matching on name and
100
+ // keeping the stock entry is how ICE answered with the 10.x host candidate.
101
+ val stockWifi = detected("wlan0")
102
+ val scoped = softAp(name = "wlan0", handle = 99L)
103
+
104
+ val merged = mergeScopedNetwork(listOf(stockWifi), scoped)
105
+
106
+ assertThat(merged).hasSize(1)
107
+ assertThat(merged.single().ipAddresses[0].address).isEqualTo(ipv4(192, 168, 43, 20))
108
+ }
109
+
110
+ /**
111
+ * Handle 0 advertised the right address and then never completed an ICE check (18:56 / 19:01).
112
+ * The real handle is what marks the socket onto the SoftAP; the SDP pin puts the scoped IP
113
+ * back in the answer the glasses see.
114
+ */
115
+ @Test
116
+ fun `the hotspot is published with its real network handle`() {
117
+ val information = toNetworkInformation(softAp(handle = 99L))
118
+
119
+ assertThat(information).isNotNull
120
+ assertThat(information!!.handle).isEqualTo(99L)
121
+ assertThat(information.type).isEqualTo(NetworkChangeDetector.ConnectionType.CONNECTION_WIFI)
122
+ }
123
+
124
+ @Test
125
+ fun `the published network carries the hotspot address`() {
126
+ val information = toNetworkInformation(softAp())!!
127
+
128
+ assertThat(information.ipAddresses).hasSize(1)
129
+ assertThat(information.ipAddresses[0].address).isEqualTo(ipv4(192, 168, 43, 20))
130
+ }
131
+
132
+ @Test
133
+ fun `an interface with no address is not published`() {
134
+ val addressless = ScopedInterface("wlan1", 42L, emptyList())
135
+
136
+ assertThat(toNetworkInformation(addressless)).isNull()
137
+ assertThat(mergeScopedNetwork(emptyList(), addressless)).isEmpty()
138
+ }
139
+
140
+ @Test
141
+ fun `formatIpv4 renders a dotted-quad`() {
142
+ assertThat(formatIpv4(ipv4(10, 15, 68, 161))).isEqualTo("10.15.68.161")
143
+ }
144
+
145
+ @Test
146
+ fun `multiple addresses are all published`() {
147
+ val multi =
148
+ ScopedInterface("wlan1", 42L, listOf(ipv4(192, 168, 43, 20), ipv4(192, 168, 43, 21)))
149
+
150
+ assertThat(toNetworkInformation(multi)!!.ipAddresses).hasSize(2)
151
+ }
152
+
153
+ /** The rejection diagnostics read this, so a handle collision has to be visible in it. */
154
+ @Test
155
+ fun `renderInventory names every entry with its type, handle and addresses`() {
156
+ val rendered = renderInventory(mergeScopedNetwork(emptyList(), softAp()))
157
+
158
+ assertThat(rendered).isEqualTo("wlan1[CONNECTION_WIFI]#42(192.168.43.20)")
159
+ }
160
+
161
+ @Test
162
+ fun `renderInventory tolerates a null list`() {
163
+ assertThat(renderInventory(null)).isEmpty()
164
+ }
165
+ }
@@ -0,0 +1,96 @@
1
+ package com.mentra.acsmeeting.network
2
+
3
+ import com.mentra.acsmeeting.network.ScopedNetworkState.Failure
4
+ import org.assertj.core.api.Assertions.assertThat
5
+ import org.junit.Test
6
+
7
+ class ScopedNetworkErrorTest {
8
+
9
+ @Test
10
+ fun `no failure maps to no error`() {
11
+ assertThat(ScopedNetworkError.from(Failure.NONE, "ssid", 30_000)).isNull()
12
+ }
13
+
14
+ @Test
15
+ fun `each failure maps to its typed error and stable code`() {
16
+ assertThat(ScopedNetworkError.from(Failure.TIMEOUT, "ap", 30_000))
17
+ .isInstanceOf(ScopedNetworkError.Timeout::class.java)
18
+ .extracting("code")
19
+ .isEqualTo(ScopedNetworkError.CODE_TIMEOUT)
20
+
21
+ assertThat(ScopedNetworkError.from(Failure.UNAVAILABLE, "ap", 30_000))
22
+ .isInstanceOf(ScopedNetworkError.Unavailable::class.java)
23
+
24
+ assertThat(ScopedNetworkError.from(Failure.LOST, "ap", 30_000))
25
+ .isInstanceOf(ScopedNetworkError.Lost::class.java)
26
+
27
+ assertThat(ScopedNetworkError.from(Failure.REQUEST_FAILED, "ap", 30_000))
28
+ .isInstanceOf(ScopedNetworkError.RequestFailed::class.java)
29
+ }
30
+
31
+ /**
32
+ * A denied permission must be its own error, not a timeout: otherwise it looks like a flaky
33
+ * hotspot and costs 30 seconds before failing.
34
+ */
35
+ @Test
36
+ fun `permission denial is distinct from a timeout`() {
37
+ val denied = ScopedNetworkError.from(Failure.PERMISSION_DENIED, "ap", 30_000)
38
+
39
+ assertThat(denied).isInstanceOf(ScopedNetworkError.PermissionDenied::class.java)
40
+ assertThat(denied?.code).isEqualTo(ScopedNetworkError.CODE_PERMISSION_DENIED)
41
+ assertThat(denied?.code).isNotEqualTo(ScopedNetworkError.CODE_TIMEOUT)
42
+ }
43
+
44
+ @Test
45
+ fun `error codes are stable and unique`() {
46
+ val codes =
47
+ listOf(
48
+ ScopedNetworkError.CODE_PERMISSION_DENIED,
49
+ ScopedNetworkError.CODE_TIMEOUT,
50
+ ScopedNetworkError.CODE_UNAVAILABLE,
51
+ ScopedNetworkError.CODE_LOST,
52
+ ScopedNetworkError.CODE_REQUEST_FAILED,
53
+ ScopedNetworkError.CODE_NO_LOCAL_ADDRESS,
54
+ ScopedNetworkError.CODE_NOT_READY,
55
+ ScopedNetworkError.CODE_WIFI_DISABLED,
56
+ ScopedNetworkError.CODE_VPN_ACTIVE,
57
+ )
58
+
59
+ assertThat(codes).doesNotHaveDuplicates()
60
+ assertThat(codes).allMatch { it.startsWith("SOFTAP_") }
61
+ }
62
+
63
+ @Test
64
+ fun `messages name the ssid so a log line is actionable`() {
65
+ assertThat(ScopedNetworkError.Timeout("MentraLive-1234", 30_000).message)
66
+ .contains("MentraLive-1234")
67
+ .contains("30000")
68
+ assertThat(ScopedNetworkError.Lost("MentraLive-1234").message).contains("MentraLive-1234")
69
+ }
70
+
71
+ @Test
72
+ fun `a capturing vpn is named as the cause and tells the user what to change`() {
73
+ val error = ScopedNetworkError.VpnCapturesApp()
74
+ assertThat(error.code).isEqualTo(ScopedNetworkError.CODE_VPN_ACTIVE)
75
+ assertThat(error.message).contains("VPN").contains("split tunneling")
76
+ }
77
+
78
+ /**
79
+ * "Joined but not usable" has to be its own code. Reported as a join success it becomes an ICE
80
+ * answer with no candidates, which is the symptom several unrelated faults share.
81
+ */
82
+ @Test
83
+ fun `a network that joined but never became usable names the missing source`() {
84
+ val error = ScopedNetworkError.NotReady("MentraLive-1234", "the kernel interface table")
85
+
86
+ assertThat(error.code).isEqualTo(ScopedNetworkError.CODE_NOT_READY)
87
+ assertThat(error.code).isNotEqualTo(ScopedNetworkError.CODE_NO_LOCAL_ADDRESS)
88
+ assertThat(error.message).contains("MentraLive-1234").contains("the kernel interface table")
89
+ }
90
+
91
+ @Test
92
+ fun `the local network permission name is the platform constant`() {
93
+ assertThat(ScopedNetworkError.LOCAL_NETWORK_PERMISSION)
94
+ .isEqualTo("android.permission.ACCESS_LOCAL_NETWORK")
95
+ }
96
+ }