@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.
Files changed (147) hide show
  1. package/README.md +116 -18
  2. package/android/build.gradle +289 -16
  3. package/android/gradle.properties.example +9 -0
  4. package/android/lc3Lib/build.gradle +95 -6
  5. package/android/settings.gradle +3 -0
  6. package/android/silero/LICENSE.md +17 -0
  7. package/android/silero/build.gradle +42 -0
  8. package/android/silero/proguard-rules.pro +24 -0
  9. package/android/silero/src/androidTest/assets/hello.wav +0 -0
  10. package/android/silero/src/androidTest/java/com/konovalov/vad/silero/VadSileroTest.kt +79 -0
  11. package/android/silero/src/main/assets/LICENSE +21 -0
  12. package/android/silero/src/main/assets/silero_vad.onnx +0 -0
  13. package/android/silero/src/main/java/com/konovalov/vad/silero/Vad.kt +160 -0
  14. package/android/silero/src/main/java/com/konovalov/vad/silero/VadSilero.kt +449 -0
  15. package/android/silero/src/main/java/com/konovalov/vad/silero/config/FrameSize.kt +15 -0
  16. package/android/silero/src/main/java/com/konovalov/vad/silero/config/Mode.kt +14 -0
  17. package/android/silero/src/main/java/com/konovalov/vad/silero/config/SampleRate.kt +12 -0
  18. package/android/silero/src/main/java/com/konovalov/vad/silero/utils/AudioUtils.kt +47 -0
  19. package/android/silero/src/main/java/com/konovalov/vad/silero/utils/TensorMap.kt +41 -0
  20. package/android/silero/src/test/java/com/konovalov/vad/silero/utils/AudioUtilsTest.kt +44 -0
  21. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkDefaults.kt +6 -0
  22. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +195 -34
  23. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +103 -21
  24. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +210 -18
  25. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +30 -2
  26. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +787 -85
  27. package/android/src/main/java/com/mentra/bluetoothsdk/audio/AudioModels.kt +6 -2
  28. package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +125 -11
  29. package/android/src/main/java/com/mentra/bluetoothsdk/controllers/ControllerManager.kt +12 -2
  30. package/android/src/main/java/com/mentra/bluetoothsdk/controllers/R1.kt +8 -2
  31. package/android/src/main/java/com/mentra/bluetoothsdk/debug/BleTraceLogger.kt +33 -17
  32. package/android/src/main/java/com/mentra/bluetoothsdk/events/BluetoothEvents.kt +101 -0
  33. package/android/src/main/java/com/mentra/bluetoothsdk/internal/MapParsing.kt +8 -0
  34. package/android/src/main/java/com/mentra/bluetoothsdk/photoreceiver/LocalPhotoReceiverRegistry.java +108 -0
  35. package/android/src/main/java/com/mentra/bluetoothsdk/photoreceiver/LocalPhotoUploadServer.kt +399 -74
  36. package/android/src/main/java/com/mentra/bluetoothsdk/photoreceiver/MentraPhotoReceiverModule.kt +100 -17
  37. package/android/src/main/java/com/mentra/bluetoothsdk/requests/DisplayRequests.kt +17 -0
  38. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.java +2 -2
  39. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +1293 -758
  40. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Mach1.java +2 -2
  41. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.java +271 -66
  42. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +197 -149
  43. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraosBle.java +12753 -27036
  44. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +64 -2
  45. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Simulated.kt +16 -4
  46. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +60 -3
  47. package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +12 -3
  48. package/android/src/main/java/com/mentra/bluetoothsdk/stt/STTTools.kt +87 -36
  49. package/android/src/main/java/com/mentra/bluetoothsdk/stt/SherpaOnnxTranscriber.kt +13 -4
  50. package/android/src/main/java/com/mentra/bluetoothsdk/stt/VadGateSpeechPolicy.kt +229 -0
  51. package/android/src/main/java/com/mentra/bluetoothsdk/tts/TTSTools.kt +180 -0
  52. package/android/src/main/java/com/mentra/bluetoothsdk/utils/AvifExifStripper.java +573 -0
  53. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadService.java +488 -50
  54. package/android/src/main/java/com/mentra/bluetoothsdk/utils/HeifExifTagReader.java +199 -0
  55. package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunkReassembler.java +18 -1
  56. package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunker.java +64 -18
  57. package/android/src/main/java/com/mentra/bluetoothsdk/utils/NexSGCUtils.kt +48 -39
  58. package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +23 -0
  59. package/android/src/test/java/com/mentra/bluetoothsdk/photoreceiver/LocalPhotoReceiverRegistryTest.java +75 -0
  60. package/android/src/test/java/com/mentra/bluetoothsdk/utils/AvifExifStripperTest.java +30 -0
  61. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadServiceTest.java +98 -0
  62. package/android/src/test/resources/avif_with_exif.avif +0 -0
  63. package/build/BluetoothSdk.types.d.ts +253 -44
  64. package/build/BluetoothSdk.types.d.ts.map +1 -1
  65. package/build/BluetoothSdk.types.js +11 -10
  66. package/build/BluetoothSdk.types.js.map +1 -1
  67. package/build/_private/BluetoothSdkModule.d.ts +37 -23
  68. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  69. package/build/_private/BluetoothSdkModule.js +43 -52
  70. package/build/_private/BluetoothSdkModule.js.map +1 -1
  71. package/build/_private/photoRequestPayload.d.ts +4 -0
  72. package/build/_private/photoRequestPayload.d.ts.map +1 -0
  73. package/build/_private/photoRequestPayload.js +25 -0
  74. package/build/_private/photoRequestPayload.js.map +1 -0
  75. package/build/index.d.ts +1 -1
  76. package/build/index.d.ts.map +1 -1
  77. package/build/index.js +25 -0
  78. package/build/index.js.map +1 -1
  79. package/build/react/useMentraBluetooth.d.ts +2 -2
  80. package/build/react/useMentraBluetooth.d.ts.map +1 -1
  81. package/build/react/useMentraBluetooth.js +3 -3
  82. package/build/react/useMentraBluetooth.js.map +1 -1
  83. package/ios/BluetoothSdkModule.swift +177 -70
  84. package/ios/LocalPhotoUploadServer.swift +370 -105
  85. package/ios/MentraBluetoothSDK.podspec +3 -1
  86. package/ios/MentraPhotoReceiverModule.swift +62 -10
  87. package/ios/Packages/CoreObjC/include/PcmConverter.h +22 -0
  88. package/ios/Packages/SherpaOnnx/SherpaOnnx.swift +668 -26
  89. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/Info.plist +13 -9
  90. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64/Headers/sherpa-onnx/c-api/c-api.h +4551 -0
  91. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64/Headers/sherpa-onnx/c-api/cxx-api.h +1745 -0
  92. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64/libsherpa-onnx.a +0 -0
  93. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64_x86_64-simulator/Headers/sherpa-onnx/c-api/c-api.h +4551 -0
  94. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64_x86_64-simulator/Headers/sherpa-onnx/c-api/cxx-api.h +1745 -0
  95. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64_x86_64-simulator/libsherpa-onnx.a +0 -0
  96. package/ios/Source/BluetoothSdkDefaults.swift +6 -0
  97. package/ios/Source/Bridge.swift +68 -19
  98. package/ios/Source/DeviceManager.swift +167 -32
  99. package/ios/Source/DeviceStore.swift +24 -8
  100. package/ios/Source/MentraBluetoothSDK.swift +803 -47
  101. package/ios/Source/ObservableStore.swift +6 -0
  102. package/ios/Source/{Audio → audio}/AudioModels.swift +9 -7
  103. package/ios/Source/{Camera → camera}/CameraModels.swift +226 -16
  104. package/ios/Source/controllers/ControllerManager.swift +4 -3
  105. package/ios/Source/controllers/R1.swift +3 -3
  106. package/ios/Source/{Events → events}/BluetoothEvents.swift +183 -1
  107. package/ios/Source/{Internal → internal}/ValueParsing.swift +10 -1
  108. package/ios/Source/{Requests → requests}/DisplayRequests.swift +28 -4
  109. package/ios/Source/services/PhoneMic.swift +1 -0
  110. package/ios/Source/sgcs/G1.swift +5 -5
  111. package/ios/Source/sgcs/G2.swift +1494 -870
  112. package/ios/Source/sgcs/Mach1.swift +4 -4
  113. package/ios/Source/sgcs/MentraLive.swift +814 -321
  114. package/ios/Source/sgcs/MentraNex.swift +604 -244
  115. package/ios/Source/sgcs/SGCManager.swift +68 -5
  116. package/ios/Source/sgcs/Simulated.swift +9 -5
  117. package/ios/Source/sgcs/mentraos_ble.pb.swift +3073 -3134
  118. package/ios/Source/status/DeviceStatus.swift +974 -0
  119. package/ios/Source/{Status → status}/WifiHotspotStatus.swift +4 -4
  120. package/ios/Source/{Streaming → streaming}/StreamModels.swift +24 -9
  121. package/ios/Source/stt/STTTools.swift +27 -12
  122. package/ios/Source/stt/SherpaOnnxTranscriber.swift +31 -8
  123. package/ios/Source/tts/TTSTools.swift +164 -0
  124. package/ios/Source/utils/JSCExperiment.swift +7 -7
  125. package/ios/Source/utils/MessageChunkReassembler.swift +20 -1
  126. package/ios/Source/utils/MessageChunker.swift +78 -21
  127. package/ios/Source/utils/TarBz2Extractor.swift +53 -2
  128. package/package.json +1 -1
  129. package/plugin/build/withAndroid.js +68 -4
  130. package/src/BluetoothSdk.types.ts +344 -60
  131. package/src/_private/BluetoothSdkModule.ts +132 -102
  132. package/src/_private/photoRequestPayload.ts +28 -0
  133. package/src/index.ts +61 -15
  134. package/src/react/useMentraBluetooth.ts +6 -4
  135. package/android/.project +0 -28
  136. package/android/lc3Lib/.project +0 -28
  137. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/Headers/cargs.h +0 -162
  138. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/Headers/sherpa-onnx/c-api/c-api.h +0 -1852
  139. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/Headers/sherpa-onnx/c-api/cxx-api.h +0 -674
  140. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64/sherpa-onnx.a +0 -0
  141. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64_x86_64-simulator/sherpa-onnx.a +0 -0
  142. package/ios/Source/Status/DeviceStatus.swift +0 -471
  143. /package/ios/Source/{Connection → connection}/ScanSession.swift +0 -0
  144. /package/ios/Source/{Errors → errors}/BluetoothError.swift +0 -0
  145. /package/ios/Source/{Internal → internal}/BluetoothAvailability.swift +0 -0
  146. /package/ios/Source/{Status → status}/RuntimeState.swift +0 -0
  147. /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?.onLog("Listening on 0.0.0.0:\(port)")
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?.onLog("Photo receiver failed on \(port): \(error)")
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
- self.writeJson(connection, status: 500, body: #"{"ok":false,"error":"server_error"}"#)
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, let bodyStartIndex = state.bodyStartIndex else {
212
+ guard let request = state.request else {
172
213
  return false
173
214
  }
174
- guard state.buffer.count >= bodyStartIndex + state.contentLength else {
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 boundary = multipartBoundary(contentType) else {
184
- writeJson(connection, status: 400, body: #"{"ok":false,"error":"multipart_boundary_required"}"#)
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
- let parsed = parseMultipart(body, boundary: boundary)
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
- 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(
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: photoBytes.count,
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":"\#(jsonEscape(requestId ?? ""))","bytes":\#(photoBytes.count)}"#
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 jsonEscape(_ value: String) -> String {
343
- value
344
- .replacingOccurrences(of: "\\", with: "\\\\")
345
- .replacingOccurrences(of: "\"", with: "\\\"")
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
- let headers: [String: String] = Dictionary(uniqueKeysWithValues: lines.dropFirst().compactMap { line in
383
+ var headers: [String: String] = [:]
384
+ for line in lines.dropFirst() {
359
385
  guard let separator = line.firstIndex(of: ":") else {
360
- return nil
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
- return (String(key), value)
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: Data]
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
  ]