@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,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
|
+
}
|