@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
@@ -0,0 +1,160 @@
1
+ package com.mentra.glassesmedia.network
2
+
3
+ import com.mentra.glassesmedia.trace.SoftApTrace
4
+ import org.webrtc.NetworkChangeDetector
5
+
6
+ /**
7
+ * Owns libwebrtc's network state for as long as a scoped SoftAP is joined, and gives it back after.
8
+ *
9
+ * `getActiveNetworkList` is only a snapshot. libwebrtc's process-wide monitor also keeps every
10
+ * network it has ever been told about through [onNetworkConnect]. AutoDetect is constructed with
11
+ * this observer and reports cellular (and leftover home Wi-Fi) throughout the ACS join that happens
12
+ * *before* ingest starts, and those handles stay in the native list. Gathering unions the snapshot
13
+ * with that history, which is how a correct inventory still produced a lone cellular candidate.
14
+ *
15
+ * ## Ownership, not filtering
16
+ *
17
+ * The monitor is process-wide and the libwebrtc AAR is deliberately shared with LiveKit, so this
18
+ * cannot simply mute callbacks and walk away. The contract is explicit:
19
+ *
20
+ * - **While SoftAP is joined** this observer owns the monitor. Stock connect/disconnect callbacks
21
+ * are suppressed (but *recorded*), every handle outside the SoftAP inventory is disconnected,
22
+ * and exactly that inventory is connected.
23
+ * - **When the hotspot goes away** ownership is handed back by [restore]: the synthetic entry is
24
+ * disconnected and every stock network whose callback was suppressed is replayed, so a WHEP or
25
+ * LiveKit connection started afterwards sees real networks. Resuming pass-through alone is not
26
+ * enough — the networks it never heard about would stay missing until the OS happened to
27
+ * re-announce them.
28
+ *
29
+ * Restoration is driven from both directions, because either may come first: [publish] runs on
30
+ * every inventory read, and each stock callback checks too, since after teardown the OS may be the
31
+ * only thing still talking to us.
32
+ */
33
+ internal class ScopedNetworkObserver(
34
+ private val downstream: NetworkChangeDetector.Observer,
35
+ private val scoped: () -> ScopedNetworkChangeDetector.ScopedInterface?,
36
+ ) : NetworkChangeDetector.Observer() {
37
+
38
+ private val lock = Any()
39
+
40
+ /**
41
+ * Stock networks by handle, kept whole rather than as bare handles: replaying a suppressed
42
+ * network on teardown requires the [NetworkChangeDetector.NetworkInformation] verbatim.
43
+ */
44
+ private val stock = linkedMapOf<Long, NetworkChangeDetector.NetworkInformation>()
45
+
46
+ private val published = mutableSetOf<Long>()
47
+ private var lastSignature: String? = null
48
+
49
+ /** True while this observer has taken over the monitor and still owes a [restore]. */
50
+ private var owning = false
51
+
52
+ /**
53
+ * Make native state match [inventory] whenever SoftAP is up. A no-op when the snapshot has not
54
+ * changed, so [ScopedNetworkChangeDetector.getActiveNetworkList] can call this on every read.
55
+ */
56
+ fun publish(inventory: List<NetworkChangeDetector.NetworkInformation>) {
57
+ if (scoped() == null) {
58
+ restore()
59
+ return
60
+ }
61
+ val signature =
62
+ inventory.joinToString("|") { info ->
63
+ val ips =
64
+ info.ipAddresses.joinToString("/") { address ->
65
+ ScopedNetworkChangeDetector.formatIpv4(address.address)
66
+ }
67
+ "${info.handle}:${info.name}:$ips"
68
+ }
69
+ synchronized(lock) {
70
+ if (signature == lastSignature) return
71
+ lastSignature = signature
72
+ owning = true
73
+
74
+ val keep = inventory.map { it.handle }.toSet()
75
+ // Stock handles announced *before* the join are the ones that poison gathering, so
76
+ // they are disconnected here even though their callbacks were never forwarded.
77
+ val dropped = (stock.keys + published).filterNot { it in keep }
78
+ dropped.forEach { handle -> downstream.onNetworkDisconnect(handle) }
79
+ published.clear()
80
+ inventory.forEach { info ->
81
+ published.add(info.handle)
82
+ downstream.onNetworkConnect(info)
83
+ }
84
+ SoftApTrace.stage(
85
+ "webrtc_network_reconciled",
86
+ "published" to inventory.size,
87
+ "dropped" to dropped.size,
88
+ )
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Hand the monitor back: drop the synthetic entry, replay every suppressed stock network.
94
+ *
95
+ * Idempotent, and safe to call when this observer never took ownership.
96
+ */
97
+ fun restore() {
98
+ synchronized(lock) {
99
+ if (!owning) return
100
+ owning = false
101
+ lastSignature = null
102
+
103
+ val synthetic = published.toList()
104
+ published.clear()
105
+ synthetic.forEach { handle -> downstream.onNetworkDisconnect(handle) }
106
+ // Replay rather than wait: these networks exist, and the monitor was told they did not.
107
+ stock.values.forEach { info -> downstream.onNetworkConnect(info) }
108
+ SoftApTrace.stage(
109
+ "webrtc_network_restored",
110
+ "syntheticDropped" to synthetic.size,
111
+ "stockReplayed" to stock.size,
112
+ )
113
+ }
114
+ }
115
+
116
+ override fun onConnectionTypeChanged(connectionType: NetworkChangeDetector.ConnectionType) {
117
+ if (scoped() != null) {
118
+ downstream.onConnectionTypeChanged(NetworkChangeDetector.ConnectionType.CONNECTION_WIFI)
119
+ return
120
+ }
121
+ restore()
122
+ downstream.onConnectionTypeChanged(connectionType)
123
+ }
124
+
125
+ override fun onNetworkConnect(networkInfo: NetworkChangeDetector.NetworkInformation) {
126
+ if (scoped() != null) {
127
+ synchronized(lock) { stock[networkInfo.handle] = networkInfo }
128
+ SoftApTrace.stage(
129
+ "webrtc_stock_connect_suppressed",
130
+ "name" to networkInfo.name,
131
+ "handle" to networkInfo.handle,
132
+ )
133
+ return
134
+ }
135
+ // Restore before recording, so this network is forwarded once below rather than also
136
+ // arriving in the replay.
137
+ restore()
138
+ synchronized(lock) { stock[networkInfo.handle] = networkInfo }
139
+ downstream.onNetworkConnect(networkInfo)
140
+ }
141
+
142
+ override fun onNetworkDisconnect(networkHandle: Long) {
143
+ if (scoped() != null) {
144
+ synchronized(lock) { stock.remove(networkHandle) }
145
+ return
146
+ }
147
+ restore()
148
+ synchronized(lock) { stock.remove(networkHandle) }
149
+ downstream.onNetworkDisconnect(networkHandle)
150
+ }
151
+
152
+ override fun onNetworkPreference(
153
+ types: List<NetworkChangeDetector.ConnectionType>,
154
+ preference: Int,
155
+ ) {
156
+ if (scoped() != null) return
157
+ restore()
158
+ downstream.onNetworkPreference(types, preference)
159
+ }
160
+ }
@@ -0,0 +1,103 @@
1
+ package com.mentra.glassesmedia.network
2
+
3
+ /**
4
+ * Decides when a joined scoped network is actually ready for ICE to gather on.
5
+ *
6
+ * `onAvailable` is not that moment. Android's own guidance is to wait for `onLinkPropertiesChanged`
7
+ * and `onCapabilitiesChanged` rather than reading properties synchronously inside `onAvailable`, and
8
+ * on device the consequence was concrete: the hotspot address can land in the kernel's interface
9
+ * table *after* `onAvailable` returns, and libwebrtc gathers from that table. Starting ingest on
10
+ * `onAvailable` therefore raced the address into existence, and losing that race is indistinguishable
11
+ * from the wiring bugs it was mistaken for.
12
+ *
13
+ * Readiness needs agreement from three places, so all three are recorded separately and the missing
14
+ * one is named. The kernel table is the odd one out: it has no callback, so the shell polls it and
15
+ * feeds the answer in through [onInterfaceTable].
16
+ *
17
+ * Pure and mutable-but-self-contained, so the awkward orderings are unit testable without a device.
18
+ */
19
+ class ScopedNetworkReadiness {
20
+
21
+ /** What each source has reported so far. Null means "not yet", not "absent". */
22
+ data class Observations(
23
+ val available: Boolean = false,
24
+ val capabilities: Boolean = false,
25
+ val linkInterface: String? = null,
26
+ val linkAddress: String? = null,
27
+ val tableOwner: String? = null,
28
+ )
29
+
30
+ sealed interface Verdict {
31
+ /**
32
+ * [owner] is the interface the kernel says holds [address]. It can differ from
33
+ * [interfaceName], which is what `LinkProperties` claimed — a disagreement worth logging,
34
+ * not worth blocking on, since ICE only ever uses the address.
35
+ */
36
+ data class Ready(
37
+ val address: String,
38
+ val interfaceName: String?,
39
+ val owner: String,
40
+ ) : Verdict
41
+
42
+ /** [missing] names the one source still to report, for the timeout message. */
43
+ data class Waiting(val missing: String) : Verdict
44
+ }
45
+
46
+ @Volatile
47
+ var observations = Observations()
48
+ private set
49
+
50
+ private val lock = Any()
51
+
52
+ fun onAvailable() = update { it.copy(available = true) }
53
+
54
+ fun onCapabilities() = update { it.copy(capabilities = true) }
55
+
56
+ /**
57
+ * Taken verbatim: `LinkProperties` carries the network's whole current state, so a later
58
+ * callback without an address means the address is gone, not that the old one still holds.
59
+ */
60
+ fun onLinkProperties(interfaceName: String?, address: String?) =
61
+ update { it.copy(linkInterface = interfaceName, linkAddress = address) }
62
+
63
+ /** Seed from the synchronous read in `onAvailable`; never clears what a callback reported. */
64
+ fun seedLinkProperties(interfaceName: String?, address: String?) =
65
+ update {
66
+ if (address == null) it else it.copy(linkInterface = interfaceName, linkAddress = address)
67
+ }
68
+
69
+ fun onInterfaceTable(owner: String?) = update { it.copy(tableOwner = owner) }
70
+
71
+ fun reset() = update { Observations() }
72
+
73
+ fun verdict(): Verdict = verdictOf(observations)
74
+
75
+ /** True once everything with a callback has reported, so only the table poll is left. */
76
+ fun callbacksSatisfied(): Boolean = callbacksSatisfiedBy(observations)
77
+
78
+ private inline fun update(transform: (Observations) -> Observations) {
79
+ synchronized(lock) { observations = transform(observations) }
80
+ }
81
+
82
+ companion object {
83
+ fun callbacksSatisfiedBy(observations: Observations): Boolean =
84
+ observations.available && observations.capabilities && observations.linkAddress != null
85
+
86
+ /** Ordered so the named blocker is the earliest unmet one, which is the useful one. */
87
+ fun verdictOf(observations: Observations): Verdict =
88
+ when {
89
+ !observations.available -> Verdict.Waiting("onAvailable")
90
+ !observations.capabilities -> Verdict.Waiting("onCapabilitiesChanged")
91
+ observations.linkAddress == null ->
92
+ Verdict.Waiting("an IPv4 address from onLinkPropertiesChanged")
93
+ observations.tableOwner == null ->
94
+ Verdict.Waiting("the kernel interface table to carry ${observations.linkAddress}")
95
+ else ->
96
+ Verdict.Ready(
97
+ observations.linkAddress,
98
+ observations.linkInterface,
99
+ observations.tableOwner,
100
+ )
101
+ }
102
+ }
103
+ }
@@ -0,0 +1,53 @@
1
+ package com.mentra.glassesmedia.network
2
+
3
+ import android.net.NetworkCapabilities
4
+
5
+ /**
6
+ * Declarative description of the SoftAP network request.
7
+ *
8
+ * `NetworkRequest.Builder` cannot be constructed in a plain JVM test, so the shape of the request
9
+ * lives here as data and [ScopedSoftApNetwork] translates it into the real builder. That makes the
10
+ * one property this architecture depends on — that the request does **not** ask for
11
+ * `NET_CAPABILITY_INTERNET` — assertable without a device.
12
+ *
13
+ * Dropping the internet capability is what keeps the phone dual-homed: Android will not promote the
14
+ * glasses hotspot to the default network, so ACS traffic stays on cellular while media crosses the
15
+ * local link. The other half of that guarantee is the MTK firmware patch that stops the AP
16
+ * advertising a default route.
17
+ */
18
+ data class ScopedNetworkRequestSpec(
19
+ val ssid: String,
20
+ val passphrase: String,
21
+ val transportTypes: Set<Int>,
22
+ val removedCapabilities: Set<Int>,
23
+ val timeoutMs: Int,
24
+ ) {
25
+ /** True when the request cannot be satisfied by, or promoted to, an internet-bearing network. */
26
+ val avoidsInternetCapability: Boolean
27
+ get() = removedCapabilities.contains(NetworkCapabilities.NET_CAPABILITY_INTERNET)
28
+
29
+ val hasPassphrase: Boolean
30
+ get() = passphrase.isNotEmpty()
31
+
32
+ companion object {
33
+ /**
34
+ * Android caps `requestNetwork` timeouts well above this; 30 s matches the value the
35
+ * Bluetooth SDK already uses for the same hotspot and is long enough for a cold AP start.
36
+ */
37
+ const val DEFAULT_TIMEOUT_MS = 30_000
38
+
39
+ /** The canonical SoftAP request. Every caller should go through this. */
40
+ fun forSoftAp(
41
+ ssid: String,
42
+ passphrase: String,
43
+ timeoutMs: Int = DEFAULT_TIMEOUT_MS,
44
+ ): ScopedNetworkRequestSpec =
45
+ ScopedNetworkRequestSpec(
46
+ ssid = ssid,
47
+ passphrase = passphrase,
48
+ transportTypes = setOf(NetworkCapabilities.TRANSPORT_WIFI),
49
+ removedCapabilities = setOf(NetworkCapabilities.NET_CAPABILITY_INTERNET),
50
+ timeoutMs = timeoutMs,
51
+ )
52
+ }
53
+ }
@@ -0,0 +1,127 @@
1
+ package com.mentra.glassesmedia.network
2
+
3
+ /**
4
+ * Pure lifecycle state machine for a scoped SoftAP network join.
5
+ *
6
+ * Split from the Android shell so the awkward parts — duplicate callbacks, callbacks arriving after
7
+ * a cancel, a timeout racing a late `onAvailable` — are unit testable without a device. The shell
8
+ * ([ScopedSoftApNetwork]) owns `ConnectivityManager` and forwards raw callbacks in here.
9
+ *
10
+ * `ConnectivityManager` gives no ordering or at-most-once guarantee: `onAvailable` can fire more
11
+ * than once, `onCapabilitiesChanged` fires repeatedly, and callbacks from a request that was already
12
+ * unregistered can still land. Every transition below is therefore idempotent and generation-guarded.
13
+ */
14
+ class ScopedNetworkState {
15
+
16
+ enum class Phase {
17
+ /** No request outstanding. */
18
+ IDLE,
19
+
20
+ /** `requestNetwork` issued, waiting for a usable network. */
21
+ REQUESTING,
22
+
23
+ /** Network joined and usable. */
24
+ AVAILABLE,
25
+
26
+ /** Terminal for this generation: released, lost, timed out, or failed. */
27
+ LOST,
28
+ }
29
+
30
+ /** Why a join ended without producing a usable network. */
31
+ enum class Failure {
32
+ NONE,
33
+ TIMEOUT,
34
+ UNAVAILABLE,
35
+ LOST,
36
+ PERMISSION_DENIED,
37
+ REQUEST_FAILED,
38
+ }
39
+
40
+ var phase: Phase = Phase.IDLE
41
+ private set
42
+
43
+ var failure: Failure = Failure.NONE
44
+ private set
45
+
46
+ /** Incremented on every new request so late callbacks from an old one can be rejected. */
47
+ var generation: Int = 0
48
+ private set
49
+
50
+ val isActive: Boolean
51
+ get() = phase == Phase.REQUESTING || phase == Phase.AVAILABLE
52
+
53
+ /**
54
+ * Begin a new join. Any in-flight generation is abandoned, so its callbacks become stale.
55
+ *
56
+ * @return the generation for this request; pass it back to every callback
57
+ */
58
+ fun startRequest(): Int {
59
+ generation += 1
60
+ phase = Phase.REQUESTING
61
+ failure = Failure.NONE
62
+ return generation
63
+ }
64
+
65
+ /** True when a callback belongs to the current, still-active generation. */
66
+ fun accepts(callbackGeneration: Int): Boolean = callbackGeneration == generation && isActive
67
+
68
+ /**
69
+ * A usable network arrived.
70
+ *
71
+ * @return true if this transitioned the machine; false when stale or already available
72
+ */
73
+ fun onAvailable(callbackGeneration: Int): Boolean {
74
+ if (!accepts(callbackGeneration)) return false
75
+ if (phase == Phase.AVAILABLE) return false // duplicate onAvailable
76
+ phase = Phase.AVAILABLE
77
+ return true
78
+ }
79
+
80
+ /** The request timed out before a usable network arrived. Ignored once available. */
81
+ fun onTimeout(callbackGeneration: Int): Boolean {
82
+ if (callbackGeneration != generation) return false
83
+ if (phase != Phase.REQUESTING) return false
84
+ phase = Phase.LOST
85
+ failure = Failure.TIMEOUT
86
+ return true
87
+ }
88
+
89
+ /** The framework reported the network cannot be provided. */
90
+ fun onUnavailable(callbackGeneration: Int): Boolean {
91
+ if (!accepts(callbackGeneration)) return false
92
+ phase = Phase.LOST
93
+ failure = Failure.UNAVAILABLE
94
+ return true
95
+ }
96
+
97
+ /** The joined network went away. This is the one the call path must react to. */
98
+ fun onLost(callbackGeneration: Int): Boolean {
99
+ if (!accepts(callbackGeneration)) return false
100
+ phase = Phase.LOST
101
+ failure = Failure.LOST
102
+ return true
103
+ }
104
+
105
+ /** `requestNetwork` threw, or the local-network permission was denied. */
106
+ fun onRequestFailed(callbackGeneration: Int, permissionDenied: Boolean): Boolean {
107
+ if (callbackGeneration != generation) return false
108
+ if (!isActive) return false
109
+ phase = Phase.LOST
110
+ failure = if (permissionDenied) Failure.PERMISSION_DENIED else Failure.REQUEST_FAILED
111
+ return true
112
+ }
113
+
114
+ /** Caller-initiated teardown. Not a failure. */
115
+ fun release(): Boolean {
116
+ if (!isActive) return false
117
+ phase = Phase.LOST
118
+ failure = Failure.NONE
119
+ return true
120
+ }
121
+
122
+ /** Return to IDLE so the same instance can be reused for the next call. */
123
+ fun reset() {
124
+ phase = Phase.IDLE
125
+ failure = Failure.NONE
126
+ }
127
+ }