@mentra/glasses-media 3.2.0-dev.259 → 3.2.0-dev.262
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/android/src/main/java/com/mentra/glassesmedia/GlassesMediaRelayModule.kt +3 -1
- package/android/src/main/java/com/mentra/glassesmedia/source/GlassesMediaSource.kt +60 -1
- package/android/src/main/java/com/mentra/glassesmedia/source/LocalWhipIngestSource.kt +2 -2
- package/android/src/main/java/com/mentra/glassesmedia/source/VideoSourceArm.kt +10 -0
- package/ios/CoreKit/Sources/GlassesMediaCore/GlassesMediaSource.swift +14 -0
- package/ios/LocalWhipIngestSource.swift +113 -29
- package/package.json +1 -1
|
@@ -38,7 +38,9 @@ class GlassesMediaRelayModule : Module() {
|
|
|
38
38
|
val context = requireNotNull(appContext.reactContext).applicationContext
|
|
39
39
|
activeId = id
|
|
40
40
|
val hold = InternetHold(context).also { internet = it }
|
|
41
|
-
check(hold.awaitValidatedCellular().validated) {
|
|
41
|
+
check(hold.awaitValidatedCellular().validated) {
|
|
42
|
+
"Turn on phone mobile data to stream through the glasses hotspot"
|
|
43
|
+
}
|
|
42
44
|
val scoped = ScopedSoftApNetwork(context).also { network = it }
|
|
43
45
|
if (!scoped.isWifiEnabled()) {
|
|
44
46
|
Handler(Looper.getMainLooper()).post {
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
package com.mentra.glassesmedia.source
|
|
2
2
|
|
|
3
|
+
import com.mentra.glassesmedia.trace.SoftApTrace
|
|
4
|
+
|
|
3
5
|
enum class SourceKind {
|
|
4
6
|
/** Subscribe to a Cloudflare WHEP endpoint. The phone is the offerer. */
|
|
5
7
|
WHEP,
|
|
@@ -83,6 +85,18 @@ interface GlassesMediaSource {
|
|
|
83
85
|
fun setPcmDeliveryEnabled(enabled: Boolean)
|
|
84
86
|
fun setTargetSize(size: TargetSize?) {}
|
|
85
87
|
fun setStateListener(listener: SourceStateListener?) {}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Drop a retiring SoftAP listener now, skipping its tombstone. No-op for every other kind.
|
|
91
|
+
*
|
|
92
|
+
* After [restart], this is the previous listener generation — not the one that just bound.
|
|
93
|
+
*/
|
|
94
|
+
fun forceCloseIngest() {}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Has the retiring SoftAP listener released its port? `true` when there is nothing to wait for.
|
|
98
|
+
*/
|
|
99
|
+
fun awaitIngestClosed(timeoutMs: Long): Boolean = true
|
|
86
100
|
}
|
|
87
101
|
|
|
88
102
|
/**
|
|
@@ -106,8 +120,11 @@ class GlassesMediaController(
|
|
|
106
120
|
* [stop] drops the source, but a WHIP listener answers `410` for a few seconds after that and
|
|
107
121
|
* still holds its port. Losing the reference here is what made the port's release unobservable
|
|
108
122
|
* from above, so the next call bound into it.
|
|
123
|
+
*
|
|
124
|
+
* After [rebindIngest], this is the current source: [GlassesMediaSource.restart] parks the
|
|
125
|
+
* previous listener on that object, so [forceCloseIngest] / [awaitIngestClosed] stay unambiguous.
|
|
109
126
|
*/
|
|
110
|
-
private var retiringIngest:
|
|
127
|
+
private var retiringIngest: GlassesMediaSource? = null
|
|
111
128
|
|
|
112
129
|
val state: SourceState
|
|
113
130
|
get() = source?.state ?: SourceState.IDLE
|
|
@@ -128,6 +145,48 @@ class GlassesMediaController(
|
|
|
128
145
|
source?.restart(config)
|
|
129
146
|
}
|
|
130
147
|
|
|
148
|
+
/**
|
|
149
|
+
* Destroy the current SoftAP listener generation and bind a new one.
|
|
150
|
+
*
|
|
151
|
+
* [GlassesMediaSource.restart] is [LocalWhipIngestSource.start]: it parks the old listener as
|
|
152
|
+
* retiring and binds a new OS-chosen port. This then force-closes that parked generation —
|
|
153
|
+
* no tombstone wait — and refuses a null or unchanged URL so a stale listener cannot be reused.
|
|
154
|
+
*
|
|
155
|
+
* Does not call [LocalWhipIngestSource.close], which would dispose the shared
|
|
156
|
+
* PeerConnectionFactory.
|
|
157
|
+
*/
|
|
158
|
+
fun rebindIngest(config: SourceConfig, timeoutMs: Long): String {
|
|
159
|
+
if (!MediaDiagnostics.SOFTAP_RECOVERY_ENABLED) {
|
|
160
|
+
SoftApTrace.failure("ingest_rebind_disabled")
|
|
161
|
+
throw IllegalStateException("SoftAP ingest rebind is disabled")
|
|
162
|
+
}
|
|
163
|
+
val current = source ?: throw IllegalStateException("No media source to rebind")
|
|
164
|
+
val oldUrl = ingestUrl
|
|
165
|
+
current.restart(config)
|
|
166
|
+
val newUrl = ingestUrl
|
|
167
|
+
if (newUrl.isNullOrBlank() || newUrl == oldUrl) {
|
|
168
|
+
SoftApTrace.failure("ingest_rebind_stale", "oldUrl" to oldUrl, "newUrl" to newUrl)
|
|
169
|
+
throw IllegalStateException(
|
|
170
|
+
"SoftAP ingest rebind did not mint a new listener (old=$oldUrl new=$newUrl)",
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
// After restart the current source's retiring handle is the previous listener.
|
|
174
|
+
retiringIngest = current
|
|
175
|
+
forceCloseIngest()
|
|
176
|
+
val closed = awaitIngestClosed(timeoutMs)
|
|
177
|
+
SoftApTrace.stage(
|
|
178
|
+
"ingest_rebind",
|
|
179
|
+
"oldUrl" to oldUrl,
|
|
180
|
+
"newUrl" to newUrl,
|
|
181
|
+
"closed" to closed,
|
|
182
|
+
)
|
|
183
|
+
if (!closed) {
|
|
184
|
+
SoftApTrace.failure("ingest_rebind_close_failed", "oldUrl" to oldUrl, "newUrl" to newUrl)
|
|
185
|
+
throw IllegalStateException("SoftAP ingest rebind did not release the old listener")
|
|
186
|
+
}
|
|
187
|
+
return newUrl
|
|
188
|
+
}
|
|
189
|
+
|
|
131
190
|
fun forceRestart() {
|
|
132
191
|
source?.forceRestart()
|
|
133
192
|
}
|
|
@@ -183,7 +183,7 @@ class LocalWhipIngestSource(
|
|
|
183
183
|
* Trivially true when nothing was ever bound, so a teardown after a join that failed before the
|
|
184
184
|
* listener existed does not spend the whole bound discovering there is nothing to wait for.
|
|
185
185
|
*/
|
|
186
|
-
fun awaitIngestClosed(timeoutMs: Long): Boolean = (retiring ?: server)?.awaitClosed(timeoutMs) ?: true
|
|
186
|
+
override fun awaitIngestClosed(timeoutMs: Long): Boolean = (retiring ?: server)?.awaitClosed(timeoutMs) ?: true
|
|
187
187
|
|
|
188
188
|
/**
|
|
189
189
|
* Drop the listener now, tombstone or not.
|
|
@@ -192,7 +192,7 @@ class LocalWhipIngestSource(
|
|
|
192
192
|
* gets a connection reset instead of `410`, which is a worse answer — but it is a better outcome
|
|
193
193
|
* than a next call that cannot bind its port.
|
|
194
194
|
*/
|
|
195
|
-
fun forceCloseIngest() {
|
|
195
|
+
override fun forceCloseIngest() {
|
|
196
196
|
// Keep the handle: forceSoftapCleanup re-asks awaitIngestClosed to confirm the port is really
|
|
197
197
|
// free. Nulling here would make that check read `null -> true` and mask a closeNow that threw,
|
|
198
198
|
// so the next Start binds a port this listener still holds. closeNow/awaitClosed are idempotent,
|
|
@@ -102,6 +102,16 @@ object MediaDiagnostics {
|
|
|
102
102
|
* timestamps is what makes the Teams jitter buffer hold.
|
|
103
103
|
*/
|
|
104
104
|
const val acsAudioTimestamps = true
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* SoftAP ingest rebind kill switch. Ships on.
|
|
108
|
+
*
|
|
109
|
+
* When false, [GlassesMediaController.rebindIngest] refuses rather than destroying the live
|
|
110
|
+
* listener. Flip locally to freeze recovery without a rebuild.
|
|
111
|
+
*/
|
|
112
|
+
@JvmField
|
|
113
|
+
@Volatile
|
|
114
|
+
var SOFTAP_RECOVERY_ENABLED = true
|
|
105
115
|
}
|
|
106
116
|
|
|
107
117
|
data class SyntheticConfig(
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import Foundation
|
|
2
2
|
|
|
3
|
+
/// Investigation arms shared with the Android [MediaDiagnostics] object.
|
|
4
|
+
public enum MediaDiagnostics {
|
|
5
|
+
/// SoftAP ingest rebind kill switch. Ships on. Flip off to refuse destroying the live listener.
|
|
6
|
+
public static var softapRecoveryEnabled = true
|
|
7
|
+
}
|
|
8
|
+
|
|
3
9
|
public enum SourceKind: String, Sendable {
|
|
4
10
|
case whep
|
|
5
11
|
case direct
|
|
@@ -33,6 +39,8 @@ public protocol GlassesMediaSource: AnyObject {
|
|
|
33
39
|
func restart(config: SourceConfig)
|
|
34
40
|
func stop()
|
|
35
41
|
func setPcmDeliveryEnabled(_ enabled: Bool)
|
|
42
|
+
func forceCloseIngest()
|
|
43
|
+
func awaitIngestClosed(timeoutMs: Int) -> Bool
|
|
36
44
|
}
|
|
37
45
|
|
|
38
46
|
public final class GlassesMediaController {
|
|
@@ -74,6 +82,12 @@ public extension GlassesMediaSource {
|
|
|
74
82
|
}
|
|
75
83
|
|
|
76
84
|
func forceRestart() {}
|
|
85
|
+
|
|
86
|
+
func forceCloseIngest() {}
|
|
87
|
+
|
|
88
|
+
func awaitIngestClosed(timeoutMs _: Int) -> Bool {
|
|
89
|
+
true
|
|
90
|
+
}
|
|
77
91
|
}
|
|
78
92
|
|
|
79
93
|
public extension SourceConfig {
|
|
@@ -26,6 +26,8 @@ public final class LocalWhipIngestSource: NSObject, DecodedGlassesMediaSource {
|
|
|
26
26
|
private var factory: RTCPeerConnectionFactory?
|
|
27
27
|
private var peer: RTCPeerConnection?
|
|
28
28
|
private var server: WhipIngestServer?
|
|
29
|
+
/// Parked by [rebindIngest] so the old generation still holds its port until force-close.
|
|
30
|
+
private var retiring: WhipIngestServer?
|
|
29
31
|
private var video: RTCVideoTrack?
|
|
30
32
|
private var audio: [RTCAudioTrack] = []
|
|
31
33
|
private var renderer: LocalMediaRenderer?
|
|
@@ -56,16 +58,94 @@ public final class LocalWhipIngestSource: NSObject, DecodedGlassesMediaSource {
|
|
|
56
58
|
queue.async { self.transition(.failed, "local_restart_requires_republish") }
|
|
57
59
|
}
|
|
58
60
|
|
|
61
|
+
/// Destroy the current listener generation and bind a new one. Fail closed: a null or
|
|
62
|
+
/// unchanged URL is refused, and the parked listener is force-closed rather than reused.
|
|
63
|
+
public func rebindIngest(config: SourceConfig, timeoutMs: Int, completion: @escaping (Result<String, Error>) -> Void) {
|
|
64
|
+
queue.async {
|
|
65
|
+
guard MediaDiagnostics.softapRecoveryEnabled else {
|
|
66
|
+
completion(.failure(LocalMediaError("SoftAP ingest rebind is disabled")))
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
guard !self.stopping else {
|
|
70
|
+
completion(.failure(LocalMediaError("SoftAP ingest rebind is not possible while stopping")))
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
let oldUrl = self.url
|
|
74
|
+
if let server = self.server {
|
|
75
|
+
self.retiring = server
|
|
76
|
+
self.server = nil
|
|
77
|
+
}
|
|
78
|
+
self.url = nil
|
|
79
|
+
self.closePeer()
|
|
80
|
+
self.startListener(config: config) { result in
|
|
81
|
+
switch result {
|
|
82
|
+
case let .failure(error):
|
|
83
|
+
completion(.failure(error))
|
|
84
|
+
case let .success(newUrl):
|
|
85
|
+
if newUrl.isEmpty || newUrl == oldUrl {
|
|
86
|
+
completion(.failure(LocalMediaError(
|
|
87
|
+
"SoftAP ingest rebind did not mint a new listener (old=\(oldUrl ?? "none") new=\(newUrl))"
|
|
88
|
+
)))
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
self.forceCloseIngestLocked()
|
|
92
|
+
let closed = self.awaitIngestClosedLocked(timeoutMs: timeoutMs)
|
|
93
|
+
NSLog("SOFTAP_TRACE stage=ingest_rebind oldUrl=\(oldUrl ?? "none") newUrl=\(newUrl) closed=\(closed)")
|
|
94
|
+
if !closed {
|
|
95
|
+
completion(.failure(LocalMediaError("SoftAP ingest rebind did not release the old listener")))
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
completion(.success(newUrl))
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
public func forceCloseIngest() {
|
|
105
|
+
if DispatchQueue.getSpecific(key: queueKey) == true {
|
|
106
|
+
forceCloseIngestLocked()
|
|
107
|
+
} else {
|
|
108
|
+
queue.sync { self.forceCloseIngestLocked() }
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
public func awaitIngestClosed(timeoutMs: Int) -> Bool {
|
|
113
|
+
if DispatchQueue.getSpecific(key: queueKey) == true {
|
|
114
|
+
return awaitIngestClosedLocked(timeoutMs: timeoutMs)
|
|
115
|
+
}
|
|
116
|
+
return queue.sync { self.awaitIngestClosedLocked(timeoutMs: timeoutMs) }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private func forceCloseIngestLocked() {
|
|
120
|
+
retiring?.stop {}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private func awaitIngestClosedLocked(timeoutMs: Int) -> Bool {
|
|
124
|
+
guard let retiring else { return true }
|
|
125
|
+
let done = DispatchSemaphore(value: 0)
|
|
126
|
+
retiring.stop { done.signal() }
|
|
127
|
+
return done.wait(timeout: .now() + .milliseconds(max(timeoutMs, 0))) == .success
|
|
128
|
+
}
|
|
129
|
+
|
|
59
130
|
/// Resolves only after the HTTP listener is bound. The owner can then tell the glasses to publish.
|
|
60
131
|
public func prepare(config: SourceConfig, completion: @escaping (Result<String, Error>) -> Void) {
|
|
61
132
|
queue.async {
|
|
62
|
-
guard self.server == nil, !self.stopping
|
|
133
|
+
guard self.server == nil, !self.stopping else {
|
|
63
134
|
completion(.failure(LocalMediaError("A free receiver and the phone's hotspot address are required"))); return
|
|
64
135
|
}
|
|
65
|
-
self.
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
136
|
+
self.startListener(config: config, completion: completion)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private func startListener(config: SourceConfig, completion: @escaping (Result<String, Error>) -> Void) {
|
|
141
|
+
guard let address = config.bindAddress, LocalMediaPolicy.isPrivate(address) else {
|
|
142
|
+
completion(.failure(LocalMediaError("A free receiver and the phone's hotspot address are required"))); return
|
|
143
|
+
}
|
|
144
|
+
self.address = address
|
|
145
|
+
generation += 1
|
|
146
|
+
let gen = generation
|
|
147
|
+
transition(.connecting, "listening")
|
|
148
|
+
if factory == nil {
|
|
69
149
|
RTCInitializeSSL()
|
|
70
150
|
let factory = GlassesPeerFactory.make(audioDevice: ReceiveOnlyAudioDevice())
|
|
71
151
|
let options = RTCPeerConnectionFactoryOptions()
|
|
@@ -75,31 +155,31 @@ public final class LocalWhipIngestSource: NSObject, DecodedGlassesMediaSource {
|
|
|
75
155
|
options.ignoreEthernetNetworkAdapter = true
|
|
76
156
|
factory.setOptions(options)
|
|
77
157
|
self.factory = factory
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
}
|
|
89
|
-
},
|
|
90
|
-
publisherFailed: { [weak self] in self?.state == .failed }
|
|
91
|
-
)
|
|
92
|
-
self.server = server
|
|
93
|
-
server.start(address: address) { [weak self] result in
|
|
94
|
-
guard let self else { completion(.failure(LocalMediaError("Receiver released"))); return }
|
|
95
|
-
self.queue.async {
|
|
96
|
-
guard gen == self.generation, !self.stopping else { completion(.failure(LocalMediaError("Receiver cancelled"))); return }
|
|
97
|
-
switch result {
|
|
98
|
-
case let .success(url): self.url = url
|
|
99
|
-
case let .failure(error): self.transition(.failed, error.localizedDescription)
|
|
100
|
-
}
|
|
101
|
-
completion(result)
|
|
158
|
+
}
|
|
159
|
+
let server = WhipIngestServer(
|
|
160
|
+
negotiate: { [weak self] offer, reply in
|
|
161
|
+
guard let self else { reply(.failure(LocalMediaError("Receiver released"))); return }
|
|
162
|
+
self.queue.async { self.negotiate(offer, completion: reply) }
|
|
163
|
+
},
|
|
164
|
+
terminate: { [weak self] in
|
|
165
|
+
self?.queue.async {
|
|
166
|
+
guard let self else { return }
|
|
167
|
+
self.closePeer()
|
|
168
|
+
if self.currentState != .idle { self.transition(.failed, "publisher_terminated") }
|
|
102
169
|
}
|
|
170
|
+
},
|
|
171
|
+
publisherFailed: { [weak self] in self?.state == .failed }
|
|
172
|
+
)
|
|
173
|
+
self.server = server
|
|
174
|
+
server.start(address: address) { [weak self] result in
|
|
175
|
+
guard let self else { completion(.failure(LocalMediaError("Receiver released"))); return }
|
|
176
|
+
self.queue.async {
|
|
177
|
+
guard gen == self.generation, !self.stopping else { completion(.failure(LocalMediaError("Receiver cancelled"))); return }
|
|
178
|
+
switch result {
|
|
179
|
+
case let .success(url): self.url = url
|
|
180
|
+
case let .failure(error): self.transition(.failed, error.localizedDescription)
|
|
181
|
+
}
|
|
182
|
+
completion(result)
|
|
103
183
|
}
|
|
104
184
|
}
|
|
105
185
|
}
|
|
@@ -121,6 +201,10 @@ public final class LocalWhipIngestSource: NSObject, DecodedGlassesMediaSource {
|
|
|
121
201
|
self.url = nil
|
|
122
202
|
self.closePeer()
|
|
123
203
|
self.transition(.idle, "stop")
|
|
204
|
+
if let retiring = self.retiring {
|
|
205
|
+
self.retiring = nil
|
|
206
|
+
retiring.stop {}
|
|
207
|
+
}
|
|
124
208
|
let server = self.server
|
|
125
209
|
self.server = nil
|
|
126
210
|
let finish = {
|