@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
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Glasses media transport
|
|
2
2
|
|
|
3
|
-
Native receivers and network helpers shared by
|
|
3
|
+
Native receivers, WHIP publishers and network helpers shared by the Mentra App. This library has no ACS SDK dependency. ACS consumes decoded media for calls; managed WebRTC streams feed the same decoded video and PCM to `PhoneWhipPublisher`.
|
|
4
4
|
|
|
5
5
|
- Android: the existing `GlassesMediaSource`, local WHIP and WHEP receivers, scoped SoftAP networking, decoded I420/PCM delivery, and transport diagnostics moved here. The ACS session and raw outgoing streams remain in `acs-meeting`.
|
|
6
6
|
- iOS: `DecodedGlassesMediaSource` delivers `CVPixelBuffer` video and interleaved PCM16. `LocalWhipIngestSource` answers the glasses' existing WHIP client; `WhepVideoSource` retains the cloud subscription path.
|
|
@@ -12,7 +12,7 @@ The publisher owns its outgoing encoders, audio policy, and destination. Receive
|
|
|
12
12
|
|
|
13
13
|
The host signs in, enables the glasses hotspot, joins it, prepares the receiver and publisher, then tells the glasses to publish to the returned `ingestUrl`. A successful listener bind is not a live stream: `live` requires a decoded video frame. iOS waits for full ICE gathering and returns only actual host candidates on the hotspot interface. Requests and queued media are bounded.
|
|
14
14
|
|
|
15
|
-
Stop the glasses publisher before stopping the receiver and releasing the network. Await `LocalWhipIngestSource.stop(completion:)` before reusing resources. It cancels pending offers, closes the peer and HTTP listener, and drains teardown before completing. A local source must not silently restart onto a new port; the host must coordinate any new URL with the glasses. WHEP can rebuild its existing URL.
|
|
15
|
+
Stop the glasses publisher before stopping the receiver and releasing the network. Await `LocalWhipIngestSource.stop(completion:)` before reusing resources on iOS. Android owners discarding a receiver use `close()` to drain HTTP negotiation and release its retained factory and EGL resources. It cancels pending offers, closes the peer and HTTP listener, and drains teardown before completing. A local source must not silently restart onto a new port; the host must coordinate any new URL with the glasses. WHEP can rebuild its existing URL.
|
|
16
16
|
|
|
17
17
|
## Verification
|
|
18
18
|
|
|
@@ -32,4 +32,20 @@ xcodebuild -project mobile/ios/Pods/Pods.xcodeproj -target AcsMeeting \
|
|
|
32
32
|
|
|
33
33
|
Before merging/releasing, exercise iPhone + glasses: first-use local-network permission and hotspot prompts; join/admit/mute/leave; music/camera sounds; rapid Start/Cancel/Start during sign-in, hotspot join, and WHIP negotiation; hotspot loss; repeated joins; screen-off/background streaming; and a sustained call while measuring latency, audio/video alignment, and thermal behavior. Screen-off operation and background gallery transfers already exist in the Mentra App; these checks qualify the new media pipeline. A simulator build and loopback HTTP tests cannot establish physical Wi-Fi/cellular routing or camera behavior.
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
## Managed WebRTC relay
|
|
36
|
+
|
|
37
|
+
`PhoneStreamCoordinator` provisions Cloudflare and owns subscribers for the entire route:
|
|
38
|
+
|
|
39
|
+
```text
|
|
40
|
+
Glasses -- WHIP over SoftAP --> shared receiver -- decoded video/PCM --> phone WHIP publisher --> Cloudflare
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Only `ingest: "whip"` selects this route. Default SRT and explicit RTMP continue publishing directly from the glasses. WHIP never falls back to SRT/RTMP when Cloudflare omits its WHIP URL. The phone needs mobile data; the glasses need no internet Wi-Fi credentials. Livestreamer's Stream Here path skips its glasses Wi-Fi setup gate accordingly.
|
|
44
|
+
|
|
45
|
+
`MentraGlassesMediaRelay.prepare` joins the persistent hotspot, prepares both native peers, and returns a local WHIP URL for the BLE start command. Cloudflare credentials stay on the phone. Video queues hold at most one frame; decoded PCM is downmixed/resampled into a bounded 200ms, 48kHz mono queue. WebRTC re-encodes both tracks. No phone microphone is captured. Android uses the WebRTC library's external AudioRecord buffer mode; iOS uses a custom `RTCAudioDevice`.
|
|
46
|
+
|
|
47
|
+
Android's process-wide network inventory exposes both the scoped hotspot and real cellular handles during a relay. Per-factory masks keep the receiver on Wi-Fi and the publisher off Wi-Fi; signaling also uses the cellular network's socket factory and DNS. ACS retains its existing hotspot-only inventory. iOS uses corresponding per-factory masks after validating the cellular default route.
|
|
48
|
+
|
|
49
|
+
An attempt-specific ID isolates late callbacks and glasses status. Reconnect tears down the glasses publisher, both phone peers and hotspot before rebuilding against the same provisioned input. Three retries bound consecutive failures; a minute of stable streaming renews the retry budget. Last-subscriber stop cancels startup/backoff, awaits native cleanup and disables the hotspot. Failed cleanup retains ownership and can be retried by stop. BLE-unreachable stop/hotspot commands are deferred until reconnection. ACS and managed streaming share a hotspot reservation. Native media queues keep running when the UI is backgrounded; Android holds a partial wake lock and uses the engine's background timers for retry/readiness.
|
|
50
|
+
|
|
51
|
+
Device qualification for this outgoing pipeline must include Android and iPhone: audio/video playback on the provisioned WHEP URL, mute/video-only capture, last-subscriber stop, cancellation at each setup step, cellular/hotspot/BLE loss and recovery, repeated starts, screen-off/background operation and a sustained thermal/audio-sync run. Unit tests and platform compilation cover lifecycle/format contracts; they do not establish those hardware results.
|
|
@@ -3,4 +3,5 @@
|
|
|
3
3
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
|
4
4
|
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
|
5
5
|
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
|
|
6
|
+
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
|
6
7
|
</manifest>
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
package com.mentra.glassesmedia
|
|
2
|
+
|
|
3
|
+
import android.content.Intent
|
|
4
|
+
import android.os.Handler
|
|
5
|
+
import android.os.Looper
|
|
6
|
+
import android.os.PowerManager
|
|
7
|
+
import android.provider.Settings
|
|
8
|
+
import com.mentra.glassesmedia.network.*
|
|
9
|
+
import com.mentra.glassesmedia.publisher.PhoneWhipPublisher
|
|
10
|
+
import com.mentra.glassesmedia.source.*
|
|
11
|
+
import expo.modules.kotlin.Promise
|
|
12
|
+
import expo.modules.kotlin.modules.Module
|
|
13
|
+
import expo.modules.kotlin.modules.ModuleDefinition
|
|
14
|
+
import java.util.concurrent.Executors
|
|
15
|
+
|
|
16
|
+
/** Native resources for one coordinator-owned relay attempt. All transitions use one executor. */
|
|
17
|
+
class GlassesMediaRelayModule : Module() {
|
|
18
|
+
private val worker = Executors.newSingleThreadExecutor()
|
|
19
|
+
private var activeId: String? = null
|
|
20
|
+
private var network: ScopedSoftApNetwork? = null
|
|
21
|
+
private var internet: InternetHold? = null
|
|
22
|
+
private var source: LocalWhipIngestSource? = null
|
|
23
|
+
private var publisher: PhoneWhipPublisher? = null
|
|
24
|
+
private var wakeLock: PowerManager.WakeLock? = null
|
|
25
|
+
|
|
26
|
+
override fun definition() = ModuleDefinition {
|
|
27
|
+
Name("MentraGlassesMediaRelay")
|
|
28
|
+
Events("onRelayState")
|
|
29
|
+
|
|
30
|
+
AsyncFunction("prepare") { options: Map<String, Any?>, promise: Promise ->
|
|
31
|
+
worker.execute {
|
|
32
|
+
try {
|
|
33
|
+
check(activeId == null) { "Previous relay has not stopped" }
|
|
34
|
+
val id = requireNotNull(options["attemptId"] as? String)
|
|
35
|
+
val endpoint = requireNotNull(options["ingestUrl"] as? String)
|
|
36
|
+
val ssid = requireNotNull(options["ssid"] as? String)
|
|
37
|
+
val password = requireNotNull(options["password"] as? String)
|
|
38
|
+
val context = requireNotNull(appContext.reactContext).applicationContext
|
|
39
|
+
activeId = id
|
|
40
|
+
val hold = InternetHold(context).also { internet = it }
|
|
41
|
+
check(hold.awaitValidatedCellular().validated) { "Turn on phone mobile data to stream through the glasses hotspot" }
|
|
42
|
+
val scoped = ScopedSoftApNetwork(context).also { network = it }
|
|
43
|
+
if (!scoped.isWifiEnabled()) {
|
|
44
|
+
Handler(Looper.getMainLooper()).post {
|
|
45
|
+
appContext.currentActivity?.startActivity(Intent(Settings.Panel.ACTION_WIFI))
|
|
46
|
+
}
|
|
47
|
+
check(scoped.awaitWifiEnabled()) { "Phone Wi-Fi is disabled" }
|
|
48
|
+
Thread.sleep(ScopedSoftApNetwork.WIFI_ENABLE_SETTLE_MS)
|
|
49
|
+
}
|
|
50
|
+
val listener = object : ScopedSoftApNetwork.Listener {
|
|
51
|
+
override fun onAvailable(network: android.net.Network, localIpv4: String) = Unit
|
|
52
|
+
override fun onLost(error: ScopedNetworkError) { emit(id, "failed", "Glasses hotspot connection was lost") }
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
scoped.join(ssid, password, listener)
|
|
56
|
+
} catch (error: ScopedNetworkError.Unavailable) {
|
|
57
|
+
// Same recovery as ACS: the first specifier can reject while leaving office Wi-Fi.
|
|
58
|
+
Thread.sleep(ScopedSoftApNetwork.UNAVAILABLE_RETRY_SETTLE_MS)
|
|
59
|
+
scoped.join(ssid, password, listener)
|
|
60
|
+
}
|
|
61
|
+
ScopedNetworkChangeDetector.registerRelayNetwork(scoped)
|
|
62
|
+
val outgoing = PhoneWhipPublisher(context, endpoint, options["captureAudio"] != false,
|
|
63
|
+
(options["bitrate"] as? Number)?.toInt() ?: 2_000_000) { state, reason -> emit(id, state, reason) }
|
|
64
|
+
publisher = outgoing
|
|
65
|
+
val incoming = LocalWhipIngestSource(context, VideoFrameListener(outgoing::onVideoFrame),
|
|
66
|
+
PcmListener(outgoing::onPcm), scopedNetwork = scoped)
|
|
67
|
+
source = incoming
|
|
68
|
+
incoming.setStateListener { state, reason ->
|
|
69
|
+
if (state == SourceState.FAILED) emit(id, "failed", "Glasses receiver: $reason")
|
|
70
|
+
}
|
|
71
|
+
incoming.setPcmDeliveryEnabled(options["captureAudio"] != false)
|
|
72
|
+
incoming.start(SourceConfig("", SourceKind.SOFTAP, scoped.localIpv4()))
|
|
73
|
+
wakeLock = context.getSystemService(PowerManager::class.java)
|
|
74
|
+
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "Mentra:managed-relay").apply { acquire() }
|
|
75
|
+
outgoing.start()
|
|
76
|
+
promise.resolve(requireNotNull(incoming.ingestUrl))
|
|
77
|
+
} catch (error: Exception) {
|
|
78
|
+
// The owner still calls stop: preserve ownership until every acquired resource is gone.
|
|
79
|
+
promise.reject("RELAY_PREPARE_FAILED", error.message, error)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
AsyncFunction("stop") { attemptId: String, promise: Promise ->
|
|
85
|
+
worker.execute {
|
|
86
|
+
try {
|
|
87
|
+
if (activeId == attemptId) cleanup()
|
|
88
|
+
promise.resolve(null)
|
|
89
|
+
} catch (error: Exception) { promise.reject("RELAY_STOP_FAILED", error.message, error) }
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
OnDestroy { worker.execute { runCatching { cleanup() }; worker.shutdown() } }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private fun emit(id: String, state: String, reason: String) {
|
|
97
|
+
sendEvent("onRelayState", mapOf("attemptId" to id, "state" to state, "reason" to reason))
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private fun cleanup() {
|
|
101
|
+
source?.setStateListener(null)
|
|
102
|
+
// Try every cleanup even if one fails. Keep the slot occupied on any failure.
|
|
103
|
+
var failure: Exception? = null
|
|
104
|
+
fun step(action: () -> Unit) { try { action() } catch (e: Exception) { failure = failure ?: e } }
|
|
105
|
+
step { source?.close(); source = null }
|
|
106
|
+
step { publisher?.close(); publisher = null }
|
|
107
|
+
step { network?.let { it.release(); ScopedNetworkChangeDetector.releaseRelayNetwork(it) }; network = null }
|
|
108
|
+
step { internet?.close(); internet = null }
|
|
109
|
+
step { wakeLock?.let { if (it.isHeld) it.release() }; wakeLock = null }
|
|
110
|
+
failure?.let { throw it }
|
|
111
|
+
activeId = null
|
|
112
|
+
}
|
|
113
|
+
}
|
package/android/src/main/java/com/mentra/glassesmedia/network/ScopedNetworkChangeDetector.kt
CHANGED
|
@@ -142,7 +142,7 @@ internal constructor(
|
|
|
142
142
|
)
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
-
val merged = mergeScopedNetwork(stock, scoped)
|
|
145
|
+
val merged = mergeScopedNetwork(stock, scoped, registry.includesInternet())
|
|
146
146
|
lastPublished = snapshotOf(merged)
|
|
147
147
|
SoftApTrace.stage(
|
|
148
148
|
"webrtc_network_inventory",
|
|
@@ -235,12 +235,39 @@ internal constructor(
|
|
|
235
235
|
context: Context,
|
|
236
236
|
scoped: () -> ScopedInterface?,
|
|
237
237
|
): Wired {
|
|
238
|
-
val filter = ScopedNetworkObserver(downstream, scoped)
|
|
238
|
+
val filter = ScopedNetworkObserver(downstream, scoped) { registry.includesInternet() }
|
|
239
239
|
return Wired(NetworkMonitorAutoDetect(filter, context), scoped, filter)
|
|
240
240
|
}
|
|
241
241
|
|
|
242
242
|
@JvmStatic
|
|
243
243
|
fun install(scopedNetworkSupplier: () -> ScopedSoftApNetwork?) {
|
|
244
|
+
registry.registerAcs(scopedNetworkSupplier)
|
|
245
|
+
installMonitor()
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private val registry = ScopedNetworkRegistry<ScopedSoftApNetwork>()
|
|
249
|
+
|
|
250
|
+
/** Receiver fallback for standalone users; never replaces ACS's live supplier. */
|
|
251
|
+
@JvmStatic
|
|
252
|
+
fun registerReceiverNetwork(network: ScopedSoftApNetwork) {
|
|
253
|
+
registry.registerReceiver(network)
|
|
254
|
+
installMonitor()
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
@JvmStatic
|
|
258
|
+
fun releaseReceiverNetwork(network: ScopedSoftApNetwork) { registry.releaseReceiver(network) }
|
|
259
|
+
|
|
260
|
+
/** Keep real internet handles visible for the outgoing peer; each factory masks its leg. */
|
|
261
|
+
@JvmStatic
|
|
262
|
+
fun registerRelayNetwork(network: ScopedSoftApNetwork) {
|
|
263
|
+
registry.registerRelay(network)
|
|
264
|
+
installMonitor()
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
@JvmStatic
|
|
268
|
+
fun releaseRelayNetwork(network: ScopedSoftApNetwork) { registry.releaseRelay(network) }
|
|
269
|
+
|
|
270
|
+
private fun installMonitor() {
|
|
244
271
|
val monitor = NetworkMonitor.getInstance()
|
|
245
272
|
// A live detector was built by an earlier factory (or the stock one) and will not be
|
|
246
273
|
// replaced until monitoring restarts; say so in the trace so a missing host candidate
|
|
@@ -252,7 +279,7 @@ internal constructor(
|
|
|
252
279
|
monitor.setNetworkChangeDetectorFactory { observer, context ->
|
|
253
280
|
val wired =
|
|
254
281
|
wire(observer, context) {
|
|
255
|
-
|
|
282
|
+
registry.current()?.let { resolveScopedInterface(context, it) }
|
|
256
283
|
}
|
|
257
284
|
ScopedNetworkChangeDetector(wired.delegate, wired.scoped, wired.observer)
|
|
258
285
|
}
|
|
@@ -300,9 +327,12 @@ internal constructor(
|
|
|
300
327
|
fun mergeScopedNetwork(
|
|
301
328
|
detected: List<NetworkChangeDetector.NetworkInformation>?,
|
|
302
329
|
scoped: ScopedInterface?,
|
|
330
|
+
includeInternet: Boolean = false,
|
|
303
331
|
): List<NetworkChangeDetector.NetworkInformation> {
|
|
304
332
|
val hotspot = scoped?.let { toNetworkInformation(it) } ?: return detected.orEmpty()
|
|
305
|
-
return
|
|
333
|
+
return if (includeInternet) {
|
|
334
|
+
detected.orEmpty().filter { it.handle != hotspot.handle && it.name != hotspot.name } + hotspot
|
|
335
|
+
} else listOf(hotspot)
|
|
306
336
|
}
|
|
307
337
|
|
|
308
338
|
/** Dotted-quad for the inventory trace. */
|
|
@@ -33,6 +33,7 @@ import org.webrtc.NetworkChangeDetector
|
|
|
33
33
|
internal class ScopedNetworkObserver(
|
|
34
34
|
private val downstream: NetworkChangeDetector.Observer,
|
|
35
35
|
private val scoped: () -> ScopedNetworkChangeDetector.ScopedInterface?,
|
|
36
|
+
private val includeInternet: () -> Boolean = { false },
|
|
36
37
|
) : NetworkChangeDetector.Observer() {
|
|
37
38
|
|
|
38
39
|
private val lock = Any()
|
|
@@ -114,17 +115,20 @@ internal class ScopedNetworkObserver(
|
|
|
114
115
|
}
|
|
115
116
|
|
|
116
117
|
override fun onConnectionTypeChanged(connectionType: NetworkChangeDetector.ConnectionType) {
|
|
117
|
-
if (scoped() != null) {
|
|
118
|
+
if (scoped() != null && !includeInternet()) {
|
|
118
119
|
downstream.onConnectionTypeChanged(NetworkChangeDetector.ConnectionType.CONNECTION_WIFI)
|
|
119
120
|
return
|
|
120
121
|
}
|
|
121
|
-
restore()
|
|
122
|
+
if (scoped() == null) restore()
|
|
122
123
|
downstream.onConnectionTypeChanged(connectionType)
|
|
123
124
|
}
|
|
124
125
|
|
|
125
126
|
override fun onNetworkConnect(networkInfo: NetworkChangeDetector.NetworkInformation) {
|
|
126
127
|
if (scoped() != null) {
|
|
127
|
-
synchronized(lock) {
|
|
128
|
+
synchronized(lock) {
|
|
129
|
+
stock[networkInfo.handle] = networkInfo
|
|
130
|
+
if (includeInternet()) publish(ScopedNetworkChangeDetector.mergeScopedNetwork(stock.values.toList(), scoped(), true))
|
|
131
|
+
}
|
|
128
132
|
SoftApTrace.stage(
|
|
129
133
|
"webrtc_stock_connect_suppressed",
|
|
130
134
|
"name" to networkInfo.name,
|
|
@@ -141,7 +145,10 @@ internal class ScopedNetworkObserver(
|
|
|
141
145
|
|
|
142
146
|
override fun onNetworkDisconnect(networkHandle: Long) {
|
|
143
147
|
if (scoped() != null) {
|
|
144
|
-
synchronized(lock) {
|
|
148
|
+
synchronized(lock) {
|
|
149
|
+
stock.remove(networkHandle)
|
|
150
|
+
if (includeInternet()) publish(ScopedNetworkChangeDetector.mergeScopedNetwork(stock.values.toList(), scoped(), true))
|
|
151
|
+
}
|
|
145
152
|
return
|
|
146
153
|
}
|
|
147
154
|
restore()
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
package com.mentra.glassesmedia.network
|
|
2
|
+
|
|
3
|
+
/** Separate registrations keep a receiver from replacing the call owner's live network supplier. */
|
|
4
|
+
internal class ScopedNetworkRegistry<T : Any> {
|
|
5
|
+
private var acs: () -> T? = { null }
|
|
6
|
+
private var receiver: T? = null
|
|
7
|
+
private var relay: T? = null
|
|
8
|
+
|
|
9
|
+
@Synchronized fun registerAcs(supplier: () -> T?) { acs = supplier }
|
|
10
|
+
@Synchronized fun registerReceiver(network: T) { receiver = network }
|
|
11
|
+
@Synchronized fun releaseReceiver(network: T) { if (receiver === network) receiver = null }
|
|
12
|
+
@Synchronized fun registerRelay(network: T) { relay = network }
|
|
13
|
+
@Synchronized fun releaseRelay(network: T) { if (relay === network) relay = null }
|
|
14
|
+
@Synchronized fun current(): T? = relay ?: acs() ?: receiver
|
|
15
|
+
@Synchronized fun includesInternet(): Boolean = relay != null
|
|
16
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
package com.mentra.glassesmedia.publisher
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.net.ConnectivityManager
|
|
5
|
+
import android.net.NetworkCapabilities
|
|
6
|
+
import android.os.Handler
|
|
7
|
+
import android.os.HandlerThread
|
|
8
|
+
import com.mentra.glassesmedia.source.I420Planes
|
|
9
|
+
import java.io.IOException
|
|
10
|
+
import java.util.concurrent.CountDownLatch
|
|
11
|
+
import java.util.concurrent.TimeUnit
|
|
12
|
+
import java.util.concurrent.atomic.AtomicBoolean
|
|
13
|
+
import okhttp3.Call
|
|
14
|
+
import okhttp3.Callback
|
|
15
|
+
import okhttp3.HttpUrl
|
|
16
|
+
import okhttp3.HttpUrl.Companion.toHttpUrl
|
|
17
|
+
import okhttp3.MediaType.Companion.toMediaType
|
|
18
|
+
import okhttp3.OkHttpClient
|
|
19
|
+
import okhttp3.Request
|
|
20
|
+
import okhttp3.RequestBody.Companion.toRequestBody
|
|
21
|
+
import okhttp3.Response
|
|
22
|
+
import org.webrtc.*
|
|
23
|
+
import org.webrtc.audio.JavaAudioDeviceModule
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* ASG WhipStreamingService's full-ICE offer/POST/answer/Location lifecycle, with decoded glasses
|
|
27
|
+
* tracks instead of camera/mic capture. One instance per attempt; the coordinator owns retries.
|
|
28
|
+
*/
|
|
29
|
+
class PhoneWhipPublisher(
|
|
30
|
+
private val context: Context,
|
|
31
|
+
private val endpoint: String,
|
|
32
|
+
private val captureAudio: Boolean,
|
|
33
|
+
private val bitrate: Int,
|
|
34
|
+
private val onState: (String, String) -> Unit,
|
|
35
|
+
) {
|
|
36
|
+
private val thread = HandlerThread("Mentra WHIP uplink").apply { start() }
|
|
37
|
+
private val queue = Handler(thread.looper)
|
|
38
|
+
private val closed = AtomicBoolean(false)
|
|
39
|
+
private val frameBusy = AtomicBoolean(false)
|
|
40
|
+
private val closeDone = CountDownLatch(1)
|
|
41
|
+
@Volatile private var cleanupFailure: Throwable? = null
|
|
42
|
+
private val pcm = RelayPcmBuffer()
|
|
43
|
+
private var factory: PeerConnectionFactory? = null
|
|
44
|
+
private var egl: EglBase? = null
|
|
45
|
+
private var adm: JavaAudioDeviceModule? = null
|
|
46
|
+
private var peer: PeerConnection? = null
|
|
47
|
+
private var videoSource: VideoSource? = null
|
|
48
|
+
private var videoTrack: VideoTrack? = null
|
|
49
|
+
private var audioSource: AudioSource? = null
|
|
50
|
+
private var audioTrack: AudioTrack? = null
|
|
51
|
+
private var client: OkHttpClient? = null
|
|
52
|
+
private var resource: HttpUrl? = null
|
|
53
|
+
private var posted = false
|
|
54
|
+
private var localSet = false
|
|
55
|
+
private var connected = false
|
|
56
|
+
private var failureSent = false
|
|
57
|
+
private var lastTimestamp = 0L
|
|
58
|
+
private val disconnectDeadline = Runnable { fail("Phone internet connection was lost") }
|
|
59
|
+
|
|
60
|
+
fun start() {
|
|
61
|
+
queue.post {
|
|
62
|
+
if (closed.get()) return@post
|
|
63
|
+
try {
|
|
64
|
+
val url = endpoint.toHttpUrl()
|
|
65
|
+
require(url.isHttps) { "Managed WHIP requires HTTPS" }
|
|
66
|
+
val manager = context.getSystemService(ConnectivityManager::class.java)
|
|
67
|
+
val internet = manager.allNetworks.firstOrNull { network ->
|
|
68
|
+
manager.getNetworkCapabilities(network)?.let {
|
|
69
|
+
it.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) &&
|
|
70
|
+
it.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
|
|
71
|
+
} == true
|
|
72
|
+
} ?: error("Phone mobile data is unavailable")
|
|
73
|
+
client = OkHttpClient.Builder().socketFactory(internet.socketFactory)
|
|
74
|
+
.dns(object : okhttp3.Dns { override fun lookup(hostname: String) = internet.getAllByName(hostname).toList() })
|
|
75
|
+
.callTimeout(20, TimeUnit.SECONDS).followRedirects(false).build()
|
|
76
|
+
PeerConnectionFactory.initialize(PeerConnectionFactory.InitializationOptions.builder(context).createInitializationOptions())
|
|
77
|
+
egl = EglBase.create()
|
|
78
|
+
adm = JavaAudioDeviceModule.builder(context)
|
|
79
|
+
.setInputSampleRate(48_000).setUseStereoInput(false)
|
|
80
|
+
.setUseHardwareAcousticEchoCanceler(false).setUseHardwareNoiseSuppressor(false)
|
|
81
|
+
.setAudioBufferCallback { buffer, _, channels, sampleRate, bytes, _ ->
|
|
82
|
+
if (channels == 1 && sampleRate == 48_000) pcm.read(buffer, bytes)
|
|
83
|
+
else for (i in 0 until minOf(bytes, buffer.capacity())) buffer.put(i, 0)
|
|
84
|
+
System.nanoTime()
|
|
85
|
+
}.createAudioDeviceModule().also {
|
|
86
|
+
// WebRTC's external-buffer mode: paced callbacks without AudioRecord/microphone.
|
|
87
|
+
it.setAudioRecordEnabled(false)
|
|
88
|
+
}
|
|
89
|
+
factory = PeerConnectionFactory.builder()
|
|
90
|
+
.setOptions(PeerConnectionFactory.Options().apply {
|
|
91
|
+
// The receiver keeps Wi-Fi; this peer uses real cellular handles from the inventory.
|
|
92
|
+
networkIgnoreMask = 2 or 8 or 16 // WIFI, VPN, LOOPBACK
|
|
93
|
+
})
|
|
94
|
+
.setAudioDeviceModule(adm)
|
|
95
|
+
.setVideoEncoderFactory(DefaultVideoEncoderFactory(egl!!.eglBaseContext, true, true))
|
|
96
|
+
.setVideoDecoderFactory(DefaultVideoDecoderFactory(egl!!.eglBaseContext))
|
|
97
|
+
.createPeerConnectionFactory()
|
|
98
|
+
val config = PeerConnection.RTCConfiguration(listOf(
|
|
99
|
+
PeerConnection.IceServer.builder("stun:stun.cloudflare.com:3478").createIceServer(),
|
|
100
|
+
)).apply { sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN }
|
|
101
|
+
peer = factory!!.createPeerConnection(config, observer) ?: error("Could not create WHIP peer")
|
|
102
|
+
videoSource = factory!!.createVideoSource(false).also { it.capturerObserver.onCapturerStarted(true) }
|
|
103
|
+
videoTrack = factory!!.createVideoTrack("glasses-video", videoSource)
|
|
104
|
+
val transceiver = peer!!.addTransceiver(videoTrack, RtpTransceiver.RtpTransceiverInit(RtpTransceiver.RtpTransceiverDirection.SEND_ONLY))
|
|
105
|
+
val codecs = factory!!.getRtpSenderCapabilities(MediaStreamTrack.MediaType.MEDIA_TYPE_VIDEO).codecs
|
|
106
|
+
val h264 = codecs.filter { it.name.equals("H264", ignoreCase = true) }
|
|
107
|
+
if (h264.isNotEmpty()) transceiver.setCodecPreferences(h264)
|
|
108
|
+
val parameters = transceiver.sender.parameters
|
|
109
|
+
parameters.encodings.forEach { it.maxBitrateBps = bitrate.coerceIn(250_000, 12_000_000) }
|
|
110
|
+
transceiver.sender.parameters = parameters
|
|
111
|
+
if (captureAudio) {
|
|
112
|
+
val constraints = MediaConstraints().apply {
|
|
113
|
+
for (key in listOf("googEchoCancellation", "googNoiseSuppression", "googAutoGainControl"))
|
|
114
|
+
mandatory.add(MediaConstraints.KeyValuePair(key, "false"))
|
|
115
|
+
}
|
|
116
|
+
audioSource = factory!!.createAudioSource(constraints)
|
|
117
|
+
audioTrack = factory!!.createAudioTrack("glasses-audio", audioSource)
|
|
118
|
+
peer!!.addTransceiver(audioTrack, RtpTransceiver.RtpTransceiverInit(RtpTransceiver.RtpTransceiverDirection.SEND_ONLY))
|
|
119
|
+
}
|
|
120
|
+
peer!!.createOffer(sdpObserver(onCreate = { offer ->
|
|
121
|
+
peer?.setLocalDescription(sdpObserver(onSet = { localSet = true; maybePost() }), offer)
|
|
122
|
+
}), MediaConstraints())
|
|
123
|
+
queue.postDelayed({ if (!connected && !closed.get()) fail("WHIP connection timed out") }, 35_000)
|
|
124
|
+
} catch (error: Exception) { fail(error.message ?: "WHIP startup failed") }
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
fun onPcm(bytes: ByteArray, rate: Int, channels: Int) {
|
|
129
|
+
if (!closed.get() && captureAudio) pcm.push(bytes, rate, channels)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
fun onVideoFrame(planes: I420Planes) {
|
|
133
|
+
if (closed.get() || !planes.planesReadable() || !frameBusy.compareAndSet(false, true)) return
|
|
134
|
+
// Copy before returning to the receiver. At most one frame is waiting on the publisher queue.
|
|
135
|
+
val buffer = JavaI420Buffer.allocate(planes.width, planes.height)
|
|
136
|
+
fun copy(src: java.nio.ByteBuffer, srcStride: Int, dst: java.nio.ByteBuffer, dstStride: Int, width: Int, height: Int) {
|
|
137
|
+
val source = src.duplicate()
|
|
138
|
+
val base = source.position()
|
|
139
|
+
val destination = dst.duplicate()
|
|
140
|
+
for (row in 0 until height) {
|
|
141
|
+
source.limit(source.capacity()).position(base + row * srcStride)
|
|
142
|
+
source.limit(base + row * srcStride + width)
|
|
143
|
+
destination.position(row * dstStride)
|
|
144
|
+
destination.put(source)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
copy(planes.y, planes.strideY, buffer.dataY, buffer.strideY, planes.width, planes.height)
|
|
149
|
+
copy(planes.u, planes.strideU, buffer.dataU, buffer.strideU, (planes.width + 1) / 2, (planes.height + 1) / 2)
|
|
150
|
+
copy(planes.v, planes.strideV, buffer.dataV, buffer.strideV, (planes.width + 1) / 2, (planes.height + 1) / 2)
|
|
151
|
+
if (!queue.post {
|
|
152
|
+
try {
|
|
153
|
+
if (!closed.get()) {
|
|
154
|
+
lastTimestamp = maxOf(System.nanoTime(), lastTimestamp + 1)
|
|
155
|
+
val frame = VideoFrame(buffer, 0, lastTimestamp)
|
|
156
|
+
videoSource?.capturerObserver?.onFrameCaptured(frame)
|
|
157
|
+
}
|
|
158
|
+
} finally { buffer.release(); frameBusy.set(false) }
|
|
159
|
+
}) { buffer.release(); frameBusy.set(false) }
|
|
160
|
+
} catch (_: Exception) { buffer.release(); frameBusy.set(false) }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private fun maybePost() {
|
|
164
|
+
val pc = peer ?: return
|
|
165
|
+
if (closed.get() || posted || !localSet || pc.iceGatheringState() != PeerConnection.IceGatheringState.COMPLETE) return
|
|
166
|
+
posted = true
|
|
167
|
+
val http = client ?: return
|
|
168
|
+
val request = Request.Builder().url(endpoint)
|
|
169
|
+
.post(pc.localDescription.description.toRequestBody("application/sdp".toMediaType())).build()
|
|
170
|
+
http.newCall(request).enqueue(object : Callback {
|
|
171
|
+
override fun onFailure(call: Call, error: IOException) { queue.post { fail("WHIP signaling failed") } }
|
|
172
|
+
override fun onResponse(call: Call, response: Response) {
|
|
173
|
+
response.use {
|
|
174
|
+
val location = response.header("Location")?.let { request.url.resolve(it) }
|
|
175
|
+
val answer = response.body?.string().orEmpty()
|
|
176
|
+
// A late successful POST still owns a remote resource and must DELETE it after stop.
|
|
177
|
+
if (closed.get()) { location?.let { delete(http, it) }; return }
|
|
178
|
+
queue.post {
|
|
179
|
+
if (closed.get()) { location?.let { delete(http, it) }; return@post }
|
|
180
|
+
if (response.code != 201 || location == null || !location.isHttps || answer.isBlank()) {
|
|
181
|
+
location?.takeIf { it.isHttps }?.let { delete(http, it) }
|
|
182
|
+
fail("WHIP server rejected publish (HTTP ${response.code})")
|
|
183
|
+
return@post
|
|
184
|
+
}
|
|
185
|
+
resource = location
|
|
186
|
+
peer?.setRemoteDescription(sdpObserver(), SessionDescription(SessionDescription.Type.ANSWER, answer))
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
})
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private fun fail(reason: String) {
|
|
194
|
+
if (closed.get() || failureSent) return
|
|
195
|
+
failureSent = true
|
|
196
|
+
onState("failed", reason)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private fun sdpObserver(onCreate: (SessionDescription) -> Unit = {}, onSet: () -> Unit = {}) = object : SdpObserver {
|
|
200
|
+
override fun onCreateSuccess(sdp: SessionDescription) { queue.post { if (!closed.get()) onCreate(sdp) } }
|
|
201
|
+
override fun onSetSuccess() { queue.post { if (!closed.get()) onSet() } }
|
|
202
|
+
override fun onCreateFailure(error: String) { queue.post { fail("WHIP offer failed") } }
|
|
203
|
+
override fun onSetFailure(error: String) { queue.post { fail("WHIP description failed") } }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private val observer = object : PeerConnection.Observer {
|
|
207
|
+
override fun onIceConnectionChange(state: PeerConnection.IceConnectionState) { queue.post {
|
|
208
|
+
if (closed.get()) return@post
|
|
209
|
+
when (state) {
|
|
210
|
+
PeerConnection.IceConnectionState.CONNECTED, PeerConnection.IceConnectionState.COMPLETED -> {
|
|
211
|
+
connected = true; queue.removeCallbacks(disconnectDeadline); onState("connected", "Phone publisher connected")
|
|
212
|
+
}
|
|
213
|
+
PeerConnection.IceConnectionState.DISCONNECTED -> { queue.removeCallbacks(disconnectDeadline); queue.postDelayed(disconnectDeadline, 10_000) }
|
|
214
|
+
PeerConnection.IceConnectionState.FAILED -> fail("WHIP ICE failed")
|
|
215
|
+
else -> Unit
|
|
216
|
+
}
|
|
217
|
+
} }
|
|
218
|
+
override fun onIceGatheringChange(state: PeerConnection.IceGatheringState) { queue.post { maybePost() } }
|
|
219
|
+
override fun onSignalingChange(state: PeerConnection.SignalingState) = Unit
|
|
220
|
+
override fun onIceConnectionReceivingChange(receiving: Boolean) = Unit
|
|
221
|
+
override fun onIceCandidate(candidate: IceCandidate) = Unit
|
|
222
|
+
override fun onIceCandidatesRemoved(candidates: Array<out IceCandidate>) = Unit
|
|
223
|
+
override fun onAddStream(stream: MediaStream) = Unit
|
|
224
|
+
override fun onRemoveStream(stream: MediaStream) = Unit
|
|
225
|
+
override fun onDataChannel(channel: DataChannel) = Unit
|
|
226
|
+
override fun onRenegotiationNeeded() = Unit
|
|
227
|
+
override fun onAddTrack(receiver: RtpReceiver, streams: Array<out MediaStream>) = Unit
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Called off WebRTC threads. Drains callbacks before disposing factory/ADM/encoder resources. */
|
|
231
|
+
fun close() {
|
|
232
|
+
if (closed.compareAndSet(false, true)) {
|
|
233
|
+
queue.post {
|
|
234
|
+
fun step(action: () -> Unit) {
|
|
235
|
+
try { action() } catch (error: Throwable) { cleanupFailure = cleanupFailure ?: error }
|
|
236
|
+
}
|
|
237
|
+
try {
|
|
238
|
+
step { resource?.let { url -> client?.let { delete(it, url) } }; resource = null }
|
|
239
|
+
step { peer?.close() }
|
|
240
|
+
step { peer?.dispose(); peer = null }
|
|
241
|
+
step { videoSource?.capturerObserver?.onCapturerStopped() }
|
|
242
|
+
step { videoTrack?.dispose() }
|
|
243
|
+
step { videoSource?.dispose() }
|
|
244
|
+
step { audioTrack?.dispose() }
|
|
245
|
+
step { audioSource?.dispose() }
|
|
246
|
+
step { factory?.dispose() }
|
|
247
|
+
step { adm?.release() }
|
|
248
|
+
step { egl?.release() }
|
|
249
|
+
} finally { closeDone.countDown(); thread.quitSafely() }
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
check(closeDone.await(20, TimeUnit.SECONDS)) { "Phone publisher cleanup is still pending" }
|
|
253
|
+
cleanupFailure?.let { throw IllegalStateException("Phone publisher cleanup failed", it) }
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private fun delete(http: OkHttpClient, url: HttpUrl) {
|
|
257
|
+
if (!url.isHttps) return
|
|
258
|
+
http.newCall(Request.Builder().url(url).delete().build()).enqueue(object : Callback {
|
|
259
|
+
override fun onFailure(call: Call, e: IOException) = Unit
|
|
260
|
+
override fun onResponse(call: Call, response: Response) { response.close() }
|
|
261
|
+
})
|
|
262
|
+
}
|
|
263
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
package com.mentra.glassesmedia.publisher
|
|
2
|
+
|
|
3
|
+
import java.nio.ByteBuffer
|
|
4
|
+
|
|
5
|
+
/** Bounded 200ms mono PCM queue. Downmix/resample decoded audio to WebRTC's 48kHz clock. */
|
|
6
|
+
class RelayPcmBuffer(private val capacity: Int = 9_600) {
|
|
7
|
+
private val samples = ShortArray(capacity)
|
|
8
|
+
private var head = 0
|
|
9
|
+
private var count = 0
|
|
10
|
+
private var phase = 0.0
|
|
11
|
+
private var rate = 0
|
|
12
|
+
private var channels = 0
|
|
13
|
+
private var previous: Double? = null
|
|
14
|
+
|
|
15
|
+
@Synchronized fun push(bytes: ByteArray, sampleRate: Int, channelCount: Int) {
|
|
16
|
+
if (sampleRate !in 8_000..192_000 || channelCount !in 1..8) return
|
|
17
|
+
if (rate != sampleRate || channels != channelCount) {
|
|
18
|
+
rate = sampleRate; channels = channelCount; phase = 0.0; previous = null
|
|
19
|
+
}
|
|
20
|
+
val step = sampleRate / 48_000.0
|
|
21
|
+
for (frame in 0 until bytes.size / (2 * channelCount)) {
|
|
22
|
+
var sum = 0.0
|
|
23
|
+
for (channel in 0 until channelCount) {
|
|
24
|
+
val offset = (frame * channelCount + channel) * 2
|
|
25
|
+
sum += ((bytes[offset].toInt() and 255) or (bytes[offset + 1].toInt() shl 8)).toShort()
|
|
26
|
+
}
|
|
27
|
+
val value = sum / channelCount
|
|
28
|
+
val prior = previous
|
|
29
|
+
if (prior != null) {
|
|
30
|
+
while (phase < 1.0) {
|
|
31
|
+
append((prior + (value - prior) * phase).toInt().toShort())
|
|
32
|
+
phase += step
|
|
33
|
+
}
|
|
34
|
+
phase -= 1.0
|
|
35
|
+
}
|
|
36
|
+
previous = value
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
private fun append(value: Short) {
|
|
41
|
+
if (count == capacity) { head = (head + 1) % capacity; count-- }
|
|
42
|
+
samples[(head + count) % capacity] = value
|
|
43
|
+
count++
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Always fills the requested buffer. Underruns are silence, never stale microphone data. */
|
|
47
|
+
@Synchronized fun read(output: ByteBuffer, byteCount: Int) {
|
|
48
|
+
for (offset in 0 until minOf(byteCount, output.capacity()) step 2) {
|
|
49
|
+
val value = if (count > 0) samples[head].also { head = (head + 1) % capacity; count-- } else 0
|
|
50
|
+
output.put(offset, (value.toInt() and 255).toByte())
|
|
51
|
+
if (offset + 1 < output.capacity()) output.put(offset + 1, (value.toInt() shr 8).toByte())
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -161,6 +161,18 @@ class LocalWhipIngestSource(
|
|
|
161
161
|
server?.let { runCatching { it.stop() } }
|
|
162
162
|
server = null
|
|
163
163
|
disposePeer()
|
|
164
|
+
scopedNetwork?.let { ScopedNetworkChangeDetector.releaseReceiverNetwork(it) }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Terminal teardown for owners that discard this receiver instead of reusing its factory. */
|
|
168
|
+
fun close() {
|
|
169
|
+
generation++
|
|
170
|
+
check(server?.closeAndAwait() != false) { "Local WHIP requests are still draining" }
|
|
171
|
+
stop()
|
|
172
|
+
factory?.dispose()
|
|
173
|
+
factory = null
|
|
174
|
+
egl?.release()
|
|
175
|
+
egl = null
|
|
164
176
|
}
|
|
165
177
|
|
|
166
178
|
// -----------------------------------------------------------------
|
|
@@ -226,9 +238,11 @@ class LocalWhipIngestSource(
|
|
|
226
238
|
peer.setRemoteDescription(
|
|
227
239
|
object : SdpAdapter() {
|
|
228
240
|
override fun onSetSuccess() {
|
|
241
|
+
if (gen != generation) { answered.countDown(); return }
|
|
229
242
|
peer.createAnswer(
|
|
230
243
|
object : SdpAdapter() {
|
|
231
244
|
override fun onCreateSuccess(sdp: SessionDescription) {
|
|
245
|
+
if (gen != generation) { answered.countDown(); return }
|
|
232
246
|
peer.setLocalDescription(
|
|
233
247
|
object : SdpAdapter() {
|
|
234
248
|
override fun onSetSuccess() = answered.countDown()
|
|
@@ -404,10 +418,9 @@ class LocalWhipIngestSource(
|
|
|
404
418
|
}
|
|
405
419
|
|
|
406
420
|
private fun ensureFactory() {
|
|
421
|
+
// Standalone receivers can supply a fallback without replacing the owner's live registration.
|
|
422
|
+
scopedNetwork?.let { ScopedNetworkChangeDetector.registerReceiverNetwork(it) }
|
|
407
423
|
if (factory != null) return
|
|
408
|
-
// Normally already installed by the module at creation; repeating it here is idempotent and
|
|
409
|
-
// covers a factory built from a code path that bypassed the module (tests, future callers).
|
|
410
|
-
scopedNetwork?.let { scoped -> ScopedNetworkChangeDetector.install { scoped } }
|
|
411
424
|
PeerConnectionFactory.initialize(
|
|
412
425
|
PeerConnectionFactory.InitializationOptions.builder(context).createInitializationOptions(),
|
|
413
426
|
)
|