@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
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
package com.mentra.glassesmedia.network
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* An IPv4 subnet, taken from the phone's own address on a joined network.
|
|
5
|
+
*
|
|
6
|
+
* SoftAP correctness is an interface question, not an address-range question: the media path is
|
|
7
|
+
* right when the selected candidate sits on the network we joined, and wrong otherwise. Matching
|
|
8
|
+
* "looks RFC1918" or "starts with 192.168.43" answers a different question and stops being true the
|
|
9
|
+
* moment an OEM picks another hotspot subnet, so every SoftAP check compares against the prefix
|
|
10
|
+
* `LinkProperties` actually reported.
|
|
11
|
+
*/
|
|
12
|
+
data class Ipv4Prefix(val address: String, val prefixLength: Int) {
|
|
13
|
+
|
|
14
|
+
private val network: Int? = packIpv4(address)?.let { it and maskFor(prefixLength) }
|
|
15
|
+
|
|
16
|
+
/** True when [candidate] is a dotted-quad IPv4 address inside this prefix. */
|
|
17
|
+
fun contains(candidate: String?): Boolean {
|
|
18
|
+
val base = network ?: return false
|
|
19
|
+
val value = packIpv4(candidate) ?: return false
|
|
20
|
+
return (value and maskFor(prefixLength)) == base
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
override fun toString(): String = "$address/$prefixLength"
|
|
24
|
+
|
|
25
|
+
companion object {
|
|
26
|
+
/**
|
|
27
|
+
* Dotted-quad to a packed int, or null when [token] is not one. Deliberately strict: a
|
|
28
|
+
* token like `192.168.43` or `candidate:1` must not be read as an address, because the
|
|
29
|
+
* callers scan whitespace-split SDP lines where most tokens are not addresses at all.
|
|
30
|
+
*/
|
|
31
|
+
fun packIpv4(token: String?): Int? {
|
|
32
|
+
val text = token ?: return null
|
|
33
|
+
val octets = text.split('.')
|
|
34
|
+
if (octets.size != 4) return null
|
|
35
|
+
var packed = 0
|
|
36
|
+
for (octet in octets) {
|
|
37
|
+
if (octet.isEmpty() || octet.length > 3) return null
|
|
38
|
+
val value = octet.toIntOrNull() ?: return null
|
|
39
|
+
if (value < 0 || value > 255) return null
|
|
40
|
+
packed = (packed shl 8) or value
|
|
41
|
+
}
|
|
42
|
+
return packed
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A /0 masks nothing; Kotlin's `shl 32` is a no-op, so that case is spelled out. */
|
|
46
|
+
private fun maskFor(prefixLength: Int): Int {
|
|
47
|
+
val bits = prefixLength.coerceIn(0, 32)
|
|
48
|
+
return if (bits == 0) 0 else (-1 shl (32 - bits))
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
package com.mentra.glassesmedia.network
|
|
2
|
+
|
|
3
|
+
import java.net.Inet4Address
|
|
4
|
+
import java.net.NetworkInterface
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The kernel's interface table, as libwebrtc's `getifaddrs` sees it.
|
|
8
|
+
*
|
|
9
|
+
* Every SoftAP ICE failure so far has come from our view of the network disagreeing with
|
|
10
|
+
* libwebrtc's, and the two views come from different sources. We publish
|
|
11
|
+
* [ConnectivityManager.getLinkProperties][android.net.ConnectivityManager.getLinkProperties] for
|
|
12
|
+
* the scoped `Network`; libwebrtc enumerates `getifaddrs` and only asks our monitor for each
|
|
13
|
+
* *interface name*'s adapter type. When those disagree — an address on a name we never typed, or a
|
|
14
|
+
* scoped address absent from the table entirely — no mask and no inventory can help, because ICE
|
|
15
|
+
* gathers per address, not per `Network`.
|
|
16
|
+
*
|
|
17
|
+
* `java.net.NetworkInterface` is getifaddrs-backed, so this is the closest thing to libwebrtc's
|
|
18
|
+
* own input that we can read from Kotlin. Pure enough to unit test, which matters: it is the
|
|
19
|
+
* evidence the next diagnosis rests on.
|
|
20
|
+
*/
|
|
21
|
+
object NetworkFacts {
|
|
22
|
+
|
|
23
|
+
/** One interface, with the flags libwebrtc's enumeration also keys off. */
|
|
24
|
+
data class Iface(
|
|
25
|
+
val name: String,
|
|
26
|
+
val index: Int,
|
|
27
|
+
val up: Boolean,
|
|
28
|
+
val loopback: Boolean,
|
|
29
|
+
val virtual: Boolean,
|
|
30
|
+
val pointToPoint: Boolean,
|
|
31
|
+
val mtu: Int,
|
|
32
|
+
/** IPv4 only, as `address/prefixLength` — the form libwebrtc turns into a Network key. */
|
|
33
|
+
val ipv4: List<String>,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
/** Read the table. Never throws: a diagnostic that crashes the call is worse than no data. */
|
|
37
|
+
fun snapshot(): List<Iface> =
|
|
38
|
+
runCatching {
|
|
39
|
+
NetworkInterface.getNetworkInterfaces()?.toList().orEmpty().map { candidate ->
|
|
40
|
+
Iface(
|
|
41
|
+
name = candidate.name ?: "?",
|
|
42
|
+
index = runCatching { candidate.index }.getOrDefault(-1),
|
|
43
|
+
up = runCatching { candidate.isUp }.getOrDefault(false),
|
|
44
|
+
loopback = runCatching { candidate.isLoopback }.getOrDefault(false),
|
|
45
|
+
virtual = runCatching { candidate.isVirtual }.getOrDefault(false),
|
|
46
|
+
pointToPoint = runCatching { candidate.isPointToPoint }.getOrDefault(false),
|
|
47
|
+
mtu = runCatching { candidate.mtu }.getOrDefault(-1),
|
|
48
|
+
ipv4 =
|
|
49
|
+
runCatching {
|
|
50
|
+
candidate.interfaceAddresses
|
|
51
|
+
.filter { it.address is Inet4Address }
|
|
52
|
+
.map { "${it.address.hostAddress}/${it.networkPrefixLength}" }
|
|
53
|
+
}.getOrDefault(emptyList()),
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
}.getOrElse { emptyList() }
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Only interfaces that can carry a host candidate: up, non-loopback, with an IPv4 address.
|
|
60
|
+
* Anything here is something ICE may gather on.
|
|
61
|
+
*/
|
|
62
|
+
fun gatherable(table: List<Iface> = snapshot()): List<Iface> =
|
|
63
|
+
table.filter { it.up && !it.loopback && it.ipv4.isNotEmpty() }
|
|
64
|
+
|
|
65
|
+
/** The interface the kernel says owns [address], or null when the table has never seen it. */
|
|
66
|
+
fun ownerOf(address: String, table: List<Iface> = snapshot()): String? =
|
|
67
|
+
table.firstOrNull { iface -> iface.ipv4.any { it.substringBefore('/') == address } }?.name
|
|
68
|
+
|
|
69
|
+
/** One log line per interface, flags abbreviated so a full table fits in a logcat entry. */
|
|
70
|
+
fun render(table: List<Iface>): String =
|
|
71
|
+
table.joinToString(" ") { iface ->
|
|
72
|
+
val flags =
|
|
73
|
+
buildString {
|
|
74
|
+
if (iface.up) append("U")
|
|
75
|
+
if (iface.loopback) append("L")
|
|
76
|
+
if (iface.virtual) append("V")
|
|
77
|
+
if (iface.pointToPoint) append("P")
|
|
78
|
+
}
|
|
79
|
+
"${iface.name}#${iface.index}[$flags mtu=${iface.mtu}](${iface.ipv4.joinToString(",")})"
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
package com.mentra.glassesmedia.network
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.net.ConnectivityManager
|
|
5
|
+
import com.mentra.glassesmedia.trace.SoftApTrace
|
|
6
|
+
import java.net.Inet4Address
|
|
7
|
+
import org.webrtc.NetworkChangeDetector
|
|
8
|
+
import org.webrtc.NetworkMonitor
|
|
9
|
+
import org.webrtc.NetworkMonitorAutoDetect
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Adds the scoped SoftAP network to libwebrtc's Android network inventory.
|
|
13
|
+
*
|
|
14
|
+
* ## Why this exists
|
|
15
|
+
*
|
|
16
|
+
* The SoftAP [Network] is requested without `NET_CAPABILITY_INTERNET` so that Android keeps ACS on
|
|
17
|
+
* cellular. But stock libwebrtc's `NetworkMonitorAutoDetect` registers a request for
|
|
18
|
+
* *internet-capable* networks, so it never sees this one, and ICE therefore never gathers a host
|
|
19
|
+
* candidate on the hotspot subnet. Holding a scoped `Network` object is not by itself enough to make
|
|
20
|
+
* libwebrtc's internally-created UDP sockets use that interface — `PeerConnectionFactory.Options`
|
|
21
|
+
* exposes `networkIgnoreMask` and `disableNetworkMonitor`, not a socket factory or a network handle.
|
|
22
|
+
*
|
|
23
|
+
* Injecting the interface into the inventory is the supported way through: it is the same technique
|
|
24
|
+
* the glasses already use for their `ap0` tethering interface in
|
|
25
|
+
* `HotspotAwareNetworkChangeDetector`.
|
|
26
|
+
*
|
|
27
|
+
* The monitor this touches is process-wide and the libwebrtc AAR is shared with LiveKit, so the
|
|
28
|
+
* takeover is scoped and reversible; [ScopedNetworkObserver] documents and enforces that contract.
|
|
29
|
+
*
|
|
30
|
+
* ## Why the handle is the real [Network] handle
|
|
31
|
+
*
|
|
32
|
+
* Two device-proven behaviours, both captured, both fatal on their own:
|
|
33
|
+
*
|
|
34
|
+
* Handle `0` makes `BindSocketToNetwork` return `NOT_IMPLEMENTED`, so libwebrtc binds the socket to
|
|
35
|
+
* `192.168.43.x` and advertises that address. The answer is truthful. ICE checks then sit on that
|
|
36
|
+
* pair with `|CR-I|-|0|0|` forever: the OS accepted the bind, and even a send-only datagram, but
|
|
37
|
+
* unmarked UDP on a non-default network is not delivered. 18:56 / 19:01 runs: ACS `CONNECTED`,
|
|
38
|
+
* `ingest_host_candidate onHotspot=true`, then `ingest_no_first_frame` and the glasses reporting
|
|
39
|
+
* `ICE did not connect`.
|
|
40
|
+
*
|
|
41
|
+
* A real handle makes `android_setsocknetwork` succeed, so the socket is marked onto the scoped
|
|
42
|
+
* SoftAP and ICE packets can actually leave and return. libwebrtc then rebinds to `0.0.0.0` and
|
|
43
|
+
* `UDPPort` substitutes the default-route (cellular) address into the candidate:
|
|
44
|
+
*
|
|
45
|
+
* ```
|
|
46
|
+
* Allocate ports on wlan0
|
|
47
|
+
* Port[...:Net[wlan0:192.168.43.x/24:Wifi:id=5]]: Gathered candidate: Cand[...:10.48.51.x:...:host...]
|
|
48
|
+
* ```
|
|
49
|
+
*
|
|
50
|
+
* That advertised address is what the glasses would send to, and they have no route to it. So the
|
|
51
|
+
* handle is real — sockets must be marked — and [SoftApSdpGuard.pinHostAddresses] rewrites the
|
|
52
|
+
* answer the glasses see back onto the scoped IP. One inventory entry, so the handle cannot collide
|
|
53
|
+
* with a decoy the way `0` did when we published more than one.
|
|
54
|
+
*
|
|
55
|
+
* ## Status
|
|
56
|
+
*
|
|
57
|
+
* Whether stock libwebrtc needs this at all is exactly what the Phase 0.5 feasibility gate
|
|
58
|
+
* (`SoftApFeasibilityGateTest`) measures. The gate fired on-device (see [install]), so
|
|
59
|
+
* [AcsMeetingModule][com.mentra.acsmeeting.AcsMeetingModule] installs it at module creation.
|
|
60
|
+
*/
|
|
61
|
+
class ScopedNetworkChangeDetector
|
|
62
|
+
internal constructor(
|
|
63
|
+
private val delegate: NetworkChangeDetector,
|
|
64
|
+
private val scopedNetworkSupplier: () -> ScopedInterface?,
|
|
65
|
+
private val observer: ScopedNetworkObserver? = null,
|
|
66
|
+
) : NetworkChangeDetector {
|
|
67
|
+
|
|
68
|
+
constructor(
|
|
69
|
+
observer: NetworkChangeDetector.Observer,
|
|
70
|
+
context: Context,
|
|
71
|
+
scopedNetwork: ScopedSoftApNetwork,
|
|
72
|
+
) : this(wire(observer, context) { resolveScopedInterface(context, scopedNetwork) })
|
|
73
|
+
|
|
74
|
+
private constructor(wired: Wired) : this(wired.delegate, wired.scoped, wired.observer)
|
|
75
|
+
|
|
76
|
+
/** The SoftAP interface as libwebrtc needs to see it. */
|
|
77
|
+
data class ScopedInterface(
|
|
78
|
+
val name: String,
|
|
79
|
+
val networkHandle: Long,
|
|
80
|
+
val ipv4Addresses: List<ByteArray>,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* One inventory entry as libwebrtc was told about it.
|
|
85
|
+
*
|
|
86
|
+
* Kept as plain values rather than the webrtc type so [lastPublished] can outlive a gathering
|
|
87
|
+
* pass without pinning native objects, and so the diagnosis can be unit tested.
|
|
88
|
+
*/
|
|
89
|
+
data class PublishedEntry(
|
|
90
|
+
val name: String,
|
|
91
|
+
val type: String,
|
|
92
|
+
val handle: Long,
|
|
93
|
+
val addresses: List<String>,
|
|
94
|
+
) {
|
|
95
|
+
override fun toString(): String = "$name[$type]#$handle(${addresses.joinToString("/")})"
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
override fun getCurrentConnectionType(): NetworkChangeDetector.ConnectionType {
|
|
99
|
+
val delegateType = delegate.currentConnectionType
|
|
100
|
+
if (delegateType == NetworkChangeDetector.ConnectionType.CONNECTION_NONE &&
|
|
101
|
+
scopedNetworkSupplier() != null
|
|
102
|
+
) {
|
|
103
|
+
return NetworkChangeDetector.ConnectionType.CONNECTION_WIFI
|
|
104
|
+
}
|
|
105
|
+
return delegateType
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
override fun supportNetworkCallback(): Boolean = delegate.supportNetworkCallback()
|
|
109
|
+
|
|
110
|
+
override fun getActiveNetworkList(): List<NetworkChangeDetector.NetworkInformation> {
|
|
111
|
+
val scoped = scopedNetworkSupplier()
|
|
112
|
+
val table = NetworkFacts.snapshot()
|
|
113
|
+
val stock = delegate.activeNetworkList
|
|
114
|
+
|
|
115
|
+
// The three views that have to agree, logged side by side. libwebrtc gathers from the
|
|
116
|
+
// kernel table; we only get to label the names in it. A scoped address missing from
|
|
117
|
+
// `ifaddrs`, or present under a different name than LinkProperties claims, is
|
|
118
|
+
// unrecoverable by any mask — and is invisible unless all three are printed together.
|
|
119
|
+
SoftApTrace.stage(
|
|
120
|
+
"softap_ifaddrs",
|
|
121
|
+
"gatherable" to NetworkFacts.render(NetworkFacts.gatherable(table)),
|
|
122
|
+
"all" to NetworkFacts.render(table),
|
|
123
|
+
)
|
|
124
|
+
SoftApTrace.stage(
|
|
125
|
+
"webrtc_stock_inventory",
|
|
126
|
+
"count" to stock.orEmpty().size,
|
|
127
|
+
"interfaces" to renderInventory(stock),
|
|
128
|
+
)
|
|
129
|
+
if (scoped != null) {
|
|
130
|
+
val address = scoped.ipv4Addresses.firstOrNull()?.let { formatIpv4(it) }
|
|
131
|
+
val owner = address?.let { NetworkFacts.ownerOf(it, table) }
|
|
132
|
+
// The smoking-gun check. `claimed` is what LinkProperties told us; `owner` is what the
|
|
133
|
+
// kernel says. If owner is null the address is not in the table at all and ICE cannot
|
|
134
|
+
// gather it; if they differ, our WIFI label is being applied to the wrong interface.
|
|
135
|
+
SoftApTrace.stage(
|
|
136
|
+
"softap_scoped_address_owner",
|
|
137
|
+
"address" to (address ?: "none"),
|
|
138
|
+
"claimed" to scoped.name,
|
|
139
|
+
"owner" to (owner ?: "ABSENT"),
|
|
140
|
+
"agrees" to (owner != null && owner == scoped.name),
|
|
141
|
+
"handle" to scoped.networkHandle,
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
val merged = mergeScopedNetwork(stock, scoped)
|
|
146
|
+
lastPublished = snapshotOf(merged)
|
|
147
|
+
SoftApTrace.stage(
|
|
148
|
+
"webrtc_network_inventory",
|
|
149
|
+
"count" to merged.size,
|
|
150
|
+
"interfaces" to lastPublished.joinToString(",") { it.toString() },
|
|
151
|
+
)
|
|
152
|
+
observer?.publish(merged)
|
|
153
|
+
return merged
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
override fun destroy() {
|
|
157
|
+
// Give the monitor back before the delegate goes away, otherwise the synthetic entry is
|
|
158
|
+
// the last thing libwebrtc was told about and every later connection inherits it.
|
|
159
|
+
observer?.restore()
|
|
160
|
+
delegate.destroy()
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
companion object {
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* What was last handed to libwebrtc, for the rejection diagnostics.
|
|
167
|
+
*
|
|
168
|
+
* A missing hotspot candidate has several causes and they are only separable by knowing
|
|
169
|
+
* whether the hotspot was in the inventory at gathering time, and whether any two entries
|
|
170
|
+
* shared a handle. Process-wide because the inventory is; read-only for callers.
|
|
171
|
+
*/
|
|
172
|
+
@Volatile
|
|
173
|
+
@JvmStatic
|
|
174
|
+
var lastPublished: List<PublishedEntry> = emptyList()
|
|
175
|
+
private set
|
|
176
|
+
|
|
177
|
+
/** An inventory entry reduced to the fields a diagnosis or a trace needs. */
|
|
178
|
+
@JvmStatic
|
|
179
|
+
fun snapshotOf(
|
|
180
|
+
entries: List<NetworkChangeDetector.NetworkInformation>?,
|
|
181
|
+
): List<PublishedEntry> =
|
|
182
|
+
entries.orEmpty().map { info ->
|
|
183
|
+
PublishedEntry(
|
|
184
|
+
info.name,
|
|
185
|
+
info.type.name,
|
|
186
|
+
info.handle,
|
|
187
|
+
info.ipAddresses.map { formatIpv4(it.address) },
|
|
188
|
+
)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** `name[type]#handle(ips)` per entry — the shape both traces and diagnostics want. */
|
|
192
|
+
@JvmStatic
|
|
193
|
+
fun renderInventory(
|
|
194
|
+
entries: List<NetworkChangeDetector.NetworkInformation>?,
|
|
195
|
+
): String = snapshotOf(entries).joinToString(",") { it.toString() }
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The handle that tells libwebrtc "bind to the address, not to a network".
|
|
199
|
+
*
|
|
200
|
+
* `AndroidNetworkMonitor::BindSocketToNetwork` maps a zero handle to `NOT_IMPLEMENTED`,
|
|
201
|
+
* which is the only branch that leaves the socket bound to a real local address — and so
|
|
202
|
+
* the only branch that yields a truthful host candidate. Named because it is load-bearing,
|
|
203
|
+
* not a placeholder.
|
|
204
|
+
*/
|
|
205
|
+
const val UNBINDABLE_HANDLE = 0L
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Register this detector as the one libwebrtc builds when it starts monitoring.
|
|
209
|
+
*
|
|
210
|
+
* `NetworkMonitor` is process-wide and only consults the factory when it creates a
|
|
211
|
+
* detector — on the first `startMonitoring` and again after every `stopMonitoring` that
|
|
212
|
+
* drops the observer count to zero. So this must run before any `PeerConnectionFactory`
|
|
213
|
+
* exists, and [scopedNetworkSupplier] must resolve the *current* join at call time rather
|
|
214
|
+
* than capture one instance: the module recreates its [ScopedSoftApNetwork] across
|
|
215
|
+
* sessions.
|
|
216
|
+
*
|
|
217
|
+
* Idempotent. Feasibility gate result that made this necessary, on a Galaxy S25 /
|
|
218
|
+
* Android 16 with webrtc-sdk 137: `NetworkMonitorAutoDetect.networkToInfo` drops every
|
|
219
|
+
* network lacking `NET_CAPABILITY_INTERNET`, so the answer to the glasses' offer carried
|
|
220
|
+
* zero host candidates and the ingest returned WHIP 500 `no_softap_host_candidate`.
|
|
221
|
+
*/
|
|
222
|
+
private data class Wired(
|
|
223
|
+
val delegate: NetworkChangeDetector,
|
|
224
|
+
val scoped: () -> ScopedInterface?,
|
|
225
|
+
val observer: ScopedNetworkObserver,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* One observer is shared: AutoDetect's callbacks go through it, and so does the
|
|
230
|
+
* reconcile in [getActiveNetworkList]. Two instances would drop stock handles on one
|
|
231
|
+
* and publish the hotspot on the other, which is how the native list stayed dirty.
|
|
232
|
+
*/
|
|
233
|
+
private fun wire(
|
|
234
|
+
downstream: NetworkChangeDetector.Observer,
|
|
235
|
+
context: Context,
|
|
236
|
+
scoped: () -> ScopedInterface?,
|
|
237
|
+
): Wired {
|
|
238
|
+
val filter = ScopedNetworkObserver(downstream, scoped)
|
|
239
|
+
return Wired(NetworkMonitorAutoDetect(filter, context), scoped, filter)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
@JvmStatic
|
|
243
|
+
fun install(scopedNetworkSupplier: () -> ScopedSoftApNetwork?) {
|
|
244
|
+
val monitor = NetworkMonitor.getInstance()
|
|
245
|
+
// A live detector was built by an earlier factory (or the stock one) and will not be
|
|
246
|
+
// replaced until monitoring restarts; say so in the trace so a missing host candidate
|
|
247
|
+
// after this point has an explanation.
|
|
248
|
+
SoftApTrace.stage(
|
|
249
|
+
"webrtc_network_detector_installed",
|
|
250
|
+
"monitorObservers" to monitor.numObservers,
|
|
251
|
+
)
|
|
252
|
+
monitor.setNetworkChangeDetectorFactory { observer, context ->
|
|
253
|
+
val wired =
|
|
254
|
+
wire(observer, context) {
|
|
255
|
+
scopedNetworkSupplier()?.let { resolveScopedInterface(context, it) }
|
|
256
|
+
}
|
|
257
|
+
ScopedNetworkChangeDetector(wired.delegate, wired.scoped, wired.observer)
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* The SoftAP inventory: exactly one entry, the scoped network, typed Wi-Fi.
|
|
263
|
+
*
|
|
264
|
+
* Publishing decoy entries for the other interfaces was tried and is wrong. The theory was
|
|
265
|
+
* that an interface the monitor has never heard of comes back `ADAPTER_TYPE_UNKNOWN == 0`
|
|
266
|
+
* and `networkIgnoreMask and 0` is always `0`, making an omitted interface unmaskable. The
|
|
267
|
+
* device log says otherwise: an interface with no monitor entry is dropped from the network
|
|
268
|
+
* list entirely, not admitted as unknown. Here `rmnet_data0` vanishes the moment its entry
|
|
269
|
+
* is gone, while `lo` survives because libwebrtc types loopback from the interface flags
|
|
270
|
+
* and the mask drops it:
|
|
271
|
+
*
|
|
272
|
+
* ```
|
|
273
|
+
* Network connected: NetInfo[name wlan0; handle 0; type 2]
|
|
274
|
+
* Count of networks: 3
|
|
275
|
+
* Net[wlan0:192.168.43.x/24:Wifi:id=5]
|
|
276
|
+
* Net[lo:...:Loopback:id=2]
|
|
277
|
+
* Net[lo:127.0.0.x/8:Loopback:id=1]
|
|
278
|
+
* ```
|
|
279
|
+
*
|
|
280
|
+
* Worse, the decoys were actively fatal. `AndroidNetworkMonitor` keys its networks by
|
|
281
|
+
* *handle*, and both the hotspot and the decoys have to use [UNBINDABLE_HANDLE], so each
|
|
282
|
+
* `onNetworkConnect` overwrote the previous one. Whichever arrived last was the only
|
|
283
|
+
* network the monitor knew, and when that was a masked decoy the answer carried no
|
|
284
|
+
* candidates at all:
|
|
285
|
+
*
|
|
286
|
+
* ```
|
|
287
|
+
* Network connected: NetInfo[name wlan0; handle 0; type 2]
|
|
288
|
+
* Network connected: NetInfo[name rmnet_data0; handle 0; type 4]
|
|
289
|
+
* Count of networks: 4 -> rmnet_data0, rmnet_data0, lo, lo (wlan0 gone)
|
|
290
|
+
* ```
|
|
291
|
+
*
|
|
292
|
+
* One entry, therefore. The invariant stays structural — the only gatherable adapter is the
|
|
293
|
+
* scoped SoftAP — and it no longer depends on a handle being unique.
|
|
294
|
+
*
|
|
295
|
+
* When the hotspot shares an interface *name* with a stock address, the scoped entry wins
|
|
296
|
+
* and carries only its `LinkProperties` addresses. Type cannot separate two addresses on
|
|
297
|
+
* one name, so that residual case is caught by `SoftApSdpGuard` instead.
|
|
298
|
+
*/
|
|
299
|
+
@JvmStatic
|
|
300
|
+
fun mergeScopedNetwork(
|
|
301
|
+
detected: List<NetworkChangeDetector.NetworkInformation>?,
|
|
302
|
+
scoped: ScopedInterface?,
|
|
303
|
+
): List<NetworkChangeDetector.NetworkInformation> {
|
|
304
|
+
val hotspot = scoped?.let { toNetworkInformation(it) } ?: return detected.orEmpty()
|
|
305
|
+
return listOf(hotspot)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Dotted-quad for the inventory trace. */
|
|
309
|
+
@JvmStatic
|
|
310
|
+
fun formatIpv4(bytes: ByteArray): String =
|
|
311
|
+
bytes.joinToString(".") { (it.toInt() and 0xff).toString() }
|
|
312
|
+
|
|
313
|
+
/** Null when the interface has no address, which libwebrtc cannot use. */
|
|
314
|
+
@JvmStatic
|
|
315
|
+
fun toNetworkInformation(
|
|
316
|
+
scoped: ScopedInterface,
|
|
317
|
+
): NetworkChangeDetector.NetworkInformation? {
|
|
318
|
+
if (scoped.ipv4Addresses.isEmpty()) return null
|
|
319
|
+
val addresses =
|
|
320
|
+
scoped.ipv4Addresses
|
|
321
|
+
.map { NetworkChangeDetector.IPAddress(it) }
|
|
322
|
+
.toTypedArray()
|
|
323
|
+
return NetworkChangeDetector.NetworkInformation(
|
|
324
|
+
scoped.name,
|
|
325
|
+
NetworkChangeDetector.ConnectionType.CONNECTION_WIFI,
|
|
326
|
+
NetworkChangeDetector.ConnectionType.CONNECTION_NONE,
|
|
327
|
+
// The real handle, so BindSocketToNetwork marks ICE sockets onto this Network.
|
|
328
|
+
// Handle 0 advertised the right address and then never completed a check; see the
|
|
329
|
+
// class doc. SoftApSdpGuard.pinHostAddresses puts the scoped IP back in the SDP.
|
|
330
|
+
scoped.networkHandle,
|
|
331
|
+
addresses,
|
|
332
|
+
)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Resolve the joined SoftAP into the interface libwebrtc should be told about. */
|
|
336
|
+
private fun resolveScopedInterface(
|
|
337
|
+
context: Context,
|
|
338
|
+
scopedNetwork: ScopedSoftApNetwork,
|
|
339
|
+
): ScopedInterface? {
|
|
340
|
+
val network = scopedNetwork.network() ?: return null
|
|
341
|
+
val manager =
|
|
342
|
+
context.getSystemService(ConnectivityManager::class.java) ?: return null
|
|
343
|
+
val properties = manager.getLinkProperties(network) ?: return null
|
|
344
|
+
val interfaceName = properties.interfaceName ?: return null
|
|
345
|
+
// LinkProperties of *this* Network, not NetworkInterface.getByName. On a phone that
|
|
346
|
+
// kept internet Wi-Fi up, wlan0 can carry both 10.x and 192.168.43.x; getByName would
|
|
347
|
+
// hand ICE both, and it just picked the 10.x.
|
|
348
|
+
val addresses =
|
|
349
|
+
properties.linkAddresses
|
|
350
|
+
.mapNotNull { (it.address as? Inet4Address)?.address }
|
|
351
|
+
if (addresses.isEmpty()) return null
|
|
352
|
+
return ScopedInterface(interfaceName, network.networkHandle, addresses)
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
package com.mentra.glassesmedia.network
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Typed failures for a scoped SoftAP join.
|
|
5
|
+
*
|
|
6
|
+
* A denied local-network permission must surface as a distinct, immediate error rather than a
|
|
7
|
+
* 30-second hang that looks like a flaky hotspot. `ACCESS_LOCAL_NETWORK` sits in the nearby-devices
|
|
8
|
+
* group, so it is often already granted during development and easy to miss until a user without it
|
|
9
|
+
* hits a silent timeout.
|
|
10
|
+
*/
|
|
11
|
+
sealed class ScopedNetworkError(val code: String, message: String) : Exception(message) {
|
|
12
|
+
|
|
13
|
+
/** `ACCESS_LOCAL_NETWORK` (or the nearby-devices prerequisite) was not granted. */
|
|
14
|
+
class PermissionDenied(val permission: String) :
|
|
15
|
+
ScopedNetworkError(
|
|
16
|
+
CODE_PERMISSION_DENIED,
|
|
17
|
+
"Local network permission not granted: $permission",
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
/** No usable network arrived before the request timeout. */
|
|
21
|
+
class Timeout(val ssid: String, val timeoutMs: Int) :
|
|
22
|
+
ScopedNetworkError(CODE_TIMEOUT, "Timed out joining $ssid after ${timeoutMs}ms")
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The framework reported it cannot provide this network at all.
|
|
26
|
+
*
|
|
27
|
+
* WifiNetworkSpecifier does not keep scanning for the request timeout. If the glasses AP is
|
|
28
|
+
* not in the current scan — or the user dismissed the system join sheet — this arrives in
|
|
29
|
+
* well under a second.
|
|
30
|
+
*/
|
|
31
|
+
class Unavailable(val ssid: String) :
|
|
32
|
+
ScopedNetworkError(
|
|
33
|
+
CODE_UNAVAILABLE,
|
|
34
|
+
"Could not join $ssid (SSID not in scan, Wi-Fi off, or the system join prompt was dismissed)",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
/** The network was joined and then went away mid-call. */
|
|
38
|
+
class Lost(val ssid: String) :
|
|
39
|
+
ScopedNetworkError(CODE_LOST, "Lost the connection to $ssid")
|
|
40
|
+
|
|
41
|
+
/** `requestNetwork` itself threw. */
|
|
42
|
+
class RequestFailed(val reason: String) :
|
|
43
|
+
ScopedNetworkError(CODE_REQUEST_FAILED, "Scoped network request failed: $reason")
|
|
44
|
+
|
|
45
|
+
/** Joined, but no IPv4 address was assigned, so nothing can bind to the link. */
|
|
46
|
+
class NoLocalAddress(val ssid: String) :
|
|
47
|
+
ScopedNetworkError(CODE_NO_LOCAL_ADDRESS, "Joined $ssid but no IPv4 address was assigned")
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Joined, but the network never became usable for ICE: a callback never reported, or the
|
|
51
|
+
* address never appeared in the kernel's interface table.
|
|
52
|
+
*
|
|
53
|
+
* Failing here is deliberate. Proceeding produces an ICE answer with no hotspot candidate,
|
|
54
|
+
* which is the same symptom as several unrelated faults and cost real time to attribute.
|
|
55
|
+
*/
|
|
56
|
+
class NotReady(val ssid: String, val missing: String) :
|
|
57
|
+
ScopedNetworkError(
|
|
58
|
+
CODE_NOT_READY,
|
|
59
|
+
"Joined $ssid but the network never became usable: still waiting for $missing",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
/** Phone Wi-Fi radio is off. WifiNetworkSpecifier then fails as Unavailable in under a second. */
|
|
63
|
+
class WifiDisabled :
|
|
64
|
+
ScopedNetworkError(CODE_WIFI_DISABLED, "Phone Wi-Fi is off; turn it on before joining the glasses hotspot")
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A VPN owns this app's UID. netd then refuses to bind our sockets to the hotspot network
|
|
68
|
+
* (`EPERM`), and the glasses' TCP handshake to the WHIP listener never completes because the
|
|
69
|
+
* reply path is captured by the tunnel. Verified on-device: a shell-UID listener on the same
|
|
70
|
+
* address accepts the glasses' connection while an app-UID listener times out. The scoped join
|
|
71
|
+
* itself succeeds, so without this check the failure only shows up ten seconds into the camera
|
|
72
|
+
* step as an opaque connect timeout.
|
|
73
|
+
*/
|
|
74
|
+
class VpnCapturesApp :
|
|
75
|
+
ScopedNetworkError(
|
|
76
|
+
CODE_VPN_ACTIVE,
|
|
77
|
+
"A VPN is routing this app's traffic, so the glasses cannot reach the phone. " +
|
|
78
|
+
"Exclude Mentra from the VPN (split tunneling) or turn the VPN off, then retry",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
companion object {
|
|
82
|
+
const val CODE_PERMISSION_DENIED = "SOFTAP_PERMISSION_DENIED"
|
|
83
|
+
const val CODE_TIMEOUT = "SOFTAP_JOIN_TIMEOUT"
|
|
84
|
+
const val CODE_UNAVAILABLE = "SOFTAP_UNAVAILABLE"
|
|
85
|
+
const val CODE_LOST = "SOFTAP_NETWORK_LOST"
|
|
86
|
+
const val CODE_REQUEST_FAILED = "SOFTAP_REQUEST_FAILED"
|
|
87
|
+
const val CODE_NO_LOCAL_ADDRESS = "SOFTAP_NO_LOCAL_ADDRESS"
|
|
88
|
+
const val CODE_NOT_READY = "SOFTAP_NETWORK_NOT_READY"
|
|
89
|
+
const val CODE_WIFI_DISABLED = "SOFTAP_WIFI_DISABLED"
|
|
90
|
+
const val CODE_VPN_ACTIVE = "SOFTAP_VPN_ACTIVE"
|
|
91
|
+
|
|
92
|
+
/** Map a terminal state-machine failure onto the typed error the call path reports. */
|
|
93
|
+
fun from(failure: ScopedNetworkState.Failure, ssid: String, timeoutMs: Int): ScopedNetworkError? =
|
|
94
|
+
when (failure) {
|
|
95
|
+
ScopedNetworkState.Failure.NONE -> null
|
|
96
|
+
ScopedNetworkState.Failure.TIMEOUT -> Timeout(ssid, timeoutMs)
|
|
97
|
+
ScopedNetworkState.Failure.UNAVAILABLE -> Unavailable(ssid)
|
|
98
|
+
ScopedNetworkState.Failure.LOST -> Lost(ssid)
|
|
99
|
+
ScopedNetworkState.Failure.PERMISSION_DENIED -> PermissionDenied(LOCAL_NETWORK_PERMISSION)
|
|
100
|
+
ScopedNetworkState.Failure.REQUEST_FAILED -> RequestFailed("requestNetwork threw")
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Enforced on Android 17+ for apps targeting SDK 37+. Hosts targeting older SDKs
|
|
105
|
+
* retain implicit access through INTERNET and must not request this permission.
|
|
106
|
+
*/
|
|
107
|
+
const val LOCAL_NETWORK_PERMISSION = "android.permission.ACCESS_LOCAL_NETWORK"
|
|
108
|
+
}
|
|
109
|
+
}
|