@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 request = readRequest(socket.getInputStream())
235
- if (request == null) {
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
- write(output, handle(request))
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 reader = stream.bufferedReader(Charsets.UTF_8)
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
- reader.readLine() ?: return null,
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 = reader.readLine() ?: break
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 = CharArray(contentLength.toInt())
477
+ val body = ByteArray(contentLength.toInt())
399
478
  var read = 0
400
479
  while (read < body.size) {
401
- val count = reader.read(body, read, body.size - read)
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(method, target, contentType, contentLength, String(body, 0, read))
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mentra/glasses-media",
3
- "version": "3.3.0-dev.488",
3
+ "version": "3.3.0-dev.489",
4
4
  "description": "Shared native glasses media receivers and local network transport",
5
5
  "license": "MIT",
6
6
  "author": "Mentra Community",