@mentra/bluetooth-sdk 0.1.17 → 0.1.19
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 +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +52 -4
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +119 -16
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +0 -6
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +22 -21
- package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +488 -15
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +457 -31
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLiveL2capChannel.kt +274 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +361 -67
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraosBle.java +7501 -3704
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +134 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +0 -6
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleJsonCompact.java +493 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadService.java +4 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleWireProtocol.java +108 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/IncidentLogBleUploadService.java +4 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900LengthCodec.java +115 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtils.java +103 -68
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunkReassembler.java +106 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunker.java +93 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/NexSGCUtils.kt +111 -11
- package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +7 -9
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/AvifExifStripperTest.java +17 -1
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/BinaryMessageChunkerTest.java +84 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/BleJsonCompactTest.java +158 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtilsEndiannessTest.java +172 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtilsTest.java +24 -0
- package/build/BluetoothSdk.types.d.ts +12 -1
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/types/index.d.ts +3 -0
- package/build/types/index.d.ts.map +1 -0
- package/build/types/index.js +2 -0
- package/build/types/index.js.map +1 -0
- package/ios/LocalPhotoUploadServer.swift +78 -0
- package/ios/MentraPhotoReceiverModule.swift +10 -0
- package/ios/Source/BluetoothSdkDefaults.swift +1 -1
- package/ios/Source/Bridge.swift +63 -0
- package/ios/Source/DeviceManager.swift +134 -29
- package/ios/Source/DeviceStore.swift +0 -5
- package/ios/Source/MentraBluetoothSDK.swift +26 -23
- package/ios/Source/OtaManifest.swift +1 -1
- package/ios/Source/internal/BleTraceLogger.swift +192 -0
- package/ios/Source/sgcs/G2.swift +518 -11
- package/ios/Source/sgcs/MentraLive.swift +607 -39
- package/ios/Source/sgcs/MentraNex.swift +416 -32
- package/ios/Source/sgcs/SGCManager.swift +155 -0
- package/ios/Source/sgcs/mentraos_ble.pb.swift +726 -122
- package/ios/Source/status/DeviceStatus.swift +0 -8
- package/ios/Source/utils/BleJsonCompact.swift +395 -0
- package/ios/Source/utils/BleWireProtocol.swift +92 -0
- package/ios/Source/utils/MessageChunkReassembler.swift +82 -0
- package/ios/Source/utils/MessageChunker.swift +73 -0
- package/package.json +14 -7
- package/src/BluetoothSdk.types.ts +13 -1
- package/src/types/index.ts +7 -0
|
@@ -7,6 +7,7 @@ import org.json.JSONObject;
|
|
|
7
7
|
|
|
8
8
|
import java.nio.charset.StandardCharsets;
|
|
9
9
|
import java.util.ArrayList;
|
|
10
|
+
import java.util.Arrays;
|
|
10
11
|
import java.util.List;
|
|
11
12
|
|
|
12
13
|
/**
|
|
@@ -34,6 +35,8 @@ public class MessageChunker {
|
|
|
34
35
|
private static final int INITIAL_CHUNK_DATA_SIZE = 80;
|
|
35
36
|
private static final int MIN_CHUNK_DATA_SIZE = 4;
|
|
36
37
|
private static final int MAX_PACKED_CHUNK_SIZE = 253;
|
|
38
|
+
private static final int MAX_BINARY_FRAGMENT_PAYLOAD = BleWireProtocol.MAX_FRAGMENT_PAYLOAD;
|
|
39
|
+
private static final int MAX_BINARY_FRAME_SIZE = BleWireProtocol.MTU_TARGET;
|
|
37
40
|
|
|
38
41
|
/**
|
|
39
42
|
* Check if a message needs to be chunked
|
|
@@ -207,6 +210,96 @@ public class MessageChunker {
|
|
|
207
210
|
return json.optInt(compactKey, defaultValue);
|
|
208
211
|
}
|
|
209
212
|
|
|
213
|
+
public static boolean needsBinaryFragmenting(byte[] payload) {
|
|
214
|
+
return payload != null && payload.length > MAX_BINARY_FRAGMENT_PAYLOAD;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
public static boolean needsBinaryFragmenting(String json) {
|
|
218
|
+
if (json == null) {
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
return needsBinaryFragmenting(json.getBytes(StandardCharsets.UTF_8));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Split raw UTF-8 payload into binary wire fragments (v2 path).
|
|
226
|
+
*/
|
|
227
|
+
public static List<BinaryFragment> createBinaryFragments(
|
|
228
|
+
byte[] payload, int msgId, boolean wakeup, boolean ackRequested) {
|
|
229
|
+
if (payload == null) {
|
|
230
|
+
throw new IllegalArgumentException("Cannot fragment null payload");
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
int fragCount = (payload.length + MAX_BINARY_FRAGMENT_PAYLOAD - 1) / MAX_BINARY_FRAGMENT_PAYLOAD;
|
|
234
|
+
if (fragCount > 255) {
|
|
235
|
+
throw new IllegalArgumentException("Payload too large for binary fragmentation");
|
|
236
|
+
}
|
|
237
|
+
if (fragCount == 0) {
|
|
238
|
+
fragCount = 1;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
List<BinaryFragment> fragments = new ArrayList<>(fragCount);
|
|
242
|
+
for (int i = 0; i < fragCount; i++) {
|
|
243
|
+
int offset = i * MAX_BINARY_FRAGMENT_PAYLOAD;
|
|
244
|
+
int len = Math.min(MAX_BINARY_FRAGMENT_PAYLOAD, payload.length - offset);
|
|
245
|
+
byte[] fragPayload = Arrays.copyOfRange(payload, offset, offset + len);
|
|
246
|
+
|
|
247
|
+
byte flags = 0;
|
|
248
|
+
if (i == 0) {
|
|
249
|
+
flags |= BleWireProtocol.BLE_WIRE_FLAG_FIRST_FRAG;
|
|
250
|
+
}
|
|
251
|
+
if (i == fragCount - 1) {
|
|
252
|
+
flags |= BleWireProtocol.BLE_WIRE_FLAG_LAST_FRAG;
|
|
253
|
+
}
|
|
254
|
+
if (wakeup && i == 0) {
|
|
255
|
+
flags |= BleWireProtocol.BLE_WIRE_FLAG_WAKE;
|
|
256
|
+
}
|
|
257
|
+
if (ackRequested && i == fragCount - 1) {
|
|
258
|
+
flags |= BleWireProtocol.BLE_WIRE_FLAG_ACK_REQUESTED;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
fragments.add(new BinaryFragment(flags, msgId, i, fragCount, fragPayload));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
Log.d(TAG, "Created " + fragments.size() + " binary fragments for "
|
|
265
|
+
+ payload.length + " byte payload");
|
|
266
|
+
return fragments;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
public static boolean allBinaryFragmentsFit(List<BinaryFragment> fragments) {
|
|
270
|
+
for (BinaryFragment fragment : fragments) {
|
|
271
|
+
byte[] packed = K900ProtocolUtils.packBinaryFragment(
|
|
272
|
+
fragment.flags,
|
|
273
|
+
fragment.msgId,
|
|
274
|
+
fragment.fragIdx,
|
|
275
|
+
fragment.fragCount,
|
|
276
|
+
fragment.payload);
|
|
277
|
+
if (packed == null || packed.length > MAX_BINARY_FRAME_SIZE) {
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Container for binary wire fragment metadata
|
|
286
|
+
*/
|
|
287
|
+
public static class BinaryFragment {
|
|
288
|
+
public final byte flags;
|
|
289
|
+
public final int msgId;
|
|
290
|
+
public final int fragIdx;
|
|
291
|
+
public final int fragCount;
|
|
292
|
+
public final byte[] payload;
|
|
293
|
+
|
|
294
|
+
public BinaryFragment(byte flags, int msgId, int fragIdx, int fragCount, byte[] payload) {
|
|
295
|
+
this.flags = flags;
|
|
296
|
+
this.msgId = msgId;
|
|
297
|
+
this.fragIdx = fragIdx;
|
|
298
|
+
this.fragCount = fragCount;
|
|
299
|
+
this.payload = payload;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
210
303
|
/**
|
|
211
304
|
* Container for chunk information
|
|
212
305
|
*/
|
|
@@ -15,6 +15,12 @@ import mentraos.ble.MentraosBle.ClearDisplay
|
|
|
15
15
|
import mentraos.ble.MentraosBle.DisconnectRequest
|
|
16
16
|
import mentraos.ble.MentraosBle.PhoneToGlasses
|
|
17
17
|
import mentraos.ble.MentraosBle.DisplayImage
|
|
18
|
+
import mentraos.ble.MentraosBle.CanvasCreateComponent
|
|
19
|
+
import mentraos.ble.MentraosBle.CanvasUpdateImage
|
|
20
|
+
import mentraos.ble.MentraosBle.CanvasUpdateText
|
|
21
|
+
import mentraos.ble.MentraosBle.CanvasDeleteComponent
|
|
22
|
+
import mentraos.ble.MentraosBle.CanvasClear
|
|
23
|
+
import mentraos.ble.MentraosBle.CanvasComponentType
|
|
18
24
|
import mentraos.ble.MentraosBle.BatteryStateRequest
|
|
19
25
|
import mentraos.ble.MentraosBle.MicStateConfig
|
|
20
26
|
import mentraos.ble.MentraosBle.BrightnessConfig
|
|
@@ -61,6 +67,25 @@ object NexProtobufUtils {
|
|
|
61
67
|
|
|
62
68
|
private const val WHITELIST_CMD: Int = 0x04
|
|
63
69
|
|
|
70
|
+
// Characters the Nex font can render: letters, digits, whitespace, and a small
|
|
71
|
+
// punctuation set. Everything else (CJK, emoji, smart quotes, …) is stripped when
|
|
72
|
+
// Chinese captions are off. Compiled once — sanitizeDisplayText runs on every
|
|
73
|
+
// caption update (high frequency), so we don't rebuild it per call.
|
|
74
|
+
private val UNSUPPORTED_GLYPH_REGEX = Regex("""[^A-Za-z0-9 \r\n.,!?;:\-\[\]\(\)\{\}'"+=/]""")
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Sanitize text bound for the glasses. When Chinese captions are disabled (the
|
|
78
|
+
* default) the Nex font can't render CJK/emoji/etc., so em-dashes are normalised
|
|
79
|
+
* to hyphens and any unsupported glyph is dropped. When enabled, text passes
|
|
80
|
+
* through untouched. Every text path that reaches the display funnels through
|
|
81
|
+
* here so the filter behaves identically for captions, text walls, and layouts.
|
|
82
|
+
*/
|
|
83
|
+
private fun sanitizeDisplayText(text: String): String {
|
|
84
|
+
val chineseCaptionsEnabled = DeviceStore.get("bluetooth", "nex_chinese_captions") as? Boolean ?: false
|
|
85
|
+
if (chineseCaptionsEnabled) return text
|
|
86
|
+
return text.replace("—", "-").replace(UNSUPPORTED_GLYPH_REGEX, "")
|
|
87
|
+
}
|
|
88
|
+
|
|
64
89
|
/**
|
|
65
90
|
* Maps dashboard depth to the value Nex firmware expects in [DisplayDistanceConfig.distance_cm].
|
|
66
91
|
* The protobuf field is still named `distance_cm`, but Nex treats it as a **tier** 1–4, not centimeters.
|
|
@@ -241,22 +266,97 @@ object NexProtobufUtils {
|
|
|
241
266
|
return generateProtobufCommandBytes(phoneToGlasses)
|
|
242
267
|
}
|
|
243
268
|
|
|
269
|
+
/**
|
|
270
|
+
* Canvas: create (or replace) a full-screen bitmap component. The firmware infers the bitmap
|
|
271
|
+
* pool from the id (10..13); we use a single fixed id for the full-screen image. Pixels follow
|
|
272
|
+
* via [generateCanvasUpdateImageCommandBytes] + the 0xB0 chunk stream.
|
|
273
|
+
*/
|
|
274
|
+
fun generateCanvasCreateBitmapCommandBytes(id: Int, x: Int, y: Int, width: Int, height: Int): ByteArray {
|
|
275
|
+
// Negative geometry would serialize as a huge unsigned varint on the
|
|
276
|
+
// wire — clamp to the drawable range before it reaches the firmware.
|
|
277
|
+
val create = CanvasCreateComponent.newBuilder()
|
|
278
|
+
.setId(id)
|
|
279
|
+
.setType(CanvasComponentType.CANVAS_BITMAP)
|
|
280
|
+
.setX(x.coerceAtLeast(0))
|
|
281
|
+
.setY(y.coerceAtLeast(0))
|
|
282
|
+
.setWidth(width.coerceAtLeast(1))
|
|
283
|
+
.setHeight(height.coerceAtLeast(1))
|
|
284
|
+
.build()
|
|
285
|
+
val phoneToGlasses = PhoneToGlasses.newBuilder()
|
|
286
|
+
.setCanvasCreateComponent(create)
|
|
287
|
+
.build()
|
|
288
|
+
return generateProtobufCommandBytes(phoneToGlasses)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Canvas: create (or replace) a text component (TEXTBOX). Content follows via CanvasUpdateText. */
|
|
292
|
+
fun generateCanvasCreateTextboxCommandBytes(
|
|
293
|
+
id: Int, x: Int, y: Int, width: Int, height: Int, borderWidth: Int, borderRadius: Int
|
|
294
|
+
): ByteArray {
|
|
295
|
+
val create = CanvasCreateComponent.newBuilder()
|
|
296
|
+
.setId(id)
|
|
297
|
+
.setType(CanvasComponentType.CANVAS_TEXTBOX)
|
|
298
|
+
.setX(x)
|
|
299
|
+
.setY(y)
|
|
300
|
+
.setWidth(width)
|
|
301
|
+
.setHeight(height)
|
|
302
|
+
.setBorderWidth(borderWidth)
|
|
303
|
+
.setBorderRadius(borderRadius)
|
|
304
|
+
.build()
|
|
305
|
+
val phoneToGlasses = PhoneToGlasses.newBuilder()
|
|
306
|
+
.setCanvasCreateComponent(create)
|
|
307
|
+
.build()
|
|
308
|
+
return generateProtobufCommandBytes(phoneToGlasses)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Canvas: set the text of a TEXTBOX / SCROLL_TEXTBOX component. */
|
|
312
|
+
fun generateCanvasUpdateTextCommandBytes(id: Int, text: String, scrollOffset: Int = 0): ByteArray {
|
|
313
|
+
val update = CanvasUpdateText.newBuilder()
|
|
314
|
+
.setId(id)
|
|
315
|
+
.setText(sanitizeDisplayText(text))
|
|
316
|
+
.setScrollOffset(scrollOffset)
|
|
317
|
+
.build()
|
|
318
|
+
val phoneToGlasses = PhoneToGlasses.newBuilder()
|
|
319
|
+
.setCanvasUpdateText(update)
|
|
320
|
+
.build()
|
|
321
|
+
return generateProtobufCommandBytes(phoneToGlasses)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Canvas: delete a single component by id. */
|
|
325
|
+
fun generateCanvasDeleteComponentCommandBytes(id: Int): ByteArray {
|
|
326
|
+
val del = CanvasDeleteComponent.newBuilder().setId(id).build()
|
|
327
|
+
val phoneToGlasses = PhoneToGlasses.newBuilder()
|
|
328
|
+
.setCanvasDeleteComponent(del)
|
|
329
|
+
.build()
|
|
330
|
+
return generateProtobufCommandBytes(phoneToGlasses)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Canvas: delete all components and exit the canvas view. */
|
|
334
|
+
fun generateCanvasClearCommandBytes(): ByteArray {
|
|
335
|
+
val phoneToGlasses = PhoneToGlasses.newBuilder()
|
|
336
|
+
.setCanvasClear(CanvasClear.newBuilder().build())
|
|
337
|
+
.build()
|
|
338
|
+
return generateProtobufCommandBytes(phoneToGlasses)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Canvas: begin streaming a 1-bit BMP into an existing bitmap component. */
|
|
342
|
+
fun generateCanvasUpdateImageCommandBytes(id: Int, streamId: String, totalChunks: Int): ByteArray {
|
|
343
|
+
val update = CanvasUpdateImage.newBuilder()
|
|
344
|
+
.setId(id)
|
|
345
|
+
.setStreamId(streamId)
|
|
346
|
+
.setTotalChunks(totalChunks)
|
|
347
|
+
.build()
|
|
348
|
+
val phoneToGlasses = PhoneToGlasses.newBuilder()
|
|
349
|
+
.setCanvasUpdateImage(update)
|
|
350
|
+
.build()
|
|
351
|
+
return generateProtobufCommandBytes(phoneToGlasses)
|
|
352
|
+
}
|
|
353
|
+
|
|
244
354
|
fun generateDisplayTextCommandBytes(text: String): ByteArray {
|
|
245
355
|
Bridge.log("Nex: === SENDING TEXT TO GLASSES ===")
|
|
246
356
|
Bridge.log("Nex: Text: \"$text\"")
|
|
247
357
|
Bridge.log("Nex: Text Length: ${text.length} characters")
|
|
248
358
|
|
|
249
|
-
|
|
250
|
-
// can't render (CJK etc.): replace m-dashes, then keep only the supported ASCII set.
|
|
251
|
-
val chineseCaptionsEnabled = DeviceStore.get("bluetooth", "nex_chinese_captions") as? Boolean ?: false
|
|
252
|
-
val displayText = if (chineseCaptionsEnabled) {
|
|
253
|
-
text
|
|
254
|
-
} else {
|
|
255
|
-
text.replace("—", "-").replace(
|
|
256
|
-
Regex("""[^A-Za-z0-9 \r\n.,!?;:\-\[\]\(\)\{\}'"+=/]"""),
|
|
257
|
-
""
|
|
258
|
-
)
|
|
259
|
-
}
|
|
359
|
+
val displayText = sanitizeDisplayText(text)
|
|
260
360
|
|
|
261
361
|
val textNewBuilder = DisplayText.newBuilder()
|
|
262
362
|
.setColor(10000)
|
|
@@ -3,10 +3,8 @@ package com.mentra.bluetoothsdk.camera
|
|
|
3
3
|
import com.mentra.bluetoothsdk.PhotoCompression
|
|
4
4
|
import com.mentra.bluetoothsdk.PhotoRequest
|
|
5
5
|
import com.mentra.bluetoothsdk.PhotoSize
|
|
6
|
-
import
|
|
7
|
-
import
|
|
8
|
-
import kotlin.test.assertNull
|
|
9
|
-
import kotlin.test.assertTrue
|
|
6
|
+
import org.assertj.core.api.Assertions.assertThat
|
|
7
|
+
import org.junit.Test
|
|
10
8
|
|
|
11
9
|
class PhotoRequestTest {
|
|
12
10
|
@Test
|
|
@@ -19,7 +17,7 @@ class PhotoRequestTest {
|
|
|
19
17
|
sound = true,
|
|
20
18
|
)
|
|
21
19
|
|
|
22
|
-
|
|
20
|
+
assertThat(request.requestId).startsWith("photo-")
|
|
23
21
|
}
|
|
24
22
|
|
|
25
23
|
@Test
|
|
@@ -35,7 +33,7 @@ class PhotoRequestTest {
|
|
|
35
33
|
)
|
|
36
34
|
)
|
|
37
35
|
|
|
38
|
-
|
|
36
|
+
assertThat(request.exposureTimeNs).isNull()
|
|
39
37
|
}
|
|
40
38
|
|
|
41
39
|
@Test
|
|
@@ -60,8 +58,8 @@ class PhotoRequestTest {
|
|
|
60
58
|
)
|
|
61
59
|
)
|
|
62
60
|
|
|
63
|
-
|
|
64
|
-
|
|
61
|
+
assertThat(withoutRequestId.requestId).startsWith("photo-")
|
|
62
|
+
assertThat(blankRequestId.requestId).startsWith("photo-")
|
|
65
63
|
}
|
|
66
64
|
|
|
67
65
|
@Test
|
|
@@ -77,6 +75,6 @@ class PhotoRequestTest {
|
|
|
77
75
|
)
|
|
78
76
|
)
|
|
79
77
|
|
|
80
|
-
|
|
78
|
+
assertThat(request.requestId).isEqualTo("photo-1")
|
|
81
79
|
}
|
|
82
80
|
}
|
|
@@ -19,12 +19,28 @@ public class AvifExifStripperTest {
|
|
|
19
19
|
java.nio.file.Files.readAllBytes(
|
|
20
20
|
new File("src/test/resources/avif_with_exif.avif").toPath());
|
|
21
21
|
assertThat(BlePhotoUploadService.containsExifMarkerInBytes(withExif)).isTrue();
|
|
22
|
+
int originalMarkerCount = countExifMarkers(withExif);
|
|
22
23
|
|
|
23
24
|
byte[] stripped = AvifExifStripper.stripForDecode(withExif);
|
|
24
25
|
assertThat(stripped.length).isLessThan(withExif.length);
|
|
25
|
-
assertThat(
|
|
26
|
+
assertThat(countExifMarkers(stripped)).isLessThan(originalMarkerCount);
|
|
26
27
|
|
|
27
28
|
byte[] jpeg = BlePhotoUploadService.convertToJpegPreservingExif(withExif);
|
|
28
29
|
assertThat(jpeg.length).isGreaterThan(100);
|
|
29
30
|
}
|
|
31
|
+
|
|
32
|
+
private static int countExifMarkers(byte[] data) {
|
|
33
|
+
byte[] marker = new byte[] {'E', 'x', 'i', 'f', 0, 0};
|
|
34
|
+
int count = 0;
|
|
35
|
+
outer:
|
|
36
|
+
for (int i = 0; i <= data.length - marker.length; i++) {
|
|
37
|
+
for (int j = 0; j < marker.length; j++) {
|
|
38
|
+
if (data[i + j] != marker[j]) {
|
|
39
|
+
continue outer;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
count++;
|
|
43
|
+
}
|
|
44
|
+
return count;
|
|
45
|
+
}
|
|
30
46
|
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
package com.mentra.bluetoothsdk.utils;
|
|
2
|
+
|
|
3
|
+
import static org.junit.Assert.assertEquals;
|
|
4
|
+
import static org.junit.Assert.assertFalse;
|
|
5
|
+
import static org.junit.Assert.assertNotNull;
|
|
6
|
+
import static org.junit.Assert.assertTrue;
|
|
7
|
+
|
|
8
|
+
import org.junit.Test;
|
|
9
|
+
import org.junit.runner.RunWith;
|
|
10
|
+
import org.robolectric.RobolectricTestRunner;
|
|
11
|
+
import org.robolectric.annotation.Config;
|
|
12
|
+
|
|
13
|
+
import java.nio.charset.StandardCharsets;
|
|
14
|
+
import java.util.List;
|
|
15
|
+
|
|
16
|
+
@RunWith(RobolectricTestRunner.class)
|
|
17
|
+
@Config(sdk = 33)
|
|
18
|
+
public class BinaryMessageChunkerTest {
|
|
19
|
+
|
|
20
|
+
@Test
|
|
21
|
+
public void smallPayloadProducesSingleFragment() {
|
|
22
|
+
byte[] payload = "{\"type\":\"ping\"}".getBytes(StandardCharsets.UTF_8);
|
|
23
|
+
|
|
24
|
+
assertFalse(MessageChunker.needsBinaryFragmenting(payload));
|
|
25
|
+
|
|
26
|
+
List<MessageChunker.BinaryFragment> fragments =
|
|
27
|
+
MessageChunker.createBinaryFragments(payload, 42, false, false);
|
|
28
|
+
|
|
29
|
+
assertEquals(1, fragments.size());
|
|
30
|
+
assertEquals(42, fragments.get(0).msgId);
|
|
31
|
+
assertEquals(0, fragments.get(0).fragIdx);
|
|
32
|
+
assertEquals(1, fragments.get(0).fragCount);
|
|
33
|
+
assertTrue(MessageChunker.allBinaryFragmentsFit(fragments));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
@Test
|
|
37
|
+
public void largePayloadSplitsIntoMultipleFragments() {
|
|
38
|
+
byte[] payload = new byte[1000];
|
|
39
|
+
for (int i = 0; i < payload.length; i++) {
|
|
40
|
+
payload[i] = (byte) ('a' + (i % 26));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
assertTrue(MessageChunker.needsBinaryFragmenting(payload));
|
|
44
|
+
|
|
45
|
+
List<MessageChunker.BinaryFragment> fragments =
|
|
46
|
+
MessageChunker.createBinaryFragments(payload, 7, true, true);
|
|
47
|
+
|
|
48
|
+
assertEquals(3, fragments.size());
|
|
49
|
+
assertEquals(7, fragments.get(0).msgId);
|
|
50
|
+
assertEquals(0, fragments.get(0).fragIdx);
|
|
51
|
+
assertEquals(2, fragments.get(2).fragIdx);
|
|
52
|
+
assertEquals(3, fragments.get(0).fragCount);
|
|
53
|
+
assertTrue((fragments.get(0).flags & BleWireProtocol.BLE_WIRE_FLAG_WAKE) != 0);
|
|
54
|
+
assertTrue((fragments.get(2).flags & BleWireProtocol.BLE_WIRE_FLAG_ACK_REQUESTED) != 0);
|
|
55
|
+
assertTrue(MessageChunker.allBinaryFragmentsFit(fragments));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
@Test
|
|
59
|
+
public void binaryReassemblerRebuildsPayload() {
|
|
60
|
+
byte[] payload = new byte[900];
|
|
61
|
+
for (int i = 0; i < payload.length; i++) {
|
|
62
|
+
payload[i] = (byte) (i & 0xFF);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
List<MessageChunker.BinaryFragment> fragments =
|
|
66
|
+
MessageChunker.createBinaryFragments(payload, 99, false, false);
|
|
67
|
+
MessageChunkReassembler reassembler = new MessageChunkReassembler();
|
|
68
|
+
|
|
69
|
+
byte[] rebuilt = null;
|
|
70
|
+
for (MessageChunker.BinaryFragment fragment : fragments) {
|
|
71
|
+
rebuilt = reassembler.addBinaryFragment(
|
|
72
|
+
fragment.msgId,
|
|
73
|
+
fragment.fragIdx,
|
|
74
|
+
fragment.fragCount,
|
|
75
|
+
fragment.payload);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
assertNotNull(rebuilt);
|
|
79
|
+
assertEquals(payload.length, rebuilt.length);
|
|
80
|
+
for (int i = 0; i < payload.length; i++) {
|
|
81
|
+
assertEquals(payload[i], rebuilt[i]);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
package com.mentra.bluetoothsdk.utils;
|
|
2
|
+
|
|
3
|
+
import static org.junit.Assert.assertEquals;
|
|
4
|
+
import static org.junit.Assert.assertFalse;
|
|
5
|
+
import static org.junit.Assert.assertTrue;
|
|
6
|
+
|
|
7
|
+
import org.json.JSONObject;
|
|
8
|
+
import org.junit.After;
|
|
9
|
+
import org.junit.Test;
|
|
10
|
+
import org.junit.runner.RunWith;
|
|
11
|
+
import org.robolectric.RobolectricTestRunner;
|
|
12
|
+
import org.robolectric.annotation.Config;
|
|
13
|
+
|
|
14
|
+
@RunWith(RobolectricTestRunner.class)
|
|
15
|
+
@Config(sdk = 33)
|
|
16
|
+
public class BleJsonCompactTest {
|
|
17
|
+
|
|
18
|
+
@After
|
|
19
|
+
public void tearDown() {
|
|
20
|
+
BleJsonCompact.resetSession();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
@Test
|
|
24
|
+
public void encodeShortensKeysAndEnums() throws Exception {
|
|
25
|
+
BleJsonCompact.markSessionConnected(1_700_000_000_000L);
|
|
26
|
+
JSONObject input =
|
|
27
|
+
new JSONObject(
|
|
28
|
+
"{"
|
|
29
|
+
+ "\"type\":\"photo_status\","
|
|
30
|
+
+ "\"requestId\":\"p1\","
|
|
31
|
+
+ "\"status\":\"capturing\","
|
|
32
|
+
+ "\"timestamp\":1700000000100,"
|
|
33
|
+
+ "\"reconnecting\":false,"
|
|
34
|
+
+ "\"captureMetadata\":{"
|
|
35
|
+
+ "\"aeStateName\":\"SEARCHING\","
|
|
36
|
+
+ "\"source\":\"button\""
|
|
37
|
+
+ "}"
|
|
38
|
+
+ "}");
|
|
39
|
+
|
|
40
|
+
JSONObject compact = BleJsonCompact.encode(input);
|
|
41
|
+
|
|
42
|
+
assertTrue(compact.has("t"));
|
|
43
|
+
assertEquals("photo_status", compact.getString("t"));
|
|
44
|
+
assertEquals("p1", compact.getString("r"));
|
|
45
|
+
assertEquals(3, compact.getInt("s"));
|
|
46
|
+
assertEquals(100L, compact.getLong("ts"));
|
|
47
|
+
assertFalse(compact.has("rc"));
|
|
48
|
+
assertEquals(1, compact.getJSONObject("cm").getInt("aes"));
|
|
49
|
+
assertEquals(1, compact.getJSONObject("cm").getInt("src"));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
@Test
|
|
53
|
+
public void decodeRestoresVerboseJson() throws Exception {
|
|
54
|
+
BleJsonCompact.markSessionConnected(1_700_000_000_000L);
|
|
55
|
+
JSONObject compact =
|
|
56
|
+
new JSONObject(
|
|
57
|
+
"{"
|
|
58
|
+
+ "\"t\":\"photo_status\","
|
|
59
|
+
+ "\"r\":\"p1\","
|
|
60
|
+
+ "\"s\":4,"
|
|
61
|
+
+ "\"ts\":42,"
|
|
62
|
+
+ "\"cm\":{\"aes\":0,\"etn\":8333333}"
|
|
63
|
+
+ "}");
|
|
64
|
+
|
|
65
|
+
JSONObject restored = BleJsonCompact.decode(compact);
|
|
66
|
+
|
|
67
|
+
assertEquals("photo_status", restored.getString("type"));
|
|
68
|
+
assertEquals("p1", restored.getString("requestId"));
|
|
69
|
+
assertEquals("captured", restored.getString("status"));
|
|
70
|
+
assertEquals(1_700_000_000_042L, restored.getLong("timestamp"));
|
|
71
|
+
assertEquals("CONVERGED", restored.getJSONObject("captureMetadata").getString("aeStateName"));
|
|
72
|
+
assertEquals(8333333L, restored.getJSONObject("captureMetadata").getLong("exposureTimeNs"));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
@Test
|
|
76
|
+
public void resolvedConfigDiffOmitsRepeatPayload() throws Exception {
|
|
77
|
+
BleJsonCompact.markSessionConnected(1_000L);
|
|
78
|
+
JSONObject config = new JSONObject("{\"source\":\"sdk\",\"transferMethod\":\"auto\"}");
|
|
79
|
+
JSONObject first =
|
|
80
|
+
new JSONObject(
|
|
81
|
+
"{"
|
|
82
|
+
+ "\"type\":\"stream_status\","
|
|
83
|
+
+ "\"status\":\"streaming\","
|
|
84
|
+
+ "\"timestamp\":1000,"
|
|
85
|
+
+ "\"resolvedConfig\":"
|
|
86
|
+
+ config
|
|
87
|
+
+ "}");
|
|
88
|
+
JSONObject second =
|
|
89
|
+
new JSONObject(
|
|
90
|
+
"{"
|
|
91
|
+
+ "\"type\":\"stream_status\","
|
|
92
|
+
+ "\"status\":\"streaming\","
|
|
93
|
+
+ "\"timestamp\":1200,"
|
|
94
|
+
+ "\"resolvedConfig\":"
|
|
95
|
+
+ config
|
|
96
|
+
+ "}");
|
|
97
|
+
|
|
98
|
+
JSONObject firstWire = BleJsonCompact.encode(first);
|
|
99
|
+
JSONObject secondWire = BleJsonCompact.encode(second);
|
|
100
|
+
|
|
101
|
+
assertTrue(firstWire.has("resolvedConfig"));
|
|
102
|
+
assertFalse(secondWire.has("resolvedConfig"));
|
|
103
|
+
assertEquals(
|
|
104
|
+
BleJsonCompact.hashConfig(config),
|
|
105
|
+
secondWire.getString(BleJsonCompact.KEY_RESOLVED_CONFIG_HASH));
|
|
106
|
+
|
|
107
|
+
JSONObject restoredSecond = BleJsonCompact.decode(secondWire);
|
|
108
|
+
assertEquals("sdk", restoredSecond.getJSONObject("resolvedConfig").getString("source"));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
@Test
|
|
112
|
+
public void cameraCommandsAreLeftUntouched() throws Exception {
|
|
113
|
+
String camera = "{\"type\":\"take_photo\",\"requestId\":\"1\"}";
|
|
114
|
+
JSONObject encoded = BleJsonCompact.encode(camera);
|
|
115
|
+
assertTrue(encoded.has("type"));
|
|
116
|
+
assertEquals("take_photo", encoded.getString("type"));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
@Test
|
|
120
|
+
public void lowRoiCommandsStayExpandedOutbound() throws Exception {
|
|
121
|
+
JSONObject ping = new JSONObject("{\"type\":\"ping\"}");
|
|
122
|
+
JSONObject transfer = new JSONObject("{\"type\":\"transfer_complete\",\"requestId\":\"1\"}");
|
|
123
|
+
|
|
124
|
+
assertTrue(BleJsonCompact.encode(ping).has("type"));
|
|
125
|
+
assertFalse(BleJsonCompact.encode(ping).has("t"));
|
|
126
|
+
assertTrue(BleJsonCompact.encode(transfer).has("type"));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
@Test
|
|
130
|
+
public void highRoiCommandsCompactOutbound() throws Exception {
|
|
131
|
+
JSONObject photoResponse = new JSONObject("{\"type\":\"photo_response\",\"status\":\"ok\"}");
|
|
132
|
+
JSONObject wifiScan = new JSONObject("{\"type\":\"wifi_scan_result\",\"networks\":[]}");
|
|
133
|
+
|
|
134
|
+
assertEquals("photo_response", BleJsonCompact.encode(photoResponse).getString("t"));
|
|
135
|
+
assertEquals("wifi_scan_result", BleJsonCompact.encode(wifiScan).getString("t"));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
@Test
|
|
139
|
+
public void decodeIfSupported_rejectsCompactLowRoi() throws Exception {
|
|
140
|
+
JSONObject compactPing = new JSONObject("{\"t\":\"ping\"}");
|
|
141
|
+
|
|
142
|
+
assertEquals(null, BleJsonCompact.decodeIfSupported(compactPing));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
@Test
|
|
146
|
+
public void decodeIfSupported_acceptsExpandedLowRoi() throws Exception {
|
|
147
|
+
JSONObject expandedPing = new JSONObject("{\"type\":\"ping\"}");
|
|
148
|
+
|
|
149
|
+
assertEquals("ping", BleJsonCompact.decodeIfSupported(expandedPing).getString("type"));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
@Test
|
|
153
|
+
public void decodeIfSupported_acceptsCompactChunkEnvelope() throws Exception {
|
|
154
|
+
JSONObject chunk = new JSONObject("{\"t\":\"ck\",\"id\":\"1\",\"c\":0,\"n\":2,\"d\":\"x\"}");
|
|
155
|
+
|
|
156
|
+
assertEquals("ck", BleJsonCompact.decodeIfSupported(chunk).getString("type"));
|
|
157
|
+
}
|
|
158
|
+
}
|