@mentra/bluetooth-sdk 0.1.8 → 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.
- 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 +165 -27
- package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +89 -21
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +189 -16
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +22 -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 +123 -10
- 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 +569 -220
- 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 +176 -104
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraosBle.java +12753 -27036
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +54 -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 +241 -43
- 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 +36 -23
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js +30 -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 +23 -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 +174 -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 +58 -19
- package/ios/Source/DeviceManager.swift +149 -17
- package/ios/Source/DeviceStore.swift +16 -6
- package/ios/Source/MentraBluetoothSDK.swift +803 -47
- package/ios/Source/ObservableStore.swift +8 -2
- package/ios/Source/{Audio → audio}/AudioModels.swift +9 -7
- package/ios/Source/{Camera → camera}/CameraModels.swift +225 -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 +2 -2
- package/ios/Source/sgcs/G2.swift +645 -403
- package/ios/Source/sgcs/Mach1.swift +2 -2
- package/ios/Source/sgcs/MentraLive.swift +812 -319
- package/ios/Source/sgcs/MentraNex.swift +412 -182
- package/ios/Source/sgcs/SGCManager.swift +57 -3
- package/ios/Source/sgcs/Simulated.swift +7 -3
- 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 +331 -59
- package/src/_private/BluetoothSdkModule.ts +113 -102
- package/src/_private/photoRequestPayload.ts +28 -0
- package/src/index.ts +58 -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
package/android/src/main/java/com/mentra/bluetoothsdk/photoreceiver/LocalPhotoUploadServer.kt
CHANGED
|
@@ -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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
193
|
+
respond(411, """{"ok":false,"error":"content_length_required"}""")
|
|
115
194
|
return
|
|
116
195
|
}
|
|
117
196
|
if (contentLength > MAX_UPLOAD_BYTES) {
|
|
118
|
-
|
|
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
|
-
|
|
208
|
+
respond(400, """{"ok":false,"error":"multipart_boundary_required"}""")
|
|
131
209
|
return
|
|
132
210
|
}
|
|
133
|
-
val parsed = parseMultipart(
|
|
134
|
-
val
|
|
135
|
-
if (
|
|
136
|
-
|
|
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
|
-
|
|
219
|
+
requestId = parsed.fields["requestId"] ?: parsed.fields["request_id"]
|
|
141
220
|
val file = File(uploadDir, "${safeFilePart(requestId ?: "photo-${System.currentTimeMillis()}")}.jpg")
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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(
|
|
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,
|
|
240
|
-
|
|
370
|
+
val files = mutableMapOf<String, StreamedFile>()
|
|
371
|
+
val tempFiles = mutableListOf<File>()
|
|
241
372
|
|
|
242
|
-
|
|
243
|
-
val
|
|
244
|
-
if (
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
if (
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
.
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
290
|
-
return
|
|
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
|
}
|