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

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,573 +0,0 @@
1
- package com.mentra.acsmeeting.network
2
-
3
- import android.content.Context
4
- import android.content.pm.PackageManager
5
- import android.net.ConnectivityManager
6
- import android.net.LinkProperties
7
- import android.net.Network
8
- import android.net.NetworkCapabilities
9
- import android.net.NetworkRequest
10
- import android.net.wifi.WifiManager
11
- import android.net.wifi.WifiNetworkSpecifier
12
- import android.os.Build
13
- import com.mentra.acsmeeting.trace.SoftApTrace
14
- import java.net.Inet4Address
15
- import java.util.concurrent.CountDownLatch
16
- import java.util.concurrent.TimeUnit
17
-
18
- /**
19
- * Joins the glasses' SoftAP as a scoped, internet-less network and keeps the [Network] handle
20
- * in-process.
21
- *
22
- * ## Why this is not reused from the Bluetooth SDK
23
- *
24
- * `MentraLocalNetworkModule` in `bluetooth-sdk` already holds a scoped `Network` for the same
25
- * hotspot, but it only exposes `HttpURLConnection`. A `Network` object cannot cross the JS bridge,
26
- * and `acs-meeting` has no Gradle edge onto `bluetooth-sdk` — adding one would couple this module to
27
- * the *published public* SDK and force an internal registry to be exported from it.
28
- *
29
- * Owning the join here instead puts the `Network` handle in the same module and lifecycle as the
30
- * `PeerConnectionFactory`, which is precisely what the libwebrtc interface-agreement problem needs:
31
- * the ingest source has to bind its sockets to the same network object this class produced. The
32
- * duplication with the Bluetooth SDK is deliberate.
33
- *
34
- * ## Dual-homing
35
- *
36
- * The request drops `NET_CAPABILITY_INTERNET` (see [ScopedNetworkRequestSpec]), so Android never
37
- * promotes the hotspot to the default network and ACS keeps using cellular.
38
- */
39
- class ScopedSoftApNetwork(private val context: Context) {
40
-
41
- /** Notified on the ConnectivityManager callback thread. */
42
- interface Listener {
43
- fun onAvailable(network: Network, localIpv4: String)
44
-
45
- fun onLost(error: ScopedNetworkError)
46
- }
47
-
48
- private val lock = Any()
49
- private val state = ScopedNetworkState()
50
- private val readiness = ScopedNetworkReadiness()
51
-
52
- private var callback: ConnectivityManager.NetworkCallback? = null
53
- private var network: Network? = null
54
- private var localIpv4: String? = null
55
- private var spec: ScopedNetworkRequestSpec? = null
56
- private var listener: Listener? = null
57
-
58
- /** The joined network, or null. Pass this to anything that must send over the SoftAP link. */
59
- fun network(): Network? = synchronized(lock) { network }
60
-
61
- /** This phone's address on the hotspot subnet, e.g. `192.168.43.20`. */
62
- fun localIpv4(): String? = synchronized(lock) { localIpv4 }
63
-
64
- /**
65
- * The subnet this phone joined, straight from `LinkProperties`.
66
- *
67
- * This is the invariant every SoftAP media check is written against: the selected ICE candidate
68
- * has to be on *this* prefix, not merely on some private range. Reading the prefix length rather
69
- * than assuming /24 is what keeps the check true when an OEM hands out a different hotspot
70
- * subnet.
71
- */
72
- fun scopedPrefix(): Ipv4Prefix? {
73
- val net = network() ?: return null
74
- val properties = connectivityManager().getLinkProperties(net) ?: return null
75
- return properties.linkAddresses
76
- .firstOrNull { it.address is Inet4Address }
77
- ?.let { link -> link.address.hostAddress?.let { Ipv4Prefix(it, link.prefixLength) } }
78
- }
79
-
80
- fun isAvailable(): Boolean = synchronized(lock) { state.phase == ScopedNetworkState.Phase.AVAILABLE }
81
-
82
- /** Station radio on? `WifiNetworkSpecifier` is a no-op while this is false. */
83
- fun isWifiEnabled(): Boolean {
84
- val wifi = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager
85
- return wifi?.isWifiEnabled == true
86
- }
87
-
88
- /**
89
- * Block until the station radio is on, or [timeoutMs] elapses. Used after the system Wi-Fi
90
- * panel is shown: the join must wait for the user, not abort and remint ACS over a broken
91
- * default route.
92
- */
93
- fun awaitWifiEnabled(timeoutMs: Long = WIFI_ENABLE_WAIT_MS): Boolean {
94
- if (isWifiEnabled()) return true
95
- val deadline = android.os.SystemClock.elapsedRealtime() + timeoutMs
96
- while (android.os.SystemClock.elapsedRealtime() < deadline) {
97
- Thread.sleep(WIFI_ENABLE_POLL_MS)
98
- if (isWifiEnabled()) return true
99
- }
100
- return isWifiEnabled()
101
- }
102
-
103
- /**
104
- * True when a VPN is this app's default network — i.e. our UID falls inside its captured
105
- * ranges. A VPN that excludes us (split tunneling) or is simply installed does not count: the
106
- * default network is then cellular/Wi-Fi and the hotspot path works.
107
- */
108
- fun isVpnCapturingApp(): Boolean {
109
- val manager = connectivityManager()
110
- val active = manager.activeNetwork ?: return false
111
- val capabilities = manager.getNetworkCapabilities(active) ?: return false
112
- return capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN)
113
- }
114
-
115
- /** Verdict of [probeGateway]: [detail] is one human-readable line for the UI and the trace. */
116
- data class GatewayProbe(val reachable: Boolean, val detail: String)
117
-
118
- /**
119
- * The hotspot's own address on the joined network — the DHCP server, since the glasses
120
- * deliberately advertise no default route. Falls back to `.1` of the phone's /24.
121
- */
122
- fun gatewayIpv4(): String? {
123
- val net = network() ?: return null
124
- val properties = connectivityManager().getLinkProperties(net)
125
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
126
- properties?.dhcpServerAddress?.hostAddress?.let { return it }
127
- }
128
- val local = localIpv4() ?: return null
129
- val prefix = local.substringBeforeLast('.', "")
130
- return if (prefix.isEmpty()) null else "$prefix.1"
131
- }
132
-
133
- /**
134
- * Prove the phone can reach the glasses over the network it just joined.
135
- *
136
- * TCP, through [Network.getSocketFactory], so the kernel routes via the hotspot's table rather
137
- * than the cellular default that unbound sockets would take. A *refused* connection is a pass:
138
- * the SYN arrived and the RST came back, which is the reachability question. Only a timeout or
139
- * an unreachable-network error is a fail.
140
- */
141
- fun probeGateway(timeoutMs: Int = GATEWAY_PROBE_TIMEOUT_MS): GatewayProbe {
142
- val net = network() ?: return GatewayProbe(false, "no scoped network")
143
- val gateway = gatewayIpv4() ?: return GatewayProbe(false, "no gateway address on the scoped network")
144
- var lastFailure = "no ports tried"
145
- for (port in GATEWAY_PROBE_PORTS) {
146
- val startedAt = android.os.SystemClock.elapsedRealtime()
147
- val socket =
148
- try {
149
- net.socketFactory.createSocket()
150
- } catch (error: java.io.IOException) {
151
- // netd answers EPERM when a VPN owns our UID and forbids binding elsewhere.
152
- val message = error.message ?: error.javaClass.simpleName
153
- val detail =
154
- if (message.contains("EPERM")) {
155
- "cannot bind to the hotspot network ($message): a VPN is capturing this app"
156
- } else {
157
- "cannot bind to the hotspot network: $message"
158
- }
159
- SoftApTrace.failure("gateway_probe", "gateway" to gateway, "result" to detail)
160
- return GatewayProbe(false, detail)
161
- }
162
- try {
163
- socket.connect(java.net.InetSocketAddress(gateway, port), timeoutMs)
164
- val elapsed = android.os.SystemClock.elapsedRealtime() - startedAt
165
- SoftApTrace.stage("gateway_probe", "gateway" to gateway, "port" to port, "result" to "connected", "ms" to elapsed)
166
- return GatewayProbe(true, "tcp $gateway:$port connected in ${elapsed}ms")
167
- } catch (error: java.net.ConnectException) {
168
- val elapsed = android.os.SystemClock.elapsedRealtime() - startedAt
169
- val message = error.message ?: ""
170
- if (message.contains("refused", ignoreCase = true)) {
171
- SoftApTrace.stage("gateway_probe", "gateway" to gateway, "port" to port, "result" to "refused", "ms" to elapsed)
172
- return GatewayProbe(true, "tcp $gateway:$port refused (host answered) in ${elapsed}ms")
173
- }
174
- lastFailure = "tcp $gateway:$port $message"
175
- } catch (error: java.net.SocketTimeoutException) {
176
- lastFailure = "tcp $gateway:$port timed out after ${timeoutMs}ms"
177
- } catch (error: java.io.IOException) {
178
- lastFailure = "tcp $gateway:$port ${error.message ?: error.javaClass.simpleName}"
179
- } finally {
180
- runCatching { socket.close() }
181
- }
182
- }
183
- SoftApTrace.failure("gateway_probe", "gateway" to gateway, "result" to lastFailure)
184
- return GatewayProbe(false, lastFailure)
185
- }
186
-
187
- /**
188
- * Whether the local-network permission is granted. Below the SDK level that enforces it, access
189
- * is implicit and this reports true.
190
- */
191
- fun hasLocalNetworkPermission(): Boolean =
192
- hasLocalNetworkPermission(Build.VERSION.SDK_INT, context.applicationInfo.targetSdkVersion) {
193
- context.checkSelfPermission(ScopedNetworkError.LOCAL_NETWORK_PERMISSION) ==
194
- PackageManager.PERMISSION_GRANTED
195
- }
196
-
197
- /**
198
- * Join [ssid] and block until it is usable or the request fails.
199
- *
200
- * @throws ScopedNetworkError on permission denial, timeout, or an unavailable network
201
- */
202
- @Throws(ScopedNetworkError::class)
203
- fun join(ssid: String, passphrase: String, listener: Listener? = null): Network {
204
- if (!hasLocalNetworkPermission()) {
205
- SoftApTrace.failure("scoped_join_permission_denied", "ssid" to ssid)
206
- throw ScopedNetworkError.PermissionDenied(ScopedNetworkError.LOCAL_NETWORK_PERMISSION)
207
- }
208
-
209
- // A disabled radio makes WifiNetworkSpecifier fail as Unavailable in under 10ms, which is
210
- // indistinguishable from a hotspot that never came up. Report the real cause instead.
211
- if (!isWifiEnabled()) {
212
- SoftApTrace.failure("scoped_join_wifi_disabled", "ssid" to ssid)
213
- throw ScopedNetworkError.WifiDisabled()
214
- }
215
-
216
- // The specifier join would succeed, but the glasses could never complete a TCP handshake
217
- // to our listener and we could not bind to the hotspot. Fail here, where the message can
218
- // say why, instead of ten seconds into the camera step.
219
- if (isVpnCapturingApp()) {
220
- SoftApTrace.failure("scoped_join_vpn_captures_app", "ssid" to ssid)
221
- throw ScopedNetworkError.VpnCapturesApp()
222
- }
223
-
224
- release()
225
-
226
- val requestSpec = ScopedNetworkRequestSpec.forSoftAp(ssid, passphrase)
227
- val manager = connectivityManager()
228
- val ready = CountDownLatch(1)
229
-
230
- val generation: Int
231
- synchronized(lock) {
232
- generation = state.startRequest()
233
- spec = requestSpec
234
- this.listener = listener
235
- readiness.reset()
236
- }
237
- SoftApTrace.stage(
238
- "scoped_join_requested",
239
- "ssid" to ssid,
240
- "timeoutMs" to requestSpec.timeoutMs,
241
- "avoidsInternetCapability" to requestSpec.avoidsInternetCapability,
242
- )
243
- val startedAtMs = System.currentTimeMillis()
244
-
245
- val networkCallback =
246
- object : ConnectivityManager.NetworkCallback() {
247
- override fun onAvailable(available: Network) {
248
- val properties = manager.getLinkProperties(available)
249
- val resolvedIpv4 = firstIpv4(properties)
250
- synchronized(lock) {
251
- if (!state.onAvailable(generation)) return
252
- network = available
253
- localIpv4 = resolvedIpv4
254
- readiness.onAvailable()
255
- readiness.seedLinkProperties(properties?.interfaceName, resolvedIpv4)
256
- }
257
- SoftApTrace.stage(
258
- "scoped_network_available",
259
- "ssid" to ssid,
260
- "localIpv4" to resolvedIpv4,
261
- "joinMs" to (System.currentTimeMillis() - startedAtMs),
262
- "defaultNetworkIsCellular" to defaultNetworkIsCellular(manager),
263
- )
264
- // Does the address we were just handed exist in the kernel's interface table,
265
- // and under the name LinkProperties claims? ICE gathers from that table, so a
266
- // mismatch here is the difference between a hotspot candidate and none. The
267
- // address can also land in the table *after* onAvailable, which is why this is
268
- // only a snapshot for the trace and readiness is decided by polling below.
269
- val table = NetworkFacts.snapshot()
270
- SoftApTrace.stage(
271
- "scoped_network_ifaddrs",
272
- "localIpv4" to resolvedIpv4,
273
- "claimed" to (properties?.interfaceName ?: "none"),
274
- "owner" to (resolvedIpv4?.let { NetworkFacts.ownerOf(it, table) } ?: "ABSENT"),
275
- "gatherable" to NetworkFacts.render(NetworkFacts.gatherable(table)),
276
- )
277
- if (readiness.callbacksSatisfied()) ready.countDown()
278
- }
279
-
280
- /**
281
- * The callback Android says to read properties from. It also arrives again when
282
- * DHCP completes, which is the case `onAvailable` alone missed.
283
- */
284
- override fun onLinkPropertiesChanged(
285
- changed: Network,
286
- properties: LinkProperties,
287
- ) {
288
- val address = firstIpv4(properties)
289
- synchronized(lock) {
290
- if (!state.accepts(generation)) return
291
- // Sticky, unlike the readiness observation: the WHIP listener is bound to
292
- // this address and mid-call disappearance is `onLost`'s business, so a
293
- // transient property update must not blank the ingest URL.
294
- if (address != null) localIpv4 = address
295
- readiness.onLinkProperties(properties.interfaceName, address)
296
- }
297
- SoftApTrace.stage(
298
- "scoped_link_properties",
299
- "interface" to (properties.interfaceName ?: "none"),
300
- "localIpv4" to (address ?: "none"),
301
- )
302
- if (readiness.callbacksSatisfied()) ready.countDown()
303
- }
304
-
305
- override fun onCapabilitiesChanged(
306
- changed: Network,
307
- capabilities: NetworkCapabilities,
308
- ) {
309
- synchronized(lock) {
310
- if (!state.accepts(generation)) return
311
- readiness.onCapabilities()
312
- }
313
- if (readiness.callbacksSatisfied()) ready.countDown()
314
- }
315
-
316
- override fun onUnavailable() {
317
- synchronized(lock) { if (!state.onUnavailable(generation)) return }
318
- SoftApTrace.failure("scoped_network_unavailable", "ssid" to ssid)
319
- ready.countDown()
320
- }
321
-
322
- override fun onLost(lost: Network) {
323
- val notify: Listener?
324
- synchronized(lock) {
325
- if (!state.onLost(generation)) return
326
- network = null
327
- localIpv4 = null
328
- notify = this@ScopedSoftApNetwork.listener
329
- }
330
- SoftApTrace.failure("scoped_network_lost", "ssid" to ssid)
331
- ready.countDown()
332
- notify?.onLost(ScopedNetworkError.Lost(ssid))
333
- }
334
- }
335
-
336
- synchronized(lock) { callback = networkCallback }
337
-
338
- try {
339
- manager.requestNetwork(request(requestSpec), networkCallback, requestSpec.timeoutMs)
340
- } catch (error: SecurityException) {
341
- synchronized(lock) { state.onRequestFailed(generation, permissionDenied = true) }
342
- clearCallback(networkCallback)
343
- SoftApTrace.failure("scoped_join_security_exception", "ssid" to ssid)
344
- throw ScopedNetworkError.PermissionDenied(ScopedNetworkError.LOCAL_NETWORK_PERMISSION)
345
- } catch (error: Exception) {
346
- synchronized(lock) { state.onRequestFailed(generation, permissionDenied = false) }
347
- clearCallback(networkCallback)
348
- SoftApTrace.failure("scoped_join_request_failed", "ssid" to ssid)
349
- throw ScopedNetworkError.RequestFailed(error.message ?: "unknown")
350
- }
351
-
352
- // requestNetwork's own timeout fires onUnavailable, but a slightly longer await guards
353
- // against never being called back at all.
354
- val awaited =
355
- ready.await(requestSpec.timeoutMs.toLong() + AWAIT_GRACE_MS, TimeUnit.MILLISECONDS)
356
- if (!awaited) synchronized(lock) { state.onTimeout(generation) }
357
-
358
- val joinedNetwork =
359
- synchronized(lock) {
360
- network.takeIf { state.phase == ScopedNetworkState.Phase.AVAILABLE }
361
- }
362
- if (joinedNetwork != null) {
363
- val verdict = awaitReadiness(ssid)
364
- val notify = synchronized(lock) { listener }
365
- notify?.onAvailable(joinedNetwork, verdict.address)
366
- probeUnmarkedUdpSend(verdict.address)
367
- return joinedNetwork
368
- }
369
-
370
- throw synchronized(lock) {
371
- val failure =
372
- ScopedNetworkError.from(state.failure, ssid, requestSpec.timeoutMs)
373
- ?: ScopedNetworkError.Timeout(ssid, requestSpec.timeoutMs)
374
- releaseLocked()
375
- failure
376
- }
377
- }
378
-
379
- /**
380
- * Block until the joined network is ready for ICE, or fail with the source that never reported.
381
- *
382
- * The kernel interface table is the one input with no callback, so it is polled: the hotspot
383
- * address is regularly absent when `onAvailable` returns and appears a moment later. Starting
384
- * ingest before it exists produced an answer with zero candidates, which looked identical to a
385
- * binding bug and was misdiagnosed as one.
386
- */
387
- @Throws(ScopedNetworkError::class)
388
- private fun awaitReadiness(ssid: String): ScopedNetworkReadiness.Verdict.Ready {
389
- val deadline = android.os.SystemClock.elapsedRealtime() + READINESS_WAIT_MS
390
- var verdict: ScopedNetworkReadiness.Verdict
391
- while (true) {
392
- val address = readiness.observations.linkAddress
393
- readiness.onInterfaceTable(address?.let { NetworkFacts.ownerOf(it) })
394
- verdict = readiness.verdict()
395
- if (verdict is ScopedNetworkReadiness.Verdict.Ready) break
396
- if (android.os.SystemClock.elapsedRealtime() >= deadline) break
397
- Thread.sleep(READINESS_POLL_MS)
398
- }
399
-
400
- val observed = readiness.observations
401
- if (verdict is ScopedNetworkReadiness.Verdict.Ready) {
402
- SoftApTrace.stage(
403
- "scoped_network_ready",
404
- "localIpv4" to verdict.address,
405
- "claimed" to (verdict.interfaceName ?: "none"),
406
- "owner" to verdict.owner,
407
- "agrees" to (verdict.interfaceName == null || verdict.interfaceName == verdict.owner),
408
- "capabilitiesSeen" to observed.capabilities,
409
- )
410
- return verdict
411
- }
412
-
413
- val waiting = verdict as ScopedNetworkReadiness.Verdict.Waiting
414
- SoftApTrace.failure(
415
- "scoped_network_not_ready",
416
- "ssid" to ssid,
417
- "missing" to waiting.missing,
418
- "localIpv4" to (observed.linkAddress ?: "none"),
419
- "claimed" to (observed.linkInterface ?: "none"),
420
- "capabilitiesSeen" to observed.capabilities,
421
- "gatherable" to NetworkFacts.render(NetworkFacts.gatherable()),
422
- )
423
- release()
424
- throw ScopedNetworkError.NotReady(ssid, waiting.missing)
425
- }
426
-
427
- /**
428
- * Whether an unmarked UDP socket on the hotspot address can even be created and written to.
429
- *
430
- * Diagnostic only, and deliberately not a gate. A successful `send` means the OS accepted the
431
- * datagram; it says nothing about whether the glasses received it or can reply, so it cannot
432
- * establish that unmarked UDP works. What it *can* do is separate "bind refused outright" from
433
- * "bound fine, nothing came back", which is the FAILED_BINDING hypothesis. The real answer to
434
- * whether this path carries traffic is the ICE connectivity-check result.
435
- */
436
- private fun probeUnmarkedUdpSend(address: String) {
437
- val gateway = gatewayIpv4()
438
- val result =
439
- runCatching {
440
- java.net.DatagramSocket(0, java.net.InetAddress.getByName(address)).use { socket ->
441
- val target = gateway ?: return@runCatching "bound to $address; no gateway to send to"
442
- socket.send(
443
- java.net.DatagramPacket(
444
- ByteArray(1),
445
- 1,
446
- java.net.InetAddress.getByName(target),
447
- UDP_PROBE_PORT,
448
- ),
449
- )
450
- "bound to ${socket.localAddress?.hostAddress}:${socket.localPort}, send accepted"
451
- }
452
- }
453
- SoftApTrace.stage(
454
- "unmarked_udp_send",
455
- "address" to address,
456
- "gateway" to (gateway ?: "none"),
457
- "accepted" to result.isSuccess,
458
- "detail" to
459
- (result.getOrNull() ?: result.exceptionOrNull()?.let { "${it.javaClass.simpleName}: ${it.message}" }),
460
- "proves" to "the OS accepted a datagram; not that the glasses received one",
461
- )
462
- }
463
-
464
- /** Unregister the callback and drop the network. Safe to call twice. */
465
- fun release() {
466
- synchronized(lock) { releaseLocked() }
467
- }
468
-
469
- private fun releaseLocked() {
470
- val active = callback
471
- if (active != null) {
472
- runCatching { connectivityManager().unregisterNetworkCallback(active) }
473
- SoftApTrace.stage("scoped_network_released")
474
- }
475
- callback = null
476
- network = null
477
- localIpv4 = null
478
- spec = null
479
- listener = null
480
- state.release()
481
- state.reset()
482
- }
483
-
484
- private fun clearCallback(expected: ConnectivityManager.NetworkCallback) {
485
- synchronized(lock) {
486
- if (callback !== expected) return
487
- runCatching { connectivityManager().unregisterNetworkCallback(expected) }
488
- callback = null
489
- }
490
- }
491
-
492
- private fun connectivityManager(): ConnectivityManager =
493
- requireNotNull(context.getSystemService(ConnectivityManager::class.java)) {
494
- "ConnectivityManager is unavailable"
495
- }
496
-
497
-
498
-
499
- /** Translate the pure spec into the framework request. */
500
- private fun request(spec: ScopedNetworkRequestSpec): NetworkRequest {
501
- val specifier =
502
- WifiNetworkSpecifier.Builder()
503
- .setSsid(spec.ssid)
504
- .apply { if (spec.hasPassphrase) setWpa2Passphrase(spec.passphrase) }
505
- .build()
506
-
507
- val builder = NetworkRequest.Builder()
508
- spec.transportTypes.forEach { builder.addTransportType(it) }
509
- spec.removedCapabilities.forEach { builder.removeCapability(it) }
510
- return builder.setNetworkSpecifier(specifier).build()
511
- }
512
-
513
- private fun firstIpv4(properties: LinkProperties?): String? =
514
- properties
515
- ?.linkAddresses
516
- ?.firstOrNull { it.address is Inet4Address }
517
- ?.address
518
- ?.hostAddress
519
-
520
- /** Dual-homing check: the default route must still be cellular after the scoped join. */
521
- private fun defaultNetworkIsCellular(manager: ConnectivityManager): Boolean {
522
- val active = manager.activeNetwork ?: return false
523
- val capabilities = manager.getNetworkCapabilities(active) ?: return false
524
- return capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
525
- }
526
-
527
- companion object {
528
- /** Android 17. `ACCESS_LOCAL_NETWORK` is enforced for apps targeting SDK 37+. */
529
- const val LOCAL_NETWORK_ENFORCED_SDK = 37
530
-
531
- /** Legacy targets retain implicit LAN access, even when the runtime grant reports denied. */
532
- internal fun hasLocalNetworkPermission(
533
- sdkInt: Int,
534
- targetSdkInt: Int,
535
- permissionGranted: () -> Boolean,
536
- ): Boolean =
537
- sdkInt < LOCAL_NETWORK_ENFORCED_SDK ||
538
- targetSdkInt < LOCAL_NETWORK_ENFORCED_SDK ||
539
- permissionGranted()
540
-
541
- private const val AWAIT_GRACE_MS = 2_000L
542
-
543
- /** How long we hold the scoped-join step open for the user to flip the Wi-Fi toggle. */
544
- const val WIFI_ENABLE_WAIT_MS = 90_000L
545
- const val WIFI_ENABLE_POLL_MS = 300L
546
- /** Radio + scan need a beat after `isWifiEnabled` flips before specifier can see the AP. */
547
- const val WIFI_ENABLE_SETTLE_MS = 1_500L
548
-
549
- /**
550
- * After `WifiNetworkSpecifier` returns Unavailable while leaving another AP (office Wi-Fi
551
- * or a phone hotspot), Samsung has already dropped that network. A second specifier from
552
- * an idle STA is the join that succeeds; this is the gap before we issue it.
553
- */
554
- const val UNAVAILABLE_RETRY_SETTLE_MS = 2_000L
555
-
556
- /**
557
- * dnsmasq (DHCP/DNS) on the hotspot, then the glasses' local HTTP servers. Any answer —
558
- * accept or refuse — proves the path; only silence fails it.
559
- */
560
- private val GATEWAY_PROBE_PORTS = intArrayOf(53, 8089, 80)
561
- private const val GATEWAY_PROBE_TIMEOUT_MS = 2_000
562
-
563
- /**
564
- * How long the address is given to appear in the kernel table after the callbacks agree.
565
- * Generous because failing here aborts the call, and the observed lag is sub-second.
566
- */
567
- private const val READINESS_WAIT_MS = 5_000L
568
- private const val READINESS_POLL_MS = 150L
569
-
570
- /** dnsmasq on the hotspot. Nothing is expected back; only the send is being observed. */
571
- private const val UDP_PROBE_PORT = 53
572
- }
573
- }
@@ -1,99 +0,0 @@
1
- package com.mentra.acsmeeting.source
2
-
3
- import java.nio.ByteBuffer
4
-
5
- /** 5x7 glyphs for the synthetic stamp. No Android canvas, so this is the font. */
6
- internal object BitmapFont {
7
- const val GLYPH_W = 5
8
- const val GLYPH_H = 7
9
-
10
- fun draw(
11
- dest: ByteBuffer,
12
- frameWidth: Int,
13
- frameHeight: Int,
14
- text: String,
15
- originX: Int,
16
- originY: Int,
17
- scale: Int,
18
- on: Byte,
19
- ) {
20
- var x = originX
21
- for (ch in text) {
22
- val glyph = glyph(ch) ?: continue
23
- blit(dest, frameWidth, frameHeight, glyph, x, originY, scale, on)
24
- x += (GLYPH_W + 1) * scale
25
- }
26
- }
27
-
28
- fun textWidth(length: Int, scale: Int): Int =
29
- if (length <= 0) 0 else length * (GLYPH_W + 1) * scale - scale
30
-
31
- fun textHeight(scale: Int): Int = GLYPH_H * scale
32
-
33
- private fun blit(
34
- dest: ByteBuffer,
35
- frameWidth: Int,
36
- frameHeight: Int,
37
- glyph: IntArray,
38
- originX: Int,
39
- originY: Int,
40
- scale: Int,
41
- on: Byte,
42
- ) {
43
- for (row in 0 until GLYPH_H) {
44
- val bits = glyph[row]
45
- for (col in 0 until GLYPH_W) {
46
- if ((bits shr (GLYPH_W - 1 - col)) and 1 == 0) continue
47
- val x0 = originX + col * scale
48
- val y0 = originY + row * scale
49
- val xEnd = (x0 + scale).coerceAtMost(frameWidth)
50
- val yEnd = (y0 + scale).coerceAtMost(frameHeight)
51
- val xStart = x0.coerceAtLeast(0)
52
- val yStart = y0.coerceAtLeast(0)
53
- if (xStart >= xEnd || yStart >= yEnd) continue
54
- for (y in yStart until yEnd) {
55
- val rowOff = y * frameWidth
56
- for (x in xStart until xEnd) dest.put(rowOff + x, on)
57
- }
58
- }
59
- }
60
- }
61
-
62
- private fun glyph(ch: Char): IntArray? = when (ch) {
63
- '0' -> G0
64
- '1' -> G1
65
- '2' -> G2
66
- '3' -> G3
67
- '4' -> G4
68
- '5' -> G5
69
- '6' -> G6
70
- '7' -> G7
71
- '8' -> G8
72
- '9' -> G9
73
- 'F' -> GF
74
- 'P' -> GP
75
- 'S' -> GS
76
- 'X' -> GX
77
- 'x' -> GX
78
- '.' -> GDOT
79
- ' ' -> GSPACE
80
- else -> null
81
- }
82
-
83
- private val G0 = intArrayOf(0b01110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110)
84
- private val G1 = intArrayOf(0b00100, 0b01100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110)
85
- private val G2 = intArrayOf(0b01110, 0b10001, 0b00001, 0b00010, 0b00100, 0b01000, 0b11111)
86
- private val G3 = intArrayOf(0b01110, 0b10001, 0b00001, 0b00110, 0b00001, 0b10001, 0b01110)
87
- private val G4 = intArrayOf(0b00010, 0b00110, 0b01010, 0b10010, 0b11111, 0b00010, 0b00010)
88
- private val G5 = intArrayOf(0b11111, 0b10000, 0b11110, 0b00001, 0b00001, 0b10001, 0b01110)
89
- private val G6 = intArrayOf(0b01110, 0b10000, 0b11110, 0b10001, 0b10001, 0b10001, 0b01110)
90
- private val G7 = intArrayOf(0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b01000, 0b01000)
91
- private val G8 = intArrayOf(0b01110, 0b10001, 0b10001, 0b01110, 0b10001, 0b10001, 0b01110)
92
- private val G9 = intArrayOf(0b01110, 0b10001, 0b10001, 0b01111, 0b00001, 0b00001, 0b01110)
93
- private val GF = intArrayOf(0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b10000)
94
- private val GP = intArrayOf(0b11110, 0b10001, 0b10001, 0b11110, 0b10000, 0b10000, 0b10000)
95
- private val GS = intArrayOf(0b01111, 0b10000, 0b10000, 0b01110, 0b00001, 0b00001, 0b11110)
96
- private val GX = intArrayOf(0b10001, 0b10001, 0b01010, 0b00100, 0b01010, 0b10001, 0b10001)
97
- private val GDOT = intArrayOf(0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00100, 0b00100)
98
- private val GSPACE = intArrayOf(0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00000)
99
- }