@mentra/glasses-media 3.2.0-dev.226 → 3.2.0-dev.232
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 +19 -3
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/com/mentra/glassesmedia/GlassesMediaRelayModule.kt +113 -0
- package/android/src/main/java/com/mentra/glassesmedia/network/ScopedNetworkChangeDetector.kt +34 -4
- package/android/src/main/java/com/mentra/glassesmedia/network/ScopedNetworkObserver.kt +11 -4
- package/android/src/main/java/com/mentra/glassesmedia/network/ScopedNetworkRegistry.kt +16 -0
- package/android/src/main/java/com/mentra/glassesmedia/publisher/PhoneWhipPublisher.kt +263 -0
- package/android/src/main/java/com/mentra/glassesmedia/publisher/RelayPcmBuffer.kt +54 -0
- package/android/src/main/java/com/mentra/glassesmedia/source/LocalWhipIngestSource.kt +16 -3
- package/android/src/main/java/com/mentra/glassesmedia/source/WhipIngestServer.kt +31 -15
- package/expo-module.config.json +6 -2
- package/ios/CoreKit/Sources/GlassesMediaCore/LocalMediaPolicy.swift +13 -1
- package/ios/CoreKit/Sources/GlassesMediaCore/RelayAudioClock.swift +56 -0
- package/ios/CoreKit/Sources/GlassesMediaCore/RelayPcmBuffer.swift +57 -0
- package/ios/GlassesHotspotNetwork.swift +30 -6
- package/ios/GlassesMedia.podspec +3 -1
- package/ios/GlassesMediaRelayModule.swift +133 -0
- package/ios/GlassesPeerFactory.swift +23 -0
- package/ios/LocalWhipIngestSource.swift +1 -1
- package/ios/PhoneWhipPublisher.swift +202 -0
- package/ios/RelayAudioDevice.swift +70 -0
- package/package.json +1 -1
|
@@ -69,6 +69,7 @@ class WhipIngestServer(
|
|
|
69
69
|
private val connections = Executors.newCachedThreadPool { runnable ->
|
|
70
70
|
Thread(runnable, "whip-ingest-conn").apply { isDaemon = true }
|
|
71
71
|
}
|
|
72
|
+
private val activeSockets = java.util.concurrent.ConcurrentHashMap.newKeySet<Socket>()
|
|
72
73
|
private val accepted = AtomicInteger()
|
|
73
74
|
|
|
74
75
|
/** Bound endpoint, or null before [start]. This is the URL the glasses must be told to POST to. */
|
|
@@ -141,6 +142,13 @@ class WhipIngestServer(
|
|
|
141
142
|
closeListener()
|
|
142
143
|
}
|
|
143
144
|
|
|
145
|
+
/** Hard teardown barrier: no request may still create a peer after this returns. */
|
|
146
|
+
fun closeAndAwait(timeoutMs: Long = 15_000): Boolean {
|
|
147
|
+
closeNow()
|
|
148
|
+
connections.shutdownNow()
|
|
149
|
+
return connections.awaitTermination(timeoutMs, TimeUnit.MILLISECONDS)
|
|
150
|
+
}
|
|
151
|
+
|
|
144
152
|
private fun closeListener() {
|
|
145
153
|
val socket = synchronized(lock) {
|
|
146
154
|
val current = server
|
|
@@ -149,6 +157,7 @@ class WhipIngestServer(
|
|
|
149
157
|
current
|
|
150
158
|
} ?: return
|
|
151
159
|
runCatching { socket.close() }
|
|
160
|
+
activeSockets.forEach { runCatching { it.close() } }
|
|
152
161
|
connections.shutdownNow()
|
|
153
162
|
SoftApTrace.stage("whip_listener_closed", "accepted" to accepted.get())
|
|
154
163
|
}
|
|
@@ -163,6 +172,10 @@ class WhipIngestServer(
|
|
|
163
172
|
Log.w(TAG, "accept failed", error)
|
|
164
173
|
return
|
|
165
174
|
}
|
|
175
|
+
val registered = synchronized(lock) {
|
|
176
|
+
if (server !== socket) false else { activeSockets.add(connection); true }
|
|
177
|
+
}
|
|
178
|
+
if (!registered) { runCatching { connection.close() }; return }
|
|
166
179
|
accepted.incrementAndGet()
|
|
167
180
|
// Each connection on its own thread: a negotiation blocks for the length of an ICE gather,
|
|
168
181
|
// and a DELETE arriving during one must not queue behind it.
|
|
@@ -170,28 +183,31 @@ class WhipIngestServer(
|
|
|
170
183
|
connections.execute { serve(connection) }
|
|
171
184
|
} catch (_: java.util.concurrent.RejectedExecutionException) {
|
|
172
185
|
runCatching { connection.close() }
|
|
186
|
+
activeSockets.remove(connection)
|
|
173
187
|
}
|
|
174
188
|
}
|
|
175
189
|
}
|
|
176
190
|
|
|
177
191
|
private fun serve(connection: Socket) {
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
+
try {
|
|
193
|
+
connection.use { socket ->
|
|
194
|
+
socket.soTimeout = READ_TIMEOUT_MS
|
|
195
|
+
val output = BufferedOutputStream(socket.getOutputStream())
|
|
196
|
+
try {
|
|
197
|
+
val request = readRequest(socket.getInputStream())
|
|
198
|
+
if (request == null) {
|
|
199
|
+
write(output, WhipIngestProtocol.Response(400, "Bad Request", body = "malformed request"))
|
|
200
|
+
return
|
|
201
|
+
}
|
|
202
|
+
write(output, handle(request))
|
|
203
|
+
} catch (error: Exception) {
|
|
204
|
+
Log.w(TAG, "connection failed", error)
|
|
205
|
+
runCatching {
|
|
206
|
+
write(output, WhipIngestProtocol.Response(400, "Bad Request", body = "read failed"))
|
|
207
|
+
}
|
|
192
208
|
}
|
|
193
209
|
}
|
|
194
|
-
}
|
|
210
|
+
} finally { activeSockets.remove(connection) }
|
|
195
211
|
}
|
|
196
212
|
|
|
197
213
|
private fun handle(request: WhipIngestProtocol.Request): WhipIngestProtocol.Response {
|
package/expo-module.config.json
CHANGED
|
@@ -31,6 +31,14 @@ public enum LocalMediaPolicy {
|
|
|
31
31
|
(bytes[0] == 192 && bytes[1] == 168)
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/// SSID association can finish before DHCP replaces the previous Wi-Fi address.
|
|
35
|
+
/// Accept only a client address on the /24 advertised by the glasses over BLE.
|
|
36
|
+
public static func isHotspotClientAddress(_ address: String, gateway: String) -> Bool {
|
|
37
|
+
guard isPrivate(gateway), sameSubnet(address, gateway), address != gateway,
|
|
38
|
+
let bytes = ipv4(address) else { return false }
|
|
39
|
+
return bytes[3] > 0 && bytes[3] < 255
|
|
40
|
+
}
|
|
41
|
+
|
|
34
42
|
/// Keep only concrete host candidates on the local link. Never rewrite a cellular socket's
|
|
35
43
|
/// advertised address: a candidate must already belong to the interface it claims.
|
|
36
44
|
public static func localSdp(_ sdp: String, address: String, answer: Bool) throws -> String {
|
|
@@ -45,7 +53,11 @@ public enum LocalMediaPolicy {
|
|
|
45
53
|
candidates += 1
|
|
46
54
|
return true
|
|
47
55
|
}
|
|
48
|
-
guard candidates > 0 else {
|
|
56
|
+
guard candidates > 0 else {
|
|
57
|
+
throw LocalMediaError(answer
|
|
58
|
+
? "Phone answer has no host ICE candidate on the glasses hotspot"
|
|
59
|
+
: "Glasses offer has no host ICE candidate on the glasses hotspot")
|
|
60
|
+
}
|
|
49
61
|
return lines.joined(separator: "\r\n") + "\r\n"
|
|
50
62
|
}
|
|
51
63
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// A paced audio worker whose stop is a barrier for the final render callback and its resources.
|
|
4
|
+
public final class RelayAudioClock {
|
|
5
|
+
private let condition = NSCondition()
|
|
6
|
+
private var quitting = false
|
|
7
|
+
private var exited = true
|
|
8
|
+
private var enabled = false
|
|
9
|
+
|
|
10
|
+
public init() {}
|
|
11
|
+
|
|
12
|
+
public func start(render: @escaping (Double) -> Void, finish: @escaping () -> Void = {}) {
|
|
13
|
+
condition.lock()
|
|
14
|
+
precondition(exited, "Previous audio worker must stop before reinitialization")
|
|
15
|
+
quitting = false; enabled = false; exited = false
|
|
16
|
+
condition.unlock()
|
|
17
|
+
let thread = Thread { [self] in
|
|
18
|
+
defer {
|
|
19
|
+
finish()
|
|
20
|
+
condition.lock(); exited = true; condition.broadcast(); condition.unlock()
|
|
21
|
+
}
|
|
22
|
+
var sampleTime: Double = 0
|
|
23
|
+
var next = ProcessInfo.processInfo.systemUptime
|
|
24
|
+
while true {
|
|
25
|
+
condition.lock()
|
|
26
|
+
while !enabled && !quitting {
|
|
27
|
+
condition.wait(); next = ProcessInfo.processInfo.systemUptime
|
|
28
|
+
}
|
|
29
|
+
let stop = quitting
|
|
30
|
+
condition.unlock()
|
|
31
|
+
if stop { return }
|
|
32
|
+
render(sampleTime)
|
|
33
|
+
sampleTime += 480
|
|
34
|
+
next += 0.01
|
|
35
|
+
let now = ProcessInfo.processInfo.systemUptime
|
|
36
|
+
if next > now { Thread.sleep(forTimeInterval: next - now) }
|
|
37
|
+
else if now - next > 0.1 { next = now }
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
thread.name = "Mentra glasses audio publish"
|
|
41
|
+
thread.qualityOfService = .userInitiated
|
|
42
|
+
thread.start()
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
public func setEnabled(_ value: Bool) {
|
|
46
|
+
condition.lock(); enabled = value; condition.broadcast(); condition.unlock()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
public func stop() {
|
|
50
|
+
condition.lock(); quitting = true; enabled = false; condition.broadcast()
|
|
51
|
+
while !exited {
|
|
52
|
+
condition.wait()
|
|
53
|
+
}
|
|
54
|
+
condition.unlock()
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Bounded decoded audio queue. Converts PCM16 to 48kHz mono without touching phone capture.
|
|
4
|
+
public final class RelayPcmBuffer {
|
|
5
|
+
private let lock = NSLock()
|
|
6
|
+
private var samples: [Int16]
|
|
7
|
+
private var head = 0
|
|
8
|
+
private var count = 0
|
|
9
|
+
private var phase = 0.0
|
|
10
|
+
private var rate = 0
|
|
11
|
+
private var channels = 0
|
|
12
|
+
private var previous: Double?
|
|
13
|
+
|
|
14
|
+
public init(capacity: Int = 9600) {
|
|
15
|
+
samples = Array(repeating: 0, count: capacity)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
public func push(_ data: Data, sampleRate: Int, channels channelCount: Int) {
|
|
19
|
+
guard (8000 ... 192_000).contains(sampleRate), (1 ... 8).contains(channelCount) else { return }
|
|
20
|
+
lock.lock(); defer { lock.unlock() }
|
|
21
|
+
if rate != sampleRate || channels != channelCount {
|
|
22
|
+
rate = sampleRate; channels = channelCount; phase = 0; previous = nil
|
|
23
|
+
}
|
|
24
|
+
let step = Double(sampleRate) / 48000
|
|
25
|
+
data.withUnsafeBytes { raw in
|
|
26
|
+
let bytes = raw.bindMemory(to: UInt8.self)
|
|
27
|
+
for frame in 0 ..< data.count / (2 * channelCount) {
|
|
28
|
+
var sum = 0.0
|
|
29
|
+
for channel in 0 ..< channelCount {
|
|
30
|
+
let offset = (frame * channelCount + channel) * 2
|
|
31
|
+
sum += Double(Int16(bitPattern: UInt16(bytes[offset]) | UInt16(bytes[offset + 1]) << 8))
|
|
32
|
+
}
|
|
33
|
+
let value = sum / Double(channelCount)
|
|
34
|
+
if let prior = previous {
|
|
35
|
+
while phase < 1 {
|
|
36
|
+
if count == samples.count { head = (head + 1) % samples.count; count -= 1 }
|
|
37
|
+
samples[(head + count) % samples.count] = Int16(prior + (value - prior) * phase)
|
|
38
|
+
count += 1
|
|
39
|
+
phase += step
|
|
40
|
+
}
|
|
41
|
+
phase -= 1
|
|
42
|
+
}
|
|
43
|
+
previous = value
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/// Pads underruns with silence. Drops oldest samples on overrun to bound live latency.
|
|
49
|
+
public func read(into output: UnsafeMutableBufferPointer<Int16>) {
|
|
50
|
+
lock.lock(); defer { lock.unlock() }
|
|
51
|
+
for i in output.indices {
|
|
52
|
+
if count > 0 {
|
|
53
|
+
output[i] = samples[head]; head = (head + 1) % samples.count; count -= 1
|
|
54
|
+
} else { output[i] = 0 }
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -7,7 +7,9 @@ import NetworkExtension
|
|
|
7
7
|
public final class GlassesHotspotNetwork {
|
|
8
8
|
private let queue = DispatchQueue(label: "com.mentra.glassesmedia.hotspot")
|
|
9
9
|
private var ssid: String?
|
|
10
|
+
private var lastHotspotSSID: String?
|
|
10
11
|
private var localAddress: String?
|
|
12
|
+
private var gatewayAddress: String?
|
|
11
13
|
private var generation = 0
|
|
12
14
|
private var applying = false
|
|
13
15
|
private var cancelled = false
|
|
@@ -17,12 +19,17 @@ public final class GlassesHotspotNetwork {
|
|
|
17
19
|
public var onLost: ((String) -> Void)?
|
|
18
20
|
public init() {}
|
|
19
21
|
|
|
20
|
-
public func join(ssid: String, passphrase: String, completion: @escaping (Result<String, Error>) -> Void) {
|
|
22
|
+
public func join(ssid: String, passphrase: String, gateway: String? = nil, completion: @escaping (Result<String, Error>) -> Void) {
|
|
21
23
|
queue.async {
|
|
22
24
|
guard self.ssid == nil, !self.applying else { completion(.failure(LocalMediaError("Previous hotspot session has not finished cleaning up"))); return }
|
|
25
|
+
if let gateway, !LocalMediaPolicy.isPrivate(gateway) {
|
|
26
|
+
completion(.failure(LocalMediaError("The glasses reported an invalid hotspot gateway"))); return
|
|
27
|
+
}
|
|
23
28
|
self.generation += 1
|
|
24
29
|
let gen = self.generation
|
|
25
30
|
self.ssid = ssid
|
|
31
|
+
self.lastHotspotSSID = ssid
|
|
32
|
+
self.gatewayAddress = gateway
|
|
26
33
|
self.cancelled = false
|
|
27
34
|
self.applying = true
|
|
28
35
|
self.joinReply = completion
|
|
@@ -75,7 +82,7 @@ public final class GlassesHotspotNetwork {
|
|
|
75
82
|
public func probeGateway(completion: @escaping (Bool, String) -> Void) {
|
|
76
83
|
queue.async {
|
|
77
84
|
guard let address = self.localAddress else { completion(false, "No joined hotspot"); return }
|
|
78
|
-
let gateway = address.split(separator: ".").prefix(3).joined(separator: ".") + ".1"
|
|
85
|
+
let gateway = self.gatewayAddress ?? address.split(separator: ".").prefix(3).joined(separator: ".") + ".1"
|
|
79
86
|
let parameters = NWParameters.tcp
|
|
80
87
|
parameters.requiredInterfaceType = .wifi
|
|
81
88
|
let connection = NWConnection(host: NWEndpoint.Host(gateway), port: 8089, using: parameters)
|
|
@@ -99,7 +106,7 @@ public final class GlassesHotspotNetwork {
|
|
|
99
106
|
}
|
|
100
107
|
}
|
|
101
108
|
|
|
102
|
-
public func awaitInternet(completion: @escaping (Bool, String) -> Void) {
|
|
109
|
+
public func awaitInternet(requireCellular: Bool = true, completion: @escaping (Bool, String) -> Void) {
|
|
103
110
|
queue.async {
|
|
104
111
|
let monitor = NWPathMonitor()
|
|
105
112
|
var finished = false
|
|
@@ -111,9 +118,21 @@ public final class GlassesHotspotNetwork {
|
|
|
111
118
|
}
|
|
112
119
|
monitor.pathUpdateHandler = { path in
|
|
113
120
|
if path.status == .satisfied, path.usesInterfaceType(.cellular) { finish(true, "cellular") }
|
|
121
|
+
// Once the hotspot is released, a return to the user's Wi-Fi is also valid.
|
|
122
|
+
// Do not mistake the departing glasses AP's local-only path for restored internet.
|
|
123
|
+
if !requireCellular, path.status == .satisfied, path.usesInterfaceType(.wifi) {
|
|
124
|
+
NEHotspotNetwork.fetchCurrent { network in
|
|
125
|
+
self.queue.async {
|
|
126
|
+
guard let network, !network.ssid.isEmpty, network.ssid != self.lastHotspotSSID else { return }
|
|
127
|
+
finish(true, "wifi")
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
114
131
|
}
|
|
115
132
|
monitor.start(queue: self.queue)
|
|
116
|
-
self.queue.asyncAfter(deadline: .now() + 15) {
|
|
133
|
+
self.queue.asyncAfter(deadline: .now() + 15) {
|
|
134
|
+
finish(false, requireCellular ? "Cellular internet did not become the default route" : "Internet did not return after leaving the glasses hotspot")
|
|
135
|
+
}
|
|
117
136
|
}
|
|
118
137
|
}
|
|
119
138
|
|
|
@@ -122,14 +141,18 @@ public final class GlassesHotspotNetwork {
|
|
|
122
141
|
NEHotspotNetwork.fetchCurrent { [weak self] network in
|
|
123
142
|
self?.queue.async {
|
|
124
143
|
guard let self, gen == self.generation, !self.cancelled else { return }
|
|
125
|
-
if network?.ssid == ssid, let address = Self.wifiAddress()
|
|
144
|
+
if network?.ssid == ssid, let address = Self.wifiAddress(),
|
|
145
|
+
self.gatewayAddress.map({ LocalMediaPolicy.isHotspotClientAddress(address, gateway: $0) }) ?? true
|
|
146
|
+
{
|
|
126
147
|
self.localAddress = address
|
|
127
148
|
self.startMonitor(generation: gen)
|
|
128
149
|
self.finishJoin(.success(address))
|
|
129
150
|
} else if remaining > 0 {
|
|
130
151
|
self.queue.asyncAfter(deadline: .now() + 0.5) { self.waitForAddress(ssid: ssid, generation: gen, remaining: remaining - 1) }
|
|
131
152
|
} else {
|
|
132
|
-
|
|
153
|
+
let association = network == nil ? "unavailable" : (network?.ssid == ssid ? "matched" : "different")
|
|
154
|
+
let address = Self.wifiAddress() ?? "none"
|
|
155
|
+
self.finishJoin(.failure(LocalMediaError("Glasses hotspot has no verified Wi-Fi address (SSID=\(association), Wi-Fi IPv4=\(address))")))
|
|
133
156
|
self.finishLeave()
|
|
134
157
|
}
|
|
135
158
|
}
|
|
@@ -160,6 +183,7 @@ public final class GlassesHotspotNetwork {
|
|
|
160
183
|
if let ssid { NEHotspotConfigurationManager.shared.removeConfiguration(forSSID: ssid) }
|
|
161
184
|
ssid = nil
|
|
162
185
|
localAddress = nil
|
|
186
|
+
gatewayAddress = nil
|
|
163
187
|
let replies = leaveReplies
|
|
164
188
|
leaveReplies.removeAll()
|
|
165
189
|
replies.forEach { $0() }
|
package/ios/GlassesMedia.podspec
CHANGED
|
@@ -11,7 +11,9 @@ Pod::Spec.new do |s|
|
|
|
11
11
|
s.platforms = { :ios => '15.1' }
|
|
12
12
|
s.swift_version = '5.9'
|
|
13
13
|
s.static_framework = true
|
|
14
|
-
s.dependency '
|
|
14
|
+
s.dependency 'ExpoModulesCore'
|
|
15
|
+
# Keep in sync with the iOS-only @livekit/react-native-webrtc podspec patch.
|
|
16
|
+
s.dependency 'WebRTC-SDK', '144.7559.15'
|
|
15
17
|
s.frameworks = 'AVFoundation', 'CoreMedia', 'CoreVideo', 'Network', 'NetworkExtension'
|
|
16
18
|
s.source_files = '*.{swift,h,m,mm}', 'CoreKit/Sources/GlassesMediaCore/*.swift'
|
|
17
19
|
s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' }
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import ExpoModulesCore
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
public final class GlassesMediaRelayModule: Module {
|
|
5
|
+
private let queue = DispatchQueue(label: "com.mentra.glassesmedia.relay")
|
|
6
|
+
private var session: ManagedRelaySession?
|
|
7
|
+
|
|
8
|
+
public func definition() -> ModuleDefinition {
|
|
9
|
+
Name("MentraGlassesMediaRelay")
|
|
10
|
+
Events("onRelayState")
|
|
11
|
+
|
|
12
|
+
AsyncFunction("prepare") { (options: [String: Any], promise: Promise) in
|
|
13
|
+
guard let id = options["attemptId"] as? String,
|
|
14
|
+
let ingest = options["ingestUrl"] as? String, let url = URL(string: ingest), url.scheme == "https",
|
|
15
|
+
let ssid = options["ssid"] as? String, let password = options["password"] as? String,
|
|
16
|
+
let gateway = options["gatewayAddress"] as? String
|
|
17
|
+
else {
|
|
18
|
+
throw LocalMediaError("Invalid relay options")
|
|
19
|
+
}
|
|
20
|
+
self.queue.async {
|
|
21
|
+
guard self.session == nil else { promise.reject(LocalMediaError("Previous relay has not stopped")); return }
|
|
22
|
+
let session = ManagedRelaySession(id: id, queue: self.queue)
|
|
23
|
+
self.session = session
|
|
24
|
+
session.start(ssid: ssid, password: password, gateway: gateway, endpoint: url,
|
|
25
|
+
captureAudio: options["captureAudio"] as? Bool ?? true,
|
|
26
|
+
bitrate: options["bitrate"] as? Int ?? 2_000_000,
|
|
27
|
+
onState: { [weak self] state, reason in
|
|
28
|
+
self?.sendEvent("onRelayState", ["attemptId": id, "state": state, "reason": reason])
|
|
29
|
+
}) { result in
|
|
30
|
+
switch result {
|
|
31
|
+
case let .success(url): promise.resolve(url)
|
|
32
|
+
case let .failure(error): promise.reject(error)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
AsyncFunction("stop") { (id: String, promise: Promise) in
|
|
39
|
+
self.queue.async {
|
|
40
|
+
guard let session = self.session, session.id == id else { promise.resolve(nil); return }
|
|
41
|
+
session.stop {
|
|
42
|
+
if self.session === session { self.session = nil }
|
|
43
|
+
promise.resolve(nil)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
OnDestroy {
|
|
49
|
+
self.queue.async {
|
|
50
|
+
self.session?.stop { self.session = nil }
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/// Serialized owner of both native peers and the persistent hotspot join; no JS media callbacks.
|
|
57
|
+
private final class ManagedRelaySession {
|
|
58
|
+
let id: String
|
|
59
|
+
private let queue: DispatchQueue
|
|
60
|
+
private let hotspot = GlassesHotspotNetwork()
|
|
61
|
+
private var receiver: LocalWhipIngestSource?
|
|
62
|
+
private var publisher: PhoneWhipPublisher?
|
|
63
|
+
private var stopped = false
|
|
64
|
+
private var stopFinished = false
|
|
65
|
+
private var stopReplies: [() -> Void] = []
|
|
66
|
+
private var ready: ((Result<String, Error>) -> Void)?
|
|
67
|
+
|
|
68
|
+
init(id: String, queue: DispatchQueue) {
|
|
69
|
+
self.id = id; self.queue = queue
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
func start(ssid: String, password: String, gateway: String, endpoint: URL, captureAudio: Bool, bitrate: Int,
|
|
73
|
+
onState: @escaping (String, String) -> Void, completion: @escaping (Result<String, Error>) -> Void)
|
|
74
|
+
{
|
|
75
|
+
ready = completion
|
|
76
|
+
hotspot.onLost = { reason in onState("failed", reason) }
|
|
77
|
+
hotspot.join(ssid: ssid, passphrase: password, gateway: gateway) { result in
|
|
78
|
+
self.queue.async {
|
|
79
|
+
guard !self.stopped else { return }
|
|
80
|
+
switch result {
|
|
81
|
+
case let .failure(error): self.finish(.failure(error))
|
|
82
|
+
case let .success(address):
|
|
83
|
+
self.hotspot.awaitInternet { usable, _ in
|
|
84
|
+
self.queue.async {
|
|
85
|
+
guard !self.stopped else { return }
|
|
86
|
+
guard usable else { self.finish(.failure(LocalMediaError("Turn on phone mobile data to stream through the glasses hotspot"))); return }
|
|
87
|
+
let publisher = PhoneWhipPublisher(endpoint: endpoint, captureAudio: captureAudio, bitrate: bitrate, onState: onState)
|
|
88
|
+
self.publisher = publisher
|
|
89
|
+
let receiver = LocalWhipIngestSource()
|
|
90
|
+
self.receiver = receiver
|
|
91
|
+
receiver.onFrame = { [weak publisher] in publisher?.onFrame($0) }
|
|
92
|
+
receiver.onPcm = { [weak publisher] in publisher?.onPcm($0, rate: $1, channels: $2) }
|
|
93
|
+
receiver.onStateChange = { state, reason in if state == .failed { onState("failed", "Glasses receiver: \(reason)") } }
|
|
94
|
+
receiver.setPcmDeliveryEnabled(captureAudio)
|
|
95
|
+
receiver.prepare(config: SourceConfig(url: "", kind: .softap, bindAddress: address)) { result in
|
|
96
|
+
self.queue.async {
|
|
97
|
+
guard !self.stopped else { return }
|
|
98
|
+
if case .success = result { publisher.start() }
|
|
99
|
+
self.finish(result)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private func finish(_ result: Result<String, Error>) {
|
|
110
|
+
let callback = ready; ready = nil; callback?(result)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
func stop(completion: @escaping () -> Void) {
|
|
114
|
+
if stopFinished { completion(); return }
|
|
115
|
+
stopReplies.append(completion)
|
|
116
|
+
guard !stopped else { return }
|
|
117
|
+
stopped = true
|
|
118
|
+
finish(.failure(LocalMediaError("Relay cancelled")))
|
|
119
|
+
hotspot.onLost = nil
|
|
120
|
+
let group = DispatchGroup()
|
|
121
|
+
if let receiver {
|
|
122
|
+
group.enter(); receiver.stop { group.leave() }
|
|
123
|
+
}
|
|
124
|
+
if let publisher {
|
|
125
|
+
group.enter(); publisher.stop { group.leave() }
|
|
126
|
+
}
|
|
127
|
+
group.enter(); hotspot.leave { group.leave() }
|
|
128
|
+
group.notify(queue: queue) {
|
|
129
|
+
self.receiver = nil; self.publisher = nil; self.stopFinished = true
|
|
130
|
+
let replies = self.stopReplies; self.stopReplies.removeAll(); replies.forEach { $0() }
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import WebRTC
|
|
2
|
+
|
|
3
|
+
/// Both legs need real interface enumeration: local Wi-Fi can be absent from the default
|
|
4
|
+
/// internet NWPath while cellular carries the uplink. Each factory's adapter mask and the
|
|
5
|
+
/// receiver's SDP policy still restrict candidates to the appropriate leg.
|
|
6
|
+
enum GlassesPeerFactory {
|
|
7
|
+
/// M144 honors this field trial for factories with custom audio devices; M137 installed
|
|
8
|
+
/// NWPathMonitor unconditionally (including when disableNetworkMonitor was set).
|
|
9
|
+
/// The SDK setting is process-wide. Set it once and never toggle it around factory creation:
|
|
10
|
+
/// receiver, relay, and React Native WebRTC factories may initialize concurrently.
|
|
11
|
+
private static let configure: Void = {
|
|
12
|
+
RTCPeerConnectionFactory.configureFieldTrials("WebRTC-Network-UseNWPathMonitor/Disabled/")
|
|
13
|
+
}()
|
|
14
|
+
|
|
15
|
+
static func make(audioDevice: RTCAudioDevice) -> RTCPeerConnectionFactory {
|
|
16
|
+
_ = configure
|
|
17
|
+
return RTCPeerConnectionFactory(
|
|
18
|
+
encoderFactory: RTCDefaultVideoEncoderFactory(),
|
|
19
|
+
decoderFactory: RTCDefaultVideoDecoderFactory(),
|
|
20
|
+
audioDevice: audioDevice
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -67,7 +67,7 @@ public final class LocalWhipIngestSource: NSObject, DecodedGlassesMediaSource {
|
|
|
67
67
|
let gen = self.generation
|
|
68
68
|
self.transition(.connecting, "listening")
|
|
69
69
|
RTCInitializeSSL()
|
|
70
|
-
let factory =
|
|
70
|
+
let factory = GlassesPeerFactory.make(audioDevice: ReceiveOnlyAudioDevice())
|
|
71
71
|
let options = RTCPeerConnectionFactoryOptions()
|
|
72
72
|
options.ignoreCellularNetworkAdapter = true
|
|
73
73
|
options.ignoreVPNNetworkAdapter = true
|