@mentra/acs-meeting 3.2.0-dev.217 → 3.2.0-dev.222

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,355 @@
1
+ package com.mentra.acsmeeting.network
2
+
3
+ import android.content.Context
4
+ import android.net.ConnectivityManager
5
+ import com.mentra.acsmeeting.trace.SoftApTrace
6
+ import java.net.Inet4Address
7
+ import org.webrtc.NetworkChangeDetector
8
+ import org.webrtc.NetworkMonitor
9
+ import org.webrtc.NetworkMonitorAutoDetect
10
+
11
+ /**
12
+ * Adds the scoped SoftAP network to libwebrtc's Android network inventory.
13
+ *
14
+ * ## Why this exists
15
+ *
16
+ * The SoftAP [Network] is requested without `NET_CAPABILITY_INTERNET` so that Android keeps ACS on
17
+ * cellular. But stock libwebrtc's `NetworkMonitorAutoDetect` registers a request for
18
+ * *internet-capable* networks, so it never sees this one, and ICE therefore never gathers a host
19
+ * candidate on the hotspot subnet. Holding a scoped `Network` object is not by itself enough to make
20
+ * libwebrtc's internally-created UDP sockets use that interface — `PeerConnectionFactory.Options`
21
+ * exposes `networkIgnoreMask` and `disableNetworkMonitor`, not a socket factory or a network handle.
22
+ *
23
+ * Injecting the interface into the inventory is the supported way through: it is the same technique
24
+ * the glasses already use for their `ap0` tethering interface in
25
+ * `HotspotAwareNetworkChangeDetector`.
26
+ *
27
+ * The monitor this touches is process-wide and the libwebrtc AAR is shared with LiveKit, so the
28
+ * takeover is scoped and reversible; [ScopedNetworkObserver] documents and enforces that contract.
29
+ *
30
+ * ## Why the handle is the real [Network] handle
31
+ *
32
+ * Two device-proven behaviours, both captured, both fatal on their own:
33
+ *
34
+ * Handle `0` makes `BindSocketToNetwork` return `NOT_IMPLEMENTED`, so libwebrtc binds the socket to
35
+ * `192.168.43.x` and advertises that address. The answer is truthful. ICE checks then sit on that
36
+ * pair with `|CR-I|-|0|0|` forever: the OS accepted the bind, and even a send-only datagram, but
37
+ * unmarked UDP on a non-default network is not delivered. 18:56 / 19:01 runs: ACS `CONNECTED`,
38
+ * `ingest_host_candidate onHotspot=true`, then `ingest_no_first_frame` and the glasses reporting
39
+ * `ICE did not connect`.
40
+ *
41
+ * A real handle makes `android_setsocknetwork` succeed, so the socket is marked onto the scoped
42
+ * SoftAP and ICE packets can actually leave and return. libwebrtc then rebinds to `0.0.0.0` and
43
+ * `UDPPort` substitutes the default-route (cellular) address into the candidate:
44
+ *
45
+ * ```
46
+ * Allocate ports on wlan0
47
+ * Port[...:Net[wlan0:192.168.43.x/24:Wifi:id=5]]: Gathered candidate: Cand[...:10.48.51.x:...:host...]
48
+ * ```
49
+ *
50
+ * That advertised address is what the glasses would send to, and they have no route to it. So the
51
+ * handle is real — sockets must be marked — and [SoftApSdpGuard.pinHostAddresses] rewrites the
52
+ * answer the glasses see back onto the scoped IP. One inventory entry, so the handle cannot collide
53
+ * with a decoy the way `0` did when we published more than one.
54
+ *
55
+ * ## Status
56
+ *
57
+ * Whether stock libwebrtc needs this at all is exactly what the Phase 0.5 feasibility gate
58
+ * (`SoftApFeasibilityGateTest`) measures. The gate fired on-device (see [install]), so
59
+ * [AcsMeetingModule][com.mentra.acsmeeting.AcsMeetingModule] installs it at module creation.
60
+ */
61
+ class ScopedNetworkChangeDetector
62
+ internal constructor(
63
+ private val delegate: NetworkChangeDetector,
64
+ private val scopedNetworkSupplier: () -> ScopedInterface?,
65
+ private val observer: ScopedNetworkObserver? = null,
66
+ ) : NetworkChangeDetector {
67
+
68
+ constructor(
69
+ observer: NetworkChangeDetector.Observer,
70
+ context: Context,
71
+ scopedNetwork: ScopedSoftApNetwork,
72
+ ) : this(wire(observer, context) { resolveScopedInterface(context, scopedNetwork) })
73
+
74
+ private constructor(wired: Wired) : this(wired.delegate, wired.scoped, wired.observer)
75
+
76
+ /** The SoftAP interface as libwebrtc needs to see it. */
77
+ data class ScopedInterface(
78
+ val name: String,
79
+ val networkHandle: Long,
80
+ val ipv4Addresses: List<ByteArray>,
81
+ )
82
+
83
+ /**
84
+ * One inventory entry as libwebrtc was told about it.
85
+ *
86
+ * Kept as plain values rather than the webrtc type so [lastPublished] can outlive a gathering
87
+ * pass without pinning native objects, and so the diagnosis can be unit tested.
88
+ */
89
+ data class PublishedEntry(
90
+ val name: String,
91
+ val type: String,
92
+ val handle: Long,
93
+ val addresses: List<String>,
94
+ ) {
95
+ override fun toString(): String = "$name[$type]#$handle(${addresses.joinToString("/")})"
96
+ }
97
+
98
+ override fun getCurrentConnectionType(): NetworkChangeDetector.ConnectionType {
99
+ val delegateType = delegate.currentConnectionType
100
+ if (delegateType == NetworkChangeDetector.ConnectionType.CONNECTION_NONE &&
101
+ scopedNetworkSupplier() != null
102
+ ) {
103
+ return NetworkChangeDetector.ConnectionType.CONNECTION_WIFI
104
+ }
105
+ return delegateType
106
+ }
107
+
108
+ override fun supportNetworkCallback(): Boolean = delegate.supportNetworkCallback()
109
+
110
+ override fun getActiveNetworkList(): List<NetworkChangeDetector.NetworkInformation> {
111
+ val scoped = scopedNetworkSupplier()
112
+ val table = NetworkFacts.snapshot()
113
+ val stock = delegate.activeNetworkList
114
+
115
+ // The three views that have to agree, logged side by side. libwebrtc gathers from the
116
+ // kernel table; we only get to label the names in it. A scoped address missing from
117
+ // `ifaddrs`, or present under a different name than LinkProperties claims, is
118
+ // unrecoverable by any mask — and is invisible unless all three are printed together.
119
+ SoftApTrace.stage(
120
+ "softap_ifaddrs",
121
+ "gatherable" to NetworkFacts.render(NetworkFacts.gatherable(table)),
122
+ "all" to NetworkFacts.render(table),
123
+ )
124
+ SoftApTrace.stage(
125
+ "webrtc_stock_inventory",
126
+ "count" to stock.orEmpty().size,
127
+ "interfaces" to renderInventory(stock),
128
+ )
129
+ if (scoped != null) {
130
+ val address = scoped.ipv4Addresses.firstOrNull()?.let { formatIpv4(it) }
131
+ val owner = address?.let { NetworkFacts.ownerOf(it, table) }
132
+ // The smoking-gun check. `claimed` is what LinkProperties told us; `owner` is what the
133
+ // kernel says. If owner is null the address is not in the table at all and ICE cannot
134
+ // gather it; if they differ, our WIFI label is being applied to the wrong interface.
135
+ SoftApTrace.stage(
136
+ "softap_scoped_address_owner",
137
+ "address" to (address ?: "none"),
138
+ "claimed" to scoped.name,
139
+ "owner" to (owner ?: "ABSENT"),
140
+ "agrees" to (owner != null && owner == scoped.name),
141
+ "handle" to scoped.networkHandle,
142
+ )
143
+ }
144
+
145
+ val merged = mergeScopedNetwork(stock, scoped)
146
+ lastPublished = snapshotOf(merged)
147
+ SoftApTrace.stage(
148
+ "webrtc_network_inventory",
149
+ "count" to merged.size,
150
+ "interfaces" to lastPublished.joinToString(",") { it.toString() },
151
+ )
152
+ observer?.publish(merged)
153
+ return merged
154
+ }
155
+
156
+ override fun destroy() {
157
+ // Give the monitor back before the delegate goes away, otherwise the synthetic entry is
158
+ // the last thing libwebrtc was told about and every later connection inherits it.
159
+ observer?.restore()
160
+ delegate.destroy()
161
+ }
162
+
163
+ companion object {
164
+
165
+ /**
166
+ * What was last handed to libwebrtc, for the rejection diagnostics.
167
+ *
168
+ * A missing hotspot candidate has several causes and they are only separable by knowing
169
+ * whether the hotspot was in the inventory at gathering time, and whether any two entries
170
+ * shared a handle. Process-wide because the inventory is; read-only for callers.
171
+ */
172
+ @Volatile
173
+ @JvmStatic
174
+ var lastPublished: List<PublishedEntry> = emptyList()
175
+ private set
176
+
177
+ /** An inventory entry reduced to the fields a diagnosis or a trace needs. */
178
+ @JvmStatic
179
+ fun snapshotOf(
180
+ entries: List<NetworkChangeDetector.NetworkInformation>?,
181
+ ): List<PublishedEntry> =
182
+ entries.orEmpty().map { info ->
183
+ PublishedEntry(
184
+ info.name,
185
+ info.type.name,
186
+ info.handle,
187
+ info.ipAddresses.map { formatIpv4(it.address) },
188
+ )
189
+ }
190
+
191
+ /** `name[type]#handle(ips)` per entry — the shape both traces and diagnostics want. */
192
+ @JvmStatic
193
+ fun renderInventory(
194
+ entries: List<NetworkChangeDetector.NetworkInformation>?,
195
+ ): String = snapshotOf(entries).joinToString(",") { it.toString() }
196
+
197
+ /**
198
+ * The handle that tells libwebrtc "bind to the address, not to a network".
199
+ *
200
+ * `AndroidNetworkMonitor::BindSocketToNetwork` maps a zero handle to `NOT_IMPLEMENTED`,
201
+ * which is the only branch that leaves the socket bound to a real local address — and so
202
+ * the only branch that yields a truthful host candidate. Named because it is load-bearing,
203
+ * not a placeholder.
204
+ */
205
+ const val UNBINDABLE_HANDLE = 0L
206
+
207
+ /**
208
+ * Register this detector as the one libwebrtc builds when it starts monitoring.
209
+ *
210
+ * `NetworkMonitor` is process-wide and only consults the factory when it creates a
211
+ * detector — on the first `startMonitoring` and again after every `stopMonitoring` that
212
+ * drops the observer count to zero. So this must run before any `PeerConnectionFactory`
213
+ * exists, and [scopedNetworkSupplier] must resolve the *current* join at call time rather
214
+ * than capture one instance: the module recreates its [ScopedSoftApNetwork] across
215
+ * sessions.
216
+ *
217
+ * Idempotent. Feasibility gate result that made this necessary, on a Galaxy S25 /
218
+ * Android 16 with webrtc-sdk 137: `NetworkMonitorAutoDetect.networkToInfo` drops every
219
+ * network lacking `NET_CAPABILITY_INTERNET`, so the answer to the glasses' offer carried
220
+ * zero host candidates and the ingest returned WHIP 500 `no_softap_host_candidate`.
221
+ */
222
+ private data class Wired(
223
+ val delegate: NetworkChangeDetector,
224
+ val scoped: () -> ScopedInterface?,
225
+ val observer: ScopedNetworkObserver,
226
+ )
227
+
228
+ /**
229
+ * One observer is shared: AutoDetect's callbacks go through it, and so does the
230
+ * reconcile in [getActiveNetworkList]. Two instances would drop stock handles on one
231
+ * and publish the hotspot on the other, which is how the native list stayed dirty.
232
+ */
233
+ private fun wire(
234
+ downstream: NetworkChangeDetector.Observer,
235
+ context: Context,
236
+ scoped: () -> ScopedInterface?,
237
+ ): Wired {
238
+ val filter = ScopedNetworkObserver(downstream, scoped)
239
+ return Wired(NetworkMonitorAutoDetect(filter, context), scoped, filter)
240
+ }
241
+
242
+ @JvmStatic
243
+ fun install(scopedNetworkSupplier: () -> ScopedSoftApNetwork?) {
244
+ val monitor = NetworkMonitor.getInstance()
245
+ // A live detector was built by an earlier factory (or the stock one) and will not be
246
+ // replaced until monitoring restarts; say so in the trace so a missing host candidate
247
+ // after this point has an explanation.
248
+ SoftApTrace.stage(
249
+ "webrtc_network_detector_installed",
250
+ "monitorObservers" to monitor.numObservers,
251
+ )
252
+ monitor.setNetworkChangeDetectorFactory { observer, context ->
253
+ val wired =
254
+ wire(observer, context) {
255
+ scopedNetworkSupplier()?.let { resolveScopedInterface(context, it) }
256
+ }
257
+ ScopedNetworkChangeDetector(wired.delegate, wired.scoped, wired.observer)
258
+ }
259
+ }
260
+
261
+ /**
262
+ * The SoftAP inventory: exactly one entry, the scoped network, typed Wi-Fi.
263
+ *
264
+ * Publishing decoy entries for the other interfaces was tried and is wrong. The theory was
265
+ * that an interface the monitor has never heard of comes back `ADAPTER_TYPE_UNKNOWN == 0`
266
+ * and `networkIgnoreMask and 0` is always `0`, making an omitted interface unmaskable. The
267
+ * device log says otherwise: an interface with no monitor entry is dropped from the network
268
+ * list entirely, not admitted as unknown. Here `rmnet_data0` vanishes the moment its entry
269
+ * is gone, while `lo` survives because libwebrtc types loopback from the interface flags
270
+ * and the mask drops it:
271
+ *
272
+ * ```
273
+ * Network connected: NetInfo[name wlan0; handle 0; type 2]
274
+ * Count of networks: 3
275
+ * Net[wlan0:192.168.43.x/24:Wifi:id=5]
276
+ * Net[lo:...:Loopback:id=2]
277
+ * Net[lo:127.0.0.x/8:Loopback:id=1]
278
+ * ```
279
+ *
280
+ * Worse, the decoys were actively fatal. `AndroidNetworkMonitor` keys its networks by
281
+ * *handle*, and both the hotspot and the decoys have to use [UNBINDABLE_HANDLE], so each
282
+ * `onNetworkConnect` overwrote the previous one. Whichever arrived last was the only
283
+ * network the monitor knew, and when that was a masked decoy the answer carried no
284
+ * candidates at all:
285
+ *
286
+ * ```
287
+ * Network connected: NetInfo[name wlan0; handle 0; type 2]
288
+ * Network connected: NetInfo[name rmnet_data0; handle 0; type 4]
289
+ * Count of networks: 4 -> rmnet_data0, rmnet_data0, lo, lo (wlan0 gone)
290
+ * ```
291
+ *
292
+ * One entry, therefore. The invariant stays structural — the only gatherable adapter is the
293
+ * scoped SoftAP — and it no longer depends on a handle being unique.
294
+ *
295
+ * When the hotspot shares an interface *name* with a stock address, the scoped entry wins
296
+ * and carries only its `LinkProperties` addresses. Type cannot separate two addresses on
297
+ * one name, so that residual case is caught by `SoftApSdpGuard` instead.
298
+ */
299
+ @JvmStatic
300
+ fun mergeScopedNetwork(
301
+ detected: List<NetworkChangeDetector.NetworkInformation>?,
302
+ scoped: ScopedInterface?,
303
+ ): List<NetworkChangeDetector.NetworkInformation> {
304
+ val hotspot = scoped?.let { toNetworkInformation(it) } ?: return detected.orEmpty()
305
+ return listOf(hotspot)
306
+ }
307
+
308
+ /** Dotted-quad for the inventory trace. */
309
+ @JvmStatic
310
+ fun formatIpv4(bytes: ByteArray): String =
311
+ bytes.joinToString(".") { (it.toInt() and 0xff).toString() }
312
+
313
+ /** Null when the interface has no address, which libwebrtc cannot use. */
314
+ @JvmStatic
315
+ fun toNetworkInformation(
316
+ scoped: ScopedInterface,
317
+ ): NetworkChangeDetector.NetworkInformation? {
318
+ if (scoped.ipv4Addresses.isEmpty()) return null
319
+ val addresses =
320
+ scoped.ipv4Addresses
321
+ .map { NetworkChangeDetector.IPAddress(it) }
322
+ .toTypedArray()
323
+ return NetworkChangeDetector.NetworkInformation(
324
+ scoped.name,
325
+ NetworkChangeDetector.ConnectionType.CONNECTION_WIFI,
326
+ NetworkChangeDetector.ConnectionType.CONNECTION_NONE,
327
+ // The real handle, so BindSocketToNetwork marks ICE sockets onto this Network.
328
+ // Handle 0 advertised the right address and then never completed a check; see the
329
+ // class doc. SoftApSdpGuard.pinHostAddresses puts the scoped IP back in the SDP.
330
+ scoped.networkHandle,
331
+ addresses,
332
+ )
333
+ }
334
+
335
+ /** Resolve the joined SoftAP into the interface libwebrtc should be told about. */
336
+ private fun resolveScopedInterface(
337
+ context: Context,
338
+ scopedNetwork: ScopedSoftApNetwork,
339
+ ): ScopedInterface? {
340
+ val network = scopedNetwork.network() ?: return null
341
+ val manager =
342
+ context.getSystemService(ConnectivityManager::class.java) ?: return null
343
+ val properties = manager.getLinkProperties(network) ?: return null
344
+ val interfaceName = properties.interfaceName ?: return null
345
+ // LinkProperties of *this* Network, not NetworkInterface.getByName. On a phone that
346
+ // kept internet Wi-Fi up, wlan0 can carry both 10.x and 192.168.43.x; getByName would
347
+ // hand ICE both, and it just picked the 10.x.
348
+ val addresses =
349
+ properties.linkAddresses
350
+ .mapNotNull { (it.address as? Inet4Address)?.address }
351
+ if (addresses.isEmpty()) return null
352
+ return ScopedInterface(interfaceName, network.networkHandle, addresses)
353
+ }
354
+ }
355
+ }
@@ -0,0 +1,109 @@
1
+ package com.mentra.acsmeeting.network
2
+
3
+ /**
4
+ * Typed failures for a scoped SoftAP join.
5
+ *
6
+ * A denied local-network permission must surface as a distinct, immediate error rather than a
7
+ * 30-second hang that looks like a flaky hotspot. `ACCESS_LOCAL_NETWORK` sits in the nearby-devices
8
+ * group, so it is often already granted during development and easy to miss until a user without it
9
+ * hits a silent timeout.
10
+ */
11
+ sealed class ScopedNetworkError(val code: String, message: String) : Exception(message) {
12
+
13
+ /** `ACCESS_LOCAL_NETWORK` (or the nearby-devices prerequisite) was not granted. */
14
+ class PermissionDenied(val permission: String) :
15
+ ScopedNetworkError(
16
+ CODE_PERMISSION_DENIED,
17
+ "Local network permission not granted: $permission",
18
+ )
19
+
20
+ /** No usable network arrived before the request timeout. */
21
+ class Timeout(val ssid: String, val timeoutMs: Int) :
22
+ ScopedNetworkError(CODE_TIMEOUT, "Timed out joining $ssid after ${timeoutMs}ms")
23
+
24
+ /**
25
+ * The framework reported it cannot provide this network at all.
26
+ *
27
+ * WifiNetworkSpecifier does not keep scanning for the request timeout. If the glasses AP is
28
+ * not in the current scan — or the user dismissed the system join sheet — this arrives in
29
+ * well under a second.
30
+ */
31
+ class Unavailable(val ssid: String) :
32
+ ScopedNetworkError(
33
+ CODE_UNAVAILABLE,
34
+ "Could not join $ssid (SSID not in scan, Wi-Fi off, or the system join prompt was dismissed)",
35
+ )
36
+
37
+ /** The network was joined and then went away mid-call. */
38
+ class Lost(val ssid: String) :
39
+ ScopedNetworkError(CODE_LOST, "Lost the connection to $ssid")
40
+
41
+ /** `requestNetwork` itself threw. */
42
+ class RequestFailed(val reason: String) :
43
+ ScopedNetworkError(CODE_REQUEST_FAILED, "Scoped network request failed: $reason")
44
+
45
+ /** Joined, but no IPv4 address was assigned, so nothing can bind to the link. */
46
+ class NoLocalAddress(val ssid: String) :
47
+ ScopedNetworkError(CODE_NO_LOCAL_ADDRESS, "Joined $ssid but no IPv4 address was assigned")
48
+
49
+ /**
50
+ * Joined, but the network never became usable for ICE: a callback never reported, or the
51
+ * address never appeared in the kernel's interface table.
52
+ *
53
+ * Failing here is deliberate. Proceeding produces an ICE answer with no hotspot candidate,
54
+ * which is the same symptom as several unrelated faults and cost real time to attribute.
55
+ */
56
+ class NotReady(val ssid: String, val missing: String) :
57
+ ScopedNetworkError(
58
+ CODE_NOT_READY,
59
+ "Joined $ssid but the network never became usable: still waiting for $missing",
60
+ )
61
+
62
+ /** Phone Wi-Fi radio is off. WifiNetworkSpecifier then fails as Unavailable in under a second. */
63
+ class WifiDisabled :
64
+ ScopedNetworkError(CODE_WIFI_DISABLED, "Phone Wi-Fi is off; turn it on before joining the glasses hotspot")
65
+
66
+ /**
67
+ * A VPN owns this app's UID. netd then refuses to bind our sockets to the hotspot network
68
+ * (`EPERM`), and the glasses' TCP handshake to the WHIP listener never completes because the
69
+ * reply path is captured by the tunnel. Verified on-device: a shell-UID listener on the same
70
+ * address accepts the glasses' connection while an app-UID listener times out. The scoped join
71
+ * itself succeeds, so without this check the failure only shows up ten seconds into the camera
72
+ * step as an opaque connect timeout.
73
+ */
74
+ class VpnCapturesApp :
75
+ ScopedNetworkError(
76
+ CODE_VPN_ACTIVE,
77
+ "A VPN is routing this app's traffic, so the glasses cannot reach the phone. " +
78
+ "Exclude Mentra from the VPN (split tunneling) or turn the VPN off, then retry",
79
+ )
80
+
81
+ companion object {
82
+ const val CODE_PERMISSION_DENIED = "SOFTAP_PERMISSION_DENIED"
83
+ const val CODE_TIMEOUT = "SOFTAP_JOIN_TIMEOUT"
84
+ const val CODE_UNAVAILABLE = "SOFTAP_UNAVAILABLE"
85
+ const val CODE_LOST = "SOFTAP_NETWORK_LOST"
86
+ const val CODE_REQUEST_FAILED = "SOFTAP_REQUEST_FAILED"
87
+ const val CODE_NO_LOCAL_ADDRESS = "SOFTAP_NO_LOCAL_ADDRESS"
88
+ const val CODE_NOT_READY = "SOFTAP_NETWORK_NOT_READY"
89
+ const val CODE_WIFI_DISABLED = "SOFTAP_WIFI_DISABLED"
90
+ const val CODE_VPN_ACTIVE = "SOFTAP_VPN_ACTIVE"
91
+
92
+ /** Map a terminal state-machine failure onto the typed error the call path reports. */
93
+ fun from(failure: ScopedNetworkState.Failure, ssid: String, timeoutMs: Int): ScopedNetworkError? =
94
+ when (failure) {
95
+ ScopedNetworkState.Failure.NONE -> null
96
+ ScopedNetworkState.Failure.TIMEOUT -> Timeout(ssid, timeoutMs)
97
+ ScopedNetworkState.Failure.UNAVAILABLE -> Unavailable(ssid)
98
+ ScopedNetworkState.Failure.LOST -> Lost(ssid)
99
+ ScopedNetworkState.Failure.PERMISSION_DENIED -> PermissionDenied(LOCAL_NETWORK_PERMISSION)
100
+ ScopedNetworkState.Failure.REQUEST_FAILED -> RequestFailed("requestNetwork threw")
101
+ }
102
+
103
+ /**
104
+ * Enforced on Android 17+ for apps targeting SDK 37+. Hosts targeting older SDKs
105
+ * retain implicit access through INTERNET and must not request this permission.
106
+ */
107
+ const val LOCAL_NETWORK_PERMISSION = "android.permission.ACCESS_LOCAL_NETWORK"
108
+ }
109
+ }
@@ -0,0 +1,160 @@
1
+ package com.mentra.acsmeeting.network
2
+
3
+ import com.mentra.acsmeeting.trace.SoftApTrace
4
+ import org.webrtc.NetworkChangeDetector
5
+
6
+ /**
7
+ * Owns libwebrtc's network state for as long as a scoped SoftAP is joined, and gives it back after.
8
+ *
9
+ * `getActiveNetworkList` is only a snapshot. libwebrtc's process-wide monitor also keeps every
10
+ * network it has ever been told about through [onNetworkConnect]. AutoDetect is constructed with
11
+ * this observer and reports cellular (and leftover home Wi-Fi) throughout the ACS join that happens
12
+ * *before* ingest starts, and those handles stay in the native list. Gathering unions the snapshot
13
+ * with that history, which is how a correct inventory still produced a lone cellular candidate.
14
+ *
15
+ * ## Ownership, not filtering
16
+ *
17
+ * The monitor is process-wide and the libwebrtc AAR is deliberately shared with LiveKit, so this
18
+ * cannot simply mute callbacks and walk away. The contract is explicit:
19
+ *
20
+ * - **While SoftAP is joined** this observer owns the monitor. Stock connect/disconnect callbacks
21
+ * are suppressed (but *recorded*), every handle outside the SoftAP inventory is disconnected,
22
+ * and exactly that inventory is connected.
23
+ * - **When the hotspot goes away** ownership is handed back by [restore]: the synthetic entry is
24
+ * disconnected and every stock network whose callback was suppressed is replayed, so a WHEP or
25
+ * LiveKit connection started afterwards sees real networks. Resuming pass-through alone is not
26
+ * enough — the networks it never heard about would stay missing until the OS happened to
27
+ * re-announce them.
28
+ *
29
+ * Restoration is driven from both directions, because either may come first: [publish] runs on
30
+ * every inventory read, and each stock callback checks too, since after teardown the OS may be the
31
+ * only thing still talking to us.
32
+ */
33
+ internal class ScopedNetworkObserver(
34
+ private val downstream: NetworkChangeDetector.Observer,
35
+ private val scoped: () -> ScopedNetworkChangeDetector.ScopedInterface?,
36
+ ) : NetworkChangeDetector.Observer() {
37
+
38
+ private val lock = Any()
39
+
40
+ /**
41
+ * Stock networks by handle, kept whole rather than as bare handles: replaying a suppressed
42
+ * network on teardown requires the [NetworkChangeDetector.NetworkInformation] verbatim.
43
+ */
44
+ private val stock = linkedMapOf<Long, NetworkChangeDetector.NetworkInformation>()
45
+
46
+ private val published = mutableSetOf<Long>()
47
+ private var lastSignature: String? = null
48
+
49
+ /** True while this observer has taken over the monitor and still owes a [restore]. */
50
+ private var owning = false
51
+
52
+ /**
53
+ * Make native state match [inventory] whenever SoftAP is up. A no-op when the snapshot has not
54
+ * changed, so [ScopedNetworkChangeDetector.getActiveNetworkList] can call this on every read.
55
+ */
56
+ fun publish(inventory: List<NetworkChangeDetector.NetworkInformation>) {
57
+ if (scoped() == null) {
58
+ restore()
59
+ return
60
+ }
61
+ val signature =
62
+ inventory.joinToString("|") { info ->
63
+ val ips =
64
+ info.ipAddresses.joinToString("/") { address ->
65
+ ScopedNetworkChangeDetector.formatIpv4(address.address)
66
+ }
67
+ "${info.handle}:${info.name}:$ips"
68
+ }
69
+ synchronized(lock) {
70
+ if (signature == lastSignature) return
71
+ lastSignature = signature
72
+ owning = true
73
+
74
+ val keep = inventory.map { it.handle }.toSet()
75
+ // Stock handles announced *before* the join are the ones that poison gathering, so
76
+ // they are disconnected here even though their callbacks were never forwarded.
77
+ val dropped = (stock.keys + published).filterNot { it in keep }
78
+ dropped.forEach { handle -> downstream.onNetworkDisconnect(handle) }
79
+ published.clear()
80
+ inventory.forEach { info ->
81
+ published.add(info.handle)
82
+ downstream.onNetworkConnect(info)
83
+ }
84
+ SoftApTrace.stage(
85
+ "webrtc_network_reconciled",
86
+ "published" to inventory.size,
87
+ "dropped" to dropped.size,
88
+ )
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Hand the monitor back: drop the synthetic entry, replay every suppressed stock network.
94
+ *
95
+ * Idempotent, and safe to call when this observer never took ownership.
96
+ */
97
+ fun restore() {
98
+ synchronized(lock) {
99
+ if (!owning) return
100
+ owning = false
101
+ lastSignature = null
102
+
103
+ val synthetic = published.toList()
104
+ published.clear()
105
+ synthetic.forEach { handle -> downstream.onNetworkDisconnect(handle) }
106
+ // Replay rather than wait: these networks exist, and the monitor was told they did not.
107
+ stock.values.forEach { info -> downstream.onNetworkConnect(info) }
108
+ SoftApTrace.stage(
109
+ "webrtc_network_restored",
110
+ "syntheticDropped" to synthetic.size,
111
+ "stockReplayed" to stock.size,
112
+ )
113
+ }
114
+ }
115
+
116
+ override fun onConnectionTypeChanged(connectionType: NetworkChangeDetector.ConnectionType) {
117
+ if (scoped() != null) {
118
+ downstream.onConnectionTypeChanged(NetworkChangeDetector.ConnectionType.CONNECTION_WIFI)
119
+ return
120
+ }
121
+ restore()
122
+ downstream.onConnectionTypeChanged(connectionType)
123
+ }
124
+
125
+ override fun onNetworkConnect(networkInfo: NetworkChangeDetector.NetworkInformation) {
126
+ if (scoped() != null) {
127
+ synchronized(lock) { stock[networkInfo.handle] = networkInfo }
128
+ SoftApTrace.stage(
129
+ "webrtc_stock_connect_suppressed",
130
+ "name" to networkInfo.name,
131
+ "handle" to networkInfo.handle,
132
+ )
133
+ return
134
+ }
135
+ // Restore before recording, so this network is forwarded once below rather than also
136
+ // arriving in the replay.
137
+ restore()
138
+ synchronized(lock) { stock[networkInfo.handle] = networkInfo }
139
+ downstream.onNetworkConnect(networkInfo)
140
+ }
141
+
142
+ override fun onNetworkDisconnect(networkHandle: Long) {
143
+ if (scoped() != null) {
144
+ synchronized(lock) { stock.remove(networkHandle) }
145
+ return
146
+ }
147
+ restore()
148
+ synchronized(lock) { stock.remove(networkHandle) }
149
+ downstream.onNetworkDisconnect(networkHandle)
150
+ }
151
+
152
+ override fun onNetworkPreference(
153
+ types: List<NetworkChangeDetector.ConnectionType>,
154
+ preference: Int,
155
+ ) {
156
+ if (scoped() != null) return
157
+ restore()
158
+ downstream.onNetworkPreference(types, preference)
159
+ }
160
+ }