@mentra/bluetooth-sdk 0.1.7 → 0.1.9
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 +11 -9
- package/android/.project +28 -0
- package/android/build.gradle +3 -0
- package/android/lc3Lib/.project +28 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +15 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +26 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +4 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +136 -4
- package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +40 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/controllers/ControllerManager.kt +1 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/controllers/R1.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/debug/BleTraceLogger.kt +149 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/events/BluetoothEvents.kt +1 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/photoreceiver/LocalPhotoUploadServer.kt +297 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/photoreceiver/MentraPhotoReceiverModule.kt +120 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +13 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.java +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +1 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Mach1.java +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.java +104 -21
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +1 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Simulated.kt +1 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +128 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunkReassembler.java +105 -0
- package/build/BluetoothSdk.types.d.ts +66 -29
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js +3 -0
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts +2 -1
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js +24 -7
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/index.d.ts +2 -2
- package/build/index.d.ts.map +1 -1
- package/build/index.js +2 -3
- package/build/index.js.map +1 -1
- package/build/photo-receiver/MentraPhotoReceiver.types.d.ts +18 -0
- package/build/photo-receiver/MentraPhotoReceiver.types.d.ts.map +1 -0
- package/build/photo-receiver/MentraPhotoReceiver.types.js +2 -0
- package/build/photo-receiver/MentraPhotoReceiver.types.js.map +1 -0
- package/build/photo-receiver/MentraPhotoReceiverModule.d.ts +10 -0
- package/build/photo-receiver/MentraPhotoReceiverModule.d.ts.map +1 -0
- package/build/photo-receiver/MentraPhotoReceiverModule.js +3 -0
- package/build/photo-receiver/MentraPhotoReceiverModule.js.map +1 -0
- package/build/photo-receiver/MentraPhotoReceiverModule.web.d.ts +10 -0
- package/build/photo-receiver/MentraPhotoReceiverModule.web.d.ts.map +1 -0
- package/build/photo-receiver/MentraPhotoReceiverModule.web.js +12 -0
- package/build/photo-receiver/MentraPhotoReceiverModule.web.js.map +1 -0
- package/build/photo-receiver/index.d.ts +3 -0
- package/build/photo-receiver/index.d.ts.map +1 -0
- package/build/photo-receiver/index.js +3 -0
- package/build/photo-receiver/index.js.map +1 -0
- package/expo-module.config.json +5 -2
- package/ios/BluetoothSdkModule.swift +19 -3
- package/ios/LocalPhotoUploadServer.swift +411 -0
- package/ios/MentraBluetoothSDK.podspec +8 -2
- package/ios/MentraPhotoReceiverModule.swift +147 -0
- package/ios/Source/Bridge.swift +20 -1
- package/ios/Source/Camera/CameraModels.swift +64 -11
- package/ios/Source/DeviceManager.swift +6 -4
- package/ios/Source/Events/BluetoothEvents.swift +3 -0
- package/ios/Source/MentraBluetoothSDK.swift +111 -5
- package/ios/Source/ObservableStore.swift +2 -2
- package/ios/Source/Streaming/StreamModels.swift +234 -40
- package/ios/Source/controllers/ControllerManager.swift +1 -1
- package/ios/Source/controllers/R1.swift +9 -3
- package/ios/Source/sgcs/G1.swift +5 -4
- package/ios/Source/sgcs/G2.swift +4 -4
- package/ios/Source/sgcs/Mach1.swift +1 -1
- package/ios/Source/sgcs/MentraLive.swift +76 -12
- package/ios/Source/sgcs/MentraNex.swift +1 -1
- package/ios/Source/sgcs/SGCManager.swift +1 -1
- package/ios/Source/sgcs/Simulated.swift +1 -1
- package/ios/Source/utils/MessageChunkReassembler.swift +89 -0
- package/package.json +6 -1
- package/src/BluetoothSdk.types.ts +90 -35
- package/src/_private/BluetoothSdkModule.ts +38 -8
- package/src/index.ts +9 -4
- package/src/photo-receiver/MentraPhotoReceiver.types.ts +20 -0
- package/src/photo-receiver/MentraPhotoReceiverModule.ts +14 -0
- package/src/photo-receiver/MentraPhotoReceiverModule.web.ts +20 -0
- 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
|
+
}
|
package/android/src/main/java/com/mentra/bluetoothsdk/photoreceiver/MentraPhotoReceiverModule.kt
ADDED
|
@@ -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
|
|
|
@@ -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
|
|