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

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 (106) hide show
  1. package/README.md +14 -3
  2. package/android/build.gradle +1 -0
  3. package/android/src/main/java/com/mentra/acsmeeting/AcsMeetingModule.kt +10 -10
  4. package/android/src/main/java/com/mentra/acsmeeting/AcsMeetingSession.kt +37 -37
  5. package/android/src/main/java/com/mentra/acsmeeting/audio/UplinkSender.kt +4 -4
  6. package/android/src/main/java/com/mentra/acsmeeting/source/MeetingVideoSourceSpec.kt +3 -0
  7. package/android/src/main/java/com/mentra/acsmeeting/video/AcsFrameSender.kt +12 -10
  8. package/android/src/main/java/com/mentra/acsmeeting/video/I420FormatSpec.kt +2 -0
  9. package/android/src/main/java/com/mentra/acsmeeting/video/Nv12Packer.kt +2 -0
  10. package/android/src/test/java/com/mentra/acsmeeting/source/{AcsInvestigationTest.kt → MediaDiagnosticsTest.kt} +10 -10
  11. package/android/src/test/java/com/mentra/acsmeeting/source/MeetingVideoSourceSpecTest.kt +3 -0
  12. package/android/src/test/java/com/mentra/acsmeeting/telemetry/VideoQualityTest.kt +2 -2
  13. package/android/src/test/java/com/mentra/acsmeeting/video/I420FormatSpecTest.kt +2 -0
  14. package/android/src/test/java/com/mentra/acsmeeting/video/VideoProfileTest.kt +2 -2
  15. package/build/AcsMeeting.types.d.ts +10 -1
  16. package/build/AcsMeeting.types.d.ts.map +1 -1
  17. package/build/AcsMeeting.types.js.map +1 -1
  18. package/build/AcsMeetingModule.d.ts +2 -2
  19. package/build/AcsMeetingModule.d.ts.map +1 -1
  20. package/build/AcsMeetingModule.js.map +1 -1
  21. package/ios/AcsMeeting.podspec +1 -1
  22. package/ios/AcsMeetingModule.swift +284 -148
  23. package/ios/PolicyKit/Package.swift +2 -1
  24. package/ios/PolicyKit/Sources/AcsAudioPolicy/CallbackOperation.swift +60 -0
  25. package/ios/PolicyKit/Sources/AcsAudioPolicy/CapturePolicy.swift +5 -0
  26. package/ios/PolicyKit/Tests/AcsAudioPolicyTests/AcsAudioPolicyTests.swift +1 -0
  27. package/ios/PolicyKit/Tests/AcsAudioPolicyTests/CallAgentRetirementTests.swift +113 -0
  28. package/package.json +3 -2
  29. package/src/AcsMeeting.types.ts +4 -1
  30. package/src/AcsMeetingModule.ts +2 -2
  31. package/android/src/androidTest/java/com/mentra/acsmeeting/network/SoftApFeasibilityGateTest.kt +0 -291
  32. package/android/src/main/java/com/mentra/acsmeeting/network/InternetHold.kt +0 -501
  33. package/android/src/main/java/com/mentra/acsmeeting/network/Ipv4Prefix.kt +0 -51
  34. package/android/src/main/java/com/mentra/acsmeeting/network/NetworkFacts.kt +0 -81
  35. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkChangeDetector.kt +0 -355
  36. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkError.kt +0 -109
  37. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkObserver.kt +0 -160
  38. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkReadiness.kt +0 -103
  39. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkRequestSpec.kt +0 -53
  40. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkState.kt +0 -127
  41. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedSoftApNetwork.kt +0 -573
  42. package/android/src/main/java/com/mentra/acsmeeting/source/BitmapFont.kt +0 -99
  43. package/android/src/main/java/com/mentra/acsmeeting/source/CloudflareWhepSource.kt +0 -487
  44. package/android/src/main/java/com/mentra/acsmeeting/source/DecodedTrackRelay.kt +0 -147
  45. package/android/src/main/java/com/mentra/acsmeeting/source/FirstFrameGate.kt +0 -45
  46. package/android/src/main/java/com/mentra/acsmeeting/source/GlassesMediaSource.kt +0 -143
  47. package/android/src/main/java/com/mentra/acsmeeting/source/LocalWhipIngestSource.kt +0 -772
  48. package/android/src/main/java/com/mentra/acsmeeting/source/MediaListeners.kt +0 -57
  49. package/android/src/main/java/com/mentra/acsmeeting/source/SdpAdapter.kt +0 -17
  50. package/android/src/main/java/com/mentra/acsmeeting/source/SelectedIcePair.kt +0 -139
  51. package/android/src/main/java/com/mentra/acsmeeting/source/SoftApIceDiagnosis.kt +0 -144
  52. package/android/src/main/java/com/mentra/acsmeeting/source/SoftApIcePolicy.kt +0 -46
  53. package/android/src/main/java/com/mentra/acsmeeting/source/SoftApSdpGuard.kt +0 -160
  54. package/android/src/main/java/com/mentra/acsmeeting/source/SyntheticFrameFactory.kt +0 -205
  55. package/android/src/main/java/com/mentra/acsmeeting/source/SyntheticI420Source.kt +0 -214
  56. package/android/src/main/java/com/mentra/acsmeeting/source/TrackRegistry.kt +0 -33
  57. package/android/src/main/java/com/mentra/acsmeeting/source/VideoSourceArm.kt +0 -110
  58. package/android/src/main/java/com/mentra/acsmeeting/source/WhipIngestProtocol.kt +0 -207
  59. package/android/src/main/java/com/mentra/acsmeeting/source/WhipIngestServer.kt +0 -356
  60. package/android/src/main/java/com/mentra/acsmeeting/telemetry/AvSyncProbe.kt +0 -155
  61. package/android/src/main/java/com/mentra/acsmeeting/telemetry/ChromaProbe.kt +0 -78
  62. package/android/src/main/java/com/mentra/acsmeeting/telemetry/PipelineStats.kt +0 -372
  63. package/android/src/main/java/com/mentra/acsmeeting/telemetry/PipelineTicker.kt +0 -79
  64. package/android/src/main/java/com/mentra/acsmeeting/telemetry/ProcessCpu.kt +0 -15
  65. package/android/src/main/java/com/mentra/acsmeeting/telemetry/RingPercentile.kt +0 -48
  66. package/android/src/main/java/com/mentra/acsmeeting/telemetry/VideoQuality.kt +0 -178
  67. package/android/src/main/java/com/mentra/acsmeeting/telemetry/VideoRateVerdict.kt +0 -145
  68. package/android/src/main/java/com/mentra/acsmeeting/trace/SoftApTrace.kt +0 -143
  69. package/android/src/main/java/com/mentra/acsmeeting/video/FrameGeometry.kt +0 -22
  70. package/android/src/main/java/com/mentra/acsmeeting/video/I420Packer.kt +0 -72
  71. package/android/src/test/java/com/mentra/acsmeeting/network/InternetHoldLifecycleTest.kt +0 -102
  72. package/android/src/test/java/com/mentra/acsmeeting/network/InternetHoldTest.kt +0 -148
  73. package/android/src/test/java/com/mentra/acsmeeting/network/Ipv4PrefixTest.kt +0 -59
  74. package/android/src/test/java/com/mentra/acsmeeting/network/NetworkFactsTest.kt +0 -59
  75. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedLocalNetworkPermissionTest.kt +0 -35
  76. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkChangeDetectorTest.kt +0 -165
  77. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkErrorTest.kt +0 -96
  78. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkLifecycleTest.kt +0 -187
  79. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkObserverTest.kt +0 -172
  80. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkReadinessTest.kt +0 -141
  81. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkRequestSpecTest.kt +0 -69
  82. package/android/src/test/java/com/mentra/acsmeeting/source/BitmapFontTest.kt +0 -22
  83. package/android/src/test/java/com/mentra/acsmeeting/source/DecodedTrackRelayTest.kt +0 -41
  84. package/android/src/test/java/com/mentra/acsmeeting/source/FirstFrameGateTest.kt +0 -60
  85. package/android/src/test/java/com/mentra/acsmeeting/source/GlassesMediaSourceTest.kt +0 -151
  86. package/android/src/test/java/com/mentra/acsmeeting/source/I420PlanesTest.kt +0 -67
  87. package/android/src/test/java/com/mentra/acsmeeting/source/SelectedIcePairTest.kt +0 -165
  88. package/android/src/test/java/com/mentra/acsmeeting/source/SoftApIceDiagnosisTest.kt +0 -148
  89. package/android/src/test/java/com/mentra/acsmeeting/source/SoftApIcePolicyTest.kt +0 -76
  90. package/android/src/test/java/com/mentra/acsmeeting/source/SoftApSdpGuardTest.kt +0 -275
  91. package/android/src/test/java/com/mentra/acsmeeting/source/SyntheticFrameFactoryTest.kt +0 -193
  92. package/android/src/test/java/com/mentra/acsmeeting/source/SyntheticI420SourceTest.kt +0 -176
  93. package/android/src/test/java/com/mentra/acsmeeting/source/TrackRegistryTest.kt +0 -45
  94. package/android/src/test/java/com/mentra/acsmeeting/source/WhipIngestProtocolTest.kt +0 -356
  95. package/android/src/test/java/com/mentra/acsmeeting/source/WhipIngestServerLoopbackTest.kt +0 -411
  96. package/android/src/test/java/com/mentra/acsmeeting/telemetry/AvSyncProbeTest.kt +0 -83
  97. package/android/src/test/java/com/mentra/acsmeeting/telemetry/ChromaProbeTest.kt +0 -61
  98. package/android/src/test/java/com/mentra/acsmeeting/telemetry/PipelineStatsTest.kt +0 -322
  99. package/android/src/test/java/com/mentra/acsmeeting/telemetry/ProcessCpuTest.kt +0 -18
  100. package/android/src/test/java/com/mentra/acsmeeting/telemetry/RingPercentileTest.kt +0 -49
  101. package/android/src/test/java/com/mentra/acsmeeting/telemetry/VideoRateVerdictTest.kt +0 -149
  102. package/android/src/test/java/com/mentra/acsmeeting/trace/SoftApTraceTest.kt +0 -97
  103. package/android/src/test/java/com/mentra/acsmeeting/video/FrameGeometryTest.kt +0 -33
  104. package/android/src/test/java/com/mentra/acsmeeting/video/I420PackerTest.kt +0 -126
  105. package/ios/PolicyKit/Sources/AcsAudioPolicy/GlassesMediaSource.swift +0 -68
  106. package/ios/WhepVideoSource.swift +0 -356
@@ -1,501 +0,0 @@
1
- package com.mentra.acsmeeting.network
2
-
3
- import android.content.Context
4
- import android.net.ConnectivityManager
5
- import android.net.LinkProperties
6
- import android.net.Network
7
- import android.net.NetworkCapabilities
8
- import android.net.NetworkRequest
9
- import android.net.wifi.WifiManager
10
- import android.os.SystemClock
11
- import com.mentra.acsmeeting.trace.SoftApTrace
12
- import java.net.Inet4Address
13
- import java.util.concurrent.CountDownLatch
14
- import java.util.concurrent.TimeUnit
15
-
16
- /**
17
- * Keeps the phone's internet on cellular across the hotspot join, and says so honestly.
18
- *
19
- * Joining the glasses' SoftAP takes the phone off office Wi-Fi. Cellular becomes the default route,
20
- * and on device it was not yet validated when that happened: `scoped_network_available` at 18:04:57
21
- * was followed by a 30 s gap before ACS even began its native join, then a 40 s step timeout, with
22
- * device-wide DNS and TLS deadline failures throughout. ACS needs the internet during that window,
23
- * so the transition has to be deliberate rather than hoped for.
24
- *
25
- * ## What a request does and does not buy
26
- *
27
- * `requestNetwork(CELLULAR + INTERNET)` asks Android to bring cellular up and keeps it up while the
28
- * request is held. It does **not** guarantee the network is validated, and it does not switch the
29
- * default route on any particular schedule. So this class does two separate things, and neither is
30
- * inferred from the other:
31
- *
32
- * - [awaitValidatedCellular] holds the request and waits for `NET_CAPABILITY_VALIDATED` on the
33
- * cellular network itself. Run *before* leaving Wi-Fi, so a phone with no working cellular fails
34
- * with a reason instead of stranding ACS on a hotspot with no internet.
35
- * - [awaitValidatedDefault] asks what the app's actual default network is now, and whether it is
36
- * validated. Run *after* the hotspot join, because that is when the default can change.
37
- *
38
- * Holding is not the same as *using*, which is what [bindProcessToCellular] is for, and why that one
39
- * has a deliberately short leash.
40
- *
41
- * Bounded, released on every exit, and idempotent: teardown calls [release] without checking.
42
- */
43
- class InternetHold(private val context: Context) {
44
-
45
- /** The app's default network as the framework currently reports it. */
46
- data class DefaultNetwork(
47
- val transport: String,
48
- val validated: Boolean,
49
- val present: Boolean,
50
- /** The leftover glasses AP from a SoftAP call — local only, no path to Teams. */
51
- val glassesHotspot: Boolean = false,
52
- ) {
53
- /** Whether ACS (or the next `teams:create`) can be expected to reach the internet over this. */
54
- val usable: Boolean
55
- get() = present && validated && !glassesHotspot
56
-
57
- override fun toString(): String {
58
- if (!present) return "none"
59
- val state = if (validated) "validated" else "unvalidated"
60
- return if (glassesHotspot) "$transport ($state, leftover-hotspot)" else "$transport ($state)"
61
- }
62
- }
63
-
64
- /** Outcome of holding cellular up. [validated] is the only field worth branching on. */
65
- data class CellularHold(val held: Boolean, val validated: Boolean, val waitedMs: Long)
66
-
67
- private val lock = Any()
68
- private var callback: ConnectivityManager.NetworkCallback? = null
69
- private var closed = false
70
- private var processPinned = false
71
- private var pinnedNetwork: Network? = null
72
-
73
- /**
74
- * Request cellular and wait until it validates, bounded by [timeoutMs].
75
- *
76
- * The request stays held afterwards — including when validation times out — so that a caller
77
- * which chooses to continue anyway does not also lose the radio. [release] is the only thing
78
- * that drops it.
79
- */
80
- fun awaitValidatedCellular(timeoutMs: Long = CELLULAR_WAIT_MS): CellularHold {
81
- val manager = connectivityManager()
82
- if (manager == null) {
83
- SoftApTrace.failure("cellular_hold_requested", "reason" to "no connectivity manager")
84
- return CellularHold(false, false, 0)
85
- }
86
- val validated = CountDownLatch(1)
87
- val startedAt = SystemClock.elapsedRealtime()
88
- // Entry as well as outcome: this blocks for up to [timeoutMs] before anything else in the
89
- // join happens, so a wearer's "nothing happened for fifteen seconds" needs a line here.
90
- SoftApTrace.stage(
91
- "cellular_hold_requested",
92
- "timeoutMs" to timeoutMs,
93
- "pinned" to synchronized(lock) { processPinned },
94
- "default" to defaultNetwork().toString(),
95
- )
96
-
97
- val watcher =
98
- object : ConnectivityManager.NetworkCallback() {
99
- override fun onCapabilitiesChanged(
100
- network: Network,
101
- capabilities: NetworkCapabilities,
102
- ) {
103
- // Validation is a capability change, not an availability one: `onAvailable`
104
- // fires for a cellular network that cannot yet carry a TLS handshake.
105
- if (isValidatedInternet(capabilities)) {
106
- validated.countDown()
107
- synchronized(lock) {
108
- // Mobile data can return as a different Network. Retire the old pin
109
- // without allowing a released callback to bind the next call.
110
- if (callback === this && processPinned && pinnedNetwork != network) {
111
- val previous = pinnedNetwork?.toString() ?: "none"
112
- val ok = manager.bindProcessToNetwork(network)
113
- if (ok) pinnedNetwork = network
114
- SoftApTrace.stage(
115
- "cellular_pin_refreshed",
116
- "ok" to ok,
117
- "network" to network,
118
- "replaced" to previous,
119
- )
120
- }
121
- }
122
- }
123
- }
124
-
125
- override fun onLost(network: Network) {
126
- synchronized(lock) {
127
- if (callback !== this || !processPinned || pinnedNetwork != network) return
128
- // Keep the intent to pin when cellular validates again, but do not leave
129
- // the entire app bound to a dead network in the meantime.
130
- manager.bindProcessToNetwork(null)
131
- pinnedNetwork = null
132
- // The intent to pin survives, so this is not a leak — but the process is
133
- // unbound from here until cellular validates again, and that window is
134
- // where an unbound socket can land on the glasses AP.
135
- SoftApTrace.stage("cellular_pin_lost", "network" to network)
136
- }
137
- }
138
- }
139
-
140
- val request =
141
- NetworkRequest.Builder()
142
- .addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
143
- .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
144
- .build()
145
-
146
- val held =
147
- runCatching {
148
- synchronized(lock) {
149
- if (closed) return CellularHold(false, false, 0)
150
- releaseLocked(reason = "re-requesting cellular")
151
- manager.requestNetwork(request, watcher)
152
- callback = watcher
153
- }
154
- }.isSuccess
155
- if (!held) {
156
- SoftApTrace.failure("cellular_validated", "held" to false, "reason" to "requestNetwork threw")
157
- return CellularHold(false, false, 0)
158
- }
159
-
160
- val ok = validated.await(timeoutMs, TimeUnit.MILLISECONDS)
161
- val waitedMs = SystemClock.elapsedRealtime() - startedAt
162
- // A network that was already validated before the request may never produce a callback we
163
- // see, so the current state is the tiebreak rather than the latch alone.
164
- val resolved = ok || cellularIsValidated(manager)
165
- SoftApTrace.stage(
166
- "cellular_validated",
167
- "held" to true,
168
- "validated" to resolved,
169
- "viaCallback" to ok,
170
- "waitedMs" to waitedMs,
171
- )
172
- // Re-pin: releaseLocked above dropped any existing pin, and the caller is about to leave
173
- // Wi-Fi. The one join that ever reached CONNECTED was pinned from here onwards, i.e. across
174
- // the hotspot join itself, not from just before the Teams join.
175
- synchronized(lock) {
176
- // Module destruction or a newer request can overtake the wait above. A retired
177
- // request must never pin the process again, even if its late callback validated.
178
- if (closed || callback !== watcher) return CellularHold(false, false, waitedMs)
179
- if (resolved) bindProcessToCellular()
180
- return CellularHold(true, resolved, waitedMs)
181
- }
182
- }
183
-
184
- /**
185
- * Pin this process's unbound sockets onto validated cellular. Returns whether it took.
186
- *
187
- * Being *on* an internet-less hotspot is not the same as Android routing around it. With the
188
- * scoped SoftAP joined, `getActiveNetwork` reports validated cellular and unbound sockets still
189
- * fail: ACS sat in CONNECTING for 91 s and gave up with code 408, and the app's own cloud
190
- * websocket dropped mid-call. The one join that reached CONNECTED — in under four seconds — was
191
- * pinned continuously from before the hotspot join.
192
- *
193
- * Pinning does not make ACS *fast*: a cold `createCallAgent` measured 30 s pinned to cellular,
194
- * about the same as unpinned. It decides whether the call connects at all, not how quickly.
195
- *
196
- * ## Hold it continuously; open one hole
197
- *
198
- * This marks *every* socket the process opens while it is in effect, and `Network.bindSocket`
199
- * has no `ServerSocket` overload to undo it. A WHIP listener opened while pinned accepted on
200
- * `192.168.43.79` but answered out the radio, so the glasses' SYN drew no reply at all and
201
- * publish died on a 10 s connect timeout.
202
- *
203
- * Pinning late — after the hotspot join, just before the Teams join — does not work either:
204
- * sockets keep the mark they were created with, so ACS re-binds its signalling on the broken
205
- * route in the unpinned window and never recovers. So the pin is held from sign-in through
206
- * teardown, and [unbindProcess] opens a hole only for the moment the WHIP `ServerSocket` is
207
- * created. ICE is unaffected throughout: libwebrtc re-marks its own sockets onto the scoped
208
- * network handle that `ScopedNetworkChangeDetector.toNetworkInformation` publishes.
209
- *
210
- * Anything that pins must unpin — via [unbindProcess], or via [release] on teardown.
211
- */
212
- fun bindProcessToCellular(): Boolean = synchronized(lock) {
213
- if (closed) return@synchronized false
214
- val manager = connectivityManager()
215
- if (manager == null) {
216
- SoftApTrace.failure("process_pinned_to_cellular", "ok" to false, "reason" to "no connectivity manager")
217
- return@synchronized false
218
- }
219
- val network = findValidatedCellular(manager)
220
- if (network == null) {
221
- SoftApTrace.failure("process_pinned_to_cellular", "ok" to false, "reason" to "no validated cellular")
222
- return@synchronized false
223
- }
224
- val alreadyPinned = synchronized(lock) { pinnedNetwork }
225
- val ok = runCatching { manager.bindProcessToNetwork(network) }.getOrDefault(false)
226
- if (ok) synchronized(lock) {
227
- processPinned = true
228
- pinnedNetwork = network
229
- }
230
- // Which network, not just that one was taken. Mobile data comes back as a different
231
- // [Network] after a hotspot join, and a pin on the previous handle is indistinguishable
232
- // from a working one until ACS times out on a route that no longer exists.
233
- SoftApTrace.stage(
234
- "process_pinned_to_cellular",
235
- "ok" to ok,
236
- "network" to network,
237
- "replaced" to (alreadyPinned?.toString() ?: "none"),
238
- )
239
- return@synchronized ok
240
- }
241
-
242
- /** Undo [bindProcessToCellular]. Safe to call when nothing is pinned, and safe to call twice. */
243
- fun unbindProcess() = synchronized(lock) {
244
- if (closed) return@synchronized
245
- val previous = synchronized(lock) { pinnedNetwork }
246
- val pinned = synchronized(lock) {
247
- processPinned.also {
248
- processPinned = false
249
- pinnedNetwork = null
250
- }
251
- }
252
- if (!pinned) {
253
- // The WHIP bind lifts the pin around itself, so "nothing was pinned" here means the
254
- // sign-in never took one — which is the state in which that ServerSocket is fine and
255
- // ACS's own sockets are not. Different bug, same-looking log without this line.
256
- SoftApTrace.stage("process_unpinned", "ok" to true, "reason" to "nothing pinned")
257
- return@synchronized
258
- }
259
- val ok = runCatching { connectivityManager()?.bindProcessToNetwork(null) }.isSuccess
260
- // The default route the process falls back onto is the whole risk of unpinning: a leftover
261
- // glasses AP here is what made the next `teams:create` fail with no internet.
262
- SoftApTrace.stage(
263
- "process_unpinned",
264
- "ok" to ok,
265
- "released" to (previous?.toString() ?: "unknown"),
266
- "default" to defaultNetwork().toString(),
267
- )
268
- }
269
-
270
- /**
271
- * Poll the app's default network until it is validated, bounded by [timeoutMs].
272
- *
273
- * Polled rather than awaited: what matters is which network *this app's* unbound sockets use,
274
- * and that is `activeNetwork`, which has no per-app change callback.
275
- */
276
- fun awaitValidatedDefault(timeoutMs: Long = DEFAULT_WAIT_MS): DefaultNetwork {
277
- val deadline = SystemClock.elapsedRealtime() + timeoutMs
278
- var current = defaultNetwork()
279
- while (!current.usable && SystemClock.elapsedRealtime() < deadline) {
280
- Thread.sleep(DEFAULT_POLL_MS)
281
- current = defaultNetwork()
282
- }
283
- val fields = arrayOf(
284
- "transport" to current.transport,
285
- "validated" to current.validated,
286
- "present" to current.present,
287
- "glassesHotspot" to current.glassesHotspot,
288
- )
289
- // Error level when unusable so it survives a level filter: this is the line that explains an
290
- // ACS join failing on a hotspot that was itself fine.
291
- if (current.usable) {
292
- SoftApTrace.stage("default_network_after_join", *fields)
293
- } else {
294
- SoftApTrace.failure("default_network_after_join", *fields)
295
- }
296
- return current
297
- }
298
-
299
- /** The app's default network right now, without waiting. */
300
- fun defaultNetwork(): DefaultNetwork {
301
- val manager = connectivityManager() ?: return DefaultNetwork("unknown", false, false)
302
- val active = manager.activeNetwork ?: return DefaultNetwork("none", false, false)
303
- val capabilities =
304
- manager.getNetworkCapabilities(active)
305
- ?: return DefaultNetwork("unknown", false, true)
306
- val transport = transportName(capabilities)
307
- val glassesHotspot =
308
- transport == "wifi" && isGlassesHotspotDefault(active, manager.getLinkProperties(active))
309
- return DefaultNetwork(
310
- transport,
311
- capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED),
312
- true,
313
- glassesHotspot,
314
- )
315
- }
316
-
317
- private fun isGlassesHotspotDefault(network: Network, links: LinkProperties?): Boolean {
318
- val ssid = currentWifiSsid()
319
- if (isGlassesHotspotSsid(ssid)) {
320
- SoftApTrace.stage("default_is_glasses_hotspot", "ssid" to ssid, "network" to network)
321
- return true
322
- }
323
- val softAp = links?.linkAddresses?.any { address ->
324
- val ip = address.address
325
- ip is Inet4Address && isAndroidSoftApIpv4(ip.address)
326
- } == true
327
- if (softAp) {
328
- SoftApTrace.stage(
329
- "default_is_glasses_hotspot",
330
- "ssid" to ssid.ifEmpty { "unknown" },
331
- "reason" to "192.168.43.0/24",
332
- "network" to network,
333
- )
334
- }
335
- return softAp
336
- }
337
-
338
- private fun currentWifiSsid(): String {
339
- val raw =
340
- runCatching {
341
- context.applicationContext.getSystemService(WifiManager::class.java)?.connectionInfo?.ssid
342
- }.getOrNull()
343
- .orEmpty()
344
- return raw.trim().trim('"')
345
- }
346
-
347
- /**
348
- * Drop the cellular pin only once the phone has a validated default internet of its own.
349
- *
350
- * Cancelling while the glasses hotspot is coming up used to unpin immediately. The AP was
351
- * still broadcasting, Android treated it as the default Wi-Fi, and the next `teams:create`
352
- * left the process over a network with no internet — which the wearer saw as
353
- * `Request failed (503)` from Cloudflare, not as a hotspot problem.
354
- *
355
- * If the default route is still the leftover AP when the wait expires, the pin stays. The
356
- * next sign-in refreshes it; unpinning onto SoftAP is the one thing we must not do.
357
- */
358
- fun releaseWhenDefaultInternetReady(timeoutMs: Long = DEFAULT_WAIT_MS) {
359
- val startedAt = SystemClock.elapsedRealtime()
360
- SoftApTrace.stage(
361
- "cellular_hold_release_requested",
362
- "timeoutMs" to timeoutMs,
363
- "pinned" to synchronized(lock) { processPinned },
364
- )
365
- val network = awaitValidatedDefault(timeoutMs)
366
- if (network.usable) {
367
- release()
368
- SoftApTrace.stage(
369
- "cellular_hold_release_done",
370
- "waitedMs" to (SystemClock.elapsedRealtime() - startedAt),
371
- "default" to network.toString(),
372
- )
373
- return
374
- }
375
- // Re-pin: a previous `leave()` may already have dropped the bind onto leftover Wi-Fi, and
376
- // keeping a dead hold is not enough — the next `teams:create` still leaves through SoftAP
377
- // and Cloudflare answers 503.
378
- bindProcessToCellular()
379
- SoftApTrace.failure(
380
- "cellular_hold_kept",
381
- "reason" to "default network is not usable internet; unpinning would strand the next request on SoftAP",
382
- "transport" to network.transport,
383
- "validated" to network.validated,
384
- "glassesHotspot" to network.glassesHotspot,
385
- )
386
- }
387
-
388
- /** Permanently retire a module-owned hold, including requests still waiting for validation. */
389
- fun close() {
390
- synchronized(lock) {
391
- closed = true
392
- releaseLocked(reason = "owner destroyed")
393
- }
394
- }
395
-
396
- /** Drop the cellular request. Safe to call when nothing is held, and safe to call twice. */
397
- fun release() {
398
- synchronized(lock) { releaseLocked() }
399
- }
400
-
401
- /**
402
- * @param reason why the hold is being dropped. `teardown` means a pin found here outlived the
403
- * code that took it and is reported as a leak; a re-request drops the pin on purpose, and
404
- * calling both the same thing would cry wolf on every second sign-in.
405
- */
406
- private fun releaseLocked(reason: String = "teardown") {
407
- // Backstop only: [bindProcessToCellular]'s caller unpins in a `finally`. Leaving the process
408
- // pinned across a teardown would break the *next* call's WHIP listener, not this one's, which
409
- // is exactly the kind of failure that is impossible to read from a log.
410
- if (processPinned) {
411
- val dropped = pinnedNetwork?.toString() ?: "unknown"
412
- runCatching { connectivityManager()?.bindProcessToNetwork(null) }
413
- processPinned = false
414
- pinnedNetwork = null
415
- if (reason == "teardown") {
416
- // Reaching the backstop means the pin outlived the code that took it. That is the
417
- // leak this class is most afraid of, so it is reported as a failure even though it
418
- // has just been repaired: the next call would have inherited it.
419
- SoftApTrace.failure("cellular_pin_leaked", "network" to dropped)
420
- } else {
421
- SoftApTrace.stage("cellular_pin_dropped", "network" to dropped, "reason" to reason)
422
- }
423
- }
424
- val active = callback
425
- if (active != null) {
426
- runCatching { connectivityManager()?.unregisterNetworkCallback(active) }
427
- }
428
- callback = null
429
- if (active != null) SoftApTrace.stage("cellular_hold_released")
430
- }
431
-
432
- private fun cellularIsValidated(manager: ConnectivityManager): Boolean =
433
- findValidatedCellular(manager) != null
434
-
435
- private fun findValidatedCellular(manager: ConnectivityManager): Network? =
436
- manager.allNetworks.firstOrNull { network ->
437
- val capabilities = manager.getNetworkCapabilities(network) ?: return@firstOrNull false
438
- capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) &&
439
- isValidatedInternet(capabilities)
440
- }
441
-
442
- private fun connectivityManager(): ConnectivityManager? =
443
- context.applicationContext.getSystemService(ConnectivityManager::class.java)
444
-
445
- companion object {
446
- /** Generous: failing here aborts a call the wearer asked for, on a phone that may be indoors. */
447
- const val CELLULAR_WAIT_MS = 15_000L
448
-
449
- /** Short: the default route normally settles in well under a second after the scoped join. */
450
- const val DEFAULT_WAIT_MS = 8_000L
451
-
452
- private const val DEFAULT_POLL_MS = 200L
453
-
454
- fun isValidatedInternet(capabilities: NetworkCapabilities): Boolean =
455
- capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
456
- capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
457
-
458
- /** For the trace only. Nothing routes by this name. */
459
- fun transportName(capabilities: NetworkCapabilities): String =
460
- transportNameOf(
461
- cellular = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR),
462
- wifi = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI),
463
- vpn = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN),
464
- ethernet = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET),
465
- )
466
-
467
- /**
468
- * VPN first: when a tunnel owns the app's UID it is the transport that decides whether ACS
469
- * reaches the internet, and it stacks on top of the others rather than replacing them.
470
- */
471
- fun transportNameOf(
472
- cellular: Boolean,
473
- wifi: Boolean,
474
- vpn: Boolean,
475
- ethernet: Boolean,
476
- ): String =
477
- when {
478
- vpn -> "vpn"
479
- cellular -> "cellular"
480
- wifi -> "wifi"
481
- ethernet -> "ethernet"
482
- else -> "other"
483
- }
484
-
485
- /**
486
- * Mentra Live SoftAP SSIDs look like `MentraLive_15f63c`. Android quotes them and, without
487
- * location permission, reports `<unknown ssid>` instead — so SSID alone is not enough.
488
- */
489
- fun isGlassesHotspotSsid(ssid: String): Boolean {
490
- val normalized = ssid.trim().trim('"')
491
- return normalized.startsWith("MentraLive", ignoreCase = true)
492
- }
493
-
494
- /** Android SoftAP's usual IPv4 LAN. Location-less `<unknown ssid>` still lands here. */
495
- fun isAndroidSoftApIpv4(bytes: ByteArray): Boolean =
496
- bytes.size == 4 &&
497
- (bytes[0].toInt() and 0xff) == 192 &&
498
- (bytes[1].toInt() and 0xff) == 168 &&
499
- (bytes[2].toInt() and 0xff) == 43
500
- }
501
- }
@@ -1,51 +0,0 @@
1
- package com.mentra.acsmeeting.network
2
-
3
- /**
4
- * An IPv4 subnet, taken from the phone's own address on a joined network.
5
- *
6
- * SoftAP correctness is an interface question, not an address-range question: the media path is
7
- * right when the selected candidate sits on the network we joined, and wrong otherwise. Matching
8
- * "looks RFC1918" or "starts with 192.168.43" answers a different question and stops being true the
9
- * moment an OEM picks another hotspot subnet, so every SoftAP check compares against the prefix
10
- * `LinkProperties` actually reported.
11
- */
12
- data class Ipv4Prefix(val address: String, val prefixLength: Int) {
13
-
14
- private val network: Int? = packIpv4(address)?.let { it and maskFor(prefixLength) }
15
-
16
- /** True when [candidate] is a dotted-quad IPv4 address inside this prefix. */
17
- fun contains(candidate: String?): Boolean {
18
- val base = network ?: return false
19
- val value = packIpv4(candidate) ?: return false
20
- return (value and maskFor(prefixLength)) == base
21
- }
22
-
23
- override fun toString(): String = "$address/$prefixLength"
24
-
25
- companion object {
26
- /**
27
- * Dotted-quad to a packed int, or null when [token] is not one. Deliberately strict: a
28
- * token like `192.168.43` or `candidate:1` must not be read as an address, because the
29
- * callers scan whitespace-split SDP lines where most tokens are not addresses at all.
30
- */
31
- fun packIpv4(token: String?): Int? {
32
- val text = token ?: return null
33
- val octets = text.split('.')
34
- if (octets.size != 4) return null
35
- var packed = 0
36
- for (octet in octets) {
37
- if (octet.isEmpty() || octet.length > 3) return null
38
- val value = octet.toIntOrNull() ?: return null
39
- if (value < 0 || value > 255) return null
40
- packed = (packed shl 8) or value
41
- }
42
- return packed
43
- }
44
-
45
- /** A /0 masks nothing; Kotlin's `shl 32` is a no-op, so that case is spelled out. */
46
- private fun maskFor(prefixLength: Int): Int {
47
- val bits = prefixLength.coerceIn(0, 32)
48
- return if (bits == 0) 0 else (-1 shl (32 - bits))
49
- }
50
- }
51
- }
@@ -1,81 +0,0 @@
1
- package com.mentra.acsmeeting.network
2
-
3
- import java.net.Inet4Address
4
- import java.net.NetworkInterface
5
-
6
- /**
7
- * The kernel's interface table, as libwebrtc's `getifaddrs` sees it.
8
- *
9
- * Every SoftAP ICE failure so far has come from our view of the network disagreeing with
10
- * libwebrtc's, and the two views come from different sources. We publish
11
- * [ConnectivityManager.getLinkProperties][android.net.ConnectivityManager.getLinkProperties] for
12
- * the scoped `Network`; libwebrtc enumerates `getifaddrs` and only asks our monitor for each
13
- * *interface name*'s adapter type. When those disagree — an address on a name we never typed, or a
14
- * scoped address absent from the table entirely — no mask and no inventory can help, because ICE
15
- * gathers per address, not per `Network`.
16
- *
17
- * `java.net.NetworkInterface` is getifaddrs-backed, so this is the closest thing to libwebrtc's
18
- * own input that we can read from Kotlin. Pure enough to unit test, which matters: it is the
19
- * evidence the next diagnosis rests on.
20
- */
21
- object NetworkFacts {
22
-
23
- /** One interface, with the flags libwebrtc's enumeration also keys off. */
24
- data class Iface(
25
- val name: String,
26
- val index: Int,
27
- val up: Boolean,
28
- val loopback: Boolean,
29
- val virtual: Boolean,
30
- val pointToPoint: Boolean,
31
- val mtu: Int,
32
- /** IPv4 only, as `address/prefixLength` — the form libwebrtc turns into a Network key. */
33
- val ipv4: List<String>,
34
- )
35
-
36
- /** Read the table. Never throws: a diagnostic that crashes the call is worse than no data. */
37
- fun snapshot(): List<Iface> =
38
- runCatching {
39
- NetworkInterface.getNetworkInterfaces()?.toList().orEmpty().map { candidate ->
40
- Iface(
41
- name = candidate.name ?: "?",
42
- index = runCatching { candidate.index }.getOrDefault(-1),
43
- up = runCatching { candidate.isUp }.getOrDefault(false),
44
- loopback = runCatching { candidate.isLoopback }.getOrDefault(false),
45
- virtual = runCatching { candidate.isVirtual }.getOrDefault(false),
46
- pointToPoint = runCatching { candidate.isPointToPoint }.getOrDefault(false),
47
- mtu = runCatching { candidate.mtu }.getOrDefault(-1),
48
- ipv4 =
49
- runCatching {
50
- candidate.interfaceAddresses
51
- .filter { it.address is Inet4Address }
52
- .map { "${it.address.hostAddress}/${it.networkPrefixLength}" }
53
- }.getOrDefault(emptyList()),
54
- )
55
- }
56
- }.getOrElse { emptyList() }
57
-
58
- /**
59
- * Only interfaces that can carry a host candidate: up, non-loopback, with an IPv4 address.
60
- * Anything here is something ICE may gather on.
61
- */
62
- fun gatherable(table: List<Iface> = snapshot()): List<Iface> =
63
- table.filter { it.up && !it.loopback && it.ipv4.isNotEmpty() }
64
-
65
- /** The interface the kernel says owns [address], or null when the table has never seen it. */
66
- fun ownerOf(address: String, table: List<Iface> = snapshot()): String? =
67
- table.firstOrNull { iface -> iface.ipv4.any { it.substringBefore('/') == address } }?.name
68
-
69
- /** One log line per interface, flags abbreviated so a full table fits in a logcat entry. */
70
- fun render(table: List<Iface>): String =
71
- table.joinToString(" ") { iface ->
72
- val flags =
73
- buildString {
74
- if (iface.up) append("U")
75
- if (iface.loopback) append("L")
76
- if (iface.virtual) append("V")
77
- if (iface.pointToPoint) append("P")
78
- }
79
- "${iface.name}#${iface.index}[$flags mtu=${iface.mtu}](${iface.ipv4.joinToString(",")})"
80
- }
81
- }