@mentra/bluetooth-sdk 0.1.18 → 0.1.20

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 (77) hide show
  1. package/README.md +1 -1
  2. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +135 -51
  3. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +120 -17
  4. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +5 -6
  5. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +34 -21
  6. package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +1 -1
  7. package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +13 -0
  8. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +1 -1
  9. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizer.kt +38 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +488 -15
  11. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +551 -159
  12. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLiveL2capChannel.kt +274 -0
  13. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +361 -67
  14. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraosBle.java +7501 -3704
  15. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +134 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +0 -6
  17. package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +2 -3
  18. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleJsonCompact.java +493 -0
  19. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadService.java +65 -5
  20. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleWireProtocol.java +108 -0
  21. package/android/src/main/java/com/mentra/bluetoothsdk/utils/IncidentLogBleUploadService.java +4 -3
  22. package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900LengthCodec.java +115 -0
  23. package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtils.java +109 -73
  24. package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunkReassembler.java +106 -0
  25. package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunker.java +93 -0
  26. package/android/src/main/java/com/mentra/bluetoothsdk/utils/NexSGCUtils.kt +111 -11
  27. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkExceptionTest.kt +18 -0
  28. package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +23 -9
  29. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizerTest.kt +31 -0
  30. package/android/src/test/java/com/mentra/bluetoothsdk/utils/AvifExifStripperTest.java +17 -1
  31. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BinaryMessageChunkerTest.java +84 -0
  32. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BleJsonCompactTest.java +158 -0
  33. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadServiceTest.java +9 -0
  34. package/android/src/test/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtilsEndiannessTest.java +172 -0
  35. package/android/src/test/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtilsTest.java +24 -0
  36. package/build/BluetoothSdk.types.d.ts +37 -1
  37. package/build/BluetoothSdk.types.d.ts.map +1 -1
  38. package/build/BluetoothSdk.types.js.map +1 -1
  39. package/build/_private/BluetoothSdkModule.d.ts +4 -0
  40. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  41. package/build/_private/BluetoothSdkModule.js.map +1 -1
  42. package/build/_private/photoRequestPayload.d.ts.map +1 -1
  43. package/build/_private/photoRequestPayload.js +1 -0
  44. package/build/_private/photoRequestPayload.js.map +1 -1
  45. package/build/index.d.ts +1 -1
  46. package/build/index.d.ts.map +1 -1
  47. package/build/index.js +5 -0
  48. package/build/index.js.map +1 -1
  49. package/build/types/index.d.ts +3 -0
  50. package/build/types/index.d.ts.map +1 -0
  51. package/build/types/index.js +2 -0
  52. package/build/types/index.js.map +1 -0
  53. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  54. package/ios/Source/DeviceManager.swift +136 -30
  55. package/ios/Source/DeviceStore.swift +5 -5
  56. package/ios/Source/MentraBluetoothSDK.swift +40 -24
  57. package/ios/Source/OtaManifest.swift +1 -1
  58. package/ios/Source/camera/CameraModels.swift +17 -3
  59. package/ios/Source/sgcs/G1.swift +1 -1
  60. package/ios/Source/sgcs/G2.swift +518 -11
  61. package/ios/Source/sgcs/MentraLive.swift +443 -42
  62. package/ios/Source/sgcs/MentraLiveL2capChannel.swift +183 -0
  63. package/ios/Source/sgcs/MentraNex.swift +416 -32
  64. package/ios/Source/sgcs/SGCManager.swift +155 -0
  65. package/ios/Source/sgcs/mentraos_ble.pb.swift +726 -122
  66. package/ios/Source/status/DeviceStatus.swift +0 -8
  67. package/ios/Source/utils/BleJsonCompact.swift +395 -0
  68. package/ios/Source/utils/BleWireProtocol.swift +92 -0
  69. package/ios/Source/utils/G1Text.swift +30 -1
  70. package/ios/Source/utils/MessageChunkReassembler.swift +82 -0
  71. package/ios/Source/utils/MessageChunker.swift +73 -0
  72. package/package.json +14 -7
  73. package/src/BluetoothSdk.types.ts +39 -1
  74. package/src/_private/BluetoothSdkModule.ts +4 -0
  75. package/src/_private/photoRequestPayload.ts +1 -0
  76. package/src/index.ts +6 -0
  77. package/src/types/index.ts +7 -0
@@ -15,6 +15,48 @@ public class MessageChunkReassembler {
15
15
 
16
16
  private final Map<String, ChunkSession> activeSessions = new ConcurrentHashMap<>();
17
17
 
18
+ private final Map<Integer, BinarySession> activeBinarySessions = new ConcurrentHashMap<>();
19
+
20
+ public byte[] addBinaryFragment(int msgId, int fragIdx, int fragCount, byte[] data) {
21
+ cleanupTimedOutSessions();
22
+
23
+ if (msgId < 0 || fragCount <= 0 || fragIdx < 0 || fragIdx >= fragCount || data == null) {
24
+ Log.w(TAG, "Dropping invalid binary fragment: msgId=" + msgId + ", index=" + fragIdx
25
+ + ", total=" + fragCount);
26
+ return null;
27
+ }
28
+
29
+ if (activeBinarySessions.size() >= MAX_CONCURRENT_SESSIONS
30
+ && !activeBinarySessions.containsKey(msgId)) {
31
+ removeOldestBinarySession();
32
+ }
33
+
34
+ BinarySession session = activeBinarySessions.compute(msgId, (ignored, existingSession) -> {
35
+ if (existingSession == null) {
36
+ return new BinarySession(msgId, fragCount);
37
+ }
38
+ if (existingSession.fragCount != fragCount) {
39
+ Log.w(TAG, "fragCount mismatch for msgId " + msgId + ", resetting session");
40
+ return new BinarySession(msgId, fragCount);
41
+ }
42
+ return existingSession;
43
+ });
44
+
45
+ if (!session.addFragment(fragIdx, data)) {
46
+ return null;
47
+ }
48
+
49
+ if (!session.isComplete()) {
50
+ return null;
51
+ }
52
+
53
+ byte[] reassembled = session.reassemble();
54
+ activeBinarySessions.remove(msgId);
55
+ Log.d(TAG, "Reassembled binary message of " + reassembled.length + " bytes from "
56
+ + fragCount + " fragments");
57
+ return reassembled;
58
+ }
59
+
18
60
  public String addChunk(String chunkId, int chunkIndex, int totalChunks, String data) {
19
61
  cleanupTimedOutSessions();
20
62
 
@@ -61,11 +103,28 @@ public class MessageChunkReassembler {
61
103
 
62
104
  public void clear() {
63
105
  activeSessions.clear();
106
+ activeBinarySessions.clear();
64
107
  }
65
108
 
66
109
  private void cleanupTimedOutSessions() {
67
110
  long now = System.currentTimeMillis();
68
111
  activeSessions.entrySet().removeIf(entry -> now - entry.getValue().createdAt > CHUNK_TIMEOUT_MS);
112
+ activeBinarySessions.entrySet()
113
+ .removeIf(entry -> now - entry.getValue().createdAt > CHUNK_TIMEOUT_MS);
114
+ }
115
+
116
+ private void removeOldestBinarySession() {
117
+ Integer oldestId = null;
118
+ long oldestTime = Long.MAX_VALUE;
119
+ for (Map.Entry<Integer, BinarySession> entry : activeBinarySessions.entrySet()) {
120
+ if (entry.getValue().createdAt < oldestTime) {
121
+ oldestTime = entry.getValue().createdAt;
122
+ oldestId = entry.getKey();
123
+ }
124
+ }
125
+ if (oldestId != null) {
126
+ activeBinarySessions.remove(oldestId);
127
+ }
69
128
  }
70
129
 
71
130
  private void removeOldestSession() {
@@ -119,4 +178,51 @@ public class MessageChunkReassembler {
119
178
  return reassembled.toString();
120
179
  }
121
180
  }
181
+
182
+ private static class BinarySession {
183
+ final int msgId;
184
+ final int fragCount;
185
+ final long createdAt;
186
+ final Map<Integer, byte[]> fragments;
187
+
188
+ BinarySession(int msgId, int fragCount) {
189
+ this.msgId = msgId;
190
+ this.fragCount = fragCount;
191
+ this.createdAt = System.currentTimeMillis();
192
+ this.fragments = new ConcurrentHashMap<>();
193
+ }
194
+
195
+ boolean addFragment(int index, byte[] data) {
196
+ if (index < 0 || index >= fragCount) {
197
+ return false;
198
+ }
199
+ fragments.put(index, data);
200
+ return true;
201
+ }
202
+
203
+ boolean isComplete() {
204
+ return fragments.size() == fragCount;
205
+ }
206
+
207
+ byte[] reassemble() {
208
+ int totalLen = 0;
209
+ for (int i = 0; i < fragCount; i++) {
210
+ byte[] fragment = fragments.get(i);
211
+ if (fragment == null) {
212
+ throw new IllegalStateException("Missing binary fragment " + i + " for msgId "
213
+ + msgId);
214
+ }
215
+ totalLen += fragment.length;
216
+ }
217
+
218
+ byte[] result = new byte[totalLen];
219
+ int offset = 0;
220
+ for (int i = 0; i < fragCount; i++) {
221
+ byte[] fragment = fragments.get(i);
222
+ System.arraycopy(fragment, 0, result, offset, fragment.length);
223
+ offset += fragment.length;
224
+ }
225
+ return result;
226
+ }
227
+ }
122
228
  }
@@ -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
- // When Chinese captions are disabled (default), strip characters the Nex font
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)
@@ -0,0 +1,18 @@
1
+ package com.mentra.bluetoothsdk
2
+
3
+ import org.assertj.core.api.Assertions.assertThat
4
+ import org.junit.Test
5
+
6
+ class BluetoothSdkExceptionTest {
7
+ @Test
8
+ fun `exception is usable without the Expo runtime`() {
9
+ val cause = IllegalArgumentException("cause")
10
+ val error = BluetoothSdkException("capture_failed", "Capture failed", cause)
11
+
12
+ assertThat(error.code).isEqualTo("capture_failed")
13
+ assertThat(error.message).isEqualTo("Capture failed")
14
+ assertThat(error.cause).isSameAs(cause)
15
+ assertThat(error).isInstanceOf(IllegalStateException::class.java)
16
+ assertThat(error.javaClass.superclass.name).doesNotStartWith("expo.modules")
17
+ }
18
+ }
@@ -1,12 +1,11 @@
1
1
  package com.mentra.bluetoothsdk.camera
2
2
 
3
3
  import com.mentra.bluetoothsdk.PhotoCompression
4
+ import com.mentra.bluetoothsdk.PhotoMode
4
5
  import com.mentra.bluetoothsdk.PhotoRequest
5
6
  import com.mentra.bluetoothsdk.PhotoSize
6
- import kotlin.test.Test
7
- import kotlin.test.assertEquals
8
- import kotlin.test.assertNull
9
- import kotlin.test.assertTrue
7
+ import org.assertj.core.api.Assertions.assertThat
8
+ import org.junit.Test
10
9
 
11
10
  class PhotoRequestTest {
12
11
  @Test
@@ -19,7 +18,7 @@ class PhotoRequestTest {
19
18
  sound = true,
20
19
  )
21
20
 
22
- assertTrue(request.requestId.startsWith("photo-"))
21
+ assertThat(request.requestId).startsWith("photo-")
23
22
  }
24
23
 
25
24
  @Test
@@ -35,7 +34,8 @@ class PhotoRequestTest {
35
34
  )
36
35
  )
37
36
 
38
- assertNull(request.exposureTimeNs)
37
+ assertThat(request.exposureTimeNs).isNull()
38
+ assertThat(request.mode).isEqualTo(PhotoMode.PHOTO)
39
39
  }
40
40
 
41
41
  @Test
@@ -60,8 +60,8 @@ class PhotoRequestTest {
60
60
  )
61
61
  )
62
62
 
63
- assertTrue(withoutRequestId.requestId.startsWith("photo-"))
64
- assertTrue(blankRequestId.requestId.startsWith("photo-"))
63
+ assertThat(withoutRequestId.requestId).startsWith("photo-")
64
+ assertThat(blankRequestId.requestId).startsWith("photo-")
65
65
  }
66
66
 
67
67
  @Test
@@ -77,6 +77,20 @@ class PhotoRequestTest {
77
77
  )
78
78
  )
79
79
 
80
- assertEquals("photo-1", request.requestId)
80
+ assertThat(request.requestId).isEqualTo("photo-1")
81
+ }
82
+
83
+ @Test
84
+ fun `fromMap preserves text mode`() {
85
+ val request =
86
+ PhotoRequest.fromMap(
87
+ mapOf(
88
+ "size" to "medium",
89
+ "mode" to "text",
90
+ "webhookUrl" to "https://example.com/upload",
91
+ )
92
+ )
93
+
94
+ assertThat(request.mode).isEqualTo(PhotoMode.TEXT)
81
95
  }
82
96
  }
@@ -0,0 +1,31 @@
1
+ package com.mentra.bluetoothsdk.sgcs
2
+
3
+ import org.assertj.core.api.Assertions.assertThat
4
+ import org.junit.Test
5
+
6
+ class G1TextSanitizerTest {
7
+
8
+ @Test
9
+ fun `strips Vietnamese diacritics unsupported by G1`() {
10
+ assertThat(sanitizeG1DisplayText("Tiếng Việt: Đặng, Nguyễn, phở bò"))
11
+ .isEqualTo("Tieng Viet: Dang, Nguyen, pho bo")
12
+ }
13
+
14
+ @Test
15
+ fun `handles decomposed marks and preserves layout`() {
16
+ assertThat(sanitizeG1DisplayText("Cafe\u0301\nĐa Nang"))
17
+ .isEqualTo("Cafe\nDa Nang")
18
+ }
19
+
20
+ @Test
21
+ fun `expands Latin letters that do not decompose into combining marks`() {
22
+ assertThat(sanitizeG1DisplayText("Øresund Łódź Æsir Œuvre Straße Ðingvellir Þingvellir"))
23
+ .isEqualTo("Oresund Lodz AEsir OEuvre Strasse Dingvellir THingvellir")
24
+ }
25
+
26
+ @Test
27
+ fun `leaves text without diacritics unchanged`() {
28
+ assertThat(sanitizeG1DisplayText("Hello, world! 123"))
29
+ .isEqualTo("Hello, world! 123")
30
+ }
31
+ }
@@ -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(BlePhotoUploadService.containsExifMarkerInBytes(stripped)).isFalse();
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
+ }