@mentra/bluetooth-sdk 0.1.9 → 0.1.11
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 +116 -18
- package/android/build.gradle +289 -16
- package/android/gradle.properties.example +9 -0
- package/android/lc3Lib/build.gradle +95 -6
- package/android/settings.gradle +3 -0
- package/android/silero/LICENSE.md +17 -0
- package/android/silero/build.gradle +42 -0
- package/android/silero/proguard-rules.pro +24 -0
- package/android/silero/src/androidTest/assets/hello.wav +0 -0
- package/android/silero/src/androidTest/java/com/konovalov/vad/silero/VadSileroTest.kt +79 -0
- package/android/silero/src/main/assets/LICENSE +21 -0
- package/android/silero/src/main/assets/silero_vad.onnx +0 -0
- package/android/silero/src/main/java/com/konovalov/vad/silero/Vad.kt +160 -0
- package/android/silero/src/main/java/com/konovalov/vad/silero/VadSilero.kt +449 -0
- package/android/silero/src/main/java/com/konovalov/vad/silero/config/FrameSize.kt +15 -0
- package/android/silero/src/main/java/com/konovalov/vad/silero/config/Mode.kt +14 -0
- package/android/silero/src/main/java/com/konovalov/vad/silero/config/SampleRate.kt +12 -0
- package/android/silero/src/main/java/com/konovalov/vad/silero/utils/AudioUtils.kt +47 -0
- package/android/silero/src/main/java/com/konovalov/vad/silero/utils/TensorMap.kt +41 -0
- package/android/silero/src/test/java/com/konovalov/vad/silero/utils/AudioUtilsTest.kt +44 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkDefaults.kt +6 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +195 -34
- package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +103 -21
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +210 -18
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +30 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +787 -85
- package/android/src/main/java/com/mentra/bluetoothsdk/audio/AudioModels.kt +6 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +125 -11
- package/android/src/main/java/com/mentra/bluetoothsdk/controllers/ControllerManager.kt +12 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/controllers/R1.kt +8 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/debug/BleTraceLogger.kt +33 -17
- package/android/src/main/java/com/mentra/bluetoothsdk/events/BluetoothEvents.kt +101 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/internal/MapParsing.kt +8 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/photoreceiver/LocalPhotoReceiverRegistry.java +108 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/photoreceiver/LocalPhotoUploadServer.kt +399 -74
- package/android/src/main/java/com/mentra/bluetoothsdk/photoreceiver/MentraPhotoReceiverModule.kt +100 -17
- package/android/src/main/java/com/mentra/bluetoothsdk/requests/DisplayRequests.kt +17 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.java +2 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +1293 -758
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Mach1.java +2 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.java +271 -66
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +197 -149
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraosBle.java +12753 -27036
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +64 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Simulated.kt +16 -4
- package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +60 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +12 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/stt/STTTools.kt +87 -36
- package/android/src/main/java/com/mentra/bluetoothsdk/stt/SherpaOnnxTranscriber.kt +13 -4
- package/android/src/main/java/com/mentra/bluetoothsdk/stt/VadGateSpeechPolicy.kt +229 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/tts/TTSTools.kt +180 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/AvifExifStripper.java +573 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadService.java +488 -50
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/HeifExifTagReader.java +199 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunkReassembler.java +18 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunker.java +64 -18
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/NexSGCUtils.kt +48 -39
- package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +23 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/photoreceiver/LocalPhotoReceiverRegistryTest.java +75 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/AvifExifStripperTest.java +30 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadServiceTest.java +98 -0
- package/android/src/test/resources/avif_with_exif.avif +0 -0
- package/build/BluetoothSdk.types.d.ts +253 -44
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js +11 -10
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts +37 -23
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js +43 -52
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/_private/photoRequestPayload.d.ts +4 -0
- package/build/_private/photoRequestPayload.d.ts.map +1 -0
- package/build/_private/photoRequestPayload.js +25 -0
- package/build/_private/photoRequestPayload.js.map +1 -0
- package/build/index.d.ts +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +25 -0
- package/build/index.js.map +1 -1
- package/build/react/useMentraBluetooth.d.ts +2 -2
- package/build/react/useMentraBluetooth.d.ts.map +1 -1
- package/build/react/useMentraBluetooth.js +3 -3
- package/build/react/useMentraBluetooth.js.map +1 -1
- package/ios/BluetoothSdkModule.swift +177 -70
- package/ios/LocalPhotoUploadServer.swift +370 -105
- package/ios/MentraBluetoothSDK.podspec +3 -1
- package/ios/MentraPhotoReceiverModule.swift +62 -10
- package/ios/Packages/CoreObjC/include/PcmConverter.h +22 -0
- package/ios/Packages/SherpaOnnx/SherpaOnnx.swift +668 -26
- package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/Info.plist +13 -9
- package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64/Headers/sherpa-onnx/c-api/c-api.h +4551 -0
- package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64/Headers/sherpa-onnx/c-api/cxx-api.h +1745 -0
- package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64/libsherpa-onnx.a +0 -0
- package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64_x86_64-simulator/Headers/sherpa-onnx/c-api/c-api.h +4551 -0
- package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64_x86_64-simulator/Headers/sherpa-onnx/c-api/cxx-api.h +1745 -0
- package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64_x86_64-simulator/libsherpa-onnx.a +0 -0
- package/ios/Source/BluetoothSdkDefaults.swift +6 -0
- package/ios/Source/Bridge.swift +68 -19
- package/ios/Source/DeviceManager.swift +167 -32
- package/ios/Source/DeviceStore.swift +24 -8
- package/ios/Source/MentraBluetoothSDK.swift +803 -47
- package/ios/Source/ObservableStore.swift +6 -0
- package/ios/Source/{Audio → audio}/AudioModels.swift +9 -7
- package/ios/Source/{Camera → camera}/CameraModels.swift +226 -16
- package/ios/Source/controllers/ControllerManager.swift +4 -3
- package/ios/Source/controllers/R1.swift +3 -3
- package/ios/Source/{Events → events}/BluetoothEvents.swift +183 -1
- package/ios/Source/{Internal → internal}/ValueParsing.swift +10 -1
- package/ios/Source/{Requests → requests}/DisplayRequests.swift +28 -4
- package/ios/Source/services/PhoneMic.swift +1 -0
- package/ios/Source/sgcs/G1.swift +5 -5
- package/ios/Source/sgcs/G2.swift +1494 -870
- package/ios/Source/sgcs/Mach1.swift +4 -4
- package/ios/Source/sgcs/MentraLive.swift +814 -321
- package/ios/Source/sgcs/MentraNex.swift +604 -244
- package/ios/Source/sgcs/SGCManager.swift +68 -5
- package/ios/Source/sgcs/Simulated.swift +9 -5
- package/ios/Source/sgcs/mentraos_ble.pb.swift +3073 -3134
- package/ios/Source/status/DeviceStatus.swift +974 -0
- package/ios/Source/{Status → status}/WifiHotspotStatus.swift +4 -4
- package/ios/Source/{Streaming → streaming}/StreamModels.swift +24 -9
- package/ios/Source/stt/STTTools.swift +27 -12
- package/ios/Source/stt/SherpaOnnxTranscriber.swift +31 -8
- package/ios/Source/tts/TTSTools.swift +164 -0
- package/ios/Source/utils/JSCExperiment.swift +7 -7
- package/ios/Source/utils/MessageChunkReassembler.swift +20 -1
- package/ios/Source/utils/MessageChunker.swift +78 -21
- package/ios/Source/utils/TarBz2Extractor.swift +53 -2
- package/package.json +1 -1
- package/plugin/build/withAndroid.js +68 -4
- package/src/BluetoothSdk.types.ts +344 -60
- package/src/_private/BluetoothSdkModule.ts +132 -102
- package/src/_private/photoRequestPayload.ts +28 -0
- package/src/index.ts +61 -15
- package/src/react/useMentraBluetooth.ts +6 -4
- package/android/.project +0 -28
- package/android/lc3Lib/.project +0 -28
- package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/Headers/cargs.h +0 -162
- package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/Headers/sherpa-onnx/c-api/c-api.h +0 -1852
- package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/Headers/sherpa-onnx/c-api/cxx-api.h +0 -674
- package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64/sherpa-onnx.a +0 -0
- package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64_x86_64-simulator/sherpa-onnx.a +0 -0
- package/ios/Source/Status/DeviceStatus.swift +0 -471
- /package/ios/Source/{Connection → connection}/ScanSession.swift +0 -0
- /package/ios/Source/{Errors → errors}/BluetoothError.swift +0 -0
- /package/ios/Source/{Internal → internal}/BluetoothAvailability.swift +0 -0
- /package/ios/Source/{Status → status}/RuntimeState.swift +0 -0
- /package/ios/Source/{Types → types}/DeviceModels.swift +0 -0
|
@@ -14,6 +14,8 @@ final class LocalPhotoUploadServer {
|
|
|
14
14
|
private let onLog: (String) -> Void
|
|
15
15
|
private let onUpload: (PhotoUpload) -> Void
|
|
16
16
|
private var listener: NWListener?
|
|
17
|
+
private var listenerGeneration = 0
|
|
18
|
+
private(set) var activePort: UInt16?
|
|
17
19
|
|
|
18
20
|
init(onLog: @escaping (String) -> Void, onUpload: @escaping (PhotoUpload) -> Void) {
|
|
19
21
|
self.onLog = onLog
|
|
@@ -23,6 +25,10 @@ final class LocalPhotoUploadServer {
|
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
func start(port: UInt16) throws -> UInt16 {
|
|
28
|
+
if listener != nil, activePort == port {
|
|
29
|
+
onLog("Already listening on 0.0.0.0:\(port)")
|
|
30
|
+
return port
|
|
31
|
+
}
|
|
26
32
|
stop()
|
|
27
33
|
try FileManager.default.createDirectory(
|
|
28
34
|
at: uploadDirectory,
|
|
@@ -36,21 +42,30 @@ final class LocalPhotoUploadServer {
|
|
|
36
42
|
}
|
|
37
43
|
|
|
38
44
|
let listener = try NWListener(using: parameters, on: endpointPort)
|
|
45
|
+
listenerGeneration += 1
|
|
46
|
+
let generation = listenerGeneration
|
|
39
47
|
let started = DispatchSemaphore(value: 0)
|
|
40
48
|
var startError: Error?
|
|
41
49
|
var isReady = false
|
|
42
50
|
|
|
43
51
|
listener.stateUpdateHandler = { [weak self] state in
|
|
52
|
+
guard let self, self.listenerGeneration == generation else {
|
|
53
|
+
return
|
|
54
|
+
}
|
|
44
55
|
switch state {
|
|
45
56
|
case .ready:
|
|
46
57
|
isReady = true
|
|
47
|
-
self
|
|
58
|
+
self.activePort = port
|
|
59
|
+
self.onLog("Listening on 0.0.0.0:\(port)")
|
|
48
60
|
started.signal()
|
|
49
61
|
case .failed(let error):
|
|
50
62
|
startError = error
|
|
51
|
-
self
|
|
63
|
+
self.activePort = nil
|
|
64
|
+
self.listener = nil
|
|
65
|
+
self.onLog("Photo receiver failed on \(port): \(error)")
|
|
52
66
|
started.signal()
|
|
53
67
|
case .cancelled:
|
|
68
|
+
self.activePort = nil
|
|
54
69
|
if !isReady {
|
|
55
70
|
startError = PhotoUploadServerError("Listener cancelled")
|
|
56
71
|
started.signal()
|
|
@@ -68,6 +83,8 @@ final class LocalPhotoUploadServer {
|
|
|
68
83
|
listener.start(queue: queue)
|
|
69
84
|
|
|
70
85
|
if started.wait(timeout: .now() + 2) == .timedOut {
|
|
86
|
+
self.listener = nil
|
|
87
|
+
listener.cancel()
|
|
71
88
|
throw PhotoUploadServerError("Timed out starting listener")
|
|
72
89
|
}
|
|
73
90
|
if let startError {
|
|
@@ -80,8 +97,10 @@ final class LocalPhotoUploadServer {
|
|
|
80
97
|
}
|
|
81
98
|
|
|
82
99
|
func stop() {
|
|
100
|
+
listenerGeneration += 1
|
|
83
101
|
listener?.cancel()
|
|
84
102
|
listener = nil
|
|
103
|
+
activePort = nil
|
|
85
104
|
}
|
|
86
105
|
|
|
87
106
|
private func handle(_ connection: NWConnection) {
|
|
@@ -97,6 +116,7 @@ final class LocalPhotoUploadServer {
|
|
|
97
116
|
return
|
|
98
117
|
}
|
|
99
118
|
if let error {
|
|
119
|
+
state.multipartParser?.cleanup()
|
|
100
120
|
self.onLog("request failed: \(error.localizedDescription)")
|
|
101
121
|
connection.cancel()
|
|
102
122
|
return
|
|
@@ -110,12 +130,22 @@ final class LocalPhotoUploadServer {
|
|
|
110
130
|
return
|
|
111
131
|
}
|
|
112
132
|
} catch {
|
|
133
|
+
state.multipartParser?.cleanup()
|
|
113
134
|
self.onLog("request failed: \(error.localizedDescription)")
|
|
114
|
-
|
|
135
|
+
if let uploadError = error as? PhotoUploadServerError {
|
|
136
|
+
self.writeJson(
|
|
137
|
+
connection,
|
|
138
|
+
status: 400,
|
|
139
|
+
body: #"{"ok":false,"error":\#(self.jsonString(uploadError.localizedDescription))}"#
|
|
140
|
+
)
|
|
141
|
+
} else {
|
|
142
|
+
self.writeJson(connection, status: 500, body: #"{"ok":false,"error":"server_error"}"#)
|
|
143
|
+
}
|
|
115
144
|
return
|
|
116
145
|
}
|
|
117
146
|
|
|
118
147
|
if isComplete {
|
|
148
|
+
state.multipartParser?.cleanup()
|
|
119
149
|
self.writeJson(connection, status: 400, body: #"{"ok":false,"error":"incomplete_request"}"#)
|
|
120
150
|
return
|
|
121
151
|
}
|
|
@@ -140,7 +170,6 @@ final class LocalPhotoUploadServer {
|
|
|
140
170
|
}
|
|
141
171
|
let request = HttpRequest.parse(headerText)
|
|
142
172
|
state.request = request
|
|
143
|
-
state.bodyStartIndex = headerRange.upperBound
|
|
144
173
|
onLog("\(request.method) \(request.path)")
|
|
145
174
|
|
|
146
175
|
if request.method == "GET", request.path == "/" || request.path == "/health" {
|
|
@@ -163,30 +192,51 @@ final class LocalPhotoUploadServer {
|
|
|
163
192
|
}
|
|
164
193
|
|
|
165
194
|
state.contentLength = contentLength
|
|
195
|
+
guard let boundary = multipartBoundary(request.headers["content-type"] ?? "") else {
|
|
196
|
+
writeJson(connection, status: 400, body: #"{"ok":false,"error":"multipart_boundary_required"}"#)
|
|
197
|
+
return true
|
|
198
|
+
}
|
|
199
|
+
state.multipartParser = MultipartStreamParser(
|
|
200
|
+
boundary: boundary,
|
|
201
|
+
uploadDirectory: uploadDirectory
|
|
202
|
+
)
|
|
166
203
|
if request.headers["expect"]?.localizedCaseInsensitiveContains("100-continue") == true {
|
|
167
204
|
write(connection, text: "HTTP/1.1 100 Continue\r\n\r\n", closeAfterSend: false)
|
|
168
205
|
}
|
|
206
|
+
|
|
207
|
+
let bodyFragment = Data(state.buffer[headerRange.upperBound..<state.buffer.endIndex])
|
|
208
|
+
state.buffer.removeAll(keepingCapacity: true)
|
|
209
|
+
try appendBodyFragment(bodyFragment, to: state)
|
|
169
210
|
}
|
|
170
211
|
|
|
171
|
-
guard let request = state.request
|
|
212
|
+
guard let request = state.request else {
|
|
172
213
|
return false
|
|
173
214
|
}
|
|
174
|
-
|
|
215
|
+
if !state.buffer.isEmpty {
|
|
216
|
+
let bodyFragment = state.buffer
|
|
217
|
+
state.buffer.removeAll(keepingCapacity: true)
|
|
218
|
+
try appendBodyFragment(bodyFragment, to: state)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
guard state.multipartParser?.isComplete == true else {
|
|
222
|
+
if state.bodyBytesReceived >= state.contentLength {
|
|
223
|
+
state.multipartParser?.cleanup()
|
|
224
|
+
writeJson(connection, status: 400, body: #"{"ok":false,"error":"multipart_boundary_missing"}"#)
|
|
225
|
+
return true
|
|
226
|
+
}
|
|
175
227
|
return false
|
|
176
228
|
}
|
|
177
229
|
|
|
178
|
-
let bodyEndIndex = bodyStartIndex + state.contentLength
|
|
179
|
-
let body = Data(state.buffer[bodyStartIndex..<bodyEndIndex])
|
|
180
230
|
let contentType = request.headers["content-type"] ?? ""
|
|
181
231
|
onLog("headers contentType=\(contentType) contentLength=\(state.contentLength)")
|
|
182
232
|
|
|
183
|
-
guard let
|
|
184
|
-
|
|
233
|
+
guard let parsed = state.multipartParser?.parsedMultipart() else {
|
|
234
|
+
state.multipartParser?.cleanup()
|
|
235
|
+
writeJson(connection, status: 400, body: #"{"ok":false,"error":"incomplete_request"}"#)
|
|
185
236
|
return true
|
|
186
237
|
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
guard let photoBytes = parsed.files["photo"] ?? parsed.files.values.first else {
|
|
238
|
+
guard let photoPart = parsed.files["photo"] ?? parsed.files.values.first else {
|
|
239
|
+
parsed.deleteFiles()
|
|
190
240
|
writeJson(connection, status: 400, body: #"{"ok":false,"error":"photo_field_required"}"#)
|
|
191
241
|
return true
|
|
192
242
|
}
|
|
@@ -194,23 +244,50 @@ final class LocalPhotoUploadServer {
|
|
|
194
244
|
let requestId = parsed.fields["requestId"] ?? parsed.fields["request_id"]
|
|
195
245
|
let filename = safeFilePart(requestId ?? "photo-\(Int(Date().timeIntervalSince1970 * 1000))")
|
|
196
246
|
let photoFile = uploadDirectory.appendingPathComponent("\(filename).jpg")
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
247
|
+
var didMovePhoto = false
|
|
248
|
+
defer {
|
|
249
|
+
if didMovePhoto {
|
|
250
|
+
parsed.deleteFiles(except: photoFile)
|
|
251
|
+
} else {
|
|
252
|
+
parsed.deleteFiles()
|
|
253
|
+
try? FileManager.default.removeItem(at: photoFile)
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
try moveFile(photoPart.file, to: photoFile)
|
|
257
|
+
didMovePhoto = true
|
|
258
|
+
let upload = PhotoUpload(
|
|
201
259
|
requestId: requestId,
|
|
202
260
|
photoFile: photoFile,
|
|
203
|
-
byteCount:
|
|
261
|
+
byteCount: photoPart.byteCount,
|
|
204
262
|
fields: parsed.fields
|
|
205
|
-
)
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
onLog("upload fields=\(parsed.fields.keys.joined(separator: ",")) requestId=\(requestId ?? "") bytes=\(photoPart.byteCount) saved=\(photoFile.path)")
|
|
206
266
|
writeJson(
|
|
207
267
|
connection,
|
|
208
268
|
status: 200,
|
|
209
|
-
body: #"{"ok":true,"requestId"
|
|
269
|
+
body: #"{"ok":true,"requestId":\#(jsonString(requestId ?? "")),"bytes":\#(photoPart.byteCount)}"#
|
|
210
270
|
)
|
|
271
|
+
queue.async { [onUpload] in
|
|
272
|
+
onUpload(upload)
|
|
273
|
+
}
|
|
211
274
|
return true
|
|
212
275
|
}
|
|
213
276
|
|
|
277
|
+
private func appendBodyFragment(_ data: Data, to state: RequestReadState) throws {
|
|
278
|
+
guard !data.isEmpty else {
|
|
279
|
+
return
|
|
280
|
+
}
|
|
281
|
+
let remaining = state.contentLength - state.bodyBytesReceived
|
|
282
|
+
guard remaining > 0 else {
|
|
283
|
+
return
|
|
284
|
+
}
|
|
285
|
+
let acceptedCount = min(data.count, remaining)
|
|
286
|
+
let accepted = Data(data[data.startIndex..<data.startIndex + acceptedCount])
|
|
287
|
+
state.bodyBytesReceived += acceptedCount
|
|
288
|
+
try state.multipartParser?.append(accepted)
|
|
289
|
+
}
|
|
290
|
+
|
|
214
291
|
private func writeJson(_ connection: NWConnection, status: Int, body: String) {
|
|
215
292
|
let reason: String
|
|
216
293
|
switch status {
|
|
@@ -229,13 +306,12 @@ final class LocalPhotoUploadServer {
|
|
|
229
306
|
}
|
|
230
307
|
|
|
231
308
|
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
|
-
"""
|
|
309
|
+
let header =
|
|
310
|
+
"HTTP/1.1 \(status) \(reason)\r\n" +
|
|
311
|
+
"Content-Type: application/json\r\n" +
|
|
312
|
+
"Content-Length: \(bodyData.count)\r\n" +
|
|
313
|
+
"Connection: close\r\n" +
|
|
314
|
+
"\r\n"
|
|
239
315
|
var response = Data(header.utf8)
|
|
240
316
|
response.append(bodyData)
|
|
241
317
|
connection.send(content: response, completion: .contentProcessed { _ in
|
|
@@ -255,61 +331,6 @@ final class LocalPhotoUploadServer {
|
|
|
255
331
|
})
|
|
256
332
|
}
|
|
257
333
|
|
|
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
334
|
private func multipartBoundary(_ contentType: String) -> String? {
|
|
314
335
|
contentType
|
|
315
336
|
.split(separator: ";")
|
|
@@ -321,28 +342,32 @@ final class LocalPhotoUploadServer {
|
|
|
321
342
|
.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
|
|
322
343
|
}
|
|
323
344
|
|
|
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
345
|
private func safeFilePart(_ value: String) -> String {
|
|
337
346
|
let allowed = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._-")
|
|
338
347
|
let sanitized = String(value.unicodeScalars.map { allowed.contains($0) ? Character($0) : "_" })
|
|
339
348
|
return sanitized.isEmpty ? "photo" : sanitized
|
|
340
349
|
}
|
|
341
350
|
|
|
342
|
-
private func
|
|
343
|
-
|
|
344
|
-
.
|
|
345
|
-
|
|
351
|
+
private func jsonString(_ value: String) -> String {
|
|
352
|
+
guard
|
|
353
|
+
let data = try? JSONSerialization.data(withJSONObject: [value]),
|
|
354
|
+
let json = String(data: data, encoding: .utf8)
|
|
355
|
+
else {
|
|
356
|
+
return #""""#
|
|
357
|
+
}
|
|
358
|
+
return String(json.dropFirst().dropLast())
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
private func moveFile(_ source: URL, to destination: URL) throws {
|
|
362
|
+
if FileManager.default.fileExists(atPath: destination.path) {
|
|
363
|
+
try FileManager.default.removeItem(at: destination)
|
|
364
|
+
}
|
|
365
|
+
do {
|
|
366
|
+
try FileManager.default.moveItem(at: source, to: destination)
|
|
367
|
+
} catch {
|
|
368
|
+
try FileManager.default.copyItem(at: source, to: destination)
|
|
369
|
+
try? FileManager.default.removeItem(at: source)
|
|
370
|
+
}
|
|
346
371
|
}
|
|
347
372
|
|
|
348
373
|
private struct HttpRequest {
|
|
@@ -355,28 +380,266 @@ final class LocalPhotoUploadServer {
|
|
|
355
380
|
let requestParts = lines.first?.split(separator: " ").map(String.init) ?? []
|
|
356
381
|
let method = requestParts.first?.uppercased() ?? ""
|
|
357
382
|
let path = requestParts.dropFirst().first?.split(separator: "?").first.map(String.init) ?? ""
|
|
358
|
-
|
|
383
|
+
var headers: [String: String] = [:]
|
|
384
|
+
for line in lines.dropFirst() {
|
|
359
385
|
guard let separator = line.firstIndex(of: ":") else {
|
|
360
|
-
|
|
386
|
+
continue
|
|
361
387
|
}
|
|
362
|
-
let key = line[..<separator].lowercased()
|
|
388
|
+
let key = String(line[..<separator].lowercased())
|
|
363
389
|
let value = line[line.index(after: separator)...].trimmingCharacters(in: .whitespaces)
|
|
364
|
-
|
|
365
|
-
}
|
|
390
|
+
headers[key] = value
|
|
391
|
+
}
|
|
366
392
|
return HttpRequest(method: method, path: path, headers: headers)
|
|
367
393
|
}
|
|
368
394
|
}
|
|
369
395
|
|
|
396
|
+
private final class MultipartStreamParser {
|
|
397
|
+
private enum Stage {
|
|
398
|
+
case initialBoundary
|
|
399
|
+
case partHeaders
|
|
400
|
+
case partBody
|
|
401
|
+
case boundarySuffix
|
|
402
|
+
case complete
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
private let marker: String
|
|
406
|
+
private let delimiter: Data
|
|
407
|
+
private let crlf = Data([13, 10])
|
|
408
|
+
private let headerDelimiter = Data([13, 10, 13, 10])
|
|
409
|
+
private let uploadDirectory: URL
|
|
410
|
+
private var stage: Stage = .initialBoundary
|
|
411
|
+
private var buffer = Data()
|
|
412
|
+
private var currentPart: PartWriter?
|
|
413
|
+
private var fields: [String: String] = [:]
|
|
414
|
+
private var files: [String: StreamedFile] = [:]
|
|
415
|
+
|
|
416
|
+
var isComplete: Bool {
|
|
417
|
+
stage == .complete
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
init(boundary: String, uploadDirectory: URL) {
|
|
421
|
+
marker = "--\(boundary)"
|
|
422
|
+
delimiter = Data("\r\n--\(boundary)".utf8)
|
|
423
|
+
self.uploadDirectory = uploadDirectory
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
func append(_ data: Data) throws {
|
|
427
|
+
guard !isComplete else {
|
|
428
|
+
return
|
|
429
|
+
}
|
|
430
|
+
buffer.append(data)
|
|
431
|
+
try processBuffer()
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
func parsedMultipart() -> ParsedMultipart? {
|
|
435
|
+
guard isComplete else {
|
|
436
|
+
return nil
|
|
437
|
+
}
|
|
438
|
+
return ParsedMultipart(fields: fields, files: files)
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
func cleanup() {
|
|
442
|
+
currentPart?.cleanup()
|
|
443
|
+
files.values.forEach { try? FileManager.default.removeItem(at: $0.file) }
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
private func processBuffer() throws {
|
|
447
|
+
while true {
|
|
448
|
+
switch stage {
|
|
449
|
+
case .initialBoundary:
|
|
450
|
+
guard let line = try popLine() else {
|
|
451
|
+
return
|
|
452
|
+
}
|
|
453
|
+
if line == "\(marker)--" {
|
|
454
|
+
stage = .complete
|
|
455
|
+
return
|
|
456
|
+
}
|
|
457
|
+
guard line == marker else {
|
|
458
|
+
throw PhotoUploadServerError("multipart_boundary_missing")
|
|
459
|
+
}
|
|
460
|
+
stage = .partHeaders
|
|
461
|
+
|
|
462
|
+
case .partHeaders:
|
|
463
|
+
guard let headerBlock = try popHeaders() else {
|
|
464
|
+
return
|
|
465
|
+
}
|
|
466
|
+
currentPart = try PartWriter(headerBlock: headerBlock, uploadDirectory: uploadDirectory)
|
|
467
|
+
stage = .partBody
|
|
468
|
+
|
|
469
|
+
case .partBody:
|
|
470
|
+
guard let currentPart else {
|
|
471
|
+
throw PhotoUploadServerError("multipart_part_missing")
|
|
472
|
+
}
|
|
473
|
+
if let delimiterRange = buffer.range(of: delimiter) {
|
|
474
|
+
let content = Data(buffer[buffer.startIndex..<delimiterRange.lowerBound])
|
|
475
|
+
try currentPart.write(content)
|
|
476
|
+
buffer.removeSubrange(buffer.startIndex..<delimiterRange.upperBound)
|
|
477
|
+
try currentPart.finish(fields: &fields, files: &files)
|
|
478
|
+
self.currentPart = nil
|
|
479
|
+
stage = .boundarySuffix
|
|
480
|
+
continue
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
let keepBytes = max(delimiter.count - 1, 0)
|
|
484
|
+
if buffer.count > keepBytes {
|
|
485
|
+
let writeCount = buffer.count - keepBytes
|
|
486
|
+
let content = Data(buffer[buffer.startIndex..<buffer.startIndex + writeCount])
|
|
487
|
+
try currentPart.write(content)
|
|
488
|
+
buffer.removeSubrange(buffer.startIndex..<buffer.startIndex + writeCount)
|
|
489
|
+
}
|
|
490
|
+
return
|
|
491
|
+
|
|
492
|
+
case .boundarySuffix:
|
|
493
|
+
guard buffer.count >= 2 else {
|
|
494
|
+
return
|
|
495
|
+
}
|
|
496
|
+
if buffer.starts(with: Data("--".utf8), at: buffer.startIndex) {
|
|
497
|
+
buffer.removeSubrange(buffer.startIndex..<buffer.startIndex + 2)
|
|
498
|
+
stage = .complete
|
|
499
|
+
return
|
|
500
|
+
}
|
|
501
|
+
if buffer.starts(with: crlf, at: buffer.startIndex) {
|
|
502
|
+
buffer.removeSubrange(buffer.startIndex..<buffer.startIndex + crlf.count)
|
|
503
|
+
stage = .partHeaders
|
|
504
|
+
continue
|
|
505
|
+
}
|
|
506
|
+
throw PhotoUploadServerError("invalid multipart boundary suffix")
|
|
507
|
+
|
|
508
|
+
case .complete:
|
|
509
|
+
return
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
private func popLine() throws -> String? {
|
|
515
|
+
guard let lineRange = buffer.range(of: crlf) else {
|
|
516
|
+
if buffer.count > LocalPhotoUploadServer.maxPartHeaderBytes {
|
|
517
|
+
throw PhotoUploadServerError("multipart line too large")
|
|
518
|
+
}
|
|
519
|
+
return nil
|
|
520
|
+
}
|
|
521
|
+
let lineData = Data(buffer[buffer.startIndex..<lineRange.lowerBound])
|
|
522
|
+
buffer.removeSubrange(buffer.startIndex..<lineRange.upperBound)
|
|
523
|
+
return String(data: lineData, encoding: .isoLatin1)
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
private func popHeaders() throws -> String? {
|
|
527
|
+
guard let headerRange = buffer.range(of: headerDelimiter) else {
|
|
528
|
+
if buffer.count > LocalPhotoUploadServer.maxPartHeaderBytes {
|
|
529
|
+
throw PhotoUploadServerError("multipart headers too large")
|
|
530
|
+
}
|
|
531
|
+
return nil
|
|
532
|
+
}
|
|
533
|
+
let headerData = Data(buffer[buffer.startIndex..<headerRange.lowerBound])
|
|
534
|
+
buffer.removeSubrange(buffer.startIndex..<headerRange.upperBound)
|
|
535
|
+
return String(data: headerData, encoding: .isoLatin1)
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
private static func firstCapture(_ pattern: String, in text: String) -> String? {
|
|
539
|
+
guard
|
|
540
|
+
let regex = try? NSRegularExpression(pattern: pattern),
|
|
541
|
+
let match = regex.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)),
|
|
542
|
+
match.numberOfRanges > 1,
|
|
543
|
+
let range = Range(match.range(at: 1), in: text)
|
|
544
|
+
else {
|
|
545
|
+
return nil
|
|
546
|
+
}
|
|
547
|
+
return String(text[range])
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
private final class PartWriter {
|
|
551
|
+
private let name: String?
|
|
552
|
+
private let file: URL?
|
|
553
|
+
private var fileHandle: FileHandle?
|
|
554
|
+
private var fieldData = Data()
|
|
555
|
+
private var byteCount = 0
|
|
556
|
+
|
|
557
|
+
init(headerBlock: String, uploadDirectory: URL) throws {
|
|
558
|
+
let disposition = headerBlock
|
|
559
|
+
.components(separatedBy: "\r\n")
|
|
560
|
+
.first { $0.caseInsensitiveHasPrefix("Content-Disposition:") } ?? ""
|
|
561
|
+
name = MultipartStreamParser.firstCapture(#"name="([^"]+)""#, in: disposition)
|
|
562
|
+
if MultipartStreamParser.firstCapture(#"filename="([^"]*)""#, in: disposition) != nil {
|
|
563
|
+
let tempFile = uploadDirectory
|
|
564
|
+
.appendingPathComponent("upload-\(Int(Date().timeIntervalSince1970 * 1000))-\(UUID().uuidString).tmp")
|
|
565
|
+
FileManager.default.createFile(atPath: tempFile.path, contents: nil)
|
|
566
|
+
file = tempFile
|
|
567
|
+
fileHandle = try FileHandle(forWritingTo: tempFile)
|
|
568
|
+
} else {
|
|
569
|
+
file = nil
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
func write(_ data: Data) throws {
|
|
574
|
+
guard !data.isEmpty else {
|
|
575
|
+
return
|
|
576
|
+
}
|
|
577
|
+
byteCount += data.count
|
|
578
|
+
if let fileHandle {
|
|
579
|
+
fileHandle.write(data)
|
|
580
|
+
} else {
|
|
581
|
+
guard fieldData.count + data.count <= LocalPhotoUploadServer.maxFieldBytes else {
|
|
582
|
+
throw PhotoUploadServerError("multipart field too large")
|
|
583
|
+
}
|
|
584
|
+
fieldData.append(data)
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
func finish(fields: inout [String: String], files: inout [String: StreamedFile]) throws {
|
|
589
|
+
fileHandle?.closeFile()
|
|
590
|
+
fileHandle = nil
|
|
591
|
+
guard let name else {
|
|
592
|
+
cleanup()
|
|
593
|
+
return
|
|
594
|
+
}
|
|
595
|
+
if let file {
|
|
596
|
+
if let previous = files.updateValue(StreamedFile(file: file, byteCount: byteCount), forKey: name) {
|
|
597
|
+
try? FileManager.default.removeItem(at: previous.file)
|
|
598
|
+
}
|
|
599
|
+
} else {
|
|
600
|
+
fields[name] = String(data: fieldData, encoding: .utf8)?
|
|
601
|
+
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
func cleanup() {
|
|
606
|
+
fileHandle?.closeFile()
|
|
607
|
+
fileHandle = nil
|
|
608
|
+
if let file {
|
|
609
|
+
try? FileManager.default.removeItem(at: file)
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
370
615
|
private final class RequestReadState {
|
|
371
616
|
var buffer = Data()
|
|
372
617
|
var request: HttpRequest?
|
|
373
|
-
var bodyStartIndex: Data.Index?
|
|
374
618
|
var contentLength = 0
|
|
619
|
+
var bodyBytesReceived = 0
|
|
620
|
+
var multipartParser: MultipartStreamParser?
|
|
375
621
|
}
|
|
376
622
|
|
|
377
623
|
private struct ParsedMultipart {
|
|
378
624
|
let fields: [String: String]
|
|
379
|
-
let files: [String:
|
|
625
|
+
let files: [String: StreamedFile]
|
|
626
|
+
|
|
627
|
+
func deleteFiles() {
|
|
628
|
+
files.values.forEach { try? FileManager.default.removeItem(at: $0.file) }
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
func deleteFiles(except keptFile: URL) {
|
|
632
|
+
files.values.forEach { part in
|
|
633
|
+
if part.file.path != keptFile.path {
|
|
634
|
+
try? FileManager.default.removeItem(at: part.file)
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
private struct StreamedFile {
|
|
641
|
+
let file: URL
|
|
642
|
+
let byteCount: Int
|
|
380
643
|
}
|
|
381
644
|
|
|
382
645
|
private struct PhotoUploadServerError: LocalizedError {
|
|
@@ -392,6 +655,8 @@ final class LocalPhotoUploadServer {
|
|
|
392
655
|
}
|
|
393
656
|
|
|
394
657
|
private static let maxHeaderBytes = 64 * 1024
|
|
658
|
+
private static let maxPartHeaderBytes = 64 * 1024
|
|
659
|
+
private static let maxFieldBytes = 64 * 1024
|
|
395
660
|
private static let maxUploadBytes = 25 * 1024 * 1024
|
|
396
661
|
}
|
|
397
662
|
|
|
@@ -55,6 +55,8 @@ Pod::Spec.new do |s|
|
|
|
55
55
|
"Source/**/*.{h,m,mm,swift,hpp,cpp,c}",
|
|
56
56
|
"Packages/CoreObjC/**/*.{h,m,mm,hpp,cpp,c}",
|
|
57
57
|
"Packages/SherpaOnnx/SherpaOnnx.swift",
|
|
58
|
+
"Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64/Headers/**/*.{h,hpp}",
|
|
59
|
+
"Packages/VAD/**/*.swift",
|
|
58
60
|
"Packages/SherpaOnnx/sherpa-onnx.xcframework/Headers/**/*.{h,hpp}",
|
|
59
61
|
"Packages/libbz2/shim.h"
|
|
60
62
|
]
|
|
@@ -70,7 +72,7 @@ Pod::Spec.new do |s|
|
|
|
70
72
|
"Packages/CoreObjC/lc3_cpp.h",
|
|
71
73
|
"Packages/CoreObjC/mdct_neon.h",
|
|
72
74
|
"Packages/CoreObjC/ltpf_neon.h",
|
|
73
|
-
"Packages/SherpaOnnx/sherpa-onnx.xcframework/Headers/sherpa-onnx/c-api/cxx-api.h",
|
|
75
|
+
"Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64/Headers/sherpa-onnx/c-api/cxx-api.h",
|
|
74
76
|
"Packages/libbz2/shim.h",
|
|
75
77
|
"Source/Bridging-Header.h"
|
|
76
78
|
]
|