@mentra/glasses-media 3.2.1-dev.314 → 3.3.0-beta.1000
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 +14 -2
- package/android/src/main/java/com/mentra/glassesmedia/network/InternetHold.kt +8 -7
- package/android/src/main/java/com/mentra/glassesmedia/publisher/PhoneWhipPublisher.kt +11 -8
- package/android/src/main/java/com/mentra/glassesmedia/source/DecodedFrameTap.kt +224 -0
- package/android/src/main/java/com/mentra/glassesmedia/source/StillPhotoInbox.kt +50 -0
- package/android/src/main/java/com/mentra/glassesmedia/source/WhipIngestProtocol.kt +22 -0
- package/android/src/main/java/com/mentra/glassesmedia/source/WhipIngestServer.kt +110 -10
- package/index.d.ts +11 -0
- package/index.js +9 -0
- package/ios/CoreKit/Sources/GlassesMediaCore/DecodedFrameTapCore.swift +204 -0
- package/ios/CoreKit/Sources/GlassesMediaCore/HotspotAccessPurpose.swift +5 -0
- package/ios/CoreKit/Sources/GlassesMediaCore/HotspotInterfacePolicy.swift +155 -0
- package/ios/CoreKit/Sources/GlassesMediaCore/HotspotJoinFailure.swift +23 -0
- package/ios/CoreKit/Sources/GlassesMediaCore/HotspotNetworkOwnership.swift +22 -0
- package/ios/CoreKit/Sources/GlassesMediaCore/MacHotspotAssociation.swift +161 -0
- package/ios/CoreKit/Sources/GlassesMediaCore/MacHotspotAuthorization.swift +59 -0
- package/ios/DecodedFrameTap.swift +64 -0
- package/ios/GlassesHotspotNetwork.swift +254 -73
- package/ios/GlassesMedia.podspec +1 -1
- package/ios/GlassesMediaRelayModule.swift +61 -11
- package/package.json +6 -1
- package/ios/CoreKit/Sources/GlassesMediaCore/MacE2EHotspotLease.swift +0 -20
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ Native receivers, WHIP publishers and network helpers shared by the Mentra App.
|
|
|
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.
|
|
7
|
-
- `GlassesHotspotNetwork`
|
|
7
|
+
- `GlassesHotspotNetwork` owns hotspot association for OTA, gallery transfers and media. iPhone uses persistent `NEHotspotConfiguration`; iOS-on-Mac uses public CoreWLAN in the app process and the standard Location permission needed to identify Wi-Fi networks. Wi-Fi carries the glasses link; cellular carries the call. The owner verifies the requested SSID and actual Wi-Fi interface address, retains that interface for loss checks and ACS binding, and serializes network writers across consumers. Cancellation retains custody until blocking native work and cleanup finish. Cleanup preserves a network the user joined afterward; a failed release rejects and retains ownership for a later cleanup attempt.
|
|
8
8
|
|
|
9
9
|
The publisher owns its outgoing encoders, audio policy, and destination. Receivers do not open the phone microphone or play the glasses microphone locally. iOS uses a receive-only audio device to drive decoded PCM delivery without changing the app's audio session.
|
|
10
10
|
|
|
@@ -53,6 +53,18 @@ The iOS internet publisher posts its offer on the first server-reflexive candida
|
|
|
53
53
|
|
|
54
54
|
Android temporarily lifts the cellular process pin while creating the local WHIP listener, then restores it before starting the outgoing publisher. Otherwise the listener inherits the cellular socket mark and cannot reply to the glasses. The outgoing audio callback supplies its own PCM frame clock: WebRTC's external-recording mode does not pace callbacks and reports zero captured bytes, so the callback fills the entire buffer and waits for the next frame interval without opening the phone microphone.
|
|
55
55
|
|
|
56
|
-
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
|
|
56
|
+
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. OTA, Gallery, ACS and managed streaming share one hotspot reservation before BLE AP changes or native association. OTA, Gallery and managed relay retain it until their owned cleanup completes. ACS still has an older retirement policy that releases its JavaScript reservation after reporting an unconfirmed stop; native association custody remains retained on failure. Consolidating that ACS retirement is a separate qualification gate. A refused OTA or Gallery caller makes no AP or association changes. 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.
|
|
57
57
|
|
|
58
58
|
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.
|
|
59
|
+
|
|
60
|
+
### Incoming OTA server and outgoing media clients
|
|
61
|
+
|
|
62
|
+
Hotspot association verifies the glasses SSID and the Wi-Fi interface/address for
|
|
63
|
+
both uses. Gallery and relay clients also wait for outgoing Local Network access
|
|
64
|
+
by connecting to the glasses HTTP endpoint. OTA selects `incoming-server`: it
|
|
65
|
+
serves already downloaded artifacts to connections initiated by the glasses, and
|
|
66
|
+
its own listener must become ready before the app sends the OTA manifest URL.
|
|
67
|
+
It does not depend on the Gallery HTTP server being available. Apple TN3179 lists
|
|
68
|
+
accepting incoming TCP as an operation that does not require Local Network
|
|
69
|
+
permission. This does not change outgoing Gallery/relay consent or the shared
|
|
70
|
+
network ownership and teardown rules.
|
|
@@ -447,13 +447,6 @@ class InternetHold(private val context: Context) {
|
|
|
447
447
|
private fun cellularIsValidated(manager: ConnectivityManager): Boolean =
|
|
448
448
|
findValidatedCellular(manager) != null
|
|
449
449
|
|
|
450
|
-
private fun findValidatedCellular(manager: ConnectivityManager): Network? =
|
|
451
|
-
manager.allNetworks.firstOrNull { network ->
|
|
452
|
-
val capabilities = manager.getNetworkCapabilities(network) ?: return@firstOrNull false
|
|
453
|
-
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) &&
|
|
454
|
-
isValidatedInternet(capabilities)
|
|
455
|
-
}
|
|
456
|
-
|
|
457
450
|
private fun connectivityManager(): ConnectivityManager? =
|
|
458
451
|
context.applicationContext.getSystemService(ConnectivityManager::class.java)
|
|
459
452
|
|
|
@@ -466,6 +459,14 @@ class InternetHold(private val context: Context) {
|
|
|
466
459
|
|
|
467
460
|
private const val DEFAULT_POLL_MS = 200L
|
|
468
461
|
|
|
462
|
+
/** Exclude validated carrier IMS/DUN networks that do not provide app internet. */
|
|
463
|
+
internal fun findValidatedCellular(manager: ConnectivityManager): Network? =
|
|
464
|
+
manager.allNetworks.firstOrNull { network ->
|
|
465
|
+
val capabilities = manager.getNetworkCapabilities(network) ?: return@firstOrNull false
|
|
466
|
+
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) &&
|
|
467
|
+
isValidatedInternet(capabilities)
|
|
468
|
+
}
|
|
469
|
+
|
|
469
470
|
fun isValidatedInternet(capabilities: NetworkCapabilities): Boolean =
|
|
470
471
|
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
|
|
471
472
|
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
|
|
@@ -2,9 +2,10 @@ package com.mentra.glassesmedia.publisher
|
|
|
2
2
|
|
|
3
3
|
import android.content.Context
|
|
4
4
|
import android.net.ConnectivityManager
|
|
5
|
-
import android.net.NetworkCapabilities
|
|
6
5
|
import android.os.Handler
|
|
7
6
|
import android.os.HandlerThread
|
|
7
|
+
import com.mentra.glassesmedia.network.InternetHold
|
|
8
|
+
import com.mentra.glassesmedia.trace.SoftApTrace
|
|
8
9
|
import com.mentra.glassesmedia.source.I420Planes
|
|
9
10
|
import java.io.IOException
|
|
10
11
|
import java.util.concurrent.CountDownLatch
|
|
@@ -65,12 +66,9 @@ class PhoneWhipPublisher(
|
|
|
65
66
|
val url = endpoint.toHttpUrl()
|
|
66
67
|
require(url.isHttps) { "Managed WHIP requires HTTPS" }
|
|
67
68
|
val manager = context.getSystemService(ConnectivityManager::class.java)
|
|
68
|
-
val internet = manager
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
it.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
|
|
72
|
-
} == true
|
|
73
|
-
} ?: error("Phone mobile data is unavailable")
|
|
69
|
+
val internet = InternetHold.findValidatedCellular(manager)
|
|
70
|
+
?: error("Phone mobile data is unavailable")
|
|
71
|
+
SoftApTrace.stage("whip_uplink_network", "network" to internet)
|
|
74
72
|
client = OkHttpClient.Builder().socketFactory(internet.socketFactory)
|
|
75
73
|
.dns(object : okhttp3.Dns { override fun lookup(hostname: String) = internet.getAllByName(hostname).toList() })
|
|
76
74
|
.callTimeout(20, TimeUnit.SECONDS).followRedirects(false).build()
|
|
@@ -167,7 +165,12 @@ class PhoneWhipPublisher(
|
|
|
167
165
|
val request = Request.Builder().url(endpoint)
|
|
168
166
|
.post(pc.localDescription.description.toRequestBody("application/sdp".toMediaType())).build()
|
|
169
167
|
http.newCall(request).enqueue(object : Callback {
|
|
170
|
-
override fun onFailure(call: Call, error: IOException) {
|
|
168
|
+
override fun onFailure(call: Call, error: IOException) {
|
|
169
|
+
// Exception messages can contain the credential-bearing publish URL. Record types only.
|
|
170
|
+
SoftApTrace.failure("whip_uplink_signaling", "error" to error.javaClass.simpleName,
|
|
171
|
+
"cause" to error.cause?.javaClass?.simpleName)
|
|
172
|
+
queue.post { fail("WHIP signaling failed (${error.javaClass.simpleName})") }
|
|
173
|
+
}
|
|
171
174
|
override fun onResponse(call: Call, response: Response) {
|
|
172
175
|
response.use {
|
|
173
176
|
val location = response.header("Location")?.let { request.url.resolve(it) }
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
package com.mentra.glassesmedia.source
|
|
2
|
+
|
|
3
|
+
import android.util.Log
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A second, optional consumer of decoded glasses video, sitting beside the ACS sender.
|
|
7
|
+
*
|
|
8
|
+
* The call owns this pipeline; the preview is a guest. [offer] therefore never blocks, never lets
|
|
9
|
+
* a `Throwable` reach the decoder thread, and never does real work — a sink may only decide
|
|
10
|
+
* whether it wants the frame, retain it, and schedule bounded work elsewhere. A preview that
|
|
11
|
+
* crashes, stalls, or falls behind must cost the call nothing.
|
|
12
|
+
*
|
|
13
|
+
* [detach] is generation-checked because sessions overlap: a call ending late must not tear down
|
|
14
|
+
* the subscription a newer call already installed.
|
|
15
|
+
*
|
|
16
|
+
* ## Cost with nothing attached
|
|
17
|
+
*
|
|
18
|
+
* With no sink and telemetry off, [offer] is one volatile read and a return. Sink, error handler
|
|
19
|
+
* and the telemetry flag live in one immutable [State] so that read is the only thing the decoder
|
|
20
|
+
* thread pays.
|
|
21
|
+
*
|
|
22
|
+
* ## Why the metrics live here
|
|
23
|
+
*
|
|
24
|
+
* These describe the call, not the preview. With [setTelemetryEnabled] on they are collected even
|
|
25
|
+
* with no sink attached, so a preview-off run is a usable baseline for the same numbers with the
|
|
26
|
+
* preview on.
|
|
27
|
+
*/
|
|
28
|
+
object DecodedFrameTap {
|
|
29
|
+
private const val TAG = "FRAME-PREVIEW"
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A snapshot of decoder-thread observations, drained by the reader.
|
|
33
|
+
*
|
|
34
|
+
* Sums rather than a percentile ring because this object cannot depend on the preview module,
|
|
35
|
+
* and because the two questions here are "is the mean cost negligible" and "was there ever a
|
|
36
|
+
* bad one" — a mean and a max answer both.
|
|
37
|
+
*/
|
|
38
|
+
data class Metrics(
|
|
39
|
+
val framesOffered: Long,
|
|
40
|
+
val framesWithSink: Long,
|
|
41
|
+
val offerTotalNs: Long,
|
|
42
|
+
val offerMaxNs: Long,
|
|
43
|
+
val cadenceSamples: Long,
|
|
44
|
+
val cadenceTotalNs: Long,
|
|
45
|
+
val cadenceMaxNs: Long,
|
|
46
|
+
val sinkExceptions: Long,
|
|
47
|
+
/** Frames the ACS sender accepted, recorded at its call sites after [offer]. */
|
|
48
|
+
val acsFramesSent: Long = 0,
|
|
49
|
+
) {
|
|
50
|
+
/** Mean time inside [offer] over every timed frame, with or without a sink. */
|
|
51
|
+
val offerMeanUs: Double get() = if (framesOffered > 0) offerTotalNs.toDouble() / framesOffered / 1000.0 else 0.0
|
|
52
|
+
val offerMaxUs: Double get() = offerMaxNs / 1000.0
|
|
53
|
+
val cadenceMeanMs: Double get() = if (cadenceSamples > 0) cadenceTotalNs.toDouble() / cadenceSamples / 1_000_000.0 else 0.0
|
|
54
|
+
val cadenceMaxMs: Double get() = cadenceMaxNs / 1_000_000.0
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private class State(
|
|
58
|
+
val sink: VideoFrameListener?,
|
|
59
|
+
val onSinkError: ((Throwable) -> Unit)?,
|
|
60
|
+
val generation: Long,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
@Volatile
|
|
64
|
+
private var state: State? = null
|
|
65
|
+
|
|
66
|
+
private var generation: Long = 0
|
|
67
|
+
private var currentSink: VideoFrameListener? = null
|
|
68
|
+
private var currentOnSinkError: ((Throwable) -> Unit)? = null
|
|
69
|
+
|
|
70
|
+
@Volatile
|
|
71
|
+
var telemetryEnabled: Boolean = false
|
|
72
|
+
private set
|
|
73
|
+
|
|
74
|
+
private val metricsLock = Any()
|
|
75
|
+
private var framesOffered = 0L
|
|
76
|
+
private var framesWithSink = 0L
|
|
77
|
+
private var offerTotalNs = 0L
|
|
78
|
+
private var offerMaxNs = 0L
|
|
79
|
+
private var cadenceSamples = 0L
|
|
80
|
+
private var cadenceTotalNs = 0L
|
|
81
|
+
private var cadenceMaxNs = 0L
|
|
82
|
+
private var sinkExceptions = 0L
|
|
83
|
+
private var acsFramesSent = 0L
|
|
84
|
+
private var lastOfferAtNs = 0L
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Install [listener] and return the generation that owns it. [onSinkError] runs on the decoder
|
|
88
|
+
* thread after a throw has been caught and counted; it must only schedule work elsewhere.
|
|
89
|
+
*/
|
|
90
|
+
@Synchronized
|
|
91
|
+
fun attach(listener: VideoFrameListener, onSinkError: ((Throwable) -> Unit)? = null): Long {
|
|
92
|
+
generation += 1
|
|
93
|
+
currentSink = listener
|
|
94
|
+
currentOnSinkError = onSinkError
|
|
95
|
+
publish()
|
|
96
|
+
Log.i(TAG, "tap attached generation=$generation")
|
|
97
|
+
return generation
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Remove the sink only if it is still the one [generation] installed. */
|
|
101
|
+
@Synchronized
|
|
102
|
+
fun detach(generation: Long): Boolean {
|
|
103
|
+
if (generation != this.generation || currentSink == null) return false
|
|
104
|
+
currentSink = null
|
|
105
|
+
currentOnSinkError = null
|
|
106
|
+
publish()
|
|
107
|
+
Log.i(TAG, "tap detached generation=$generation")
|
|
108
|
+
return true
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** True while [generation] still owns the installed sink. */
|
|
112
|
+
@Synchronized
|
|
113
|
+
fun isCurrent(generation: Long): Boolean = generation == this.generation && currentSink != null
|
|
114
|
+
|
|
115
|
+
/** Collect cadence and offer cost even with no sink, so preview off/on can be compared. */
|
|
116
|
+
@Synchronized
|
|
117
|
+
fun setTelemetryEnabled(enabled: Boolean) {
|
|
118
|
+
telemetryEnabled = enabled
|
|
119
|
+
publish()
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
fun hasSink(): Boolean = state?.sink != null
|
|
123
|
+
|
|
124
|
+
private fun publish() {
|
|
125
|
+
val wasCollecting = state != null
|
|
126
|
+
state = if (currentSink == null && !telemetryEnabled) {
|
|
127
|
+
null
|
|
128
|
+
} else {
|
|
129
|
+
State(currentSink, currentOnSinkError, generation)
|
|
130
|
+
}
|
|
131
|
+
if (!wasCollecting && state != null) {
|
|
132
|
+
// The first gap after collection resumes would span the whole idle period.
|
|
133
|
+
synchronized(metricsLock) { lastOfferAtNs = 0L }
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Read and clear the accumulated observations. Called about once a second by the reporter. */
|
|
138
|
+
fun drainMetrics(): Metrics = synchronized(metricsLock) {
|
|
139
|
+
val snapshot = Metrics(
|
|
140
|
+
framesOffered = framesOffered,
|
|
141
|
+
framesWithSink = framesWithSink,
|
|
142
|
+
offerTotalNs = offerTotalNs,
|
|
143
|
+
offerMaxNs = offerMaxNs,
|
|
144
|
+
cadenceSamples = cadenceSamples,
|
|
145
|
+
cadenceTotalNs = cadenceTotalNs,
|
|
146
|
+
cadenceMaxNs = cadenceMaxNs,
|
|
147
|
+
sinkExceptions = sinkExceptions,
|
|
148
|
+
acsFramesSent = acsFramesSent,
|
|
149
|
+
)
|
|
150
|
+
framesOffered = 0
|
|
151
|
+
framesWithSink = 0
|
|
152
|
+
offerTotalNs = 0
|
|
153
|
+
offerMaxNs = 0
|
|
154
|
+
cadenceSamples = 0
|
|
155
|
+
cadenceTotalNs = 0
|
|
156
|
+
cadenceMaxNs = 0
|
|
157
|
+
sinkExceptions = 0
|
|
158
|
+
acsFramesSent = 0
|
|
159
|
+
snapshot
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The ACS sender accepted a frame. Counted under the same gate as [offer], so the send rate is
|
|
164
|
+
* available whenever the tap's other call metrics are, including preview-off baselines.
|
|
165
|
+
*/
|
|
166
|
+
fun recordAcsSend() {
|
|
167
|
+
if (state == null) return
|
|
168
|
+
synchronized(metricsLock) { acsFramesSent += 1 }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Called on libwebrtc's decode thread, immediately before the frame goes to ACS.
|
|
173
|
+
*
|
|
174
|
+
* The catch is not defensive padding: the alternative is a preview bug throwing on the decoder
|
|
175
|
+
* thread and taking the wearer's call video with it.
|
|
176
|
+
*/
|
|
177
|
+
fun offer(planes: I420Planes) {
|
|
178
|
+
val current = state ?: return
|
|
179
|
+
val started = System.nanoTime()
|
|
180
|
+
val listener = current.sink
|
|
181
|
+
|
|
182
|
+
var failure: Throwable? = null
|
|
183
|
+
if (listener != null) {
|
|
184
|
+
try {
|
|
185
|
+
listener.onVideoFrame(planes)
|
|
186
|
+
} catch (error: Throwable) {
|
|
187
|
+
failure = error
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
val elapsed = System.nanoTime() - started
|
|
192
|
+
val firstFailure: Boolean
|
|
193
|
+
synchronized(metricsLock) {
|
|
194
|
+
framesOffered += 1
|
|
195
|
+
if (lastOfferAtNs > 0) {
|
|
196
|
+
val gap = started - lastOfferAtNs
|
|
197
|
+
cadenceSamples += 1
|
|
198
|
+
cadenceTotalNs += gap
|
|
199
|
+
if (gap > cadenceMaxNs) cadenceMaxNs = gap
|
|
200
|
+
}
|
|
201
|
+
lastOfferAtNs = started
|
|
202
|
+
offerTotalNs += elapsed
|
|
203
|
+
if (elapsed > offerMaxNs) offerMaxNs = elapsed
|
|
204
|
+
if (listener != null) framesWithSink += 1
|
|
205
|
+
firstFailure = failure != null && sinkExceptions == 0L
|
|
206
|
+
if (failure != null) sinkExceptions += 1
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (failure != null) {
|
|
210
|
+
if (firstFailure) {
|
|
211
|
+
try {
|
|
212
|
+
Log.w(TAG, "preview sink threw generation=${current.generation}; call frame unaffected", failure)
|
|
213
|
+
} catch (ignored: Throwable) {
|
|
214
|
+
// Logging renders the sink's throwable, whose message and stack are preview code too.
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
try {
|
|
218
|
+
current.onSinkError?.invoke(failure)
|
|
219
|
+
} catch (ignored: Throwable) {
|
|
220
|
+
// The handler is preview code too; it gets no more access to this thread than the sink.
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
package com.mentra.glassesmedia.source
|
|
2
|
+
|
|
3
|
+
import java.util.concurrent.CompletableFuture
|
|
4
|
+
import java.util.concurrent.ConcurrentHashMap
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Stills the glasses upload to the phone's ingest listener during a Direct-link call.
|
|
8
|
+
*
|
|
9
|
+
* A still is only accepted for a request id the host registered with [expect] before it asked the
|
|
10
|
+
* glasses to shoot, so nothing else on the hotspot can push an image onto the outgoing Teams tile.
|
|
11
|
+
* Process-wide because the listener is rebuilt on every SoftAP recovery while the photo request
|
|
12
|
+
* belongs to the meeting.
|
|
13
|
+
*/
|
|
14
|
+
object StillPhotoInbox {
|
|
15
|
+
|
|
16
|
+
/** Largest still accepted. A full-size Mentra Live JPEG is 1-3 MB. */
|
|
17
|
+
const val MAX_BYTES = 16L * 1024 * 1024
|
|
18
|
+
|
|
19
|
+
private val pending = ConcurrentHashMap<String, CompletableFuture<ByteArray>>()
|
|
20
|
+
|
|
21
|
+
/** Register [requestId]. Replaces an earlier registration of the same id. */
|
|
22
|
+
fun expect(requestId: String): CompletableFuture<ByteArray> {
|
|
23
|
+
val future = CompletableFuture<ByteArray>()
|
|
24
|
+
pending.put(requestId, future)?.cancel(false)
|
|
25
|
+
return future
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** True when a POST for [requestId] should be read. */
|
|
29
|
+
fun isExpected(requestId: String): Boolean = pending[requestId]?.isDone == false
|
|
30
|
+
|
|
31
|
+
/** Hand over a still. False when nothing is waiting for [requestId]. */
|
|
32
|
+
fun deliver(requestId: String, bytes: ByteArray): Boolean {
|
|
33
|
+
val future = pending[requestId] ?: return false
|
|
34
|
+
return future.complete(bytes)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The registration for [requestId], if any. */
|
|
38
|
+
fun lookup(requestId: String): CompletableFuture<ByteArray>? = pending[requestId]
|
|
39
|
+
|
|
40
|
+
/** Forget [requestId]; a waiter sees cancellation. */
|
|
41
|
+
fun cancel(requestId: String) {
|
|
42
|
+
pending.remove(requestId)?.cancel(false)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Leave, End, or a new join. */
|
|
46
|
+
fun cancelAll() {
|
|
47
|
+
val all = pending.keys.toList()
|
|
48
|
+
all.forEach { cancel(it) }
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -43,11 +43,33 @@ object WhipIngestProtocol {
|
|
|
43
43
|
*/
|
|
44
44
|
data class State(val activeSessionId: String? = null, val stopped: Boolean = false)
|
|
45
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Base of the still endpoint on the same listener. During a call the glasses POST a full-size
|
|
48
|
+
* JPEG to `/photo/<requestId>` instead of the cloud, since their own hotspot has no internet.
|
|
49
|
+
*/
|
|
50
|
+
const val STILL_PATH = "/photo"
|
|
51
|
+
|
|
46
52
|
/** Where to point the `Location` header. WHIP clients DELETE against exactly this URL. */
|
|
47
53
|
data class Endpoint(val host: String, val port: Int) {
|
|
48
54
|
fun sessionUrl(sessionId: String) = "http://$host:$port$BASE_PATH/$sessionId"
|
|
55
|
+
|
|
56
|
+
/** Where the glasses upload the still for [requestId]. */
|
|
57
|
+
fun stillUrl(requestId: String) = "http://$host:$port$STILL_PATH/$requestId"
|
|
49
58
|
}
|
|
50
59
|
|
|
60
|
+
private val STILL_ID = Regex("[A-Za-z0-9_-]{1,64}")
|
|
61
|
+
|
|
62
|
+
/** `/photo/<id>` -> `<id>` for a well-formed id; null for every other target. */
|
|
63
|
+
fun stillRequestIdOf(target: String): String? {
|
|
64
|
+
val path = target.substringBefore('?')
|
|
65
|
+
if (!path.startsWith("$STILL_PATH/")) return null
|
|
66
|
+
val id = path.removePrefix("$STILL_PATH/")
|
|
67
|
+
return id.takeIf { STILL_ID.matches(it) }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** True for an id the still endpoint could ever accept, so the host can mint compatible ones. */
|
|
71
|
+
fun isValidStillRequestId(requestId: String): Boolean = STILL_ID.matches(requestId)
|
|
72
|
+
|
|
51
73
|
data class Response(
|
|
52
74
|
val status: Int,
|
|
53
75
|
val reason: String,
|
|
@@ -2,6 +2,7 @@ package com.mentra.glassesmedia.source
|
|
|
2
2
|
|
|
3
3
|
import android.util.Log
|
|
4
4
|
import com.mentra.glassesmedia.trace.SoftApTrace
|
|
5
|
+
import java.io.BufferedInputStream
|
|
5
6
|
import java.io.BufferedOutputStream
|
|
6
7
|
import java.io.IOException
|
|
7
8
|
import java.io.InputStream
|
|
@@ -231,12 +232,18 @@ class WhipIngestServer(
|
|
|
231
232
|
socket.soTimeout = READ_TIMEOUT_MS
|
|
232
233
|
val output = BufferedOutputStream(socket.getOutputStream())
|
|
233
234
|
try {
|
|
234
|
-
val
|
|
235
|
-
|
|
235
|
+
val input = BufferedInputStream(socket.getInputStream())
|
|
236
|
+
val head = readHead(input)
|
|
237
|
+
if (head == null) {
|
|
236
238
|
write(output, WhipIngestProtocol.Response(400, "Bad Request", body = "malformed request"))
|
|
237
239
|
return
|
|
238
240
|
}
|
|
239
|
-
|
|
241
|
+
val stillId = WhipIngestProtocol.stillRequestIdOf(head.target)
|
|
242
|
+
if (stillId != null) {
|
|
243
|
+
write(output, receiveStill(head, stillId, input))
|
|
244
|
+
return
|
|
245
|
+
}
|
|
246
|
+
write(output, handle(readBody(head, input)))
|
|
240
247
|
} catch (error: Exception) {
|
|
241
248
|
Log.w(TAG, "connection failed", error)
|
|
242
249
|
runCatching {
|
|
@@ -336,6 +343,54 @@ class WhipIngestServer(
|
|
|
336
343
|
}
|
|
337
344
|
}
|
|
338
345
|
|
|
346
|
+
/**
|
|
347
|
+
* `POST /photo/<id>`: a full-size still from the glasses. Read only for an id the host is
|
|
348
|
+
* waiting on, so an unsolicited upload never costs the phone a multi-megabyte buffer.
|
|
349
|
+
*/
|
|
350
|
+
private fun receiveStill(head: Head, requestId: String, input: InputStream): WhipIngestProtocol.Response {
|
|
351
|
+
if (!head.method.equals("POST", ignoreCase = true)) {
|
|
352
|
+
return WhipIngestProtocol.Response(405, "Method Not Allowed", mapOf("Allow" to "POST"), "POST a JPEG")
|
|
353
|
+
}
|
|
354
|
+
if (!StillPhotoInbox.isExpected(requestId)) {
|
|
355
|
+
SoftApTrace.stage("still_upload_unexpected", "requestId" to requestId)
|
|
356
|
+
return WhipIngestProtocol.Response(404, "Not Found", body = "no still pending for $requestId")
|
|
357
|
+
}
|
|
358
|
+
val length = head.contentLength
|
|
359
|
+
if (length == null || length <= 0) {
|
|
360
|
+
return WhipIngestProtocol.Response(411, "Length Required", body = "Content-Length required")
|
|
361
|
+
}
|
|
362
|
+
if (length > StillPhotoInbox.MAX_BYTES) {
|
|
363
|
+
return WhipIngestProtocol.Response(413, "Content Too Large", body = "still exceeds ${StillPhotoInbox.MAX_BYTES} bytes")
|
|
364
|
+
}
|
|
365
|
+
val startedAt = System.nanoTime() / 1_000_000
|
|
366
|
+
val bytes = ByteArray(length.toInt())
|
|
367
|
+
var read = 0
|
|
368
|
+
while (read < bytes.size) {
|
|
369
|
+
val count = input.read(bytes, read, bytes.size - read)
|
|
370
|
+
if (count < 0) break
|
|
371
|
+
read += count
|
|
372
|
+
}
|
|
373
|
+
if (read < bytes.size) {
|
|
374
|
+
SoftApTrace.failure("still_upload_truncated", "requestId" to requestId, "read" to read, "expected" to bytes.size)
|
|
375
|
+
return WhipIngestProtocol.Response(400, "Bad Request", body = "body ended after $read of ${bytes.size} bytes")
|
|
376
|
+
}
|
|
377
|
+
val delivered = StillPhotoInbox.deliver(requestId, bytes)
|
|
378
|
+
SoftApTrace.stage(
|
|
379
|
+
"still_upload_received",
|
|
380
|
+
"requestId" to requestId,
|
|
381
|
+
"bytes" to bytes.size,
|
|
382
|
+
"readMs" to (System.nanoTime() / 1_000_000 - startedAt),
|
|
383
|
+
"delivered" to delivered,
|
|
384
|
+
)
|
|
385
|
+
if (!delivered) return WhipIngestProtocol.Response(410, "Gone", body = "still for $requestId is no longer wanted")
|
|
386
|
+
return WhipIngestProtocol.Response(
|
|
387
|
+
200,
|
|
388
|
+
"OK",
|
|
389
|
+
mapOf("Content-Type" to "application/json"),
|
|
390
|
+
"{\"ok\":true,\"bytes\":${bytes.size}}",
|
|
391
|
+
)
|
|
392
|
+
}
|
|
393
|
+
|
|
339
394
|
/** Frees the slot only if this session still holds it, so a newer publisher is not evicted. */
|
|
340
395
|
private fun releaseSlot(sessionId: String) {
|
|
341
396
|
synchronized(lock) {
|
|
@@ -362,21 +417,41 @@ class WhipIngestServer(
|
|
|
362
417
|
*/
|
|
363
418
|
const val TOMBSTONE_MS = 3_000L
|
|
364
419
|
|
|
420
|
+
/** Request line and headers, read without consuming any of the body. */
|
|
421
|
+
internal class Head(
|
|
422
|
+
val method: String,
|
|
423
|
+
val target: String,
|
|
424
|
+
val contentType: String?,
|
|
425
|
+
val contentLength: Long?,
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
/** Longest request line or header line accepted; real ones are well under 200 bytes. */
|
|
429
|
+
private const val MAX_HEADER_LINE = 8 * 1024
|
|
430
|
+
|
|
365
431
|
/**
|
|
366
432
|
* Reads a request into [WhipIngestProtocol.Request]. Only `Content-Length` bodies are
|
|
367
433
|
* supported: the glasses' WHIP client sends one, and `chunked` would be a silent 400 otherwise,
|
|
368
434
|
* so it is rejected explicitly by the missing-length path below.
|
|
369
435
|
*/
|
|
370
436
|
fun readRequest(stream: InputStream): WhipIngestProtocol.Request? {
|
|
371
|
-
val
|
|
437
|
+
val input = if (stream is BufferedInputStream) stream else BufferedInputStream(stream)
|
|
438
|
+
val head = readHead(input) ?: return null
|
|
439
|
+
return readBody(head, input)
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Header parsing is byte-exact so the body that follows can be binary: the still endpoint
|
|
444
|
+
* reads a JPEG off the same stream, which a character reader would have buffered and decoded.
|
|
445
|
+
*/
|
|
446
|
+
internal fun readHead(input: InputStream): Head? {
|
|
372
447
|
val (method, target) = WhipIngestProtocol.parseRequestLine(
|
|
373
|
-
|
|
448
|
+
readLine(input) ?: return null,
|
|
374
449
|
) ?: return null
|
|
375
450
|
SoftApTrace.stage("whip_request_line_read", "method" to method)
|
|
376
451
|
|
|
377
452
|
val headers = mutableMapOf<String, String>()
|
|
378
453
|
while (true) {
|
|
379
|
-
val line =
|
|
454
|
+
val line = readLine(input) ?: break
|
|
380
455
|
if (line.isEmpty()) break
|
|
381
456
|
val separator = line.indexOf(':')
|
|
382
457
|
if (separator <= 0) continue
|
|
@@ -386,24 +461,49 @@ class WhipIngestServer(
|
|
|
386
461
|
val contentType = WhipIngestProtocol.headerValue(headers, "Content-Type")
|
|
387
462
|
val contentLength = WhipIngestProtocol.headerValue(headers, "Content-Length")?.toLongOrNull()
|
|
388
463
|
SoftApTrace.stage("whip_request_headers_read", "contentLength" to contentLength)
|
|
464
|
+
return Head(method, target, contentType, contentLength)
|
|
465
|
+
}
|
|
389
466
|
|
|
467
|
+
internal fun readBody(head: Head, input: InputStream): WhipIngestProtocol.Request {
|
|
468
|
+
val contentLength = head.contentLength
|
|
390
469
|
// Do not read a body we have already decided to refuse. decide() checks the length first for
|
|
391
470
|
// exactly this reason.
|
|
392
471
|
if (contentLength == null || contentLength <= 0 ||
|
|
393
472
|
contentLength > WhipIngestProtocol.MAX_BODY_BYTES
|
|
394
473
|
) {
|
|
395
|
-
return WhipIngestProtocol.Request(method, target, contentType, contentLength)
|
|
474
|
+
return WhipIngestProtocol.Request(head.method, head.target, head.contentType, contentLength)
|
|
396
475
|
}
|
|
397
476
|
|
|
398
|
-
val body =
|
|
477
|
+
val body = ByteArray(contentLength.toInt())
|
|
399
478
|
var read = 0
|
|
400
479
|
while (read < body.size) {
|
|
401
|
-
val count =
|
|
480
|
+
val count = input.read(body, read, body.size - read)
|
|
402
481
|
if (count < 0) break
|
|
403
482
|
read += count
|
|
404
483
|
SoftApTrace.stage("whip_request_body_read", "read" to read, "expected" to body.size)
|
|
405
484
|
}
|
|
406
|
-
return WhipIngestProtocol.Request(
|
|
485
|
+
return WhipIngestProtocol.Request(
|
|
486
|
+
head.method,
|
|
487
|
+
head.target,
|
|
488
|
+
head.contentType,
|
|
489
|
+
contentLength,
|
|
490
|
+
String(body, 0, read, Charsets.UTF_8),
|
|
491
|
+
)
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** One CRLF- or LF-terminated line, or null at end of stream before any byte. */
|
|
495
|
+
private fun readLine(input: InputStream): String? {
|
|
496
|
+
val line = java.io.ByteArrayOutputStream()
|
|
497
|
+
while (true) {
|
|
498
|
+
val next = input.read()
|
|
499
|
+
if (next < 0) return if (line.size() == 0) null else line.toString(Charsets.ISO_8859_1.name())
|
|
500
|
+
if (next == '\n'.code) break
|
|
501
|
+
line.write(next)
|
|
502
|
+
if (line.size() > MAX_HEADER_LINE) throw IOException("header line too long")
|
|
503
|
+
}
|
|
504
|
+
val bytes = line.toByteArray()
|
|
505
|
+
val end = if (bytes.isNotEmpty() && bytes.last() == '\r'.code.toByte()) bytes.size - 1 else bytes.size
|
|
506
|
+
return String(bytes, 0, end, Charsets.ISO_8859_1)
|
|
407
507
|
}
|
|
408
508
|
|
|
409
509
|
/** Convenience for tests and teardown paths that must not outlive the tombstone. */
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Shared native association owner for the local glasses hotspot. */
|
|
2
|
+
export const glassesHotspot: {
|
|
3
|
+
join(
|
|
4
|
+
id: string,
|
|
5
|
+
ssid: string,
|
|
6
|
+
password: string,
|
|
7
|
+
gateway?: string,
|
|
8
|
+
purpose?: "outgoing-client" | "incoming-server",
|
|
9
|
+
): Promise<string>
|
|
10
|
+
leave(id: string): Promise<void>
|
|
11
|
+
}
|
package/index.js
CHANGED
|
@@ -1 +1,10 @@
|
|
|
1
1
|
// Native library. Media stays in native code; publishers consume decoded frames and PCM.
|
|
2
|
+
|
|
3
|
+
// Load only when this transport is requested. Importing the package does not
|
|
4
|
+
// start a native session or require Expo's application entry point.
|
|
5
|
+
const native = () => require("expo-modules-core").requireNativeModule("MentraGlassesMediaRelay")
|
|
6
|
+
export const glassesHotspot = Object.freeze({
|
|
7
|
+
join: (id, ssid, password, gateway, purpose = "outgoing-client") =>
|
|
8
|
+
native().joinLocalHotspot(id, ssid, password, gateway ?? null, purpose),
|
|
9
|
+
leave: (id) => native().leaveLocalHotspot(id),
|
|
10
|
+
})
|