@mentra/bluetooth-sdk 0.1.7 → 0.1.8

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 (82) hide show
  1. package/README.md +11 -9
  2. package/android/.project +28 -0
  3. package/android/build.gradle +3 -0
  4. package/android/lc3Lib/.project +28 -0
  5. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +15 -3
  6. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +26 -1
  7. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +4 -2
  8. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +136 -4
  9. package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +40 -3
  10. package/android/src/main/java/com/mentra/bluetoothsdk/controllers/ControllerManager.kt +1 -0
  11. package/android/src/main/java/com/mentra/bluetoothsdk/controllers/R1.kt +1 -1
  12. package/android/src/main/java/com/mentra/bluetoothsdk/debug/BleTraceLogger.kt +149 -0
  13. package/android/src/main/java/com/mentra/bluetoothsdk/events/BluetoothEvents.kt +1 -0
  14. package/android/src/main/java/com/mentra/bluetoothsdk/photoreceiver/LocalPhotoUploadServer.kt +297 -0
  15. package/android/src/main/java/com/mentra/bluetoothsdk/photoreceiver/MentraPhotoReceiverModule.kt +120 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +13 -0
  17. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.java +1 -1
  18. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +1 -0
  19. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Mach1.java +1 -1
  20. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.java +104 -21
  21. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +1 -1
  22. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +1 -0
  23. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Simulated.kt +1 -0
  24. package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +128 -3
  25. package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunkReassembler.java +105 -0
  26. package/build/BluetoothSdk.types.d.ts +66 -29
  27. package/build/BluetoothSdk.types.d.ts.map +1 -1
  28. package/build/BluetoothSdk.types.js +3 -0
  29. package/build/BluetoothSdk.types.js.map +1 -1
  30. package/build/_private/BluetoothSdkModule.d.ts +2 -1
  31. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  32. package/build/_private/BluetoothSdkModule.js +24 -7
  33. package/build/_private/BluetoothSdkModule.js.map +1 -1
  34. package/build/index.d.ts +2 -2
  35. package/build/index.d.ts.map +1 -1
  36. package/build/index.js +2 -3
  37. package/build/index.js.map +1 -1
  38. package/build/photo-receiver/MentraPhotoReceiver.types.d.ts +18 -0
  39. package/build/photo-receiver/MentraPhotoReceiver.types.d.ts.map +1 -0
  40. package/build/photo-receiver/MentraPhotoReceiver.types.js +2 -0
  41. package/build/photo-receiver/MentraPhotoReceiver.types.js.map +1 -0
  42. package/build/photo-receiver/MentraPhotoReceiverModule.d.ts +10 -0
  43. package/build/photo-receiver/MentraPhotoReceiverModule.d.ts.map +1 -0
  44. package/build/photo-receiver/MentraPhotoReceiverModule.js +3 -0
  45. package/build/photo-receiver/MentraPhotoReceiverModule.js.map +1 -0
  46. package/build/photo-receiver/MentraPhotoReceiverModule.web.d.ts +10 -0
  47. package/build/photo-receiver/MentraPhotoReceiverModule.web.d.ts.map +1 -0
  48. package/build/photo-receiver/MentraPhotoReceiverModule.web.js +12 -0
  49. package/build/photo-receiver/MentraPhotoReceiverModule.web.js.map +1 -0
  50. package/build/photo-receiver/index.d.ts +3 -0
  51. package/build/photo-receiver/index.d.ts.map +1 -0
  52. package/build/photo-receiver/index.js +3 -0
  53. package/build/photo-receiver/index.js.map +1 -0
  54. package/expo-module.config.json +5 -2
  55. package/ios/BluetoothSdkModule.swift +19 -3
  56. package/ios/LocalPhotoUploadServer.swift +411 -0
  57. package/ios/MentraBluetoothSDK.podspec +8 -2
  58. package/ios/MentraPhotoReceiverModule.swift +147 -0
  59. package/ios/Source/Bridge.swift +20 -1
  60. package/ios/Source/Camera/CameraModels.swift +64 -11
  61. package/ios/Source/DeviceManager.swift +6 -4
  62. package/ios/Source/Events/BluetoothEvents.swift +3 -0
  63. package/ios/Source/MentraBluetoothSDK.swift +111 -5
  64. package/ios/Source/Streaming/StreamModels.swift +234 -40
  65. package/ios/Source/controllers/ControllerManager.swift +1 -1
  66. package/ios/Source/controllers/R1.swift +9 -3
  67. package/ios/Source/sgcs/G1.swift +5 -4
  68. package/ios/Source/sgcs/G2.swift +4 -4
  69. package/ios/Source/sgcs/Mach1.swift +1 -1
  70. package/ios/Source/sgcs/MentraLive.swift +76 -12
  71. package/ios/Source/sgcs/MentraNex.swift +1 -1
  72. package/ios/Source/sgcs/SGCManager.swift +1 -1
  73. package/ios/Source/sgcs/Simulated.swift +1 -1
  74. package/ios/Source/utils/MessageChunkReassembler.swift +89 -0
  75. package/package.json +6 -1
  76. package/src/BluetoothSdk.types.ts +90 -35
  77. package/src/_private/BluetoothSdkModule.ts +38 -8
  78. package/src/index.ts +9 -4
  79. package/src/photo-receiver/MentraPhotoReceiver.types.ts +20 -0
  80. package/src/photo-receiver/MentraPhotoReceiverModule.ts +14 -0
  81. package/src/photo-receiver/MentraPhotoReceiverModule.web.ts +20 -0
  82. package/src/photo-receiver/index.ts +2 -0
@@ -0,0 +1,149 @@
1
+ package com.mentra.bluetoothsdk.debug
2
+
3
+ import android.content.Context
4
+ import android.os.Build
5
+ import android.os.Process
6
+ import android.util.Log
7
+ import org.json.JSONArray
8
+ import org.json.JSONObject
9
+
10
+ object BleTraceLogger {
11
+ private const val TAG = "MentraBleTrace"
12
+ private const val MAX_PAYLOAD_CHARS = 3000
13
+ private val sensitiveKeyParts =
14
+ listOf("password", "pass", "token", "secret", "authorization", "auth", "email")
15
+
16
+ @JvmStatic
17
+ fun logJson(direction: String, layer: String, payload: JSONObject?, bytes: Int? = null) {
18
+ if (payload == null) {
19
+ Log.i(TAG, format(direction, layer, caller(), "null", bytes, "null"))
20
+ return
21
+ }
22
+
23
+ val sanitized = sanitize(payload)
24
+ Log.i(TAG, format(direction, layer, caller(), extractType(payload), bytes, sanitized.toString()))
25
+ }
26
+
27
+ @JvmStatic
28
+ fun logMap(direction: String, layer: String, type: String?, payload: Map<String, Any>) {
29
+ val sanitized = sanitize(JSONObject(payload))
30
+ Log.i(TAG, format(direction, layer, caller(), type ?: extractType(sanitized), null, sanitized.toString()))
31
+ }
32
+
33
+ @JvmStatic
34
+ fun logLifecycle(
35
+ context: Context?,
36
+ component: String,
37
+ event: String,
38
+ extra: Map<String, Any?> = emptyMap(),
39
+ ) {
40
+ val payload = JSONObject()
41
+ payload.put("event", event)
42
+ payload.put("component", component)
43
+ payload.put("pid", Process.myPid())
44
+ payload.put("model", Build.MODEL)
45
+ payload.put("sdkInt", Build.VERSION.SDK_INT)
46
+
47
+ context?.let {
48
+ payload.put("package", it.packageName)
49
+ packageVersion(it)?.let { version -> payload.put("version", version) }
50
+ }
51
+
52
+ extra.forEach { (key, value) -> payload.put(key, value ?: JSONObject.NULL) }
53
+ Log.i(TAG, format("phone_app", "app_lifecycle", caller(), event, null, sanitize(payload).toString()))
54
+ }
55
+
56
+ private fun format(
57
+ direction: String,
58
+ layer: String,
59
+ source: String,
60
+ type: String,
61
+ bytes: Int?,
62
+ payload: String,
63
+ ): String {
64
+ val bytesText = bytes?.let { " bytes=$it" } ?: ""
65
+ return "BLE_TRACE direction=$direction layer=$layer source=$source type=$type$bytesText payload=${truncate(payload)}"
66
+ }
67
+
68
+ private fun extractType(payload: JSONObject): String {
69
+ payload.optString("type").takeIf { it.isNotBlank() }?.let { return it }
70
+ payload.optString("C").takeIf { it.isNotBlank() }?.let { cValue ->
71
+ try {
72
+ val inner = JSONObject(cValue)
73
+ inner.optString("type").takeIf { it.isNotBlank() }?.let { return it }
74
+ } catch (_: Exception) {
75
+ return "k900:${cValue.take(40)}"
76
+ }
77
+ }
78
+ return "unknown"
79
+ }
80
+
81
+ private fun sanitize(value: JSONObject): JSONObject {
82
+ val output = JSONObject()
83
+ value.keys().forEach { key ->
84
+ output.put(key, sanitizeValue(key, value.opt(key)))
85
+ }
86
+ return output
87
+ }
88
+
89
+ private fun sanitize(value: JSONArray): JSONArray {
90
+ val output = JSONArray()
91
+ for (index in 0 until value.length()) {
92
+ output.put(sanitizeValue(null, value.opt(index)))
93
+ }
94
+ return output
95
+ }
96
+
97
+ private fun sanitizeValue(key: String?, value: Any?): Any? {
98
+ if (key != null && sensitiveKeyParts.any { key.contains(it, ignoreCase = true) }) {
99
+ return "<redacted>"
100
+ }
101
+ if (key == "C" && value is String) {
102
+ try {
103
+ return sanitize(JSONObject(value)).toString()
104
+ } catch (_: Exception) {
105
+ return truncate(value)
106
+ }
107
+ }
108
+ return when (value) {
109
+ is JSONObject -> sanitize(value)
110
+ is JSONArray -> sanitize(value)
111
+ JSONObject.NULL -> JSONObject.NULL
112
+ else -> value
113
+ }
114
+ }
115
+
116
+ private fun caller(): String {
117
+ val frame =
118
+ Throwable().stackTrace.firstOrNull {
119
+ !it.className.contains("BleTraceLogger") &&
120
+ !it.className.startsWith("java.lang.") &&
121
+ !it.className.startsWith("kotlin.")
122
+ }
123
+ return frame?.let {
124
+ "${it.className.substringAfterLast('.')}.${it.methodName}(${it.fileName}:${it.lineNumber})"
125
+ } ?: "unknown"
126
+ }
127
+
128
+ private fun packageVersion(context: Context): String? =
129
+ try {
130
+ val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0)
131
+ val versionCode =
132
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
133
+ packageInfo.longVersionCode
134
+ } else {
135
+ @Suppress("DEPRECATION")
136
+ packageInfo.versionCode.toLong()
137
+ }
138
+ "${packageInfo.versionName ?: "unknown"}+$versionCode"
139
+ } catch (_: Exception) {
140
+ null
141
+ }
142
+
143
+ private fun truncate(value: String): String {
144
+ if (value.length <= MAX_PAYLOAD_CHARS) {
145
+ return value
146
+ }
147
+ return "${value.take(MAX_PAYLOAD_CHARS)}...(truncated ${value.length - MAX_PAYLOAD_CHARS} chars)"
148
+ }
149
+ }
@@ -58,6 +58,7 @@ interface MentraBluetoothSdkListener {
58
58
  fun onHotspotError(event: HotspotErrorEvent) {}
59
59
  fun onGalleryStatus(event: GalleryStatusEvent) {}
60
60
  fun onPhotoResponse(event: PhotoResponseEvent) {}
61
+ fun onPhotoStatus(event: PhotoStatusEvent) {}
61
62
  fun onStreamStatus(event: StreamStatusEvent) {}
62
63
  fun onKeepAliveAck(event: KeepAliveAckEvent) {}
63
64
  fun onMicPcm(event: MicPcmEvent) {}
@@ -0,0 +1,297 @@
1
+ package com.mentra.bluetoothsdk.photoreceiver
2
+
3
+ import android.content.Context
4
+ import kotlinx.coroutines.CoroutineScope
5
+ import kotlinx.coroutines.Dispatchers
6
+ import kotlinx.coroutines.Job
7
+ import kotlinx.coroutines.SupervisorJob
8
+ import kotlinx.coroutines.cancel
9
+ import kotlinx.coroutines.isActive
10
+ import kotlinx.coroutines.launch
11
+ import java.io.BufferedInputStream
12
+ import java.io.ByteArrayOutputStream
13
+ import java.io.EOFException
14
+ import java.io.File
15
+ import java.io.InputStream
16
+ import java.io.OutputStream
17
+ import java.net.InetSocketAddress
18
+ import java.net.ServerSocket
19
+ import java.net.Socket
20
+ import java.net.SocketException
21
+ import java.nio.charset.StandardCharsets
22
+ import java.util.Locale
23
+
24
+ data class PhotoUpload(
25
+ val requestId: String?,
26
+ val photoFile: File,
27
+ val byteCount: Int,
28
+ val fields: Map<String, String>,
29
+ )
30
+
31
+ class LocalPhotoUploadServer(
32
+ context: Context,
33
+ private val onLog: (String) -> Unit,
34
+ private val onUpload: (PhotoUpload) -> Unit,
35
+ ) : AutoCloseable {
36
+ private val appContext = context.applicationContext
37
+ private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
38
+ private var serverSocket: ServerSocket? = null
39
+ private var serverJob: Job? = null
40
+ private val uploadDir = File(appContext.cacheDir, "mentra-photo-uploads")
41
+
42
+ @Volatile
43
+ var running: Boolean = false
44
+ private set
45
+
46
+ fun start(port: Int): Int {
47
+ stop()
48
+ uploadDir.mkdirs()
49
+ 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)
56
+ }
57
+ onLog("Listening on 0.0.0.0:${socket.localPort}")
58
+ return socket.localPort
59
+ }
60
+
61
+ fun stop() {
62
+ running = false
63
+ serverJob?.cancel()
64
+ serverJob = null
65
+ try {
66
+ serverSocket?.close()
67
+ } catch (_: Throwable) {
68
+ }
69
+ serverSocket = null
70
+ }
71
+
72
+ override fun close() {
73
+ stop()
74
+ scope.cancel()
75
+ }
76
+
77
+ private fun acceptLoop(socket: ServerSocket) {
78
+ while (scope.isActive && !socket.isClosed) {
79
+ try {
80
+ val client = socket.accept()
81
+ scope.launch { handleClient(client) }
82
+ } catch (error: SocketException) {
83
+ if (running) onLog("Accept failed: ${error.message}")
84
+ } catch (error: Throwable) {
85
+ if (running) onLog("Accept failed: ${error.message ?: error::class.java.simpleName}")
86
+ }
87
+ }
88
+ }
89
+
90
+ private fun handleClient(socket: Socket) {
91
+ socket.use { client ->
92
+ client.soTimeout = 20_000
93
+ val input = BufferedInputStream(client.getInputStream())
94
+ val output = client.getOutputStream()
95
+ try {
96
+ val headerBytes = readHeaders(input)
97
+ val headerText = headerBytes.toString(StandardCharsets.ISO_8859_1)
98
+ val request = HttpRequest.parse(headerText)
99
+ onLog("${request.method} ${request.path} from ${client.inetAddress.hostAddress}")
100
+
101
+ if (request.method == "GET" && (request.path == "/" || request.path == "/health")) {
102
+ writeJson(output, 200, """{"ok":true,"service":"mentra-photo-upload-receiver"}""")
103
+ return
104
+ }
105
+
106
+ if (request.method != "POST" || request.path != "/upload") {
107
+ writeJson(output, 404, """{"ok":false,"error":"not_found"}""")
108
+ return
109
+ }
110
+
111
+ val contentType = request.headers["content-type"].orEmpty()
112
+ val contentLength = request.headers["content-length"]?.toLongOrNull()
113
+ if (contentLength == null || contentLength <= 0) {
114
+ writeJson(output, 411, """{"ok":false,"error":"content_length_required"}""")
115
+ return
116
+ }
117
+ if (contentLength > MAX_UPLOAD_BYTES) {
118
+ writeJson(output, 413, """{"ok":false,"error":"upload_too_large"}""")
119
+ return
120
+ }
121
+ if (request.headers["expect"]?.contains("100-continue", ignoreCase = true) == true) {
122
+ output.write("HTTP/1.1 100 Continue\r\n\r\n".toByteArray(StandardCharsets.US_ASCII))
123
+ output.flush()
124
+ }
125
+
126
+ onLog("headers contentType=$contentType contentLength=$contentLength userAgent=${request.headers["user-agent"].orEmpty()}")
127
+ val body = readExact(input, contentLength.toInt())
128
+ val boundary = multipartBoundary(contentType)
129
+ if (boundary == null) {
130
+ writeJson(output, 400, """{"ok":false,"error":"multipart_boundary_required"}""")
131
+ return
132
+ }
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"}""")
137
+ return
138
+ }
139
+
140
+ val requestId = parsed.fields["requestId"] ?: parsed.fields["request_id"]
141
+ 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,
150
+ ),
151
+ )
152
+ writeJson(output, 200, """{"ok":true,"requestId":"${jsonEscape(requestId.orEmpty())}","bytes":${photoBytes.size}}""")
153
+ } catch (error: Throwable) {
154
+ onLog("request failed: ${error.message ?: error::class.java.simpleName}")
155
+ writeJson(output, 500, """{"ok":false,"error":"${jsonEscape(error.message ?: "server_error")}"}""")
156
+ }
157
+ }
158
+ }
159
+
160
+ private fun readHeaders(input: InputStream): ByteArray {
161
+ val out = ByteArrayOutputStream()
162
+ val delimiter = byteArrayOf(13, 10, 13, 10)
163
+ var matched = 0
164
+ while (true) {
165
+ val byte = input.read()
166
+ if (byte == -1) throw EOFException("Socket closed before HTTP headers completed")
167
+ out.write(byte)
168
+ matched = if (byte.toByte() == delimiter[matched]) {
169
+ matched + 1
170
+ } else if (byte.toByte() == delimiter[0]) {
171
+ 1
172
+ } else {
173
+ 0
174
+ }
175
+ if (matched == delimiter.size) return out.toByteArray()
176
+ if (out.size() > MAX_HEADER_BYTES) throw IllegalArgumentException("HTTP headers too large")
177
+ }
178
+ }
179
+
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
+ private fun writeJson(output: OutputStream, status: Int, body: String) {
192
+ val reason = when (status) {
193
+ 200 -> "OK"
194
+ 400 -> "Bad Request"
195
+ 404 -> "Not Found"
196
+ 411 -> "Length Required"
197
+ 413 -> "Payload Too Large"
198
+ else -> "Internal Server Error"
199
+ }
200
+ val bytes = body.toByteArray(StandardCharsets.UTF_8)
201
+ output.write("HTTP/1.1 $status $reason\r\n".toByteArray(StandardCharsets.US_ASCII))
202
+ output.write("Content-Type: application/json\r\n".toByteArray(StandardCharsets.US_ASCII))
203
+ output.write("Content-Length: ${bytes.size}\r\n".toByteArray(StandardCharsets.US_ASCII))
204
+ output.write("Connection: close\r\n\r\n".toByteArray(StandardCharsets.US_ASCII))
205
+ output.write(bytes)
206
+ output.flush()
207
+ }
208
+
209
+ private data class HttpRequest(
210
+ val method: String,
211
+ val path: String,
212
+ val headers: Map<String, String>,
213
+ ) {
214
+ companion object {
215
+ fun parse(headerText: String): HttpRequest {
216
+ val lines = headerText.split("\r\n").filter { it.isNotBlank() }
217
+ val requestParts = lines.firstOrNull()?.split(" ").orEmpty()
218
+ val method = requestParts.getOrNull(0)?.uppercase(Locale.US).orEmpty()
219
+ val path = requestParts.getOrNull(1)?.substringBefore("?").orEmpty()
220
+ val headers = lines.drop(1).mapNotNull { line ->
221
+ val separator = line.indexOf(':')
222
+ if (separator <= 0) return@mapNotNull null
223
+ line.substring(0, separator).lowercase(Locale.US) to line.substring(separator + 1).trim()
224
+ }.toMap()
225
+ return HttpRequest(method, path, headers)
226
+ }
227
+ }
228
+ }
229
+
230
+ private data class ParsedMultipart(
231
+ val fields: Map<String, String>,
232
+ val files: Map<String, ByteArray>,
233
+ )
234
+
235
+ private fun parseMultipart(body: ByteArray, boundary: String): ParsedMultipart {
236
+ val text = body.toString(StandardCharsets.ISO_8859_1)
237
+ val marker = "--$boundary"
238
+ val fields = mutableMapOf<String, String>()
239
+ val files = mutableMapOf<String, ByteArray>()
240
+ var cursor = 0
241
+
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
265
+ } else {
266
+ fields[name] = bytes.toString(StandardCharsets.UTF_8).trim()
267
+ }
268
+ }
269
+ cursor = nextMarker + 2
270
+ }
271
+
272
+ return ParsedMultipart(fields = fields, files = files)
273
+ }
274
+
275
+ private fun multipartBoundary(contentType: String): String? {
276
+ return contentType.split(';')
277
+ .map { it.trim() }
278
+ .firstOrNull { it.startsWith("boundary=", ignoreCase = true) }
279
+ ?.substringAfter('=')
280
+ ?.trim()
281
+ ?.trim('"')
282
+ ?.takeIf { it.isNotBlank() }
283
+ }
284
+
285
+ private fun safeFilePart(value: String): String {
286
+ return value.replace(Regex("[^A-Za-z0-9._-]"), "_").ifBlank { "photo" }
287
+ }
288
+
289
+ private fun jsonEscape(value: String): String {
290
+ return value.replace("\\", "\\\\").replace("\"", "\\\"")
291
+ }
292
+
293
+ companion object {
294
+ private const val MAX_HEADER_BYTES = 64 * 1024
295
+ private const val MAX_UPLOAD_BYTES = 25L * 1024L * 1024L
296
+ }
297
+ }
@@ -0,0 +1,120 @@
1
+ package com.mentra.bluetoothsdk.photoreceiver
2
+
3
+ import android.content.Context
4
+ import android.net.Uri
5
+ import expo.modules.kotlin.exception.Exceptions
6
+ import expo.modules.kotlin.modules.Module
7
+ import expo.modules.kotlin.modules.ModuleDefinition
8
+ import java.net.Inet4Address
9
+ import java.net.NetworkInterface
10
+
11
+ class MentraPhotoReceiverModule : Module() {
12
+ private var photoUploadServer: LocalPhotoUploadServer? = null
13
+
14
+ override fun definition() = ModuleDefinition {
15
+ Name("MentraPhotoReceiver")
16
+
17
+ Events("photoUpload", "receiverStatus")
18
+
19
+ AsyncFunction("isSupported") {
20
+ true
21
+ }
22
+
23
+ AsyncFunction("startPhotoReceiver") {
24
+ startPhotoReceiver()
25
+ }
26
+
27
+ AsyncFunction("stopPhotoReceiver") {
28
+ stopPhotoReceiverInternal()
29
+ }
30
+
31
+ OnDestroy {
32
+ stopPhotoReceiverInternal()
33
+ }
34
+ }
35
+
36
+ private fun startPhotoReceiver(): Map<String, Any> {
37
+ val host = bestLocalIpv4Address()
38
+ ?: throw IllegalStateException("No Wi-Fi/LAN IPv4 address found for this phone.")
39
+ val server = photoUploadServer ?: LocalPhotoUploadServer(
40
+ context = reactContext(),
41
+ onLog = { message -> emitStatus(message) },
42
+ onUpload = ::handlePhotoUpload,
43
+ ).also {
44
+ photoUploadServer = it
45
+ }
46
+
47
+ var lastError: Throwable? = null
48
+ for (port in PHOTO_PORTS) {
49
+ try {
50
+ val actualPort = server.start(port)
51
+ val uploadUrl = "http://$host:$actualPort/upload"
52
+ emitStatus("Photo receiver ready at $uploadUrl")
53
+ return mapOf(
54
+ "uploadUrl" to uploadUrl,
55
+ "host" to host,
56
+ "port" to actualPort,
57
+ )
58
+ } catch (error: Throwable) {
59
+ lastError = error
60
+ emitStatus("Port $port unavailable: ${error.message ?: error::class.java.simpleName}")
61
+ }
62
+ }
63
+
64
+ throw IllegalStateException(
65
+ "Could not start phone photo receiver: ${lastError?.message ?: "all ports unavailable"}",
66
+ )
67
+ }
68
+
69
+ private fun stopPhotoReceiverInternal() {
70
+ photoUploadServer?.stop()
71
+ emitStatus("Photo receiver stopped")
72
+ }
73
+
74
+ private fun handlePhotoUpload(upload: PhotoUpload) {
75
+ val fileUri = Uri.fromFile(upload.photoFile).toString()
76
+ sendEvent(
77
+ "photoUpload",
78
+ mapOf(
79
+ "requestId" to upload.requestId,
80
+ "fileUri" to fileUri,
81
+ "byteCount" to upload.byteCount,
82
+ ),
83
+ )
84
+ emitStatus("Photo uploaded (${upload.byteCount} bytes)")
85
+ }
86
+
87
+ private fun emitStatus(message: String) {
88
+ sendEvent(
89
+ "receiverStatus",
90
+ mapOf("message" to message),
91
+ )
92
+ }
93
+
94
+ private fun reactContext(): Context {
95
+ return appContext.reactContext
96
+ ?: appContext.currentActivity
97
+ ?: throw Exceptions.ReactContextLost()
98
+ }
99
+
100
+ private fun bestLocalIpv4Address(): String? {
101
+ val candidates = mutableListOf<Inet4Address>()
102
+ val interfaces = NetworkInterface.getNetworkInterfaces()?.toList().orEmpty()
103
+ for (networkInterface in interfaces) {
104
+ if (!networkInterface.isUp || networkInterface.isLoopback) {
105
+ continue
106
+ }
107
+ val addresses = networkInterface.inetAddresses.toList().filterIsInstance<Inet4Address>()
108
+ candidates += addresses.filterNot { it.isLoopbackAddress || it.isLinkLocalAddress }
109
+ }
110
+
111
+ return candidates.firstOrNull { address ->
112
+ val host = address.hostAddress.orEmpty()
113
+ host.startsWith("192.168.") || host.startsWith("10.") || host.matches(Regex("^172\\.(1[6-9]|2[0-9]|3[0-1])\\..*"))
114
+ }?.hostAddress ?: candidates.firstOrNull()?.hostAddress
115
+ }
116
+
117
+ private companion object {
118
+ val PHOTO_PORTS = listOf(8787, 8788, 8789, 8790)
119
+ }
120
+ }
@@ -11,6 +11,7 @@ import android.os.IBinder
11
11
  import androidx.core.app.NotificationCompat
12
12
  import androidx.core.content.ContextCompat
13
13
  import com.mentra.bluetoothsdk.Bridge
14
+ import com.mentra.bluetoothsdk.debug.BleTraceLogger
14
15
 
15
16
  class ForegroundService : Service() {
16
17
  companion object {
@@ -21,11 +22,18 @@ class ForegroundService : Service() {
21
22
  override fun onCreate() {
22
23
  super.onCreate()
23
24
  Bridge.log("ForegroundService: onCreate() called")
25
+ BleTraceLogger.logLifecycle(this, "ForegroundService", "service_create")
24
26
  startForegroundWithAutoDetectedType()
25
27
  }
26
28
 
27
29
  override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
28
30
  Bridge.log("ForegroundService: onStartCommand() called")
31
+ BleTraceLogger.logLifecycle(
32
+ this,
33
+ "ForegroundService",
34
+ "service_start_command",
35
+ mapOf("action" to intent?.action, "flags" to flags, "startId" to startId)
36
+ )
29
37
  // Re-check permissions in case they changed
30
38
  startForegroundWithAutoDetectedType()
31
39
  return START_STICKY
@@ -171,5 +179,10 @@ class ForegroundService : Service() {
171
179
  }
172
180
  }
173
181
 
182
+ override fun onDestroy() {
183
+ BleTraceLogger.logLifecycle(this, "ForegroundService", "service_destroy")
184
+ super.onDestroy()
185
+ }
186
+
174
187
  override fun onBind(intent: Intent?): IBinder? = null
175
188
  }
@@ -1530,7 +1530,7 @@ public class G1 extends SGCManager {
1530
1530
  }
1531
1531
 
1532
1532
  @Override
1533
- public void requestPhoto(String requestId, String appId, String size, String webhookUrl, String authToken, String compress, boolean flash, boolean sound, Long exposureTimeNs) {
1533
+ public void requestPhoto(String requestId, String appId, String size, String webhookUrl, String authToken, String compress, boolean flash, boolean sound, Long exposureTimeNs, Integer iso) {
1534
1534
 
1535
1535
  }
1536
1536
 
@@ -2362,6 +2362,7 @@ class G2 : SGCManager() {
2362
2362
  flash: Boolean,
2363
2363
  sound: Boolean,
2364
2364
  exposureTimeNs: Long?,
2365
+ iso: Int?,
2365
2366
  ) {
2366
2367
  Bridge.log("G2: requestPhoto - not supported (no camera)")
2367
2368
  }
@@ -132,7 +132,7 @@ public class Mach1 extends SGCManager {
132
132
  }
133
133
 
134
134
  @Override
135
- public void requestPhoto(@NonNull String requestId, @NonNull String appId, @NonNull String size, @Nullable String webhookUrl, @Nullable String authToken, @Nullable String compress, boolean flash, boolean sound, @Nullable Long exposureTimeNs) {
135
+ public void requestPhoto(@NonNull String requestId, @NonNull String appId, @NonNull String size, @Nullable String webhookUrl, @Nullable String authToken, @Nullable String compress, boolean flash, boolean sound, @Nullable Long exposureTimeNs, @Nullable Integer iso) {
136
136
 
137
137
  }
138
138