@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
@@ -0,0 +1,493 @@
1
+ package com.mentra.bluetoothsdk.utils;
2
+
3
+ import java.nio.charset.StandardCharsets;
4
+ import java.util.ArrayList;
5
+ import java.util.Arrays;
6
+ import java.util.Collections;
7
+ import java.util.HashMap;
8
+ import java.util.HashSet;
9
+ import java.util.Iterator;
10
+ import java.util.List;
11
+ import java.util.Map;
12
+ import java.util.Set;
13
+ import org.json.JSONArray;
14
+ import org.json.JSONException;
15
+ import org.json.JSONObject;
16
+
17
+ /** BLE Wire Protocol v2 JSON compaction: short keys, enum ints, omitted defaults, config diff. */
18
+ public final class BleJsonCompact {
19
+
20
+ private static final Map<String, String> LONG_TO_SHORT = new HashMap<>();
21
+ private static final Map<String, String> SHORT_TO_LONG = new HashMap<>();
22
+
23
+ private static final Map<String, Integer> PHOTO_STATUS_TO_INT = new HashMap<>();
24
+ private static final Map<Integer, String> PHOTO_STATUS_FROM_INT = new HashMap<>();
25
+ private static final Map<String, Integer> KIND_TO_INT = new HashMap<>();
26
+ private static final Map<Integer, String> KIND_FROM_INT = new HashMap<>();
27
+ private static final Map<String, Integer> SOURCE_TO_INT = new HashMap<>();
28
+ private static final Map<Integer, String> SOURCE_FROM_INT = new HashMap<>();
29
+ private static final Map<String, Integer> AE_STATE_TO_INT = new HashMap<>();
30
+ private static final Map<Integer, String> AE_STATE_FROM_INT = new HashMap<>();
31
+
32
+ public static final String KEY_RESOLVED_CONFIG_HASH = "rch";
33
+
34
+ /** ROI 8–10 message types: compact outbound and accept compact inbound. */
35
+ private static final Set<String> HIGH_ROI_MESSAGE_TYPES =
36
+ Collections.unmodifiableSet(
37
+ new HashSet<>(
38
+ Arrays.asList(
39
+ "photo_status",
40
+ "stream_status",
41
+ "wifi_scan_result",
42
+ "start_stream",
43
+ "photo_response")));
44
+
45
+ /** Structural chunk envelope (always compact on wire). */
46
+ private static final Set<String> CHUNK_MESSAGE_TYPES =
47
+ Collections.unmodifiableSet(new HashSet<>(Arrays.asList("ck", "chunked_msg")));
48
+
49
+ private static long sessionConnectEpochMs;
50
+ private static boolean resolvedConfigSent;
51
+ private static String currentSessionResolvedConfigHash;
52
+ private static final Map<String, JSONObject> resolvedConfigByHash = new HashMap<>();
53
+
54
+ static {
55
+ putKey("type", "t");
56
+ putKey("requestId", "r");
57
+ putKey("timestamp", "ts");
58
+ putKey("status", "s");
59
+ putKey("kind", "k");
60
+ putKey("source", "src");
61
+ putKey("requestedCaptureConfig", "rcc");
62
+ putKey("meteredPreview", "mp");
63
+ putKey("exposureTimeNs", "etn");
64
+ putKey("captureMetadata", "cm");
65
+ putKey("aeStateName", "aes");
66
+ putKey("transferMethod", "tm");
67
+ putKey("manual", "m");
68
+ putKey("reconnecting", "rc");
69
+
70
+ putEnum(PHOTO_STATUS_TO_INT, PHOTO_STATUS_FROM_INT, "accepted", 0);
71
+ putEnum(PHOTO_STATUS_TO_INT, PHOTO_STATUS_FROM_INT, "queued", 1);
72
+ putEnum(PHOTO_STATUS_TO_INT, PHOTO_STATUS_FROM_INT, "configuring", 2);
73
+ putEnum(PHOTO_STATUS_TO_INT, PHOTO_STATUS_FROM_INT, "capturing", 3);
74
+ putEnum(PHOTO_STATUS_TO_INT, PHOTO_STATUS_FROM_INT, "captured", 4);
75
+ putEnum(PHOTO_STATUS_TO_INT, PHOTO_STATUS_FROM_INT, "uploading", 5);
76
+ putEnum(PHOTO_STATUS_TO_INT, PHOTO_STATUS_FROM_INT, "uploaded", 6);
77
+ putEnum(PHOTO_STATUS_TO_INT, PHOTO_STATUS_FROM_INT, "failed", 7);
78
+
79
+ putEnum(KIND_TO_INT, KIND_FROM_INT, "lifecycle", 0);
80
+ putEnum(KIND_TO_INT, KIND_FROM_INT, "snapshot", 1);
81
+
82
+ putEnum(SOURCE_TO_INT, SOURCE_FROM_INT, "sdk", 0);
83
+ putEnum(SOURCE_TO_INT, SOURCE_FROM_INT, "button", 1);
84
+
85
+ putEnum(AE_STATE_TO_INT, AE_STATE_FROM_INT, "CONVERGED", 0);
86
+ putEnum(AE_STATE_TO_INT, AE_STATE_FROM_INT, "SEARCHING", 1);
87
+ putEnum(AE_STATE_TO_INT, AE_STATE_FROM_INT, "INACTIVE", 2);
88
+ putEnum(AE_STATE_TO_INT, AE_STATE_FROM_INT, "LOCKED", 3);
89
+ putEnum(AE_STATE_TO_INT, AE_STATE_FROM_INT, "FLASH_REQUIRED", 4);
90
+ putEnum(AE_STATE_TO_INT, AE_STATE_FROM_INT, "PRECAPTURE", 5);
91
+ }
92
+
93
+ private BleJsonCompact() {}
94
+
95
+ public static void resetSession() {
96
+ sessionConnectEpochMs = 0L;
97
+ resolvedConfigSent = false;
98
+ currentSessionResolvedConfigHash = null;
99
+ resolvedConfigByHash.clear();
100
+ }
101
+
102
+ public static void markSessionConnected(long epochMs) {
103
+ resetSession();
104
+ sessionConnectEpochMs = epochMs;
105
+ }
106
+
107
+ public static boolean isCameraCommandJson(String jsonData) {
108
+ if (jsonData == null || jsonData.isEmpty()) {
109
+ return false;
110
+ }
111
+ return jsonData.contains("cs_pho")
112
+ || jsonData.contains("cs_cpho")
113
+ || jsonData.contains("cs_vid")
114
+ || jsonData.contains("\"type\":\"take_photo\"");
115
+ }
116
+
117
+ public static boolean shouldCompactOutbound(String messageType) {
118
+ return messageType != null && HIGH_ROI_MESSAGE_TYPES.contains(messageType);
119
+ }
120
+
121
+ public static boolean supportsCompactInbound(String messageType) {
122
+ if (messageType == null || messageType.isEmpty()) {
123
+ return false;
124
+ }
125
+ return HIGH_ROI_MESSAGE_TYPES.contains(messageType)
126
+ || CHUNK_MESSAGE_TYPES.contains(messageType);
127
+ }
128
+
129
+ /** Compact wire form uses short `t` key without verbose `type`. */
130
+ public static boolean isCompactWireForm(JSONObject json) {
131
+ return json != null && json.has("t") && !json.has("type");
132
+ }
133
+
134
+ public static String extractMessageType(JSONObject json) {
135
+ if (json == null) {
136
+ return "";
137
+ }
138
+ if (json.has("type")) {
139
+ return json.optString("type", "");
140
+ }
141
+ if (json.has("t")) {
142
+ return json.optString("t", "");
143
+ }
144
+ return "";
145
+ }
146
+
147
+ public static JSONObject encode(JSONObject json) throws JSONException {
148
+ if (json == null) {
149
+ return null;
150
+ }
151
+ if (isCameraCommandJson(json.toString())) {
152
+ return json;
153
+ }
154
+ String messageType = extractMessageType(json);
155
+ if (!shouldCompactOutbound(messageType)) {
156
+ return json;
157
+ }
158
+ return compactObject(json, true);
159
+ }
160
+
161
+ public static JSONObject encode(String jsonData) throws JSONException {
162
+ if (jsonData == null || !jsonData.startsWith("{")) {
163
+ return null;
164
+ }
165
+ return encode(new JSONObject(jsonData));
166
+ }
167
+
168
+ public static JSONObject decode(JSONObject json) throws JSONException {
169
+ if (json == null) {
170
+ return null;
171
+ }
172
+ if (isCameraCommandJson(json.toString())) {
173
+ return json;
174
+ }
175
+ return expandObject(json, true);
176
+ }
177
+
178
+ /**
179
+ * Expand compact wire JSON only when the message type is on the ROI allowlist.
180
+ *
181
+ * @return expanded JSON, passthrough expanded JSON, or {@code null} if compact form is unsupported
182
+ */
183
+ public static JSONObject decodeIfSupported(JSONObject json) throws JSONException {
184
+ if (json == null) {
185
+ return null;
186
+ }
187
+ if (isCameraCommandJson(json.toString())) {
188
+ return json;
189
+ }
190
+ if (!isCompactWireForm(json)) {
191
+ return json;
192
+ }
193
+ String compactType = json.optString("t", "");
194
+ if (!supportsCompactInbound(compactType)) {
195
+ return null;
196
+ }
197
+ return expandObject(json, true);
198
+ }
199
+
200
+ public static JSONObject decode(String jsonData) throws JSONException {
201
+ if (jsonData == null || !jsonData.startsWith("{")) {
202
+ return null;
203
+ }
204
+ return decode(new JSONObject(jsonData));
205
+ }
206
+
207
+ private static void putKey(String longKey, String shortKey) {
208
+ LONG_TO_SHORT.put(longKey, shortKey);
209
+ SHORT_TO_LONG.put(shortKey, longKey);
210
+ }
211
+
212
+ private static void putEnum(
213
+ Map<String, Integer> toInt, Map<Integer, String> fromInt, String value, int code) {
214
+ toInt.put(value, code);
215
+ fromInt.put(code, value);
216
+ }
217
+
218
+ private static JSONObject compactObject(JSONObject input, boolean topLevel) throws JSONException {
219
+ JSONObject output = new JSONObject();
220
+ String messageType = input.optString("type", input.optString("t", ""));
221
+ boolean skipResolvedConfig = false;
222
+
223
+ if (topLevel && shouldDiffResolvedConfig(messageType) && input.has("resolvedConfig")) {
224
+ JSONObject resolvedConfig = input.getJSONObject("resolvedConfig");
225
+ String hash = hashConfig(resolvedConfig);
226
+ resolvedConfigByHash.put(hash, cloneObject(resolvedConfig));
227
+ if (resolvedConfigSent && hash.equals(currentSessionResolvedConfigHash)) {
228
+ output.put(KEY_RESOLVED_CONFIG_HASH, hash);
229
+ skipResolvedConfig = true;
230
+ } else {
231
+ currentSessionResolvedConfigHash = hash;
232
+ resolvedConfigSent = true;
233
+ }
234
+ }
235
+
236
+ Iterator<String> keys = input.keys();
237
+ while (keys.hasNext()) {
238
+ String key = keys.next();
239
+ if (skipResolvedConfig && "resolvedConfig".equals(key)) {
240
+ continue;
241
+ }
242
+ Object value = input.get(key);
243
+ String shortKey = LONG_TO_SHORT.getOrDefault(key, key);
244
+ Object compacted = compactValue(key, value, messageType);
245
+ if (shouldOmit(compacted)) {
246
+ continue;
247
+ }
248
+ output.put(shortKey, compacted);
249
+ }
250
+
251
+ if (topLevel) {
252
+ compactTimestamp(output);
253
+ }
254
+ return output;
255
+ }
256
+
257
+ private static boolean shouldDiffResolvedConfig(String messageType) {
258
+ return "photo_status".equals(messageType) || "stream_status".equals(messageType);
259
+ }
260
+
261
+ private static Object compactValue(String longKey, Object value, String messageType)
262
+ throws JSONException {
263
+ if (value == JSONObject.NULL || value == null) {
264
+ return null;
265
+ }
266
+ if (value instanceof JSONObject) {
267
+ return compactObject((JSONObject) value, false);
268
+ }
269
+ if (value instanceof JSONArray) {
270
+ return compactArray((JSONArray) value);
271
+ }
272
+ if (value instanceof Boolean || value instanceof Number) {
273
+ return value;
274
+ }
275
+ String str = String.valueOf(value);
276
+ if ("status".equals(longKey) && "photo_status".equals(messageType)) {
277
+ Integer code = PHOTO_STATUS_TO_INT.get(str);
278
+ if (code != null) {
279
+ return code;
280
+ }
281
+ }
282
+ if ("kind".equals(longKey)) {
283
+ Integer code = KIND_TO_INT.get(str);
284
+ if (code != null) {
285
+ return code;
286
+ }
287
+ }
288
+ if ("source".equals(longKey)) {
289
+ Integer code = SOURCE_TO_INT.get(str);
290
+ if (code != null) {
291
+ return code;
292
+ }
293
+ }
294
+ if ("aeStateName".equals(longKey)) {
295
+ Integer code = AE_STATE_TO_INT.get(str);
296
+ if (code != null) {
297
+ return code;
298
+ }
299
+ }
300
+ return str;
301
+ }
302
+
303
+ private static JSONArray compactArray(JSONArray array) throws JSONException {
304
+ JSONArray out = new JSONArray();
305
+ for (int i = 0; i < array.length(); i++) {
306
+ Object value = array.get(i);
307
+ if (value instanceof JSONObject) {
308
+ out.put(compactObject((JSONObject) value, false));
309
+ } else {
310
+ out.put(value);
311
+ }
312
+ }
313
+ return out;
314
+ }
315
+
316
+ private static boolean shouldOmit(Object value) {
317
+ if (value == null || value == JSONObject.NULL) {
318
+ return true;
319
+ }
320
+ if (value instanceof Boolean && !((Boolean) value)) {
321
+ return true;
322
+ }
323
+ if (value instanceof JSONObject && ((JSONObject) value).length() == 0) {
324
+ return true;
325
+ }
326
+ if (value instanceof JSONArray && ((JSONArray) value).length() == 0) {
327
+ return true;
328
+ }
329
+ return false;
330
+ }
331
+
332
+ private static void compactTimestamp(JSONObject output) throws JSONException {
333
+ String tsKey = output.has("timestamp") ? "timestamp" : (output.has("ts") ? "ts" : null);
334
+ if (tsKey == null) {
335
+ return;
336
+ }
337
+ long absolute = output.optLong(tsKey);
338
+ if (absolute <= 0) {
339
+ return;
340
+ }
341
+ if (sessionConnectEpochMs <= 0) {
342
+ sessionConnectEpochMs = absolute;
343
+ }
344
+ output.put("ts", absolute - sessionConnectEpochMs);
345
+ if (!"ts".equals(tsKey)) {
346
+ output.remove(tsKey);
347
+ }
348
+ }
349
+
350
+ private static JSONObject expandObject(JSONObject input, boolean topLevel) throws JSONException {
351
+ JSONObject output = new JSONObject();
352
+ String messageType = input.optString("type", input.optString("t", ""));
353
+
354
+ Iterator<String> keys = input.keys();
355
+ while (keys.hasNext()) {
356
+ String key = keys.next();
357
+ Object value = input.get(key);
358
+ if (topLevel && "ts".equals(key)) {
359
+ output.put("ts", value);
360
+ continue;
361
+ }
362
+ String longKey = SHORT_TO_LONG.getOrDefault(key, key);
363
+ Object expanded = expandValue(longKey, value, messageType);
364
+ if (expanded == null || expanded == JSONObject.NULL) {
365
+ continue;
366
+ }
367
+ output.put(longKey, expanded);
368
+ }
369
+
370
+ if (topLevel) {
371
+ expandTimestamp(output);
372
+ expandResolvedConfigHash(output);
373
+ cacheResolvedConfigIfPresent(output);
374
+ }
375
+ return output;
376
+ }
377
+
378
+ private static void cacheResolvedConfigIfPresent(JSONObject output) throws JSONException {
379
+ if (!output.has("resolvedConfig")) {
380
+ return;
381
+ }
382
+ JSONObject resolvedConfig = output.getJSONObject("resolvedConfig");
383
+ String hash = hashConfig(resolvedConfig);
384
+ resolvedConfigByHash.put(hash, cloneObject(resolvedConfig));
385
+ currentSessionResolvedConfigHash = hash;
386
+ resolvedConfigSent = true;
387
+ }
388
+
389
+ private static void expandResolvedConfigHash(JSONObject output) throws JSONException {
390
+ if (output.has("resolvedConfig") || !output.has(KEY_RESOLVED_CONFIG_HASH)) {
391
+ return;
392
+ }
393
+ String hash = output.optString(KEY_RESOLVED_CONFIG_HASH, "");
394
+ JSONObject cached = resolvedConfigByHash.get(hash);
395
+ if (cached != null) {
396
+ output.put("resolvedConfig", cloneObject(cached));
397
+ }
398
+ output.remove(KEY_RESOLVED_CONFIG_HASH);
399
+ }
400
+
401
+ private static Object expandValue(String longKey, Object value, String messageType)
402
+ throws JSONException {
403
+ if (value == JSONObject.NULL || value == null) {
404
+ return null;
405
+ }
406
+ if (value instanceof JSONObject) {
407
+ return expandObject((JSONObject) value, false);
408
+ }
409
+ if (value instanceof JSONArray) {
410
+ JSONArray out = new JSONArray();
411
+ JSONArray array = (JSONArray) value;
412
+ for (int i = 0; i < array.length(); i++) {
413
+ Object item = array.get(i);
414
+ if (item instanceof JSONObject) {
415
+ out.put(expandObject((JSONObject) item, false));
416
+ } else {
417
+ out.put(item);
418
+ }
419
+ }
420
+ return out;
421
+ }
422
+ if ("status".equals(longKey) && value instanceof Number && "photo_status".equals(messageType)) {
423
+ String restored = PHOTO_STATUS_FROM_INT.get(((Number) value).intValue());
424
+ return restored != null ? restored : value;
425
+ }
426
+ if ("kind".equals(longKey) && value instanceof Number) {
427
+ String restored = KIND_FROM_INT.get(((Number) value).intValue());
428
+ return restored != null ? restored : value;
429
+ }
430
+ if ("source".equals(longKey) && value instanceof Number) {
431
+ String restored = SOURCE_FROM_INT.get(((Number) value).intValue());
432
+ return restored != null ? restored : value;
433
+ }
434
+ if ("aeStateName".equals(longKey) && value instanceof Number) {
435
+ String restored = AE_STATE_FROM_INT.get(((Number) value).intValue());
436
+ return restored != null ? restored : value;
437
+ }
438
+ return value;
439
+ }
440
+
441
+ private static void expandTimestamp(JSONObject output) throws JSONException {
442
+ if (!output.has("timestamp") && output.has("ts")) {
443
+ long delta = output.optLong("ts");
444
+ long absolute = sessionConnectEpochMs > 0 ? sessionConnectEpochMs + delta : delta;
445
+ output.put("timestamp", absolute);
446
+ output.remove("ts");
447
+ if (sessionConnectEpochMs <= 0) {
448
+ sessionConnectEpochMs = absolute - delta;
449
+ }
450
+ }
451
+ }
452
+
453
+ public static String hashConfig(JSONObject config) {
454
+ try {
455
+ return fnv1a32Hex(canonicalObject(config).toString());
456
+ } catch (JSONException e) {
457
+ return fnv1a32Hex(config.toString());
458
+ }
459
+ }
460
+
461
+ private static JSONObject canonicalObject(JSONObject input) throws JSONException {
462
+ List<String> keys = new ArrayList<>();
463
+ Iterator<String> it = input.keys();
464
+ while (it.hasNext()) {
465
+ keys.add(it.next());
466
+ }
467
+ Collections.sort(keys);
468
+ JSONObject out = new JSONObject();
469
+ for (String key : keys) {
470
+ Object value = input.get(key);
471
+ if (value instanceof JSONObject) {
472
+ out.put(key, canonicalObject((JSONObject) value));
473
+ } else {
474
+ out.put(key, value);
475
+ }
476
+ }
477
+ return out;
478
+ }
479
+
480
+ private static JSONObject cloneObject(JSONObject input) throws JSONException {
481
+ return new JSONObject(input.toString());
482
+ }
483
+
484
+ private static String fnv1a32Hex(String value) {
485
+ byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
486
+ int hash = 0x811c9dc5;
487
+ for (byte b : bytes) {
488
+ hash ^= (b & 0xff);
489
+ hash *= 0x01000193;
490
+ }
491
+ return String.format("%08x", hash);
492
+ }
493
+ }
@@ -37,7 +37,10 @@ import org.json.JSONObject;
37
37
  */
38
38
  public class BlePhotoUploadService {
39
39
  private static final String TAG = "BlePhotoUploadService";
40
- private static final int JPEG_QUALITY = 90;
40
+ // AVIF sources already went through a lossy pass on the glasses; re-encode at
41
+ // high-but-not-max quality to avoid inflating upload size. JPEG fast-path
42
+ // payloads from text mode are uploaded as-is.
43
+ private static final int AVIF_TO_JPEG_QUALITY = 90;
41
44
 
42
45
  public interface UploadCallback {
43
46
  void onSuccess(String requestId, String responseBody);
@@ -81,6 +84,18 @@ public class BlePhotoUploadService {
81
84
  */
82
85
  @VisibleForTesting
83
86
  static byte[] convertToJpegPreservingExif(byte[] imageData) throws Exception {
87
+ long conversionStartMs = System.currentTimeMillis();
88
+ if (isJpeg(imageData)) {
89
+ Log.d(
90
+ TAG,
91
+ "BLE relay pass-through: input already JPEG ("
92
+ + imageData.length
93
+ + " bytes), skipping decode/re-encode in "
94
+ + (System.currentTimeMillis() - conversionStartMs)
95
+ + "ms");
96
+ return imageData;
97
+ }
98
+
84
99
  File inputFile = File.createTempFile("ble_photo_in_", guessExtension(imageData));
85
100
  File outputFile = File.createTempFile("ble_photo_out_", ".jpg");
86
101
  try {
@@ -92,19 +107,39 @@ public class BlePhotoUploadService {
92
107
 
93
108
  String imuJson = readImuJsonFromBleImage(imageData, inputFile.getAbsolutePath());
94
109
 
110
+ long decodeStartMs = System.currentTimeMillis();
95
111
  Bitmap bitmap = decodeImage(imageData);
112
+ long decodeDurationMs = System.currentTimeMillis() - decodeStartMs;
96
113
  if (bitmap == null) {
97
114
  throw new Exception("Failed to decode image data");
98
115
  }
99
116
 
100
- Log.d(TAG, "Decoded image to bitmap: " + bitmap.getWidth() + "x" + bitmap.getHeight());
101
-
117
+ Log.d(
118
+ TAG,
119
+ "AVIF decode complete: "
120
+ + bitmap.getWidth()
121
+ + "x"
122
+ + bitmap.getHeight()
123
+ + " in "
124
+ + decodeDurationMs
125
+ + "ms");
126
+
127
+ long encodeStartMs = System.currentTimeMillis();
102
128
  try (FileOutputStream fos = new FileOutputStream(outputFile)) {
103
- bitmap.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, fos);
129
+ bitmap.compress(Bitmap.CompressFormat.JPEG, AVIF_TO_JPEG_QUALITY, fos);
104
130
  } finally {
105
131
  bitmap.recycle();
106
132
  }
133
+ long encodeDurationMs = System.currentTimeMillis() - encodeStartMs;
134
+ Log.d(
135
+ TAG,
136
+ "AVIF->JPEG encode complete: quality="
137
+ + AVIF_TO_JPEG_QUALITY
138
+ + " in "
139
+ + encodeDurationMs
140
+ + "ms");
107
141
 
142
+ long metadataStartMs = System.currentTimeMillis();
108
143
  if (imuJson != null && !imuJson.isEmpty()) {
109
144
  logImuData(imuJson);
110
145
  // IMU EXIF is enrichment, not the payload: a write failure must not drop the
@@ -128,8 +163,27 @@ public class BlePhotoUploadService {
128
163
  + "). If rawHasExifMarker=true, EXIF may be present but unreadable via"
129
164
  + " ExifInterface on this container.");
130
165
  }
166
+ Log.d(
167
+ TAG,
168
+ "AVIF metadata handling complete in "
169
+ + (System.currentTimeMillis() - metadataStartMs)
170
+ + "ms");
131
171
 
132
- return java.nio.file.Files.readAllBytes(outputFile.toPath());
172
+ byte[] jpegData = java.nio.file.Files.readAllBytes(outputFile.toPath());
173
+ Log.d(
174
+ TAG,
175
+ "Phone image conversion complete: AVIF "
176
+ + imageData.length
177
+ + " bytes -> JPEG "
178
+ + jpegData.length
179
+ + " bytes in "
180
+ + (System.currentTimeMillis() - conversionStartMs)
181
+ + "ms (decode="
182
+ + decodeDurationMs
183
+ + "ms, encode="
184
+ + encodeDurationMs
185
+ + "ms)");
186
+ return jpegData;
133
187
  } finally {
134
188
  if (!inputFile.delete()) {
135
189
  inputFile.deleteOnExit();
@@ -251,6 +305,12 @@ public class BlePhotoUploadService {
251
305
  exif.saveAttributes();
252
306
  }
253
307
 
308
+ private static boolean isJpeg(byte[] imageData) {
309
+ return imageData.length >= 2
310
+ && (imageData[0] & 0xFF) == 0xFF
311
+ && (imageData[1] & 0xFF) == 0xD8;
312
+ }
313
+
254
314
  private static String guessExtension(byte[] imageData) {
255
315
  if (imageData.length > 12
256
316
  && imageData[4] == 'f'