@mentra/glasses-media 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 (56) hide show
  1. package/README.md +35 -0
  2. package/android/build.gradle +84 -0
  3. package/android/src/main/AndroidManifest.xml +6 -0
  4. package/android/src/main/java/com/mentra/glassesmedia/network/InternetHold.kt +501 -0
  5. package/android/src/main/java/com/mentra/glassesmedia/network/Ipv4Prefix.kt +51 -0
  6. package/android/src/main/java/com/mentra/glassesmedia/network/NetworkFacts.kt +81 -0
  7. package/android/src/main/java/com/mentra/glassesmedia/network/ScopedNetworkChangeDetector.kt +355 -0
  8. package/android/src/main/java/com/mentra/glassesmedia/network/ScopedNetworkError.kt +109 -0
  9. package/android/src/main/java/com/mentra/glassesmedia/network/ScopedNetworkObserver.kt +160 -0
  10. package/android/src/main/java/com/mentra/glassesmedia/network/ScopedNetworkReadiness.kt +103 -0
  11. package/android/src/main/java/com/mentra/glassesmedia/network/ScopedNetworkRequestSpec.kt +53 -0
  12. package/android/src/main/java/com/mentra/glassesmedia/network/ScopedNetworkState.kt +127 -0
  13. package/android/src/main/java/com/mentra/glassesmedia/network/ScopedSoftApNetwork.kt +573 -0
  14. package/android/src/main/java/com/mentra/glassesmedia/source/BitmapFont.kt +99 -0
  15. package/android/src/main/java/com/mentra/glassesmedia/source/CloudflareWhepSource.kt +487 -0
  16. package/android/src/main/java/com/mentra/glassesmedia/source/DecodedTrackRelay.kt +147 -0
  17. package/android/src/main/java/com/mentra/glassesmedia/source/FirstFrameGate.kt +45 -0
  18. package/android/src/main/java/com/mentra/glassesmedia/source/GlassesMediaSource.kt +143 -0
  19. package/android/src/main/java/com/mentra/glassesmedia/source/LocalWhipIngestSource.kt +772 -0
  20. package/android/src/main/java/com/mentra/glassesmedia/source/MediaListeners.kt +57 -0
  21. package/android/src/main/java/com/mentra/glassesmedia/source/SdpAdapter.kt +17 -0
  22. package/android/src/main/java/com/mentra/glassesmedia/source/SelectedIcePair.kt +139 -0
  23. package/android/src/main/java/com/mentra/glassesmedia/source/SoftApIceDiagnosis.kt +144 -0
  24. package/android/src/main/java/com/mentra/glassesmedia/source/SoftApIcePolicy.kt +46 -0
  25. package/android/src/main/java/com/mentra/glassesmedia/source/SoftApSdpGuard.kt +160 -0
  26. package/android/src/main/java/com/mentra/glassesmedia/source/SyntheticFrameFactory.kt +205 -0
  27. package/android/src/main/java/com/mentra/glassesmedia/source/SyntheticI420Source.kt +214 -0
  28. package/android/src/main/java/com/mentra/glassesmedia/source/TrackRegistry.kt +33 -0
  29. package/android/src/main/java/com/mentra/glassesmedia/source/VideoSourceArm.kt +110 -0
  30. package/android/src/main/java/com/mentra/glassesmedia/source/WhipIngestProtocol.kt +207 -0
  31. package/android/src/main/java/com/mentra/glassesmedia/source/WhipIngestServer.kt +356 -0
  32. package/android/src/main/java/com/mentra/glassesmedia/telemetry/AvSyncProbe.kt +155 -0
  33. package/android/src/main/java/com/mentra/glassesmedia/telemetry/ChromaProbe.kt +78 -0
  34. package/android/src/main/java/com/mentra/glassesmedia/telemetry/PipelineStats.kt +372 -0
  35. package/android/src/main/java/com/mentra/glassesmedia/telemetry/PipelineTicker.kt +79 -0
  36. package/android/src/main/java/com/mentra/glassesmedia/telemetry/ProcessCpu.kt +15 -0
  37. package/android/src/main/java/com/mentra/glassesmedia/telemetry/RingPercentile.kt +48 -0
  38. package/android/src/main/java/com/mentra/glassesmedia/telemetry/VideoQuality.kt +178 -0
  39. package/android/src/main/java/com/mentra/glassesmedia/telemetry/VideoRateVerdict.kt +145 -0
  40. package/android/src/main/java/com/mentra/glassesmedia/trace/SoftApTrace.kt +143 -0
  41. package/android/src/main/java/com/mentra/glassesmedia/video/FrameGeometry.kt +22 -0
  42. package/android/src/main/java/com/mentra/glassesmedia/video/I420Packer.kt +72 -0
  43. package/expo-module.config.json +12 -0
  44. package/index.js +1 -0
  45. package/ios/CoreKit/Sources/GlassesMediaCore/GlassesMediaSource.swift +104 -0
  46. package/ios/CoreKit/Sources/GlassesMediaCore/LocalMediaPolicy.swift +51 -0
  47. package/ios/CoreKit/Sources/GlassesMediaCore/WhipIngestServer.swift +145 -0
  48. package/ios/CoreKit/Sources/GlassesMediaCore/WhipRequest.swift +59 -0
  49. package/ios/DecodedGlassesMediaSource.swift +9 -0
  50. package/ios/DecodedPcm.swift +33 -0
  51. package/ios/GlassesHotspotNetwork.swift +185 -0
  52. package/ios/GlassesMedia.podspec +18 -0
  53. package/ios/LocalWhipIngestSource.swift +325 -0
  54. package/ios/ReceiveOnlyAudioDevice.swift +106 -0
  55. package/ios/WhepVideoSource.swift +332 -0
  56. package/package.json +27 -0
package/README.md ADDED
@@ -0,0 +1,35 @@
1
+ # Glasses media transport
2
+
3
+ Native receivers and network helpers shared by media publishers. This library has no ACS SDK dependency. `acs-meeting` is its first consumer; a future Cloudflare publisher can consume the same decoded video and PCM without joining a meeting.
4
+
5
+ - Android: the existing `GlassesMediaSource`, local WHIP and WHEP receivers, scoped SoftAP networking, decoded I420/PCM delivery, and transport diagnostics moved here. The ACS session and raw outgoing streams remain in `acs-meeting`.
6
+ - iOS: `DecodedGlassesMediaSource` delivers `CVPixelBuffer` video and interleaved PCM16. `LocalWhipIngestSource` answers the glasses' existing WHIP client; `WhepVideoSource` retains the cloud subscription path.
7
+ - `GlassesHotspotNetwork` uses the same persistent `NEHotspotConfiguration` join as gallery transfers. Wi-Fi carries the glasses link; cellular carries the call. It verifies the joined SSID and `en0` address, reports network loss, and retains a cancelled join until the uncancellable system callback has been cleaned up.
8
+
9
+ The publisher owns its outgoing encoders, audio policy, and destination. Receivers do not open the phone microphone or play the glasses microphone locally. iOS uses a receive-only audio device to drive decoded PCM delivery without changing the app's audio session.
10
+
11
+ ## Lifecycle
12
+
13
+ The host signs in, enables the glasses hotspot, joins it, prepares the receiver and publisher, then tells the glasses to publish to the returned `ingestUrl`. A successful listener bind is not a live stream: `live` requires a decoded video frame. iOS waits for full ICE gathering and returns only actual host candidates on the hotspot interface. Requests and queued media are bounded.
14
+
15
+ Stop the glasses publisher before stopping the receiver and releasing the network. Await `LocalWhipIngestSource.stop(completion:)` before reusing resources. It cancels pending offers, closes the peer and HTTP listener, and drains teardown before completing. A local source must not silently restart onto a new port; the host must coordinate any new URL with the glasses. WHEP can rebuild its existing URL.
16
+
17
+ ## Verification
18
+
19
+ ```sh
20
+ swift test --package-path mobile/modules/glasses-media/ios/CoreKit
21
+ swift test --package-path mobile/modules/acs-meeting/ios/PolicyKit
22
+ ./scripts/check-android-compile.sh bluetooth-sdk :mentra-glasses-media:testDebugUnitTest :mentra-acs-meeting:testDebugUnitTest
23
+ ```
24
+
25
+ After iOS prebuild and `pod install`, compile the actual shared-library consumer:
26
+
27
+ ```sh
28
+ xcodebuild -project mobile/ios/Pods/Pods.xcodeproj -target AcsMeeting \
29
+ -configuration Debug -sdk iphonesimulator -arch arm64 \
30
+ ONLY_ACTIVE_ARCH=YES CODE_SIGNING_ALLOWED=NO build
31
+ ```
32
+
33
+ Before merging/releasing, exercise iPhone + glasses: first-use local-network permission and hotspot prompts; join/admit/mute/leave; music/camera sounds; rapid Start/Cancel/Start during sign-in, hotspot join, and WHIP negotiation; hotspot loss; repeated joins; screen-off/background streaming; and a sustained call while measuring latency, audio/video alignment, and thermal behavior. Screen-off operation and background gallery transfers already exist in the Mentra App; these checks qualify the new media pipeline. A simulator build and loopback HTTP tests cannot establish physical Wi-Fi/cellular routing or camera behavior.
34
+
35
+ Cloudflare publishing and changes to RTMP/SRT routes are outside this library extraction.
@@ -0,0 +1,84 @@
1
+ apply plugin: 'com.android.library'
2
+
3
+ group = 'com.mentra.glassesmedia'
4
+ version = '0.1.0'
5
+
6
+ def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
7
+ apply from: expoModulesCorePlugin
8
+ applyKotlinExpoModulesCorePlugin()
9
+ useCoreDependencies()
10
+ useExpoPublishing()
11
+
12
+ def useManagedAndroidSdkVersions = false
13
+ if (useManagedAndroidSdkVersions) {
14
+ useDefaultAndroidSdkVersions()
15
+ } else {
16
+ buildscript {
17
+ ext.safeExtGet = { prop, fallback ->
18
+ rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
19
+ }
20
+ }
21
+ project.android {
22
+ compileSdkVersion safeExtGet("compileSdkVersion", 36)
23
+ defaultConfig {
24
+ minSdkVersion safeExtGet("minSdkVersion", 24)
25
+ targetSdkVersion safeExtGet("targetSdkVersion", 36)
26
+ }
27
+ }
28
+ }
29
+
30
+ android {
31
+ namespace "com.mentra.glassesmedia"
32
+ defaultConfig {
33
+ versionCode 1
34
+ versionName "0.1.0"
35
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
36
+ }
37
+ compileOptions {
38
+ coreLibraryDesugaringEnabled true
39
+ }
40
+ buildTypes {
41
+ internal {
42
+ initWith release
43
+ matchingFallbacks = ['release']
44
+ }
45
+ }
46
+ testOptions {
47
+ unitTests {
48
+ includeAndroidResources = true
49
+ // WhipIngestServer is exercised over a real loopback socket in JVM tests, and its stage
50
+ // logging reaches android.util.Log. Without this the stubbed android.jar throws "Stub!"
51
+ // and the socket behaviour we actually want to assert is unreachable.
52
+ returnDefaultValues = true
53
+ }
54
+ }
55
+ }
56
+
57
+ // Expo assemble uses --configure-on-demand --build-cache. Incremental Kotlin in this
58
+ // module has already produced two poisoned outputs (a phantom canReuseSource overload,
59
+ // then WhenMappings "already exists"). A full recompile of this library is cheap; a
60
+ // half-written runtime_library_classes_dir is not. Do not delete AGP's dest in doFirst —
61
+ // that leaves bundleLibRuntimeToDirDebug as a missing directory on the next assemble.
62
+ afterEvaluate {
63
+ tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach {
64
+ incremental = false
65
+ }
66
+ }
67
+
68
+ dependencies {
69
+ coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.4'
70
+ implementation 'com.squareup.okhttp3:okhttp:4.12.0'
71
+ implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
72
+ // Same libwebrtc AAR as @livekit/react-native-webrtc so Gradle dedupes instead
73
+ // of shipping two WebRTC stacks. P4 confirms remote AudioTrackSink.
74
+ implementation 'io.github.webrtc-sdk:android:137.7151.05'
75
+ testImplementation 'junit:junit:4.13.2'
76
+ testImplementation 'org.mockito:mockito-core:5.11.0'
77
+ testImplementation 'org.assertj:assertj-core:3.24.2'
78
+ // Phase 0.5 SoftAP feasibility gate. These run on a device, not in CI: the gate needs a real
79
+ // phone with cellular active and the glasses hotspot up. See SoftApFeasibilityGateTest.
80
+ androidTestImplementation 'androidx.test:runner:1.5.2'
81
+ androidTestImplementation 'androidx.test:rules:1.5.0'
82
+ androidTestImplementation 'androidx.test.ext:junit:1.1.5'
83
+ androidTestImplementation 'org.assertj:assertj-core:3.24.2'
84
+ }
@@ -0,0 +1,6 @@
1
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
2
+ <uses-permission android:name="android.permission.INTERNET" />
3
+ <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
4
+ <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
5
+ <uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
6
+ </manifest>
@@ -0,0 +1,501 @@
1
+ package com.mentra.glassesmedia.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.glassesmedia.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
+ }