@mentra/bluetooth-sdk 0.1.9 → 0.1.10

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 +165 -27
  23. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +89 -21
  24. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +189 -16
  25. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +22 -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 +123 -10
  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 +569 -220
  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 +176 -104
  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 +54 -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 +241 -43
  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 +36 -23
  68. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  69. package/build/_private/BluetoothSdkModule.js +30 -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 +23 -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 +174 -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 +58 -19
  98. package/ios/Source/DeviceManager.swift +149 -17
  99. package/ios/Source/DeviceStore.swift +16 -6
  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 +225 -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 +2 -2
  111. package/ios/Source/sgcs/G2.swift +645 -403
  112. package/ios/Source/sgcs/Mach1.swift +2 -2
  113. package/ios/Source/sgcs/MentraLive.swift +812 -319
  114. package/ios/Source/sgcs/MentraNex.swift +412 -182
  115. package/ios/Source/sgcs/SGCManager.swift +57 -3
  116. package/ios/Source/sgcs/Simulated.swift +7 -3
  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 +331 -59
  131. package/src/_private/BluetoothSdkModule.ts +113 -102
  132. package/src/_private/photoRequestPayload.ts +28 -0
  133. package/src/index.ts +58 -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
@@ -1,6 +1,7 @@
1
1
  package com.mentra.bluetoothsdk.photoreceiver
2
2
 
3
3
  import android.content.Context
4
+ import com.mentra.bluetoothsdk.debug.BleTraceLogger
4
5
  import kotlinx.coroutines.CoroutineScope
5
6
  import kotlinx.coroutines.Dispatchers
6
7
  import kotlinx.coroutines.Job
@@ -12,6 +13,7 @@ import java.io.BufferedInputStream
12
13
  import java.io.ByteArrayOutputStream
13
14
  import java.io.EOFException
14
15
  import java.io.File
16
+ import java.io.FileOutputStream
15
17
  import java.io.InputStream
16
18
  import java.io.OutputStream
17
19
  import java.net.InetSocketAddress
@@ -20,6 +22,9 @@ import java.net.Socket
20
22
  import java.net.SocketException
21
23
  import java.nio.charset.StandardCharsets
22
24
  import java.util.Locale
25
+ import java.util.UUID
26
+ import java.util.concurrent.Semaphore
27
+ import org.json.JSONObject
23
28
 
24
29
  data class PhotoUpload(
25
30
  val requestId: String?,
@@ -38,21 +43,40 @@ class LocalPhotoUploadServer(
38
43
  private var serverSocket: ServerSocket? = null
39
44
  private var serverJob: Job? = null
40
45
  private val uploadDir = File(appContext.cacheDir, "mentra-photo-uploads")
46
+ private val clientSlots = Semaphore(MAX_ACTIVE_CLIENTS)
41
47
 
42
48
  @Volatile
43
49
  var running: Boolean = false
44
50
  private set
45
51
 
52
+ val activePort: Int?
53
+ get() = serverSocket
54
+ ?.takeIf { running && !it.isClosed }
55
+ ?.localPort
56
+
46
57
  fun start(port: Int): Int {
58
+ val activeSocket = serverSocket
59
+ if (running && activeSocket != null && !activeSocket.isClosed && activeSocket.localPort == port) {
60
+ onLog("Already listening on 0.0.0.0:${activeSocket.localPort}")
61
+ return activeSocket.localPort
62
+ }
47
63
  stop()
48
64
  uploadDir.mkdirs()
49
65
  val socket = ServerSocket()
50
- socket.reuseAddress = true
51
- socket.bind(InetSocketAddress("0.0.0.0", port))
52
- serverSocket = socket
53
- running = true
54
- serverJob = scope.launch {
55
- acceptLoop(socket)
66
+ try {
67
+ socket.reuseAddress = true
68
+ socket.bind(InetSocketAddress("0.0.0.0", port))
69
+ serverSocket = socket
70
+ running = true
71
+ serverJob = scope.launch {
72
+ acceptLoop(socket)
73
+ }
74
+ } catch (error: Throwable) {
75
+ try {
76
+ socket.close()
77
+ } catch (_: Throwable) {
78
+ }
79
+ throw error
56
80
  }
57
81
  onLog("Listening on 0.0.0.0:${socket.localPort}")
58
82
  return socket.localPort
@@ -78,7 +102,17 @@ class LocalPhotoUploadServer(
78
102
  while (scope.isActive && !socket.isClosed) {
79
103
  try {
80
104
  val client = socket.accept()
81
- scope.launch { handleClient(client) }
105
+ if (!clientSlots.tryAcquire()) {
106
+ rejectClient(client)
107
+ continue
108
+ }
109
+ scope.launch {
110
+ try {
111
+ handleClient(client)
112
+ } finally {
113
+ clientSlots.release()
114
+ }
115
+ }
82
116
  } catch (error: SocketException) {
83
117
  if (running) onLog("Accept failed: ${error.message}")
84
118
  } catch (error: Throwable) {
@@ -89,33 +123,78 @@ class LocalPhotoUploadServer(
89
123
 
90
124
  private fun handleClient(socket: Socket) {
91
125
  socket.use { client ->
126
+ val requestStartMs = System.currentTimeMillis()
92
127
  client.soTimeout = 20_000
93
128
  val input = BufferedInputStream(client.getInputStream())
94
129
  val output = client.getOutputStream()
130
+ var request: HttpRequest? = null
131
+ var requestId: String? = null
132
+ var contentLength: Long? = null
133
+
134
+ traceWifiInput(
135
+ "photo_receiver_connection",
136
+ client,
137
+ requestStartMs,
138
+ mutableMapOf(
139
+ "timestampMs" to requestStartMs,
140
+ ),
141
+ )
142
+
143
+ fun respond(status: Int, body: String) {
144
+ writeJson(output, status, body)
145
+ traceWifiOutput(
146
+ "photo_receiver_response",
147
+ client,
148
+ requestStartMs,
149
+ mutableMapOf(
150
+ "method" to request?.method,
151
+ "path" to request?.path,
152
+ "requestId" to requestId,
153
+ "contentLength" to contentLength,
154
+ "statusCode" to status,
155
+ "success" to (status in 200..299),
156
+ "responseBytes" to body.toByteArray(StandardCharsets.UTF_8).size,
157
+ "durationMs" to (System.currentTimeMillis() - requestStartMs),
158
+ ),
159
+ )
160
+ }
161
+
95
162
  try {
96
163
  val headerBytes = readHeaders(input)
97
164
  val headerText = headerBytes.toString(StandardCharsets.ISO_8859_1)
98
- val request = HttpRequest.parse(headerText)
165
+ request = HttpRequest.parse(headerText)
99
166
  onLog("${request.method} ${request.path} from ${client.inetAddress.hostAddress}")
167
+ contentLength = request.headers["content-length"]?.toLongOrNull()
168
+ traceWifiInput(
169
+ "photo_receiver_request",
170
+ client,
171
+ requestStartMs,
172
+ mutableMapOf(
173
+ "method" to request.method,
174
+ "path" to request.path,
175
+ "contentType" to request.headers["content-type"].orEmpty(),
176
+ "contentLength" to contentLength,
177
+ "userAgent" to request.headers["user-agent"].orEmpty(),
178
+ ),
179
+ )
100
180
 
101
181
  if (request.method == "GET" && (request.path == "/" || request.path == "/health")) {
102
- writeJson(output, 200, """{"ok":true,"service":"mentra-photo-upload-receiver"}""")
182
+ respond(200, """{"ok":true,"service":"mentra-photo-upload-receiver"}""")
103
183
  return
104
184
  }
105
185
 
106
186
  if (request.method != "POST" || request.path != "/upload") {
107
- writeJson(output, 404, """{"ok":false,"error":"not_found"}""")
187
+ respond(404, """{"ok":false,"error":"not_found"}""")
108
188
  return
109
189
  }
110
190
 
111
191
  val contentType = request.headers["content-type"].orEmpty()
112
- val contentLength = request.headers["content-length"]?.toLongOrNull()
113
192
  if (contentLength == null || contentLength <= 0) {
114
- writeJson(output, 411, """{"ok":false,"error":"content_length_required"}""")
193
+ respond(411, """{"ok":false,"error":"content_length_required"}""")
115
194
  return
116
195
  }
117
196
  if (contentLength > MAX_UPLOAD_BYTES) {
118
- writeJson(output, 413, """{"ok":false,"error":"upload_too_large"}""")
197
+ respond(413, """{"ok":false,"error":"upload_too_large"}""")
119
198
  return
120
199
  }
121
200
  if (request.headers["expect"]?.contains("100-continue", ignoreCase = true) == true) {
@@ -124,35 +203,80 @@ class LocalPhotoUploadServer(
124
203
  }
125
204
 
126
205
  onLog("headers contentType=$contentType contentLength=$contentLength userAgent=${request.headers["user-agent"].orEmpty()}")
127
- val body = readExact(input, contentLength.toInt())
128
206
  val boundary = multipartBoundary(contentType)
129
207
  if (boundary == null) {
130
- writeJson(output, 400, """{"ok":false,"error":"multipart_boundary_required"}""")
208
+ respond(400, """{"ok":false,"error":"multipart_boundary_required"}""")
131
209
  return
132
210
  }
133
- val parsed = parseMultipart(body, boundary)
134
- val photoBytes = parsed.files["photo"] ?: parsed.files.values.firstOrNull()
135
- if (photoBytes == null) {
136
- writeJson(output, 400, """{"ok":false,"error":"photo_field_required"}""")
211
+ val parsed = parseMultipart(ContentLengthInputStream(input, contentLength), boundary)
212
+ val photoPart = parsed.files["photo"] ?: parsed.files.values.firstOrNull()
213
+ if (photoPart == null) {
214
+ parsed.deleteFiles()
215
+ respond(400, """{"ok":false,"error":"photo_field_required"}""")
137
216
  return
138
217
  }
139
218
 
140
- val requestId = parsed.fields["requestId"] ?: parsed.fields["request_id"]
219
+ requestId = parsed.fields["requestId"] ?: parsed.fields["request_id"]
141
220
  val file = File(uploadDir, "${safeFilePart(requestId ?: "photo-${System.currentTimeMillis()}")}.jpg")
142
- file.writeBytes(photoBytes)
143
- onLog("upload fields=${parsed.fields.keys.joinToString(",")} requestId=${requestId.orEmpty()} bytes=${photoBytes.size} saved=${file.absolutePath}")
144
- onUpload(
145
- PhotoUpload(
146
- requestId = requestId,
147
- photoFile = file,
148
- byteCount = photoBytes.size,
149
- fields = parsed.fields,
221
+ try {
222
+ moveFile(photoPart.file, file)
223
+ } finally {
224
+ parsed.deleteFilesExcept(file)
225
+ }
226
+ onLog("upload fields=${parsed.fields.keys.joinToString(",")} requestId=${requestId.orEmpty()} bytes=${photoPart.byteCount} saved=${file.absolutePath}")
227
+ val upload = PhotoUpload(
228
+ requestId = requestId,
229
+ photoFile = file,
230
+ byteCount = photoPart.byteCount,
231
+ fields = parsed.fields,
232
+ )
233
+ traceWifiInput(
234
+ "photo_receiver_upload_received",
235
+ client,
236
+ requestStartMs,
237
+ mutableMapOf(
238
+ "method" to request.method,
239
+ "path" to request.path,
240
+ "requestId" to requestId,
241
+ "source" to parsed.fields["source"],
242
+ "contentLength" to contentLength,
243
+ "photoBytes" to photoPart.byteCount,
244
+ "savedFileName" to file.name,
245
+ "durationMs" to (System.currentTimeMillis() - requestStartMs),
150
246
  ),
151
247
  )
152
- writeJson(output, 200, """{"ok":true,"requestId":"${jsonEscape(requestId.orEmpty())}","bytes":${photoBytes.size}}""")
248
+ respond(200, """{"ok":true,"requestId":${jsonString(requestId.orEmpty())},"bytes":${photoPart.byteCount}}""")
249
+ try {
250
+ onUpload(upload)
251
+ } catch (callbackError: Throwable) {
252
+ onLog("upload callback failed: ${callbackError.message ?: callbackError::class.java.simpleName}")
253
+ }
153
254
  } catch (error: Throwable) {
154
255
  onLog("request failed: ${error.message ?: error::class.java.simpleName}")
155
- writeJson(output, 500, """{"ok":false,"error":"${jsonEscape(error.message ?: "server_error")}"}""")
256
+ traceWifiInput(
257
+ "photo_receiver_request_error",
258
+ client,
259
+ requestStartMs,
260
+ mutableMapOf(
261
+ "method" to request?.method,
262
+ "path" to request?.path,
263
+ "requestId" to requestId,
264
+ "contentLength" to contentLength,
265
+ "durationMs" to (System.currentTimeMillis() - requestStartMs),
266
+ "errorClass" to error::class.java.simpleName,
267
+ "errorMessage" to (error.message ?: error::class.java.simpleName),
268
+ ),
269
+ )
270
+ respond(500, """{"ok":false,"error":${jsonString(error.message ?: "server_error")}}""")
271
+ }
272
+ }
273
+ }
274
+
275
+ private fun rejectClient(socket: Socket) {
276
+ socket.use { client ->
277
+ try {
278
+ writeJson(client.getOutputStream(), 503, """{"ok":false,"error":"too_many_connections"}""")
279
+ } catch (_: Throwable) {
156
280
  }
157
281
  }
158
282
  }
@@ -177,17 +301,6 @@ class LocalPhotoUploadServer(
177
301
  }
178
302
  }
179
303
 
180
- private fun readExact(input: InputStream, length: Int): ByteArray {
181
- val body = ByteArray(length)
182
- var offset = 0
183
- while (offset < length) {
184
- val read = input.read(body, offset, length - offset)
185
- if (read == -1) throw EOFException("Socket closed after $offset of $length body bytes")
186
- offset += read
187
- }
188
- return body
189
- }
190
-
191
304
  private fun writeJson(output: OutputStream, status: Int, body: String) {
192
305
  val reason = when (status) {
193
306
  200 -> "OK"
@@ -195,6 +308,7 @@ class LocalPhotoUploadServer(
195
308
  404 -> "Not Found"
196
309
  411 -> "Length Required"
197
310
  413 -> "Payload Too Large"
311
+ 503 -> "Service Unavailable"
198
312
  else -> "Internal Server Error"
199
313
  }
200
314
  val bytes = body.toByteArray(StandardCharsets.UTF_8)
@@ -229,47 +343,154 @@ class LocalPhotoUploadServer(
229
343
 
230
344
  private data class ParsedMultipart(
231
345
  val fields: Map<String, String>,
232
- val files: Map<String, ByteArray>,
346
+ val files: Map<String, StreamedFile>,
347
+ ) {
348
+ fun deleteFiles() {
349
+ files.values.forEach { it.file.delete() }
350
+ }
351
+
352
+ fun deleteFilesExcept(keptFile: File) {
353
+ files.values.forEach { part ->
354
+ if (part.file.absolutePath != keptFile.absolutePath) {
355
+ part.file.delete()
356
+ }
357
+ }
358
+ }
359
+ }
360
+
361
+ private data class StreamedFile(
362
+ val file: File,
363
+ val byteCount: Int,
233
364
  )
234
365
 
235
- private fun parseMultipart(body: ByteArray, boundary: String): ParsedMultipart {
236
- val text = body.toString(StandardCharsets.ISO_8859_1)
366
+ private fun parseMultipart(input: InputStream, boundary: String): ParsedMultipart {
237
367
  val marker = "--$boundary"
368
+ val delimiter = "\r\n$marker".toByteArray(StandardCharsets.ISO_8859_1)
238
369
  val fields = mutableMapOf<String, String>()
239
- val files = mutableMapOf<String, ByteArray>()
240
- var cursor = 0
370
+ val files = mutableMapOf<String, StreamedFile>()
371
+ val tempFiles = mutableListOf<File>()
241
372
 
242
- while (true) {
243
- val markerStart = text.indexOf(marker, cursor)
244
- if (markerStart < 0) break
245
- var partStart = markerStart + marker.length
246
- if (text.startsWith("--", partStart)) break
247
- if (text.startsWith("\r\n", partStart)) partStart += 2
248
-
249
- val headerEnd = text.indexOf("\r\n\r\n", partStart)
250
- if (headerEnd < 0) break
251
- val dataStart = headerEnd + 4
252
- val nextMarker = text.indexOf("\r\n$marker", dataStart)
253
- if (nextMarker < 0) break
254
-
255
- val headerBlock = text.substring(partStart, headerEnd)
256
- val disposition = headerBlock.lineSequence()
257
- .firstOrNull { it.startsWith("Content-Disposition:", ignoreCase = true) }
258
- .orEmpty()
259
- val name = Regex("""name="([^"]+)"""").find(disposition)?.groupValues?.get(1)
260
- val filename = Regex("""filename="([^"]*)"""").find(disposition)?.groupValues?.get(1)
261
- if (name != null) {
262
- val bytes = body.copyOfRange(dataStart, nextMarker)
263
- if (filename != null) {
264
- files[name] = bytes
373
+ try {
374
+ val firstLine = readAsciiLine(input)
375
+ if (firstLine == "$marker--") {
376
+ return ParsedMultipart(fields = fields, files = files)
377
+ }
378
+ if (firstLine != marker) {
379
+ throw IllegalArgumentException("multipart_boundary_missing")
380
+ }
381
+
382
+ var done = false
383
+ while (!done) {
384
+ val headerBlock = readPartHeaders(input)
385
+ val disposition = headerBlock.lineSequence()
386
+ .firstOrNull { it.startsWith("Content-Disposition:", ignoreCase = true) }
387
+ .orEmpty()
388
+ val name = Regex("""name="([^"]+)"""").find(disposition)?.groupValues?.get(1)
389
+ val filename = Regex("""filename="([^"]*)"""").find(disposition)?.groupValues?.get(1)
390
+ if (name == null) {
391
+ streamPartTo(input, delimiter, DiscardOutputStream)
392
+ } else if (filename != null) {
393
+ val tempFile = File(uploadDir, "upload-${System.currentTimeMillis()}-${UUID.randomUUID()}.tmp")
394
+ tempFiles += tempFile
395
+ val byteCount = FileOutputStream(tempFile).use { fileOutput ->
396
+ streamPartTo(input, delimiter, fileOutput)
397
+ }
398
+ files.put(name, StreamedFile(tempFile, byteCount.toInt()))?.file?.delete()
265
399
  } else {
266
- fields[name] = bytes.toString(StandardCharsets.UTF_8).trim()
400
+ val fieldOutput = LimitedByteArrayOutputStream(MAX_FIELD_BYTES)
401
+ streamPartTo(input, delimiter, fieldOutput)
402
+ fields[name] = fieldOutput.toByteArray().toString(StandardCharsets.UTF_8).trim()
267
403
  }
404
+ done = readBoundarySuffix(input)
405
+ }
406
+
407
+ return ParsedMultipart(fields = fields, files = files)
408
+ } catch (error: Throwable) {
409
+ tempFiles.forEach { it.delete() }
410
+ throw error
411
+ }
412
+ }
413
+
414
+ private fun readAsciiLine(input: InputStream): String {
415
+ val out = ByteArrayOutputStream()
416
+ while (true) {
417
+ val byte = input.read()
418
+ if (byte == -1) throw EOFException("Socket closed before multipart line completed")
419
+ if (byte == '\n'.code) {
420
+ val bytes = out.toByteArray()
421
+ val length = if (bytes.isNotEmpty() && bytes.last() == '\r'.code.toByte()) bytes.size - 1 else bytes.size
422
+ return String(bytes, 0, length, StandardCharsets.ISO_8859_1)
423
+ }
424
+ out.write(byte)
425
+ if (out.size() > MAX_PART_HEADER_BYTES) throw IllegalArgumentException("multipart line too large")
426
+ }
427
+ }
428
+
429
+ private fun readPartHeaders(input: InputStream): String {
430
+ val out = ByteArrayOutputStream()
431
+ val delimiter = byteArrayOf(13, 10, 13, 10)
432
+ var matched = 0
433
+ while (true) {
434
+ val byte = input.read()
435
+ if (byte == -1) throw EOFException("Socket closed before multipart headers completed")
436
+ out.write(byte)
437
+ matched = if (byte.toByte() == delimiter[matched]) {
438
+ matched + 1
439
+ } else if (byte.toByte() == delimiter[0]) {
440
+ 1
441
+ } else {
442
+ 0
443
+ }
444
+ if (matched == delimiter.size) {
445
+ val bytes = out.toByteArray()
446
+ return String(bytes, 0, bytes.size - delimiter.size, StandardCharsets.ISO_8859_1)
447
+ }
448
+ if (out.size() > MAX_PART_HEADER_BYTES) throw IllegalArgumentException("multipart headers too large")
449
+ }
450
+ }
451
+
452
+ private fun streamPartTo(input: InputStream, delimiter: ByteArray, output: OutputStream): Long {
453
+ val tail = ArrayDeque<Byte>()
454
+ var byteCount = 0L
455
+ while (true) {
456
+ val next = input.read()
457
+ if (next == -1) throw EOFException("Socket closed before multipart boundary completed")
458
+ tail.addLast(next.toByte())
459
+ if (tail.size > delimiter.size) {
460
+ output.write(tail.removeFirst().toInt() and 0xff)
461
+ byteCount += 1
268
462
  }
269
- cursor = nextMarker + 2
463
+ if (tail.size == delimiter.size && tailMatches(tail, delimiter)) {
464
+ output.flush()
465
+ return byteCount
466
+ }
467
+ }
468
+ }
469
+
470
+ private fun tailMatches(tail: ArrayDeque<Byte>, delimiter: ByteArray): Boolean {
471
+ if (tail.size != delimiter.size) return false
472
+ tail.forEachIndexed { index, byte ->
473
+ if (byte != delimiter[index]) return false
270
474
  }
475
+ return true
476
+ }
271
477
 
272
- return ParsedMultipart(fields = fields, files = files)
478
+ private fun readBoundarySuffix(input: InputStream): Boolean {
479
+ val first = input.read()
480
+ if (first == -1) throw EOFException("Socket closed after multipart boundary")
481
+ return when (first) {
482
+ '-'.code -> {
483
+ val second = input.read()
484
+ if (second != '-'.code) throw IllegalArgumentException("invalid multipart closing boundary")
485
+ true
486
+ }
487
+ '\r'.code -> {
488
+ val second = input.read()
489
+ if (second != '\n'.code) throw IllegalArgumentException("invalid multipart boundary separator")
490
+ false
491
+ }
492
+ else -> throw IllegalArgumentException("invalid multipart boundary suffix")
493
+ }
273
494
  }
274
495
 
275
496
  private fun multipartBoundary(contentType: String): String? {
@@ -286,12 +507,116 @@ class LocalPhotoUploadServer(
286
507
  return value.replace(Regex("[^A-Za-z0-9._-]"), "_").ifBlank { "photo" }
287
508
  }
288
509
 
289
- private fun jsonEscape(value: String): String {
290
- return value.replace("\\", "\\\\").replace("\"", "\\\"")
510
+ private fun jsonString(value: String): String {
511
+ return JSONObject.quote(value)
512
+ }
513
+
514
+ private fun moveFile(source: File, destination: File) {
515
+ if (destination.exists()) {
516
+ destination.delete()
517
+ }
518
+ if (!source.renameTo(destination)) {
519
+ source.copyTo(destination, overwrite = true)
520
+ source.delete()
521
+ }
522
+ }
523
+
524
+ private fun traceWifiInput(
525
+ type: String,
526
+ client: Socket,
527
+ startMs: Long,
528
+ values: MutableMap<String, Any?> = mutableMapOf(),
529
+ ) {
530
+ traceWifi("wifi_to_phone", "wifi_http_input", type, client, startMs, values)
531
+ }
532
+
533
+ private fun traceWifiOutput(
534
+ type: String,
535
+ client: Socket,
536
+ startMs: Long,
537
+ values: MutableMap<String, Any?> = mutableMapOf(),
538
+ ) {
539
+ traceWifi("phone_to_wifi", "wifi_http_output", type, client, startMs, values)
540
+ }
541
+
542
+ private fun traceWifi(
543
+ direction: String,
544
+ layer: String,
545
+ type: String,
546
+ client: Socket,
547
+ startMs: Long,
548
+ values: MutableMap<String, Any?>,
549
+ ) {
550
+ val payload = JSONObject()
551
+ putJson(payload, "type", type)
552
+ putJson(payload, "remoteHost", client.inetAddress?.hostAddress)
553
+ putJson(payload, "remotePort", client.port)
554
+ putJson(payload, "localHost", client.localAddress?.hostAddress)
555
+ putJson(payload, "localPort", client.localPort)
556
+ putJson(payload, "startMs", startMs)
557
+ values.forEach { (key, value) -> putJson(payload, key, value) }
558
+ try {
559
+ BleTraceLogger.logJson(direction, layer, payload, null)
560
+ } catch (_: Throwable) {
561
+ }
562
+ }
563
+
564
+ private fun putJson(payload: JSONObject, key: String, value: Any?) {
565
+ if (value == null) return
566
+ try {
567
+ payload.put(key, value)
568
+ } catch (_: Throwable) {
569
+ }
291
570
  }
292
571
 
293
572
  companion object {
294
573
  private const val MAX_HEADER_BYTES = 64 * 1024
574
+ private const val MAX_PART_HEADER_BYTES = 64 * 1024
575
+ private const val MAX_FIELD_BYTES = 64 * 1024
295
576
  private const val MAX_UPLOAD_BYTES = 25L * 1024L * 1024L
577
+ private const val MAX_ACTIVE_CLIENTS = 2
578
+ }
579
+ }
580
+
581
+ private object DiscardOutputStream : OutputStream() {
582
+ override fun write(b: Int) {
583
+ }
584
+ }
585
+
586
+ private class LimitedByteArrayOutputStream(
587
+ private val maxBytes: Int,
588
+ ) : ByteArrayOutputStream() {
589
+ override fun write(b: Int) {
590
+ if (size() + 1 > maxBytes) throw IllegalArgumentException("multipart field too large")
591
+ super.write(b)
592
+ }
593
+
594
+ override fun write(b: ByteArray, off: Int, len: Int) {
595
+ if (size() + len > maxBytes) throw IllegalArgumentException("multipart field too large")
596
+ super.write(b, off, len)
597
+ }
598
+ }
599
+
600
+ private class ContentLengthInputStream(
601
+ private val input: InputStream,
602
+ private var remainingBytes: Long,
603
+ ) : InputStream() {
604
+ override fun read(): Int {
605
+ if (remainingBytes <= 0) return -1
606
+ val byte = input.read()
607
+ if (byte != -1) {
608
+ remainingBytes -= 1
609
+ }
610
+ return byte
611
+ }
612
+
613
+ override fun read(buffer: ByteArray, offset: Int, length: Int): Int {
614
+ if (remainingBytes <= 0) return -1
615
+ val readLength = minOf(length.toLong(), remainingBytes).toInt()
616
+ val byteCount = input.read(buffer, offset, readLength)
617
+ if (byteCount > 0) {
618
+ remainingBytes -= byteCount.toLong()
619
+ }
620
+ return byteCount
296
621
  }
297
622
  }