@mentra/bluetooth-sdk 0.1.7 → 0.1.9

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.
Files changed (83) hide show
  1. package/README.md +11 -9
  2. package/android/.project +28 -0
  3. package/android/build.gradle +3 -0
  4. package/android/lc3Lib/.project +28 -0
  5. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +15 -3
  6. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +26 -1
  7. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +4 -2
  8. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +136 -4
  9. package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +40 -3
  10. package/android/src/main/java/com/mentra/bluetoothsdk/controllers/ControllerManager.kt +1 -0
  11. package/android/src/main/java/com/mentra/bluetoothsdk/controllers/R1.kt +1 -1
  12. package/android/src/main/java/com/mentra/bluetoothsdk/debug/BleTraceLogger.kt +149 -0
  13. package/android/src/main/java/com/mentra/bluetoothsdk/events/BluetoothEvents.kt +1 -0
  14. package/android/src/main/java/com/mentra/bluetoothsdk/photoreceiver/LocalPhotoUploadServer.kt +297 -0
  15. package/android/src/main/java/com/mentra/bluetoothsdk/photoreceiver/MentraPhotoReceiverModule.kt +120 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +13 -0
  17. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.java +1 -1
  18. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +1 -0
  19. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Mach1.java +1 -1
  20. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.java +104 -21
  21. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +1 -1
  22. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +1 -0
  23. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Simulated.kt +1 -0
  24. package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +128 -3
  25. package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunkReassembler.java +105 -0
  26. package/build/BluetoothSdk.types.d.ts +66 -29
  27. package/build/BluetoothSdk.types.d.ts.map +1 -1
  28. package/build/BluetoothSdk.types.js +3 -0
  29. package/build/BluetoothSdk.types.js.map +1 -1
  30. package/build/_private/BluetoothSdkModule.d.ts +2 -1
  31. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  32. package/build/_private/BluetoothSdkModule.js +24 -7
  33. package/build/_private/BluetoothSdkModule.js.map +1 -1
  34. package/build/index.d.ts +2 -2
  35. package/build/index.d.ts.map +1 -1
  36. package/build/index.js +2 -3
  37. package/build/index.js.map +1 -1
  38. package/build/photo-receiver/MentraPhotoReceiver.types.d.ts +18 -0
  39. package/build/photo-receiver/MentraPhotoReceiver.types.d.ts.map +1 -0
  40. package/build/photo-receiver/MentraPhotoReceiver.types.js +2 -0
  41. package/build/photo-receiver/MentraPhotoReceiver.types.js.map +1 -0
  42. package/build/photo-receiver/MentraPhotoReceiverModule.d.ts +10 -0
  43. package/build/photo-receiver/MentraPhotoReceiverModule.d.ts.map +1 -0
  44. package/build/photo-receiver/MentraPhotoReceiverModule.js +3 -0
  45. package/build/photo-receiver/MentraPhotoReceiverModule.js.map +1 -0
  46. package/build/photo-receiver/MentraPhotoReceiverModule.web.d.ts +10 -0
  47. package/build/photo-receiver/MentraPhotoReceiverModule.web.d.ts.map +1 -0
  48. package/build/photo-receiver/MentraPhotoReceiverModule.web.js +12 -0
  49. package/build/photo-receiver/MentraPhotoReceiverModule.web.js.map +1 -0
  50. package/build/photo-receiver/index.d.ts +3 -0
  51. package/build/photo-receiver/index.d.ts.map +1 -0
  52. package/build/photo-receiver/index.js +3 -0
  53. package/build/photo-receiver/index.js.map +1 -0
  54. package/expo-module.config.json +5 -2
  55. package/ios/BluetoothSdkModule.swift +19 -3
  56. package/ios/LocalPhotoUploadServer.swift +411 -0
  57. package/ios/MentraBluetoothSDK.podspec +8 -2
  58. package/ios/MentraPhotoReceiverModule.swift +147 -0
  59. package/ios/Source/Bridge.swift +20 -1
  60. package/ios/Source/Camera/CameraModels.swift +64 -11
  61. package/ios/Source/DeviceManager.swift +6 -4
  62. package/ios/Source/Events/BluetoothEvents.swift +3 -0
  63. package/ios/Source/MentraBluetoothSDK.swift +111 -5
  64. package/ios/Source/ObservableStore.swift +2 -2
  65. package/ios/Source/Streaming/StreamModels.swift +234 -40
  66. package/ios/Source/controllers/ControllerManager.swift +1 -1
  67. package/ios/Source/controllers/R1.swift +9 -3
  68. package/ios/Source/sgcs/G1.swift +5 -4
  69. package/ios/Source/sgcs/G2.swift +4 -4
  70. package/ios/Source/sgcs/Mach1.swift +1 -1
  71. package/ios/Source/sgcs/MentraLive.swift +76 -12
  72. package/ios/Source/sgcs/MentraNex.swift +1 -1
  73. package/ios/Source/sgcs/SGCManager.swift +1 -1
  74. package/ios/Source/sgcs/Simulated.swift +1 -1
  75. package/ios/Source/utils/MessageChunkReassembler.swift +89 -0
  76. package/package.json +6 -1
  77. package/src/BluetoothSdk.types.ts +90 -35
  78. package/src/_private/BluetoothSdkModule.ts +38 -8
  79. package/src/index.ts +9 -4
  80. package/src/photo-receiver/MentraPhotoReceiver.types.ts +20 -0
  81. package/src/photo-receiver/MentraPhotoReceiverModule.ts +14 -0
  82. package/src/photo-receiver/MentraPhotoReceiverModule.web.ts +20 -0
  83. package/src/photo-receiver/index.ts +2 -0
@@ -0,0 +1,411 @@
1
+ import Foundation
2
+ import Network
3
+
4
+ struct PhotoUpload {
5
+ let requestId: String?
6
+ let photoFile: URL
7
+ let byteCount: Int
8
+ let fields: [String: String]
9
+ }
10
+
11
+ final class LocalPhotoUploadServer {
12
+ private let queue = DispatchQueue(label: "com.mentra.examples.photo-upload")
13
+ private let uploadDirectory: URL
14
+ private let onLog: (String) -> Void
15
+ private let onUpload: (PhotoUpload) -> Void
16
+ private var listener: NWListener?
17
+
18
+ init(onLog: @escaping (String) -> Void, onUpload: @escaping (PhotoUpload) -> Void) {
19
+ self.onLog = onLog
20
+ self.onUpload = onUpload
21
+ uploadDirectory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
22
+ .appendingPathComponent("mentra-photo-uploads", isDirectory: true)
23
+ }
24
+
25
+ func start(port: UInt16) throws -> UInt16 {
26
+ stop()
27
+ try FileManager.default.createDirectory(
28
+ at: uploadDirectory,
29
+ withIntermediateDirectories: true
30
+ )
31
+
32
+ let parameters = NWParameters.tcp
33
+ parameters.allowLocalEndpointReuse = true
34
+ guard let endpointPort = NWEndpoint.Port(rawValue: port) else {
35
+ throw PhotoUploadServerError("Invalid port \(port)")
36
+ }
37
+
38
+ let listener = try NWListener(using: parameters, on: endpointPort)
39
+ let started = DispatchSemaphore(value: 0)
40
+ var startError: Error?
41
+ var isReady = false
42
+
43
+ listener.stateUpdateHandler = { [weak self] state in
44
+ switch state {
45
+ case .ready:
46
+ isReady = true
47
+ self?.onLog("Listening on 0.0.0.0:\(port)")
48
+ started.signal()
49
+ case .failed(let error):
50
+ startError = error
51
+ self?.onLog("Photo receiver failed on \(port): \(error)")
52
+ started.signal()
53
+ case .cancelled:
54
+ if !isReady {
55
+ startError = PhotoUploadServerError("Listener cancelled")
56
+ started.signal()
57
+ }
58
+ default:
59
+ break
60
+ }
61
+ }
62
+
63
+ listener.newConnectionHandler = { [weak self] connection in
64
+ self?.handle(connection)
65
+ }
66
+
67
+ self.listener = listener
68
+ listener.start(queue: queue)
69
+
70
+ if started.wait(timeout: .now() + 2) == .timedOut {
71
+ throw PhotoUploadServerError("Timed out starting listener")
72
+ }
73
+ if let startError {
74
+ self.listener = nil
75
+ listener.cancel()
76
+ throw startError
77
+ }
78
+
79
+ return port
80
+ }
81
+
82
+ func stop() {
83
+ listener?.cancel()
84
+ listener = nil
85
+ }
86
+
87
+ private func handle(_ connection: NWConnection) {
88
+ connection.start(queue: queue)
89
+ receive(connection, state: RequestReadState())
90
+ }
91
+
92
+ private func receive(_ connection: NWConnection, state: RequestReadState) {
93
+ connection.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) {
94
+ [weak self] data, _, isComplete, error in
95
+ guard let self else {
96
+ connection.cancel()
97
+ return
98
+ }
99
+ if let error {
100
+ self.onLog("request failed: \(error.localizedDescription)")
101
+ connection.cancel()
102
+ return
103
+ }
104
+ if let data, !data.isEmpty {
105
+ state.buffer.append(data)
106
+ }
107
+
108
+ do {
109
+ if try self.handleIfComplete(connection, state: state) {
110
+ return
111
+ }
112
+ } catch {
113
+ self.onLog("request failed: \(error.localizedDescription)")
114
+ self.writeJson(connection, status: 500, body: #"{"ok":false,"error":"server_error"}"#)
115
+ return
116
+ }
117
+
118
+ if isComplete {
119
+ self.writeJson(connection, status: 400, body: #"{"ok":false,"error":"incomplete_request"}"#)
120
+ return
121
+ }
122
+ self.receive(connection, state: state)
123
+ }
124
+ }
125
+
126
+ private func handleIfComplete(_ connection: NWConnection, state: RequestReadState) throws -> Bool {
127
+ if state.request == nil {
128
+ guard let headerRange = state.buffer.range(of: Data([13, 10, 13, 10])) else {
129
+ if state.buffer.count > Self.maxHeaderBytes {
130
+ writeJson(connection, status: 413, body: #"{"ok":false,"error":"headers_too_large"}"#)
131
+ return true
132
+ }
133
+ return false
134
+ }
135
+
136
+ let headerData = state.buffer[state.buffer.startIndex..<headerRange.lowerBound]
137
+ guard let headerText = String(data: headerData, encoding: .isoLatin1) else {
138
+ writeJson(connection, status: 400, body: #"{"ok":false,"error":"invalid_headers"}"#)
139
+ return true
140
+ }
141
+ let request = HttpRequest.parse(headerText)
142
+ state.request = request
143
+ state.bodyStartIndex = headerRange.upperBound
144
+ onLog("\(request.method) \(request.path)")
145
+
146
+ if request.method == "GET", request.path == "/" || request.path == "/health" {
147
+ writeJson(connection, status: 200, body: #"{"ok":true,"service":"mentra-photo-upload-receiver"}"#)
148
+ return true
149
+ }
150
+
151
+ guard request.method == "POST", request.path == "/upload" else {
152
+ writeJson(connection, status: 404, body: #"{"ok":false,"error":"not_found"}"#)
153
+ return true
154
+ }
155
+
156
+ guard let contentLength = request.headers["content-length"].flatMap(Int.init), contentLength > 0 else {
157
+ writeJson(connection, status: 411, body: #"{"ok":false,"error":"content_length_required"}"#)
158
+ return true
159
+ }
160
+ guard contentLength <= Self.maxUploadBytes else {
161
+ writeJson(connection, status: 413, body: #"{"ok":false,"error":"upload_too_large"}"#)
162
+ return true
163
+ }
164
+
165
+ state.contentLength = contentLength
166
+ if request.headers["expect"]?.localizedCaseInsensitiveContains("100-continue") == true {
167
+ write(connection, text: "HTTP/1.1 100 Continue\r\n\r\n", closeAfterSend: false)
168
+ }
169
+ }
170
+
171
+ guard let request = state.request, let bodyStartIndex = state.bodyStartIndex else {
172
+ return false
173
+ }
174
+ guard state.buffer.count >= bodyStartIndex + state.contentLength else {
175
+ return false
176
+ }
177
+
178
+ let bodyEndIndex = bodyStartIndex + state.contentLength
179
+ let body = Data(state.buffer[bodyStartIndex..<bodyEndIndex])
180
+ let contentType = request.headers["content-type"] ?? ""
181
+ onLog("headers contentType=\(contentType) contentLength=\(state.contentLength)")
182
+
183
+ guard let boundary = multipartBoundary(contentType) else {
184
+ writeJson(connection, status: 400, body: #"{"ok":false,"error":"multipart_boundary_required"}"#)
185
+ return true
186
+ }
187
+
188
+ let parsed = parseMultipart(body, boundary: boundary)
189
+ guard let photoBytes = parsed.files["photo"] ?? parsed.files.values.first else {
190
+ writeJson(connection, status: 400, body: #"{"ok":false,"error":"photo_field_required"}"#)
191
+ return true
192
+ }
193
+
194
+ let requestId = parsed.fields["requestId"] ?? parsed.fields["request_id"]
195
+ let filename = safeFilePart(requestId ?? "photo-\(Int(Date().timeIntervalSince1970 * 1000))")
196
+ let photoFile = uploadDirectory.appendingPathComponent("\(filename).jpg")
197
+ try photoBytes.write(to: photoFile, options: .atomic)
198
+
199
+ onLog("upload fields=\(parsed.fields.keys.joined(separator: ",")) requestId=\(requestId ?? "") bytes=\(photoBytes.count) saved=\(photoFile.path)")
200
+ onUpload(PhotoUpload(
201
+ requestId: requestId,
202
+ photoFile: photoFile,
203
+ byteCount: photoBytes.count,
204
+ fields: parsed.fields
205
+ ))
206
+ writeJson(
207
+ connection,
208
+ status: 200,
209
+ body: #"{"ok":true,"requestId":"\#(jsonEscape(requestId ?? ""))","bytes":\#(photoBytes.count)}"#
210
+ )
211
+ return true
212
+ }
213
+
214
+ private func writeJson(_ connection: NWConnection, status: Int, body: String) {
215
+ let reason: String
216
+ switch status {
217
+ case 200:
218
+ reason = "OK"
219
+ case 400:
220
+ reason = "Bad Request"
221
+ case 404:
222
+ reason = "Not Found"
223
+ case 411:
224
+ reason = "Length Required"
225
+ case 413:
226
+ reason = "Payload Too Large"
227
+ default:
228
+ reason = "Internal Server Error"
229
+ }
230
+
231
+ let bodyData = Data(body.utf8)
232
+ let header = """
233
+ HTTP/1.1 \(status) \(reason)\r
234
+ Content-Type: application/json\r
235
+ Content-Length: \(bodyData.count)\r
236
+ Connection: close\r
237
+ \r
238
+ """
239
+ var response = Data(header.utf8)
240
+ response.append(bodyData)
241
+ connection.send(content: response, completion: .contentProcessed { _ in
242
+ connection.cancel()
243
+ })
244
+ }
245
+
246
+ private func write(
247
+ _ connection: NWConnection,
248
+ text: String,
249
+ closeAfterSend: Bool
250
+ ) {
251
+ connection.send(content: Data(text.utf8), completion: .contentProcessed { _ in
252
+ if closeAfterSend {
253
+ connection.cancel()
254
+ }
255
+ })
256
+ }
257
+
258
+ private func parseMultipart(_ body: Data, boundary: String) -> ParsedMultipart {
259
+ let marker = Data("--\(boundary)".utf8)
260
+ let nextMarkerPrefix = Data("\r\n--\(boundary)".utf8)
261
+ let headerDelimiter = Data([13, 10, 13, 10])
262
+ let crlf = Data([13, 10])
263
+ let closing = Data([45, 45])
264
+ var fields: [String: String] = [:]
265
+ var files: [String: Data] = [:]
266
+ var cursor = body.startIndex
267
+
268
+ while let markerRange = body.range(of: marker, options: [], in: cursor..<body.endIndex) {
269
+ var partStart = markerRange.upperBound
270
+ if body.starts(with: closing, at: partStart) {
271
+ break
272
+ }
273
+ if body.starts(with: crlf, at: partStart) {
274
+ partStart += crlf.count
275
+ }
276
+ guard
277
+ let headerRange = body.range(of: headerDelimiter, options: [], in: partStart..<body.endIndex)
278
+ else {
279
+ break
280
+ }
281
+
282
+ let dataStart = headerRange.upperBound
283
+ guard
284
+ let nextMarkerRange = body.range(
285
+ of: nextMarkerPrefix,
286
+ options: [],
287
+ in: dataStart..<body.endIndex
288
+ )
289
+ else {
290
+ break
291
+ }
292
+
293
+ let headerData = body[partStart..<headerRange.lowerBound]
294
+ let headerBlock = String(data: headerData, encoding: .isoLatin1) ?? ""
295
+ let disposition = headerBlock
296
+ .components(separatedBy: "\r\n")
297
+ .first { $0.caseInsensitiveHasPrefix("Content-Disposition:") } ?? ""
298
+ if let name = firstCapture(#"name="([^"]+)""#, in: disposition) {
299
+ let bytes = Data(body[dataStart..<nextMarkerRange.lowerBound])
300
+ if firstCapture(#"filename="([^"]*)""#, in: disposition) != nil {
301
+ files[name] = bytes
302
+ } else {
303
+ fields[name] = String(data: bytes, encoding: .utf8)?
304
+ .trimmingCharacters(in: .whitespacesAndNewlines)
305
+ }
306
+ }
307
+ cursor = nextMarkerRange.lowerBound + crlf.count
308
+ }
309
+
310
+ return ParsedMultipart(fields: fields, files: files)
311
+ }
312
+
313
+ private func multipartBoundary(_ contentType: String) -> String? {
314
+ contentType
315
+ .split(separator: ";")
316
+ .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
317
+ .first { $0.caseInsensitiveHasPrefix("boundary=") }?
318
+ .split(separator: "=", maxSplits: 1)
319
+ .last
320
+ .map(String.init)?
321
+ .trimmingCharacters(in: CharacterSet(charactersIn: "\""))
322
+ }
323
+
324
+ private func firstCapture(_ pattern: String, in text: String) -> String? {
325
+ guard
326
+ let regex = try? NSRegularExpression(pattern: pattern),
327
+ let match = regex.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)),
328
+ match.numberOfRanges > 1,
329
+ let range = Range(match.range(at: 1), in: text)
330
+ else {
331
+ return nil
332
+ }
333
+ return String(text[range])
334
+ }
335
+
336
+ private func safeFilePart(_ value: String) -> String {
337
+ let allowed = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._-")
338
+ let sanitized = String(value.unicodeScalars.map { allowed.contains($0) ? Character($0) : "_" })
339
+ return sanitized.isEmpty ? "photo" : sanitized
340
+ }
341
+
342
+ private func jsonEscape(_ value: String) -> String {
343
+ value
344
+ .replacingOccurrences(of: "\\", with: "\\\\")
345
+ .replacingOccurrences(of: "\"", with: "\\\"")
346
+ }
347
+
348
+ private struct HttpRequest {
349
+ let method: String
350
+ let path: String
351
+ let headers: [String: String]
352
+
353
+ static func parse(_ headerText: String) -> HttpRequest {
354
+ let lines = headerText.components(separatedBy: "\r\n").filter { !$0.isEmpty }
355
+ let requestParts = lines.first?.split(separator: " ").map(String.init) ?? []
356
+ let method = requestParts.first?.uppercased() ?? ""
357
+ let path = requestParts.dropFirst().first?.split(separator: "?").first.map(String.init) ?? ""
358
+ let headers: [String: String] = Dictionary(uniqueKeysWithValues: lines.dropFirst().compactMap { line in
359
+ guard let separator = line.firstIndex(of: ":") else {
360
+ return nil
361
+ }
362
+ let key = line[..<separator].lowercased()
363
+ let value = line[line.index(after: separator)...].trimmingCharacters(in: .whitespaces)
364
+ return (String(key), value)
365
+ })
366
+ return HttpRequest(method: method, path: path, headers: headers)
367
+ }
368
+ }
369
+
370
+ private final class RequestReadState {
371
+ var buffer = Data()
372
+ var request: HttpRequest?
373
+ var bodyStartIndex: Data.Index?
374
+ var contentLength = 0
375
+ }
376
+
377
+ private struct ParsedMultipart {
378
+ let fields: [String: String]
379
+ let files: [String: Data]
380
+ }
381
+
382
+ private struct PhotoUploadServerError: LocalizedError {
383
+ let message: String
384
+
385
+ init(_ message: String) {
386
+ self.message = message
387
+ }
388
+
389
+ var errorDescription: String? {
390
+ message
391
+ }
392
+ }
393
+
394
+ private static let maxHeaderBytes = 64 * 1024
395
+ private static let maxUploadBytes = 25 * 1024 * 1024
396
+ }
397
+
398
+ private extension Data {
399
+ func starts(with prefix: Data, at index: Data.Index) -> Bool {
400
+ guard index >= startIndex, index + prefix.count <= endIndex else {
401
+ return false
402
+ }
403
+ return self[index..<index + prefix.count].elementsEqual(prefix)
404
+ }
405
+ }
406
+
407
+ private extension String {
408
+ func caseInsensitiveHasPrefix(_ prefix: String) -> Bool {
409
+ range(of: prefix, options: [.anchored, .caseInsensitive]) != nil
410
+ }
411
+ }
@@ -37,7 +37,9 @@ Pod::Spec.new do |s|
37
37
  }
38
38
 
39
39
  # iOS frameworks required by Bluetooth SDK
40
- s.frameworks = 'AVFoundation', 'CoreBluetooth', 'UIKit', 'CoreGraphics'
40
+ ios_frameworks = ['AVFoundation', 'CoreBluetooth', 'UIKit', 'CoreGraphics']
41
+ ios_frameworks << 'Network' if include_expo_adapter
42
+ s.frameworks = ios_frameworks
41
43
 
42
44
  # System libraries required by MentraOS
43
45
  s.library = 'bz2'
@@ -56,7 +58,11 @@ Pod::Spec.new do |s|
56
58
  "Packages/SherpaOnnx/sherpa-onnx.xcframework/Headers/**/*.{h,hpp}",
57
59
  "Packages/libbz2/shim.h"
58
60
  ]
59
- native_source_files << "BluetoothSdkModule.swift" if include_expo_adapter
61
+ native_source_files.concat([
62
+ "BluetoothSdkModule.swift",
63
+ "LocalPhotoUploadServer.swift",
64
+ "MentraPhotoReceiverModule.swift"
65
+ ]) if include_expo_adapter
60
66
  s.source_files = native_source_files
61
67
 
62
68
  # Explicitly mark C++ headers and internal headers as private to prevent exposure in public interface
@@ -0,0 +1,147 @@
1
+ import Darwin
2
+ import ExpoModulesCore
3
+ import Foundation
4
+
5
+ public class MentraPhotoReceiverModule: Module {
6
+ private var photoUploadServer: LocalPhotoUploadServer?
7
+
8
+ public func definition() -> ModuleDefinition {
9
+ Name("MentraPhotoReceiver")
10
+
11
+ Events("photoUpload", "receiverStatus")
12
+
13
+ AsyncFunction("isSupported") {
14
+ true
15
+ }
16
+
17
+ AsyncFunction("startPhotoReceiver") { () -> [String: Any] in
18
+ try startPhotoReceiver()
19
+ }
20
+
21
+ AsyncFunction("stopPhotoReceiver") {
22
+ stopPhotoReceiverInternal()
23
+ }
24
+
25
+ OnDestroy {
26
+ stopPhotoReceiverInternal()
27
+ }
28
+ }
29
+
30
+ private func startPhotoReceiver() throws -> [String: Any] {
31
+ guard let host = bestLocalIPv4Address() else {
32
+ throw PhotoReceiverError("No Wi-Fi/LAN IPv4 address found for this phone.")
33
+ }
34
+
35
+ let server = photoUploadServer ?? LocalPhotoUploadServer(
36
+ onLog: { [weak self] message in
37
+ self?.emitStatus(message: message)
38
+ },
39
+ onUpload: { [weak self] upload in
40
+ self?.handlePhotoUpload(upload)
41
+ }
42
+ )
43
+ photoUploadServer = server
44
+
45
+ var lastError: Error?
46
+ for port in photoPorts {
47
+ do {
48
+ let actualPort = try server.start(port: UInt16(port))
49
+ let uploadUrl = "http://\(host):\(actualPort)/upload"
50
+ emitStatus(message: "Photo receiver ready at \(uploadUrl)")
51
+ return [
52
+ "uploadUrl": uploadUrl,
53
+ "host": host,
54
+ "port": actualPort,
55
+ ]
56
+ } catch {
57
+ lastError = error
58
+ emitStatus(message: "Port \(port) unavailable: \(error.localizedDescription)")
59
+ }
60
+ }
61
+
62
+ throw PhotoReceiverError(
63
+ "Could not start phone photo receiver: \(lastError?.localizedDescription ?? "all ports unavailable")"
64
+ )
65
+ }
66
+
67
+ private func stopPhotoReceiverInternal() {
68
+ photoUploadServer?.stop()
69
+ emitStatus(message: "Photo receiver stopped")
70
+ }
71
+
72
+ private func handlePhotoUpload(_ upload: PhotoUpload) {
73
+ sendEvent("photoUpload", [
74
+ "requestId": upload.requestId as Any,
75
+ "fileUri": upload.photoFile.absoluteString,
76
+ "byteCount": upload.byteCount,
77
+ ])
78
+ emitStatus(message: "Photo uploaded (\(upload.byteCount) bytes)")
79
+ }
80
+
81
+ private func emitStatus(message: String) {
82
+ sendEvent("receiverStatus", [
83
+ "message": message,
84
+ ])
85
+ }
86
+
87
+ private func bestLocalIPv4Address() -> String? {
88
+ var interfaces: UnsafeMutablePointer<ifaddrs>?
89
+ guard getifaddrs(&interfaces) == 0, let first = interfaces else {
90
+ return nil
91
+ }
92
+ defer { freeifaddrs(interfaces) }
93
+
94
+ var fallback: String?
95
+ var cursor: UnsafeMutablePointer<ifaddrs>? = first
96
+ while let current = cursor {
97
+ defer { cursor = current.pointee.ifa_next }
98
+ let interface = current.pointee
99
+ guard let addressPointer = interface.ifa_addr,
100
+ addressPointer.pointee.sa_family == UInt8(AF_INET) else {
101
+ continue
102
+ }
103
+
104
+ let name = String(cString: interface.ifa_name)
105
+ var address = addressPointer.pointee
106
+ var hostname = [CChar](repeating: 0, count: Int(NI_MAXHOST))
107
+ let result = getnameinfo(
108
+ &address,
109
+ socklen_t(address.sa_len),
110
+ &hostname,
111
+ socklen_t(hostname.count),
112
+ nil,
113
+ 0,
114
+ NI_NUMERICHOST
115
+ )
116
+ guard result == 0 else {
117
+ continue
118
+ }
119
+
120
+ let ip = String(cString: hostname)
121
+ guard ip != "127.0.0.1" else {
122
+ continue
123
+ }
124
+ if name == "en0" {
125
+ return ip
126
+ }
127
+ fallback = fallback ?? ip
128
+ }
129
+
130
+ return fallback
131
+ }
132
+
133
+ private let photoPorts = [8787, 8788, 8789, 8790]
134
+ }
135
+
136
+ final class PhotoReceiverError: Exception, @unchecked Sendable {
137
+ private let message: String
138
+
139
+ init(_ message: String) {
140
+ self.message = message
141
+ super.init()
142
+ }
143
+
144
+ override var reason: String {
145
+ message
146
+ }
147
+ }
@@ -270,11 +270,12 @@ class Bridge {
270
270
  }
271
271
 
272
272
  static func sendPhotoError(requestId: String, errorCode: String, errorMessage: String) {
273
+ let timestamp = Int(Date().timeIntervalSince1970 * 1000)
273
274
  var event: [String: Any] = [
274
275
  "type": "photo_response",
275
276
  "state": "error",
276
277
  "requestId": requestId,
277
- "timestamp": Int(Date().timeIntervalSince1970 * 1000),
278
+ "timestamp": timestamp,
278
279
  ]
279
280
  if !errorCode.isEmpty {
280
281
  event["errorCode"] = errorCode
@@ -283,6 +284,24 @@ class Bridge {
283
284
  event["errorMessage"] = errorMessage
284
285
  }
285
286
  Bridge.sendTypedMessage("photo_response", body: event)
287
+
288
+ var status: [String: Any] = [
289
+ "type": "photo_status",
290
+ "status": "failed",
291
+ "requestId": requestId,
292
+ "timestamp": timestamp,
293
+ ]
294
+ if !errorCode.isEmpty {
295
+ status["errorCode"] = errorCode
296
+ }
297
+ if !errorMessage.isEmpty {
298
+ status["errorMessage"] = errorMessage
299
+ }
300
+ Bridge.sendTypedMessage("photo_status", body: status)
301
+ }
302
+
303
+ static func sendPhotoStatus(_ status: [String: Any]) {
304
+ Bridge.sendTypedMessage("photo_status", body: status)
286
305
  }
287
306
 
288
307
  static func sendMiniappSelected(packageName: String) {