@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.
- package/README.md +35 -0
- package/android/build.gradle +84 -0
- package/android/src/main/AndroidManifest.xml +6 -0
- package/android/src/main/java/com/mentra/glassesmedia/network/InternetHold.kt +501 -0
- package/android/src/main/java/com/mentra/glassesmedia/network/Ipv4Prefix.kt +51 -0
- package/android/src/main/java/com/mentra/glassesmedia/network/NetworkFacts.kt +81 -0
- package/android/src/main/java/com/mentra/glassesmedia/network/ScopedNetworkChangeDetector.kt +355 -0
- package/android/src/main/java/com/mentra/glassesmedia/network/ScopedNetworkError.kt +109 -0
- package/android/src/main/java/com/mentra/glassesmedia/network/ScopedNetworkObserver.kt +160 -0
- package/android/src/main/java/com/mentra/glassesmedia/network/ScopedNetworkReadiness.kt +103 -0
- package/android/src/main/java/com/mentra/glassesmedia/network/ScopedNetworkRequestSpec.kt +53 -0
- package/android/src/main/java/com/mentra/glassesmedia/network/ScopedNetworkState.kt +127 -0
- package/android/src/main/java/com/mentra/glassesmedia/network/ScopedSoftApNetwork.kt +573 -0
- package/android/src/main/java/com/mentra/glassesmedia/source/BitmapFont.kt +99 -0
- package/android/src/main/java/com/mentra/glassesmedia/source/CloudflareWhepSource.kt +487 -0
- package/android/src/main/java/com/mentra/glassesmedia/source/DecodedTrackRelay.kt +147 -0
- package/android/src/main/java/com/mentra/glassesmedia/source/FirstFrameGate.kt +45 -0
- package/android/src/main/java/com/mentra/glassesmedia/source/GlassesMediaSource.kt +143 -0
- package/android/src/main/java/com/mentra/glassesmedia/source/LocalWhipIngestSource.kt +772 -0
- package/android/src/main/java/com/mentra/glassesmedia/source/MediaListeners.kt +57 -0
- package/android/src/main/java/com/mentra/glassesmedia/source/SdpAdapter.kt +17 -0
- package/android/src/main/java/com/mentra/glassesmedia/source/SelectedIcePair.kt +139 -0
- package/android/src/main/java/com/mentra/glassesmedia/source/SoftApIceDiagnosis.kt +144 -0
- package/android/src/main/java/com/mentra/glassesmedia/source/SoftApIcePolicy.kt +46 -0
- package/android/src/main/java/com/mentra/glassesmedia/source/SoftApSdpGuard.kt +160 -0
- package/android/src/main/java/com/mentra/glassesmedia/source/SyntheticFrameFactory.kt +205 -0
- package/android/src/main/java/com/mentra/glassesmedia/source/SyntheticI420Source.kt +214 -0
- package/android/src/main/java/com/mentra/glassesmedia/source/TrackRegistry.kt +33 -0
- package/android/src/main/java/com/mentra/glassesmedia/source/VideoSourceArm.kt +110 -0
- package/android/src/main/java/com/mentra/glassesmedia/source/WhipIngestProtocol.kt +207 -0
- package/android/src/main/java/com/mentra/glassesmedia/source/WhipIngestServer.kt +356 -0
- package/android/src/main/java/com/mentra/glassesmedia/telemetry/AvSyncProbe.kt +155 -0
- package/android/src/main/java/com/mentra/glassesmedia/telemetry/ChromaProbe.kt +78 -0
- package/android/src/main/java/com/mentra/glassesmedia/telemetry/PipelineStats.kt +372 -0
- package/android/src/main/java/com/mentra/glassesmedia/telemetry/PipelineTicker.kt +79 -0
- package/android/src/main/java/com/mentra/glassesmedia/telemetry/ProcessCpu.kt +15 -0
- package/android/src/main/java/com/mentra/glassesmedia/telemetry/RingPercentile.kt +48 -0
- package/android/src/main/java/com/mentra/glassesmedia/telemetry/VideoQuality.kt +178 -0
- package/android/src/main/java/com/mentra/glassesmedia/telemetry/VideoRateVerdict.kt +145 -0
- package/android/src/main/java/com/mentra/glassesmedia/trace/SoftApTrace.kt +143 -0
- package/android/src/main/java/com/mentra/glassesmedia/video/FrameGeometry.kt +22 -0
- package/android/src/main/java/com/mentra/glassesmedia/video/I420Packer.kt +72 -0
- package/expo-module.config.json +12 -0
- package/index.js +1 -0
- package/ios/CoreKit/Sources/GlassesMediaCore/GlassesMediaSource.swift +104 -0
- package/ios/CoreKit/Sources/GlassesMediaCore/LocalMediaPolicy.swift +51 -0
- package/ios/CoreKit/Sources/GlassesMediaCore/WhipIngestServer.swift +145 -0
- package/ios/CoreKit/Sources/GlassesMediaCore/WhipRequest.swift +59 -0
- package/ios/DecodedGlassesMediaSource.swift +9 -0
- package/ios/DecodedPcm.swift +33 -0
- package/ios/GlassesHotspotNetwork.swift +185 -0
- package/ios/GlassesMedia.podspec +18 -0
- package/ios/LocalWhipIngestSource.swift +325 -0
- package/ios/ReceiveOnlyAudioDevice.swift +106 -0
- package/ios/WhepVideoSource.swift +332 -0
- 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
|
+
}
|