@mentra/glasses-media 3.3.0-dev.488 → 3.3.0-dev.489
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.
|
@@ -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. */
|