@mentra/glasses-media 3.2.1 → 3.3.0-beta.954

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 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` uses the same persistent `NEHotspotConfiguration` join as gallery transfers. Wi-Fi carries the glasses link; cellular carries the call. It verifies the joined SSID and `en0` address, reports network loss, and retains a cancelled join until the uncancellable system callback has been cleaned up.
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 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.
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.
@@ -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/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
+ })
@@ -0,0 +1,5 @@
1
+ /// The traffic needed after joining the verified glasses Wi-Fi network.
2
+ public enum HotspotAccessPurpose: String {
3
+ case outgoingClient = "outgoing-client"
4
+ case incomingServer = "incoming-server"
5
+ }
@@ -0,0 +1,155 @@
1
+ import Foundation
2
+
3
+ /// One IPv4 address row reported by `getifaddrs`.
4
+ public struct HotspotInterfaceAddress: Equatable {
5
+ public let name: String
6
+ public let isUp: Bool
7
+ public let ipv4: String
8
+
9
+ public init(name: String, isUp: Bool, ipv4: String) {
10
+ self.name = name
11
+ self.isUp = isUp
12
+ self.ipv4 = ipv4
13
+ }
14
+ }
15
+
16
+ /// One `getifaddrs` sample: every attached interface's kernel index (its `AF_LINK` row, present
17
+ /// with or without an IPv4 address) and every IPv4 address row.
18
+ public struct HotspotInterfaceSnapshot: Equatable {
19
+ public let links: [String: Int]
20
+ public let addresses: [HotspotInterfaceAddress]
21
+ /// Attached interfaces whose `AF_LINK` type is `IFT_ETHER`. Apple platforms report Wi-Fi and
22
+ /// wired Ethernet this way; cellular, VPN tunnel and loopback links have other types.
23
+ public let ethernetLinks: Set<String>
24
+
25
+ public init(links: [String: Int], addresses: [HotspotInterfaceAddress], ethernetLinks: Set<String> = []) {
26
+ self.links = links
27
+ self.addresses = addresses
28
+ self.ethernetLinks = ethernetLinks
29
+ }
30
+ }
31
+
32
+ /// Network framework's type for one interface on one evaluated path.
33
+ public struct HotspotInterfaceReport: Equatable {
34
+ public let name: String
35
+ public let index: Int
36
+ public let isWifi: Bool
37
+
38
+ public init(name: String, index: Int, isWifi: Bool) {
39
+ self.name = name
40
+ self.index = index
41
+ self.isWifi = isWifi
42
+ }
43
+ }
44
+
45
+ /// The Wi-Fi interface carrying the glasses hotspot and the DHCP address it held when verified.
46
+ public struct HotspotInterfaceBinding: Hashable {
47
+ public let interface: String
48
+ public let index: Int
49
+ public let address: String
50
+
51
+ public init(interface: String, index: Int, address: String) {
52
+ self.interface = interface
53
+ self.index = index
54
+ self.address = address
55
+ }
56
+ }
57
+
58
+ /// Wi-Fi identity learned during one hotspot session. Create a new value for every join.
59
+ ///
60
+ /// A path's interface list describes routes, not hardware: after Wi-Fi moves to an AP without
61
+ /// internet, both the default and the Wi-Fi-constrained path can omit it. An interface's type
62
+ /// does not change while it stays attached, so a positive Wi-Fi report is kept for the same
63
+ /// name and kernel index until that interface is detached or replaced. Omission from a later
64
+ /// report is not evidence. Any non-Wi-Fi report for a name rejects it for the rest of the session.
65
+ public struct HotspotWifiIdentity: Equatable {
66
+ public private(set) var trusted: [String: Int] = [:]
67
+ public private(set) var rejected: Set<String> = []
68
+ /// Trusted names later seen detached or replaced. A fresh report may trust the name again
69
+ /// for discovery, but an address verified before the change stays lost.
70
+ public private(set) var detached: Set<String> = []
71
+
72
+ public init() {}
73
+
74
+ public mutating func observe(_ reports: [HotspotInterfaceReport]) {
75
+ for report in reports where !report.isWifi {
76
+ rejected.insert(report.name)
77
+ trusted[report.name] = nil
78
+ }
79
+ for report in reports where report.isWifi && !report.name.isEmpty && report.index > 0 && !rejected.contains(report.name) {
80
+ // A new index means a different interface took the name; only this report vouches for it.
81
+ if let index = trusted[report.name], index != report.index { detached.insert(report.name) }
82
+ trusted[report.name] = report.index
83
+ }
84
+ }
85
+
86
+ /// Forget an interface that is no longer attached, or whose name now has another index.
87
+ /// Only a new positive report can re-establish it.
88
+ public mutating func observe(_ snapshot: HotspotInterfaceSnapshot) {
89
+ for (name, index) in trusted where snapshot.links[name] != index {
90
+ trusted[name] = nil
91
+ detached.insert(name)
92
+ }
93
+ }
94
+
95
+ /// Pin a binding chosen from hotspot client evidence (see `HotspotInterfacePolicy.select`)
96
+ /// as this session's Wi-Fi interface. It then ends exactly like a reported one.
97
+ public mutating func adopt(_ binding: HotspotInterfaceBinding) {
98
+ guard !rejected.contains(binding.interface), !detached.contains(binding.interface) else { return }
99
+ trusted[binding.interface] = binding.index
100
+ }
101
+ }
102
+
103
+ /// Wi-Fi is not always `en0`: a Mac can have built-in Ethernet on `en0` and Wi-Fi on `en1`.
104
+ /// Identify the hotspot link by interface type and address, never by name or by the first
105
+ /// private address, and keep that exact interface for the rest of the session.
106
+ public enum HotspotInterfacePolicy {
107
+ /// The unique up IPv4 address, on an interface this session trusts as Wi-Fi and that is still
108
+ /// attached with the same index, that can be the hotspot client. With a BLE-reported gateway
109
+ /// it must be a valid client on that gateway's /24. Missing or ambiguous candidates return nil.
110
+ ///
111
+ /// A phone on cellular has no Wi-Fi path when the join starts, and after association iOS
112
+ /// leaves the internet-less glasses AP out of both paths, so no Wi-Fi report ever arrives.
113
+ /// Only when the session has no trusted Wi-Fi interface, the hotspot client address is the
114
+ /// evidence instead: the unique valid client of the BLE gateway's /24 on an attached
115
+ /// Ethernet-type link that no path has reported as non-Wi-Fi. Callers bind it only after the
116
+ /// glasses SSID matched, then require a connection from it to reach the gateway, and must
117
+ /// `adopt` it so the session pins that interface.
118
+ public static func select(snapshot: HotspotInterfaceSnapshot, identity: HotspotWifiIdentity,
119
+ gateway: String?) -> HotspotInterfaceBinding?
120
+ {
121
+ if !identity.trusted.isEmpty {
122
+ return unique(snapshot, gateway: gateway) { identity.trusted[$0] }
123
+ }
124
+ guard let gateway else { return nil }
125
+ return unique(snapshot, gateway: gateway) { name in
126
+ guard snapshot.ethernetLinks.contains(name), !identity.rejected.contains(name),
127
+ !identity.detached.contains(name) else { return nil }
128
+ return snapshot.links[name]
129
+ }
130
+ }
131
+
132
+ private static func unique(_ snapshot: HotspotInterfaceSnapshot, gateway: String?,
133
+ index eligibleIndex: (String) -> Int?) -> HotspotInterfaceBinding?
134
+ {
135
+ let bindings = Set(snapshot.addresses.compactMap { row -> HotspotInterfaceBinding? in
136
+ guard let index = eligibleIndex(row.name), snapshot.links[row.name] == index,
137
+ row.isUp, LocalMediaPolicy.isPrivate(row.ipv4) else { return nil }
138
+ if let gateway, !LocalMediaPolicy.isHotspotClientAddress(row.ipv4, gateway: gateway) { return nil }
139
+ return HotspotInterfaceBinding(interface: row.name, index: index, address: row.ipv4)
140
+ })
141
+ return bindings.count == 1 ? bindings.first : nil
142
+ }
143
+
144
+ /// A verified link remains usable only while the same attached interface is up with the same
145
+ /// address. Later path reports may omit it. Another interface acquiring that address, a
146
+ /// detach or replacement under the same name, or a non-Wi-Fi report ends it for the session.
147
+ /// Pass an identity that has already observed `snapshot`.
148
+ public static func isIntact(_ binding: HotspotInterfaceBinding, snapshot: HotspotInterfaceSnapshot,
149
+ identity: HotspotWifiIdentity) -> Bool
150
+ {
151
+ guard identity.trusted[binding.interface] == binding.index, !identity.detached.contains(binding.interface),
152
+ snapshot.links[binding.interface] == binding.index else { return false }
153
+ return snapshot.addresses.contains { $0.name == binding.interface && $0.isUp && $0.ipv4 == binding.address }
154
+ }
155
+ }
@@ -0,0 +1,23 @@
1
+ import Foundation
2
+
3
+ /// Explicit bridge facts; never forward native userInfo or arbitrary NSError descriptions.
4
+ public struct HotspotJoinFailure {
5
+ public let code: String
6
+ public let message: String
7
+
8
+ public init(_ error: Error) {
9
+ if error is CancellationError || (error as? LocalMediaError)?.message == "Hotspot join cancelled" {
10
+ code = "ERR_HOTSPOT_CANCELLED"
11
+ message = "Hotspot join cancelled"
12
+ } else if let local = error as? LocalMediaError {
13
+ code = "ERR_HOTSPOT_JOIN"
14
+ // These messages are authored by the owned hotspot implementation, not NSError.
15
+ message = String(local.message.filter { !$0.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains) }.prefix(512))
16
+ } else {
17
+ code = "ERR_HOTSPOT_NATIVE"
18
+ let native = error as NSError
19
+ let domain = native.domain.range(of: "^[A-Za-z0-9_.-]{1,128}$", options: .regularExpression) == nil ? "unavailable" : native.domain
20
+ message = "Hotspot join failed (\(domain), \(native.code))"
21
+ }
22
+ }
23
+ }
@@ -0,0 +1,22 @@
1
+ import Foundation
2
+
3
+ /// One process-wide writer across OTA, gallery and native media sessions.
4
+ /// Callers release only after native association and owned cleanup have ended.
5
+ final class HotspotNetworkOwnership {
6
+ static let shared = HotspotNetworkOwnership()
7
+ private let lock = NSLock()
8
+ private var owner: UUID?
9
+
10
+ func acquire() -> UUID? {
11
+ lock.lock(); defer { lock.unlock() }
12
+ guard owner == nil else { return nil }
13
+ let token = UUID()
14
+ owner = token
15
+ return token
16
+ }
17
+
18
+ func release(_ token: UUID) {
19
+ lock.lock(); defer { lock.unlock() }
20
+ if owner == token { owner = nil }
21
+ }
22
+ }