@mentra/glasses-media 3.2.0-dev.261 → 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.
@@ -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) { "Turn on phone mobile data to stream through the glasses hotspot" }
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: LocalWhipIngestSource? = null
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, let address = config.bindAddress, LocalMediaPolicy.isPrivate(address) else {
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.address = address
66
- self.generation += 1
67
- let gen = self.generation
68
- self.transition(.connecting, "listening")
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
- let server = WhipIngestServer(
79
- negotiate: { [weak self] offer, reply in
80
- guard let self else { reply(.failure(LocalMediaError("Receiver released"))); return }
81
- self.queue.async { self.negotiate(offer, completion: reply) }
82
- },
83
- terminate: { [weak self] in
84
- self?.queue.async {
85
- guard let self else { return }
86
- self.closePeer()
87
- if self.currentState != .idle { self.transition(.failed, "publisher_terminated") }
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 = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mentra/glasses-media",
3
- "version": "3.2.0-dev.261",
3
+ "version": "3.2.0-dev.262",
4
4
  "description": "Shared native glasses media receivers and local network transport",
5
5
  "license": "MIT",
6
6
  "author": "Mentra Community",