@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.
- package/README.md +11 -9
- package/android/.project +28 -0
- package/android/build.gradle +3 -0
- package/android/lc3Lib/.project +28 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +15 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +26 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +4 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +136 -4
- package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +40 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/controllers/ControllerManager.kt +1 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/controllers/R1.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/debug/BleTraceLogger.kt +149 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/events/BluetoothEvents.kt +1 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/photoreceiver/LocalPhotoUploadServer.kt +297 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/photoreceiver/MentraPhotoReceiverModule.kt +120 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +13 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.java +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +1 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Mach1.java +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.java +104 -21
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +1 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Simulated.kt +1 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +128 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunkReassembler.java +105 -0
- package/build/BluetoothSdk.types.d.ts +66 -29
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js +3 -0
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts +2 -1
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js +24 -7
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/index.d.ts +2 -2
- package/build/index.d.ts.map +1 -1
- package/build/index.js +2 -3
- package/build/index.js.map +1 -1
- package/build/photo-receiver/MentraPhotoReceiver.types.d.ts +18 -0
- package/build/photo-receiver/MentraPhotoReceiver.types.d.ts.map +1 -0
- package/build/photo-receiver/MentraPhotoReceiver.types.js +2 -0
- package/build/photo-receiver/MentraPhotoReceiver.types.js.map +1 -0
- package/build/photo-receiver/MentraPhotoReceiverModule.d.ts +10 -0
- package/build/photo-receiver/MentraPhotoReceiverModule.d.ts.map +1 -0
- package/build/photo-receiver/MentraPhotoReceiverModule.js +3 -0
- package/build/photo-receiver/MentraPhotoReceiverModule.js.map +1 -0
- package/build/photo-receiver/MentraPhotoReceiverModule.web.d.ts +10 -0
- package/build/photo-receiver/MentraPhotoReceiverModule.web.d.ts.map +1 -0
- package/build/photo-receiver/MentraPhotoReceiverModule.web.js +12 -0
- package/build/photo-receiver/MentraPhotoReceiverModule.web.js.map +1 -0
- package/build/photo-receiver/index.d.ts +3 -0
- package/build/photo-receiver/index.d.ts.map +1 -0
- package/build/photo-receiver/index.js +3 -0
- package/build/photo-receiver/index.js.map +1 -0
- package/expo-module.config.json +5 -2
- package/ios/BluetoothSdkModule.swift +19 -3
- package/ios/LocalPhotoUploadServer.swift +411 -0
- package/ios/MentraBluetoothSDK.podspec +8 -2
- package/ios/MentraPhotoReceiverModule.swift +147 -0
- package/ios/Source/Bridge.swift +20 -1
- package/ios/Source/Camera/CameraModels.swift +64 -11
- package/ios/Source/DeviceManager.swift +6 -4
- package/ios/Source/Events/BluetoothEvents.swift +3 -0
- package/ios/Source/MentraBluetoothSDK.swift +111 -5
- package/ios/Source/ObservableStore.swift +2 -2
- package/ios/Source/Streaming/StreamModels.swift +234 -40
- package/ios/Source/controllers/ControllerManager.swift +1 -1
- package/ios/Source/controllers/R1.swift +9 -3
- package/ios/Source/sgcs/G1.swift +5 -4
- package/ios/Source/sgcs/G2.swift +4 -4
- package/ios/Source/sgcs/Mach1.swift +1 -1
- package/ios/Source/sgcs/MentraLive.swift +76 -12
- package/ios/Source/sgcs/MentraNex.swift +1 -1
- package/ios/Source/sgcs/SGCManager.swift +1 -1
- package/ios/Source/sgcs/Simulated.swift +1 -1
- package/ios/Source/utils/MessageChunkReassembler.swift +89 -0
- package/package.json +6 -1
- package/src/BluetoothSdk.types.ts +90 -35
- package/src/_private/BluetoothSdkModule.ts +38 -8
- package/src/index.ts +9 -4
- package/src/photo-receiver/MentraPhotoReceiver.types.ts +20 -0
- package/src/photo-receiver/MentraPhotoReceiverModule.ts +14 -0
- package/src/photo-receiver/MentraPhotoReceiverModule.web.ts +20 -0
- 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
|
-
|
|
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
|
|
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
|
+
}
|
package/ios/Source/Bridge.swift
CHANGED
|
@@ -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":
|
|
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) {
|